Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,33 @@ TOOL taking a hash (`test` and `test-result`), not as richer printing.
error there takes the agent's turn down with it
- Unexpected failures die, per the reliability principles above

# Repository agent instructions (`.caos/agent.json`)

A repository steers the agent with a checked-in `.caos/agent.json`:

```json
{"instructions": "Build with `nix build`. Run the `test` tool before finishing a change."}
```

- `instructions` (a string) is standing guidance for every agent turn on that
tree: build/test commands, conventions, what "ready to publish" means.
Unknown fields are ignored so the schema can grow without breaking a
harness already baked into a worker image; a malformed file — bad JSON, or
a non-string `instructions` — fails the round loudly rather than silently
running without the repository's rules.
- `llm-step` rereads the file from the CURRENT workspace on every round and
appends it to the curried system prompt — the same freshness contract as
the tool registry, so an agent that edits the file sees the change on its
next request. A subagent's snapshot keeps `agent.json` (and only it) when
the parent's `.caos` harness state is stripped.
- `agent.json` is the ONE `.caos` entry a workspace may carry at rest.
Conversation bases, `/update-tree` proposals, and published PR tips accept
it and refuse everything else under `.caos`; the inline file tools read and
edit it like any other file.
- Per-repo validation policy lives here: the TUI's publication-preparation
turn no longer instructs a generic "build and test", so a repository that
wants gates before a PR names them in its own instructions.

# Merging and conflict resolution

An agent resolves a git merge from inside a conversation. The obstacle is
Expand Down Expand Up @@ -553,20 +580,21 @@ step.json exists ONLY in a step-commit tree (`mint_step` injects it), never in
inside a step tree, and the harness tells them apart by FILENAME. So four small
local rules, no "persistence exemption":

