fix(missions): capture from the clone point, and let agents create files
ci / gates (push) Failing after 7s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped

Two defects found by running a real coding mission (019fc372) rather than a
test. Both made a coding phase look like it produced nothing.

**Capture measured the wrong baseline.** It diffed the working tree against
HEAD, which is correct only while work stays uncommitted. `rust_sdlc` has a
*committer* role, so committing is the intended path — meaning a mission that
did its job properly leaves a clean tree and captured nothing. That is exactly
what happened: the agent created `DELIVERY_PROBE.md`, committed it as
`aa3be95`, and the artifact recorded `empty: true` beside a commit that
plainly contained the work.

`mission_workspace` now records the clone point in `.git/clawmates-base` (in
`.git/`, so it travels with the checkout, stays invisible to the repository,
and cannot be reached by an agent through its pinned workspace), refreshed
whenever `fetch_and_reset` moves HEAD. Capture diffs from there, covering
committed, staged and unstaged changes in one pass. Checkouts predating the
marker fall back to HEAD and say so via `base_recorded: false`.

**Agents could not create files.** `coding_readwrite` granted `file_edit` but
not `file_write`. `file_edit` replaces an exact existing string and rejects an
empty `old_string`, so creating a new file was impossible. The mission
transcript is unambiguous: "the tool rejected empty old_string... the shell is
restricted", after which the agent worked around it through `shell`. The
comment above that profile has claimed it grants file_write since the day it
was written; the list never contained it.

Also broadens capture from coding/benchmark/security_scan to every phase kind
of a repo-bearing mission: `phase_task_text` tells research phases to "save
findings under /mission/repo/research/", so filtering by kind would have
discarded every research brief such a mission produced.

Regression tests cover committed-only and committed-plus-uncommitted work
against a real git repo.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-02 10:20:00 -07:00
co-authored by Claude Opus 5
parent 322c1be89c
commit 409ca65ee7
5 changed files with 189 additions and 11 deletions
+25 -6
View File
@@ -120,10 +120,28 @@ pub async fn capture_phase_diff_at(
return Ok(None);
}
let base_sha = git(&repo, &["rev-parse", "HEAD"])
.await
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "unknown".to_string());
// Diff from where the mission *started*, not from HEAD.
//
// `HEAD` is the wrong baseline the moment an agent commits, and committing
// is the normal path — `rust_sdlc` has a committer role. A mission that did
// its job properly leaves a clean tree, so a HEAD-relative diff reports
// nothing changed. That is exactly what happened on mission 019fc372: the
// agent committed the file it was asked to create and capture recorded
// `empty: true` beside a commit that plainly contained the work.
//
// Diffing against the recorded clone point covers committed, staged and
// unstaged changes in one pass. Falling back to HEAD keeps checkouts made
// before the base was recorded working, at the cost of missing committed
// work — which is why the fallback says so in the metadata.
let recorded_base = mission_workspace::base_commit(&repo);
let base_is_recorded = recorded_base.is_some();
let base_sha = match recorded_base {
Some(sha) => sha,
None => git(&repo, &["rev-parse", "HEAD"])
.await
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "HEAD".to_string()),
};
// `--intent-to-add` registers untracked files with the index without
// staging their content, which is what makes them appear in `git diff`.
@@ -139,11 +157,11 @@ pub async fn capture_phase_diff_at(
// A repo with nothing to add is fine; keep going and let the diff be empty.
let _ = git(&repo, &add).await;
let mut diff_args = vec!["diff", "HEAD", "--"];
let mut diff_args = vec!["diff", base_sha.as_str(), "--"];
diff_args.extend(excludes.iter().map(String::as_str));
let patch = git(&repo, &diff_args).await.unwrap_or_default();
let mut stat_args = vec!["diff", "HEAD", "--stat", "--"];
let mut stat_args = vec!["diff", base_sha.as_str(), "--stat", "--"];
stat_args.extend(excludes.iter().map(String::as_str));
let diffstat = git(&repo, &stat_args).await.unwrap_or_default();
@@ -175,6 +193,7 @@ pub async fn capture_phase_diff_at(
let meta = json!({
"base_sha": base_sha,
"base_recorded": base_is_recorded,
"files_changed": files_changed,
"insertions": insertions,
"deletions": deletions,