- **Inline tools** refuse `.caos/step.json` specifically, not all of `.caos/`,
so `.caos/conflicts` is editable like any file (deleting a path's rows is an
`edit`).
- **Inline tools** refuse the reserved `.caos/` names, not the directory
wholesale: `.caos/conflicts` (deleting a path's rows is an `edit`) and the
checked-in `.caos/agent.json` are editable like any file.
- **`mint_step`** PRESERVES an existing `.caos/` when it injects `step.json`
(symlinking `.caos/conflicts` in alongside), rather than assuming `.caos/` is
absent.
- **Compute tools** (`bash`/build/test) see `ws` as-is, `.caos/conflicts`
included — it is workspace state. (A build run mid-merge keys on a tree that
still carries the file, so it won't cache-hit the post-resolution build;
negligible, and only during resolution.)
- **Publish** (the tui's PR flow) requires the conversation TIP to have no
`.caos/` entry after the guard below. Earlier merge commits remain in the
published history with their conflict scaffolding, but the PR's final tree
cannot carry even a leftover empty `.caos/conflicts`.
- **Publish** (the tui's PR flow) requires the conversation TIP's `.caos/` to
hold nothing beyond a checked-in `agent.json` after the guard below. Earlier
merge commits remain in the published history with their conflict
scaffolding, but the PR's final tree cannot carry even a leftover empty
`.caos/conflicts`.

Both `.caos/conflicts` and the inline markers sit in the diff the whole time,
so a mid-merge head is fully reviewable.
Expand Down
16 changes: 10 additions & 6 deletions design/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ A root user event is schematically:
There is no `v` field. Durable object and request IDs accepted at protocol
boundaries are canonical lowercase 40-character Git SHA-1 values. Ordinary
workspace bases and user proposals may not introduce the reserved top-level
`.caos` path.
`.caos` path, with one exception: a checked-in `.caos/agent.json` carrying
repository instructions for the agent (SPEC, "Repository agent
instructions").

Replay walks the first-parent event spine through its first `base` event, whose
value must equal that event's first parent; ancestry below it is ordinary
Expand Down Expand Up @@ -237,11 +239,13 @@ histories differ. This applies only the user's `P..U` delta instead of treating
all changes since `P` as user edits.

PR publication is an ordinary turn: when the exact fetched base is not already
an ancestor of the conversation head, the agent calls `merge` with it, then
resolves and tests. When it is already an ancestor, the turn is explicitly told
not to merge it again. A clean conversation head without `.caos` becomes the PR
branch tip, preserving the conversation history so later publications are
fast-forwards.
an ancestor of the conversation head, the agent calls `merge` with it and
resolves the result. When it is already an ancestor, the turn is explicitly
told not to merge it again. Validation policy comes from the repository's own
`.caos/agent.json` instructions rather than a generic build-and-test request.
A clean conversation head whose `.caos` holds nothing beyond that checked-in
`agent.json` becomes the PR branch tip, preserving the conversation history so
later publications are fast-forwards.

`/from` materializes a new conversation before its first new message. Its
source must be a recognized conversation event whose complete inherited event
Expand Down
30 changes: 17 additions & 13 deletions rust/crates/caos-cli/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,14 @@ matches it exactly.

Publishing also leaves the checkout untouched. The first `Ctrl+P` opens a base
branch prompt with `origin`'s advertised default selected; type another branch
to override it, then press `Ctrl+P` again. CAOS starts a visible agent turn that
merges the exact fetched base with the standard `merge` tool, then resolves and
tests the result. When that fetched base is already an ancestor of the current
conversation head, the preparation turn runs without asking the agent to merge
it again. For another base, only this conversation's delta is applied, so child
to override it, then press `Ctrl+P` again. CAOS starts a visible agent turn
that merges the exact fetched base with the standard `merge` tool and resolves
the result, validated per the repository's checked-in `.caos/agent.json`
instructions (SPEC, "Repository agent instructions") — the request itself no
longer imposes a generic build-and-test step. When that fetched base is
already an ancestor of the current conversation head, the preparation turn
runs without asking the agent to merge it again.
For another base, only this conversation's delta is applied, so child
conversations form clean PR stacks. Unresolved conflicts stop before the branch
moves. The checkout and index remain untouched.

Expand All @@ -238,14 +241,14 @@ pushes it, and uses the authenticated `gh` CLI to find or open its pull
request. The first publication carries the conversation history; later
publications advance the branch by fast-forward. The selected PR base is an
ancestor of the published head, and the final tree must contain no reserved
`.caos` state.
`.caos` state beyond a checked-in `.caos/agent.json`.

`/publish-branch` is the sharing-only form of publication: it does not run the
agent merge-and-test preparation turn and does not create a PR. It still checks
the current tip for unresolved conflict records, conflict markers, and reserved
`.caos` state before moving the branch. It can publish a completed conversation
whose workspace diff is empty, since the transcript history itself is useful
to another client.
`/publish-branch` is the sharing-only form of publication: it does not run
the agent merge-and-validate preparation turn and does not create a PR. It
still checks the current tip for unresolved conflict records, conflict
markers, and reserved `.caos` state before moving the branch. It can publish
a completed conversation whose workspace diff is empty, since the transcript
history itself is useful to another client.

`/update-tree <message>` is the one command that reads the working tree back
into a conversation. It sends an ordinary user turn — authored by your git
Expand All @@ -258,7 +261,8 @@ the snapshot covers tracked edits, new files, and deletions, honoring
left clean. That matters: after a later agent turn you can press `Ctrl+L` again
to check out the new head without the clean-tree guard tripping on leftover
local changes. The agent then runs over the changes you folded in. A working
tree carrying the harness's reserved top-level `.caos` entry is refused.
tree carrying reserved top-level `.caos` state (anything beyond a checked-in
`.caos/agent.json`) is refused.

The intended loop is `Ctrl+L` (check out the head) → edit files → `/update-tree
<message>` → let the agent respond → `Ctrl+L` again. You can also commit the
Expand Down
23 changes: 19 additions & 4 deletions rust/crates/caos-cli/src/bin/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4003,19 +4003,21 @@ impl App {
}
}

// No generic "build and test" here: how a workspace is validated is the
// repository's call, made through its checked-in `.caos/agent.json`
// instructions (folded into the agent's system prompt), not this client's.
fn publish_turn_message(target: &str, base_is_ancestor: bool) -> String {
if base_is_ancestor {
format!(
"Prepare this conversation for publication. The selected PR base `{target}` is \
already an ancestor of this conversation, so do not call `merge` for it again. \
Build and test, then finish only when the workspace is ready to publish."
Finish only when the workspace is ready to publish."
)
} else {
format!(
"Prepare this conversation for publication. First call the existing `merge` tool \
with `theirs` exactly `{target}`. Resolve every entry in `.caos/conflicts`, remove \
`.caos/conflicts`, then build and test. Finish only when the workspace is ready to \
publish."
with `theirs` exactly `{target}`. Resolve every entry in `.caos/conflicts` and \
remove `.caos/conflicts`. Finish only when the workspace is ready to publish."
)
}
}
Expand Down Expand Up @@ -4125,6 +4127,19 @@ mod tests {
assert!(needs_merge.contains("`theirs` exactly `abc123`"));
}

#[test]
fn publish_prompts_leave_validation_to_the_repository_instructions() {
// Validation policy lives in the repo's `.caos/agent.json`, which the
// harness folds into the system prompt; the publish request must not
// impose a generic build-and-test step on repositories that define
// their own.
for base_is_ancestor in [true, false] {
let message = publish_turn_message("abc123", base_is_ancestor);
assert!(!message.to_lowercase().contains("build and test"));
assert!(message.contains("ready to publish"));
}
}

fn summary(id: &str) -> UserConversationSummary {
UserConversationSummary {
id: id.to_string(),
Expand Down
70 changes: 64 additions & 6 deletions rust/crates/caos-cli/src/bin/tui/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
use std::path::Path;
use std::process::{Command, Output};

use caos_cli::AGENT_CONFIG_NAME;

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PreparedPublishConversation {
pub(crate) head: String,
Expand Down Expand Up @@ -138,12 +140,27 @@ pub(crate) fn prepare_publish_workspace(
require_success("git grep", markers)?;
}

let reserved = capture_required("git", &["ls-tree", "--name-only", head, ".caos"], cwd)?;
if !reserved.is_empty() {
return Err(
"the publish head still contains reserved .caos state; remove .caos/conflicts after resolving it"
.to_string(),
);
let caos_entry = capture_required("git", &["ls-tree", "--name-only", head, ".caos"], cwd)?;
if !caos_entry.is_empty() {
// `.caos/agent.json` — checked-in repository instructions — publishes
// with the tree; everything else under `.caos` is harness state and
// must be gone by the tip (even an empty, resolved conflict record).
let entries = capture_required(
"git",
&["ls-tree", "--name-only", &format!("{head}:.caos")],
cwd,
)?;
let reserved: Vec<&str> = entries
.lines()
.filter(|name| !name.is_empty() && *name != AGENT_CONFIG_NAME)
.collect();
if !reserved.is_empty() {
return Err(format!(
"the publish head still contains reserved .caos state ({}); \
remove .caos/conflicts after resolving it",
reserved.join(", ")
));
}
}

Ok(PreparedPublishConversation {
Expand Down Expand Up @@ -791,6 +808,47 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}

#[test]
fn prepared_publish_head_keeps_checked_in_agent_instructions() {
let dir = temp_repo("publish-agent-config");
let base = commit_file(&dir, "base\n", "base");
std::fs::create_dir_all(dir.join(".caos")).unwrap();
std::fs::write(
dir.join(".caos/agent.json"),
"{\"instructions\":\"run the tests\"}",
)
.unwrap();
capture_required("git", &["add", ".caos/agent.json"], &dir).unwrap();
capture_required("git", &["commit", "-q", "-m", "instructions"], &dir).unwrap();
let head = capture_required("git", &["rev-parse", "HEAD"], &dir).unwrap();

let prepared = prepare_publish_workspace(&head, &base, &dir).unwrap();

assert_eq!(prepared.head, head);
}

#[test]
fn prepared_publish_head_refuses_conflicts_beside_agent_instructions() {
let dir = temp_repo("publish-agent-config-conflicts");
let base = commit_file(&dir, "base\n", "base");
std::fs::create_dir_all(dir.join(".caos")).unwrap();
std::fs::write(
dir.join(".caos/agent.json"),
"{\"instructions\":\"run the tests\"}",
)
.unwrap();
std::fs::write(dir.join(".caos/conflicts"), "").unwrap();
capture_required("git", &["add", ".caos"], &dir).unwrap();
capture_required("git", &["commit", "-q", "-m", "leftover record"], &dir).unwrap();
let head = capture_required("git", &["rev-parse", "HEAD"], &dir).unwrap();

let error = prepare_publish_workspace(&head, &base, &dir).unwrap_err();

assert!(error.contains("reserved .caos state"));
assert!(error.contains("conflicts"));
assert!(!error.contains("agent.json"));
}

#[test]
fn prepared_publish_head_refuses_unresolved_conflicts() {
let dir = temp_repo("publish-conflicts");
Expand Down
78 changes: 72 additions & 6 deletions rust/crates/caos-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ pub const MODEL_API_SECRET_VALUE_FILE: &str = ".anthropic-api-key-value";
/// The readers granted the model credential: both conversation LLM workers
/// (the turn engine, and the stateless title/auxiliary caller).
pub const MODEL_API_SECRET_READERS: [&str; 2] = ["DEEP-DEPS/llm-step", "DEEP-DEPS/llm-call"];
/// The one `.caos` entry a workspace may carry at rest: checked-in repository
/// instructions the agent harness folds into its system prompt (SPEC,
/// "Repository agent instructions").
pub const AGENT_CONFIG_NAME: &str = "agent.json";
const AUTO_NAME_PREFIX: &str = "talk-";
const MERGE_REF_CANDIDATES: &[&str] = &["main", "master", "origin/main", "origin/master"];
pub const DEFAULT_MODEL: &str = "claude-opus-4-8";
Expand Down Expand Up @@ -424,13 +428,26 @@ fn reject_reserved_caos(t: &GitTransport, root: &str, what: &str) -> Result<(),
&["rev-parse", "--verify", "--quiet", &format!("{root}:.caos")],
None,
)
.is_ok()
.is_err()
{
return Err(format!(
"the {what} contains top-level .caos state; choose a clean workspace"
));
return Ok(());
}
Ok(())
// `.caos/agent.json` — checked-in repository instructions for the agent —
// is the one entry a workspace may legitimately carry at rest; the rest of
// `.caos` is harness state.
let entries = t.git_capture(&["ls-tree", "--name-only", &format!("{root}:.caos")], None)?;
let reserved: Vec<&str> = entries
.lines()
.filter(|name| !name.is_empty() && *name != AGENT_CONFIG_NAME)
.collect();
if reserved.is_empty() {
return Ok(());
}
Err(format!(
"the {what} contains reserved top-level .caos state ({}); \
only .caos/{AGENT_CONFIG_NAME} may be checked in",
reserved.join(", ")
))
}

/// Persist a message without taking ownership of the surrounding turn
Expand Down Expand Up @@ -2035,6 +2052,9 @@ fn workspace_diff(
) -> Result<WorkspaceDiff, String> {
validate_hash(base_commit, "conversation workspace base")?;
validate_hash(head, "conversation workspace head")?;
// Hide the transient merge-conflict record, but not `.caos/agent.json`:
// an edit to the checked-in repository instructions is a real workspace
// change that publishes with the conversation.
let patch = t.git_capture(
&[
"diff",
Expand All @@ -2044,7 +2064,7 @@ fn workspace_diff(
head,
"--",
".",
":(exclude).caos",
":(exclude).caos/conflicts",
],
None,
)?;
Expand Down Expand Up @@ -4978,6 +4998,52 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}

#[test]
fn reserved_caos_permits_only_checked_in_agent_instructions() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!(
"caos-chat-agent-config-{}-{unique}",
std::process::id()
));
let remote = root.join("remote.git");
let repo = root.join("client");
std::fs::create_dir_all(&remote).unwrap();
test_git(&remote, &["init", "--quiet", "--bare"]);
std::fs::create_dir_all(&repo).unwrap();
test_git(&repo, &["init", "--quiet"]);
configure_test_repo(&repo, "Alice");
test_git(
&repo,
&["remote", "add", CAOS_REMOTE, remote.to_str().unwrap()],
);
let transport = GitTransport::discover(&repo).unwrap();

std::fs::create_dir_all(repo.join(".caos")).unwrap();
std::fs::write(
repo.join(".caos/agent.json"),
"{\"instructions\":\"run the tests\"}\n",
)
.unwrap();
std::fs::write(repo.join("workspace"), "base\n").unwrap();
test_git(&repo, &["add", "."]);
test_git(&repo, &["commit", "--quiet", "-m", "instructions only"]);
let instructions_only = test_git(&repo, &["rev-parse", "HEAD"]);
reject_reserved_caos(&transport, &instructions_only, "base tree").unwrap();

std::fs::write(repo.join(".caos/conflicts"), "").unwrap();
test_git(&repo, &["add", "."]);
test_git(&repo, &["commit", "--quiet", "-m", "with harness state"]);
let with_conflicts = test_git(&repo, &["rev-parse", "HEAD"]);
let error = reject_reserved_caos(&transport, &with_conflicts, "base tree").unwrap_err();
assert!(error.contains("conflicts"), "{error}");
assert!(error.contains("agent.json may be checked in"), "{error}");

std::fs::remove_dir_all(root).unwrap();
}

#[test]
fn workspace_proposal_three_way_merge_preserves_both_sides() {
let unique = std::time::SystemTime::now()
Expand Down
Loading