Commit Graph
100 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 5 248948cc84 fix: three things that were known and written nowhere
deploy / test (push) Successful in 5m59s
deploy / build (push) Successful in 5m53s
All three have the same shape — the system learns something and only stderr
hears it — and each was flagged in the handoff as a silent-discard defect.

The gate's install outcome. `container_tool_hooks::install` returned Some or
None and both call sites wrote `let _ =`. A mission whose gate never installed
left a record indistinguishable from one whose gate stood there and matched
nothing. `EnsuredContainer` now carries the outcome to the callers that have a
pool, and they record `gate.installed` (with the settings path) or
`gate.absent` on the mission, so "was this mission gated?" is answerable from
the mission.

The inert marker. `vm_tool_gate` writes an `inert` file when it cannot parse
its input and allows everything, precisely so an inert gate does not look like
a permissive one. The only reader was a unit test. `drain_inert` now reads and
clears it at every tap drain, and a `gate.inert` event with the occurrence count
lands beside the calls that ran unchecked.

The judge's spend. `LlmEvent::Usage` arrived on every judge call and was
matched by `Ok(_) => {}`. Two plan exhaustions (2026-08-29, 2026-09-09) with
no row anywhere saying a judge token had been spent; `usage_events` had no
provider or model column. The loop now accumulates requests and tokens onto the
Verdict — counting a request BEFORE the stream opens, so a 429 the provider
refused still counts, because the retry storm was made of those — and
`record` writes a `kind = 'judge'` row with provider, model, mission and
request count. Migration 0085 adds the columns, all nullable, so the two
existing writers are untouched.

Tests: a scripted-provider verdict records one request and nonzero tokens; a
provider that refuses still records the request and zero tokens.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-13 22:23:09 -05:00
Omar SobhandClaude Opus 5 758760cedb feat(skill-use): a compliance check for web-search-triage, from what the tap recorded
deploy / test (push) Successful in 5m19s
deploy / build (push) Successful in 5m44s
The scorer could see that agents OPENED web-search-triage (trigger=pass on both
files-arm runs) and nothing about whether they followed it — compliance was
not_applicable because no mechanical check existed. The evidence was in the
recorded arguments the whole time. On the runs that read the skill, the parent
decomposed the sweep into per-source fetches and sent each to a subagent; on
01a09b42 two of those spawn prompts read "Return the URL, date if visible, and
the key content". The task never asked for a date. The skill's "undated is a
finding" did. On the runs that did not read it: inline curls, no subagents, no
date.

Two of the skill's rules leave a mark in arguments, and the check scores
exactly those two. The ranking rule: every URL a fetch was sent to is classified
against a short allow-list of primary hosts (rank 0) and a short skip-list of
aggregators (rank 3+); fetching an aggregator is the visible violation, fetching
primary sources the visible compliance, and anything unrecognised is unranked
and decides nothing. The date rule: reported as extra evidence on a pass, never
required for one, because a curl to an abstract page has no prompt to ask in.

`Agent` is a fetching tool here on purpose. The URLs on the files-arm runs live
in the spawn PROMPT; a check that only read curl lines would have scored those
runs as fetching nothing.

`Verdict::PassWith(String)` carries the evidence and serialises under the same
"pass" tag, so no reader grows a fourth branch and the one that looks finds
the date fingerprint in `why`.

One-sided like every check in this module: no tools is not observable, no fetch
is not applicable, an unrankable fetch is not a violation.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-13 17:01:36 -05:00
Omar SobhandClaude Opus 5 7dd3aa0965 feat(skills): files is the default delivery arm
deploy / test (push) Successful in 4m59s
deploy / build (push) Successful in 5m28s
The A/B has its answer. Across four matched production runs — same recipe,
same task, same three offered skills — the MCP-door arm retrieved 1 in 9 and
the file arm retrieved 3 of 3, with the judge loop closing on the same run
(01a098dd). A signal, not a rate; but 0, 1, 0 → 3 on an otherwise identical
task is not noise, and the mechanism is explained rather than guessed: the
door is a deferred tool the agents never load, and Read is not.

A code default and not CLAWMATES_SKILL_DELIVERY on one server, for the reason
always_inject moved into the skill files: a setting that exists only in one
deployment is a setting nobody can find. The env var still overrides, and
`index` and `inline` stay selectable per mission so the comparison remains
runnable against one binary.

Garbage in the env var still falls to `inline`, not to the default — an
unreadable value must not silently select an arm that needs something
installed. A test pins the default so the next change to it is a decision
made with the numbers in front of you, not a slip.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-13 09:41:45 -05:00
Omar SobhandClaude Opus 5 00160739de feat(skills): a files arm — progressive disclosure through Read, not a deferred tool
deploy / test (push) Successful in 5m18s
deploy / build (push) Successful in 5m36s
The `index` arm retrieves through `ReadMcpResourceTool`, which is DEFERRED:
absent from the agent's default tool list until `ToolSearch` loads it. Across
three matched production runs (same recipe, same task, same three offered
uris) it retrieved 1 skill in 9 chances:

    01a07812  delegation forced      no instruction    0/3
    01a0842e  no delegation          no instruction    1/3
    01a09877  no delegation          told to load it   0/3

The third run is the decisive one. The preamble said in plain words to run
ToolSearch first; all three prompts carried it; zero ToolSearch calls, and the
three reasoning narratives never mention skills at all. The section was not
declined, it was never engaged with. Instruction is not the lever.

`Read` is a core tool. Never deferred, and every one of those agents used it.
So this arm keeps progressive disclosure exactly as `index` has it — a name, a
`when_to_use`, and a pointer the agent has to follow — and changes only what
the pointer is: a path under /mission/skills instead of an MCP uri. The bodies
are written into the container at launch (every visible skill, one tar upload;
bindings resolve per agent at turn time so a per-mission subset is not knowable
here) and a `Read` of that path is a tapped tool call, so Trigger is exactly as
observable as before.

A third arm and not a replacement, selected per mission like the others, so
the comparison runs against one binary. `resolve` falls back to `inline` when
the files were not written, for the reason `index` does: a pointer to nothing
reads as an agent ignoring its skills.

The writer and reader of a path are one pair of functions
(`skill_file_path` / `skill_from_file_path`), matched by the scorer through
the same seam `parse_uri` uses, and the end-to-end test fails when the matcher
is broken. `Mode::is_retrieval` exists so the next arm cannot silently inherit
`inline`'s "not observable" for what is a miss.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-12 22:33:41 -05:00
Omar SobhandClaude Opus 5 8d6310f126 fix(judge): stop asking an exhausted plan the same question 180 times
deploy / test (push) Successful in 4m51s
deploy / build (push) Successful in 5m34s
The retry ran on the sweep's own 10s tick for a 30-minute window, so a phase
whose judge was unreachable re-judged up to 180 times. A verdict is not one
request either: `evaluator` is agentic and loops up to `MAX_TOOL_CALLS + 1`
rounds, resending the whole growing history each time, against evidence the
code's own comment sizes at ~120 KB. One unjudgeable phase could therefore
issue on the order of 2,000 model requests.

That is most of why the z.ai weekly plan kept emptying with no mission having
visibly done anything expensive — twice now, 2026-08-29 and 2026-09-09. Nothing
recorded it, because `usage_events` carries no provider or model column.

Two changes:

Read the error before retrying. z.ai answers an exhausted plan with a 429
carrying code 1310 and its own reset timestamp. Retrying that is arithmetic,
not optimism: the reset was two days out and the phase spent its whole window
asking anyway. It now fails immediately and says which problem this is —
"the judge provider's plan limit is exhausted until 2026-09-11 10:01:33" sends
you to the plan, where "the independent validator could not be reached" sent
you into the mission. The classifier is deliberately conservative; anything
that does not positively identify itself as an exhausted plan stays retryable,
because giving up on a transport blip costs a phase that did nothing wrong —
which is how mission 01a011bf lost its script phase.

Back off. Waiting as long as we have already waited doubles total elapsed per
attempt, so the schedule is exponential with no attempt counter to store:
10, 20, 40, 80, 160, 300, 300 … — about ten attempts in the same window instead
of a hundred and eighty. `judge_retry_after` holds the clock and the sweep's
SELECT honours it; a landed verdict clears it alongside `judge_blocked_since`.

Verified rather than asserted: the migration applies and rolls back against a
real postgres, and replacing the backoff with the old fixed tick makes
`the_backoff_is_exponential_and_capped` fail (181 attempts, not ~10).

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-09 11:47:08 -07:00
Omar SobhandClaude Opus 5 1072964326 fix(skills): the door is a deferred tool, so say how to load it
deploy / test (push) Successful in 4m43s
deploy / build (push) Successful in 5m27s
`READ_IT` has always named `ReadMcpResourceTool` in every index entry. That is
not enough, because the tool is DEFERRED — not on the agent's default list, and
uncallable until `ToolSearch` loads its schema. Naming a tool the agent cannot
call reads, from the outside, exactly like an agent ignoring its skills.

Measured on a matched pair in production. Same recipe, same `index` arm, same
three offered uris, one variable:

    01a07812   76 tool calls, ToolSearch x4 (web_fetch, RemoteTrigger),
               never searched for the door        -> 0 skills retrieved
    01a0842e   ToolSearch(select:ReadMcpResourceTool), then the fetch
                                                  -> 1 skill retrieved, trigger=pass

One agent worked the extra step out unprompted; the other did not. A capability
that depends on the model guessing a tool is loadable is not delivered, so the
preamble now says the step out loud.

The reader keeps both spellings. `mode_in_prompt` scores the arm off a RECORDED
prompt and `retain_events_until` holds those for 90 days, so editing the writer
alone would have re-labelled every stored `index` run as `inline` — including
the pair above, whose whole value is that they are comparable. `INDEX_PREAMBLE_V1`
is kept as a reader-only constant and matched alongside the current text.

Verified rather than assumed: the real stored prompt from `01a07812` still
matches V1 as an exact line, the compatibility test fails when the fallback is
removed, and a second test asserts V1 stays a prefix of the current preamble
since `concat!` cannot take a const.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-09 06:55:37 -07:00
Omar SobhandClaude Opus 5 42c24de6a9 feat(skills): always_inject belongs beside the skill, not in one database
deploy / test (push) Successful in 5m14s
deploy / build (push) Successful in 5m30s
Migration 0083 added the column for a measured failure — under the `index`
arm, `workspace-repo-commit-protocol` scored Trigger=FAIL while its boundary
check passed, because a rule that applies to everyone who writes reads to each
agent as nobody's in particular. The column shipped and was never set: prod ran
0 of 53 skills flagged, and the post-v0.8.5 validation mission made 76 tool
calls with ZERO ReadMcpResourceTool among them. Not plumbing — the door
answered 200 from inside that container, and the agents used ToolSearch four
times to reach for other tools they did not have.

Setting it by hand fixes one database. A rebuilt one comes up un-flagged, with
nothing in the repo recording that the skill was ever meant to be injected —
the same shape as every silent-success defect in this project.

So the frontmatter carries it, the loader parses it, and the upsert writes it.
The file wins on conflict: builtins are code-managed, and a setting that exists
only in one database is a setting nobody can find.

Guarded both ways. `always_inject` defaults FALSE, because defaulting true
would quietly abolish the index arm rather than fix it; and a test asserts the
shipped skill still carries the flag, verified by flipping it to false and
watching the test fail.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-07 04:19:52 -07:00
Omar SobhandClaude Opus 5 bda6bef4db fix(evaluator): a cleanup that already succeeded is not an error
`Sandbox::purge` removes the verification copy, and then `Drop` runs
`remove_dir_all` on the path purge just deleted and prints a failure. Prod
logged it on every mission:

    evaluator_tools: could not remove the verification copy at
    /var/lib/clawmates-missions/_verify/01a07812-… (No such file or directory)

That is the success path reporting itself as a fault. It matters beyond
tidiness: this is the same line that carries a REAL stranded-copy error, and a
message that cries wolf once a mission is a message nobody reads the day it is
true — which is how two root-owned copies sat stranded for hours the first
time.

`NotFound` is now the expected outcome and says nothing. Every other error
still speaks.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-07 04:19:42 -07:00
Omar SobhandClaude Opus 5 0b9baa942f build(runtime): follow ZeroClaw v0.8.5 onto Rust 1.98
deploy / test (push) Successful in 4m47s
deploy / build (push) Successful in 59s
v0.8.5 moved upstream's own container builders to rust:1.98-slim (#9527) and
kept 1.96 only as the declared SOURCE floor - what the crates promise, not what
upstream actually builds with. We were pinned at 1.96 and had never compiled
this code on it; the local check ran on 1.97. Track upstream instead of
trusting the floor, staying on the bookworm variant so the binary's glibc still
matches the debian:bookworm-slim runtime stage.

CARGO_BUILD_JOBS defaults to 6 because the whole fleet is offline and gw-04 is
now both the only reachable x86_64 host and the box serving production, so a
build must not take every core from the services running beside it.

Built and deployed: clawmates-runtime:v085 reports zeroclaw 0.8.5, health 200
with every component ok including the new relay, pairing survived the recreate,
and the claude_cli/kimi_cli slots still resolve alongside upstream's grok_cli.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-06 10:20:24 -07:00
Omar SobhandClaude Opus 5 2a3409ec53 docs: the judge is back and the subagent path is no longer a claim
deploy / test (push) Successful in 4m49s
deploy / build (push) Successful in 57s
The z.ai quota reset on schedule. glm-5.3 answers on the same key and has
since passed a real done_when — phase completed on iteration 0, with the
verdict naming the arXiv ids it checked rather than waving the phase through.

Mission 01a07498 was the failed validation run plus one change, and it closed
the honest negative the last handoff recorded: 87 tool calls, 43 from the main
turn and 44 across 4 general-purpose subagents, 4 distinct subagent_ids against
4 Agent spawns. Before this the field was correct in unit tests and had never
been watched writing.

The one change was the finding. The earlier task invited delegation and got
none; naming the tool and forbidding the single-turn shortcut produced four
spawns from the same recipe and the same delivery arm. A fan-out path that is
merely invited measures nothing.

Also records that postgres is clawmates-postgres-1 locally and
clawmates_postgres_1 on gw-04 — the wrong one reports "No such container",
which reads like a down stack rather than a typo.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-05 19:54:37 -07:00
Omar SobhandClaude Opus 5 daf8d12157 docs: hand off — what shipped, what is verified, and what is blocked
deploy / test (push) Successful in 4m42s
deploy / build (push) Successful in 58s
Seven commits this pass, all deployed. The handoff leads with the thing that
will otherwise waste the next session's first hour: `glm-5.3` hit a hard z.ai
quota on 2026-08-29 (code 1310, resets 09-04), it is the DEFAULT validator on
both stacks, and the `ZAI_API_KEY` fingerprints are identical — so every mission
declaring a `done_when` fails its evaluation on local and production alike,
with its artifacts fully delivered and correct.

That failure is not a bug to fix. `evaluator.rs:480` refuses to fall back to the
agent's own provider because a same-family verdict would claim an independence
it does not have. It is also NOT the malformed-prompt 429 we hit before: this
one carries a code and a reset date.

Records the validation run honestly rather than as a clean sweep. Three of four
things confirmed live — `always_inject` delivering a body beside an index entry
in one prompt, retrieval still firing through the door, the corrected gate
installed and quiet against 23 body-free Bash calls, attribution 34/34. The
fourth did not happen: those agents never delegated, so the subagent field is
written and null, and the path that motivated it has still never been watched
populating `mission_events`. A task that invites delegation does not force it;
the next attempt should instruct it outright.

Also carried forward: local test state that production does not share
(`workspace-repo-commit-protocol.always_inject = true`, set by hand), the three
fork items sitting behind one runtime image rebuild with tank offline, and two
silent-discard defects found by sweep and left unfixed — `container_tool_hooks::
install`'s outcome is recorded nowhere, which makes "did this mission run gated?"
unanswerable once the container is reaped.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-08-29 22:31:43 -05:00
Omar SobhandClaude Opus 5 f26de3ba76 feat(world): the agent page can answer what an agent DID, not only what it is doing
deploy / test (push) Successful in 6m13s
deploy / build (push) Successful in 6m47s
The command centre's metric band reads a live feed: tokens in the last minute,
credits in the last hour, active routines, pending approvals. Every one of those
is correctly zero once a mission ends — so an operator opening an agent that ran
`JEPA Research` was shown six zeros, with nothing saying the page had understood
a different question than the one they asked.

The data was never missing. `usage_events` carries a row per turn and
`mission_events` carries every attributed tool call. Verified against production
before any of this was written:

    Tomasz     21,697 tokens   22.00 credits   96 tool calls
    Seong-min  18,125          19.00           49
    Adrian     13,855          14.00           32
    Yara        9,686          11.00           11
    Wei         7,228           8.00           18
    Osei        4,304           5.00            5

The tool counts sum to 211, which is exactly what `mission_events` holds. The
page simply never asked.

`agent.last_run` is a SEPARATE taxonomy event, not a fallback folded into
`telemetry`, and that is the whole design. `agent.task.update` already refuses to
emit for a finished mission so that "idle" stays truthful; quietly substituting
a two-day-old number into a tile the UI promises is live would undo exactly
that. The two travel apart and the card says which it is showing:

  SPEND        last-run credits, unit becomes `cr total`, tagged LAST RUN
  THROUGHPUT   last-run tokens, unit becomes `tokens · last run`, and the
               sparkline is SUPPRESSED — a flat line drawn from one repeated
               number reads as "measured and steady" when nothing was measured
  WORKING ON   idle stays idle, but names the mission, tool calls, tokens,
    NOW        status and how long ago, instead of one line of nothing
  LOOPS/DOORS  left live; zero is the correct answer there

Live always wins. History appears only where the live value is genuinely
nothing, so an agent mid-turn can never see a stale figure.

Two details that would have been silent bugs:

- `stateKey` keys the retained value per AGENT. One shared key would let the
  last agent in the roster overwrite every other agent's summary, and a late
  subscriber would paint one agent's last run onto all of them — plausible
  numbers belonging to someone else.
- `usage_events` carries no mission id, so its rows are attributed by the
  mission's time window. `mission_events` needs no such guess, which is why the
  tool count is the trustworthy half of the row and the token figure is the
  approximate one. Said so in the doc comment rather than implying both are
  equally solid.

Refreshed on the seed and then once a minute, not on the 2s poll: historical by
definition, but not seed-only either, or a mission finishing mid-session leaves
the card reading whatever it read before.

Suite: 108 binaries, 842 Rust tests, 92 frontend tests, tsc clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-08-28 20:55:32 -05:00
Omar SobhandClaude Opus 5 2f1a870949 feat(skills): a skill that must be read cannot be left to be noticed
deploy / test (push) Successful in 5m24s
deploy / build (push) Successful in 5m55s
The `index` arm hands an agent a list of uris and trusts it to fetch what
applies. Measured on the first A/B pair, that is mostly what happens — each
agent fetched the skill bound to its own role and no other, which is the result
that made Trigger observable at all.

`workspace-repo-commit-protocol` is the case it fails on. It scored Trigger=FAIL
beside a PASSING boundary check: the rule was live and unread. A procedure that
applies to everyone who writes reads as nobody's in particular, so no agent
recognises it as theirs and no agent fetches it.

Upstream ZeroClaw arrived at the same place from the other direction and gave
its compact injection mode an `always: true` frontmatter escape hatch (#9520).
This is that hatch as a column: `skills.always_inject`, default FALSE, so
nothing changes for an existing skill and the inline arm is untouched either
way.

Two halves, because delivering it and scoring it are different mistakes:

- Delivery: under `Index`, an `always_inject` skill renders its BODY.
- Scoring: the arm belongs to the PROMPT and `always_inject` belongs to the
  SKILL, so the scorer now asks per skill which one it got. A skill whose body
  is in the prompt was handed over, and a Trigger miss cannot be charged against
  an agent that was never asked to fetch anything.

`skill_was_indexed` reads that off the rendered prompt via `READ_IT`, a
constant now shared with `index_entry` — two spellings of one marker is how a
detector quietly stops detecting.

Suite: 108 binaries, 840 tests, green.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-08-27 09:58:01 -05:00
Omar SobhandClaude Opus 5 563b074116 docs: ZeroClaw upstream, scanned against what we actually run
331 behind, 54 ahead. The previous scan said 218 and its conclusion about the
egress commit was wrong, so it is marked superseded rather than edited.

Merge cost is smaller than the number suggests: 660 files changed upstream, 52
by us, and **18 overlap**. `claude_cli.rs` — the provider every mission runs
through — exists in our tree and in zero upstream files, so it cannot conflict.

The find worth recording is not a feature. Upstream defaulted skills to compact
injection on 2026-08-05 (#8313), then restored the full default for v0.8.x on
2026-08-13 (#9913). Eight days. That is our `index` arm, tried at larger scale
and pulled back out of the stable line — evidence bearing directly on our own
open question of whether to flip the default, and with our own data at n=1 per
arm it argues for more pairs before flipping, not fewer.

Their documentation also states plainly what ours should: "Compact mode reduces
prompt size; it is not an isolation boundary for untrusted skill sources."
Progressive disclosure is a token optimisation. It is not a security control.

Also noted, as a documented limit rather than a surprise: upstream fixed
case-insensitive allowlist matching (#9568) and symlink-escape path resolution
(#9384) in their command gate. Ours resolves no paths, so a symlink to `curl`
defeats it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-08-27 09:58:01 -05:00
Omar SobhandClaude Opus 5 fde1341618 docs: what a mission container can reach, and why item 4 could not fix it
deploy / test (push) Successful in 5m15s
deploy / build (push) Successful in 5m43s
Plan item 4 said to pull upstream's `0db7d999a` egress policy as "defence for
the egress problem we have not solved". It cannot see the problem.

`claude_cli` runs the claude binary as a SUBPROCESS
(`Command::new(&self.binary_path).spawn()`), so every mission tool call happens
inside that child. `net_guard`'s only call sites upstream are `link_enricher`,
`helpers/domain_guard` and `plugins/egress` — ZeroClaw's own Rust HTTP. A
mission agent's `curl` never touches the guarded stack. Pulling the commit
hardens the CHAT tier; it leaves mission egress exactly as it is. Item 4 is
corrected in place rather than deleted, because the reasoning is the useful
part.

What is actually true, measured on gw-04 with controls in both directions:

  positive  1.1.1.1:443             REACHABLE
  negative  192.0.2.1:80 TEST-NET   blocked
  tailnet   gw-02 100.84.218.70:22  REACHABLE
  host SSH  docker gw 172.23.0.1:22 REACHABLE
  169.254.169.254                   REACHABLE
  postgres                          REACHABLE, password-required
  LAN 192.168.1.1                   blocked

A mission agent reaches the entire tailnet and SSH on its own host. It matters
more here than it would elsewhere: these agents run model-generated shell over
content fetched from the open web — 151 of 158 production Bash calls were
curl/wget — so the instruction stream and the data stream are one stream.

The first run of this probe attached only `clawmates_core`, reported "no
internet", and was discarded: its positive control failed, so it measured
nothing. A mission container is on BOTH networks and that is what must be
reproduced.

Recorded in full, including the half that is fine — postgres refuses
unauthenticated TCP and no database credentials are forwarded into a mission
container — because a report that lists only the bad half is not a measurement.

Remediation is written down and deliberately NOT applied, on the operator's
call. It is DOCKER-USER rules dropping the private world with the core subnet
accepted first; never a public host allow-list as the opening move, because
`JEPA Research` alone fetched a dozen hosts nobody would have pre-approved and
a mission that cannot read cannot do research.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-08-27 09:11:26 -05:00
Omar SobhandClaude Opus 5 bd7fd46305 fix(mission-runtime): a mission with no egress no longer launches
`clawmates_core` is `internal: true`. Verified from a container attached to it
and nothing else: no default route, and every external address unreachable —
the positive control fails, which is what makes the network's own configuration
visible rather than inferred.

So the attach to `clawmates_edge` is not an optimisation. Without it a mission
has no route off the host: no provider call, no fetch, no work. The result was
discarded:

    let _ = self.docker.connect_network(EDGE_NETWORK, …).await;

which makes a failure here indistinguishable from success. The mission starts,
the phase runs, every tool call fails for a reason nothing reports, and the
phase can still reach `completed`. Green-with-nothing, again.

Not fatal on the error alone: re-attaching an already-connected container is
also an error, and a benign one on any relaunch path. So the container's own
network list settles it rather than the return code — already attached is
logged and continues, genuinely not attached fails the launch with a message
that says what it means. `inspect` failing counts as NOT attached, because the
whole point is to stop guessing that egress is present.

Behaviour change worth stating plainly: a mission that would previously have
run blind now refuses to start. That is the intended trade — a mission which
cannot reach anything cannot do the work it reports having done.

Suite: 108 binaries, 838 tests, green.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-08-27 09:11:09 -05:00
Omar SobhandClaude Opus 5 fe5c7d2c87 fix(tap): a subagent's tool calls are no longer credited to its parent
deploy / test (push) Successful in 4m57s
deploy / build (push) Successful in 5m34s
The previous commit's message ended "mission agents are spawning subagents and
nothing in our design accounts for it." Twelve spawns across the two production
missions, all of them used as a fetch mechanism — container missions have no
`WebFetch` or `WebSearch`, so they reach the network through `Bash` + `curl`,
and 151 of 158 Bash calls are exactly that.

Measured against the real claude 2.1.246 binary rather than reasoned about,
because the containers were reaped and the question had three possible answers:

  1. A subagent's tool calls DO fire both hooks. `PostToolUse` records them, and
     `PreToolUse` blocked a subagent's denied curl and got the reason back to
     it. `Agent` is not a gate bypass — worth knowing before shipping the rule
     in the previous commit.
  2. They carry the PARENT's session_id. One parent plus one subagent produced
     three events on one id. This is why attribution resolved 119/119: a
     subagent never adds a session, so attribute_sessions' exact count holds.
  3. Only `agent_type` / `agent_id` tell them apart — present on a subagent's
     payload, absent on the parent's own.

`hook_script` appends the raw payload, so both fields were already on disk in
every production run. `parse()` read past them. The guest was never the lossy
half, so nothing container-side changes and no redeploy of the image is needed.

`Observed.subagent` / `.subagent_id` now carry them into `mission_events.detail`.
A blank `agent_type` reads as "the turn's own agent", because absence IS the
signal here and a subagent named "" is not a thing.

Same defect class as the tap discarding tool ARGUMENTS until 2026-08-21: the
record looked complete while being wrong about who did the work.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-08-26 21:09:04 -05:00
Omar SobhandClaude Opus 5 5a2ed8fb42 fix(gate): the exfiltration rule matched a spelling production never writes
One rule stood between a mission agent and sending the checkout off the
machine, and it was `Match::Command` on `curl -x post` — the segment had to
BEGIN with `curl -X POST`. The tap already held the answer to whether that is
what agents write. It is not:

  158 Bash calls from the two production missions
  166 curl invocations, every one a GET
      84  curl -s              26  curl -s -L --max-time
      22  curl -s --max-time   20  curl -sL          7  curl -s -o

Every one begins `curl -s`, and that `-s` pushes the needle off position zero.
Run against the real generated guest script, all of these were ALLOWED:

  curl -s -X POST https://…  -d @/mission/repo/secret.md
  curl -d @report.md https://…      curl -F [email protected] https://…
  curl -T report.md https://…       curl --upload-file report.md https://…
  wget --post-file=report.md https://…

Zero denials in production therefore meant nothing. A gate with nothing to deny
and a gate anchored to a spelling its own traffic never uses produce identical
output — the shape this codebase keeps meeting.

`Match::Carries(cmd)` matches a segment that STARTS with the command and
contains the needle anywhere after it, so flag order stops mattering. A rule now
carries several needles, because one action has many spellings and a rule per
spelling is how half of them get missed.

`CarriesExact` exists for the flags whose CASE is their meaning: curl's `-F`
uploads a form and `-f` fails quietly, as in the wholly ordinary `curl -fsSL`.
Lowercasing the command before splitting made those one string, so segments are
now lowercased individually and the exact rules read the original.

`--data-urlencode` is deliberately absent: with `-G` it builds a query string
for a GET, and denying the read idiom to catch a rare POST spelling is the trade
this module refuses to make.

Also closed a divergence between the two implementations of one policy: the
generated shell had no text-tool exemption, so it denied
`echo --dangerously-skip-permissions` while the Rust predicate allowed it.

Evidence, not assertion: all 158 recorded production commands replayed through
the new script deny 0, and the six shapes above deny with the reason reaching
the model.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-08-26 21:08:52 -05:00
Omar SobhandClaude Opus 5 7bcf7865f0 docs: prod ran a mission, and the whole chain held
deploy / test (push) Successful in 4m35s
deploy / build (push) Successful in 55s
`ClawHDF5` and `JEPA Research` were launched from the UI. The first completed,
and everything shipped over the previous two passes engaged correctly on its
first execution anywhere outside the local stack:

  skills door   installed — api_origin() derived the host from the server's
                own container id, which had never run where it was not tested
  staffing      Topic Research, 3 roles / 4 deliveries, not rust_sdlc's 5 / 14
  drain         92 tool calls
  attribution   92 of 92, across a phase with TWO passes and six turns — the
                case attribute_sessions had never met, and it attributes
                nothing at all unless the counts match exactly
  boundary      all 8 Write/Edit paths under /mission/repo
  arm           inline, 0 retrievals; prod leaves the env unset
  judge         pass 0 met=false "zero URLs — grep -c http returns 0"
                pass 1 met=true  "57 http references"

The judge line is the one worth rereading: the loop converged on the exact
mechanically-checked defect it named, and pass 0 would otherwise have shipped
a report whose every claim was unattributed while reporting `completed`.

Two traps recorded rather than smoothed over:

- The drain selects phases `IN ('completed','failed')`, so a phase on its
  second pass shows zero tool calls and reads as broken while being correct.
- I reused a diagnostic query with no `WHERE mission_id`. That was fine while
  prod held one mission and silently wrong the moment a second launched — it
  compared one mission's tap against two missions' events. The production
  drain query is correctly scoped; the diagnostic was not.

Unexplained: one agent called the `Agent` tool 4 times. Mission agents are
spawning subagents and nothing in our design accounts for it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 09:43:59 -05:00
Omar SobhandClaude Opus 5 accae7fa94 docs: the delivery A/B, and the first retrieval nobody asked for
deploy / test (push) Successful in 5m22s
deploy / build (push) Successful in 5m49s
Runs 9 and 10: identical task text, one server process, and a task that never
mentions skills, MCP or retrieval. Run 8 demonstrated the instrument, but its
retrieval was instructed by the task — it showed the pipe worked, not that an
agent would judge relevance.

Under `index`, two of four skills were fetched, and attribution is the part
that matters:

    Solveig (lead_researcher)  ->  web-search-triage
    Olamide (report_writer)    ->  scientific-writing-conventions

Each agent reached for the skill bound to its OWN role and neither reached for
another's. An agent that fetched all four would have shown only that it could.

The regression the A/B existed to catch did not appear: 34% fewer tokens, 59
tool calls against 89, both arms passed the independent judge, and the
deliverables came out slightly larger rather than thinner.

Two readings the data does not support, recorded because the first draft of
this section made one of them:

- Every `tool.call` in a phase carries the DRAIN timestamp, not the call time.
  All 59 rows of run 10 read `12:48:12`. Ordering by that column said the
  report writer had fetched both skills; `agent_id` says otherwise.
- The prompt saving is 15-43%, not an order of magnitude. Skill bodies are a
  minority of a turn prompt. Progressive disclosure is worth doing for Trigger,
  not for context economy.

`workspace-repo-commit-protocol` scores Trigger=FAIL beside boundary=pass: it
behaved correctly without reading the rule. That verdict is left standing and
argued with in the text rather than tuned away.

n=1 per arm. A signal, not a rate.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 07:50:17 -05:00
Omar SobhandClaude Opus 5 22eeaa6f15 feat(auth): the door that can delegate no longer needs a person's session
`/mcp` — `email_send`, `slack_post`, `delegate` — authenticated with
`authenticate`, which accepts only `full`. Nothing hands it a token today, so
this cost nothing yet; the moment something did, the only credential that
worked would have been an owner's session, held by an agent runtime.

`SCOPE_AGENT_DOOR` is that credential's narrow form. `full` still works, so
the UI and every human caller are unaffected, and the route now names what it
accepts rather than accepting everything by default.

The test that matters is not that each scope opens its own route: it is that
holding one grants nothing the other has. Both tokens live where an agent can
read them.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 07:34:30 -05:00
Omar SobhandClaude Opus 5 f52cff3e04 feat(skill-use): progressive disclosure, as an arm and not a switch
Trigger — did the agent reach for the skill when it applied? — cannot be
measured while every body is inlined into the prompt. Nothing was reached
for. `skill_use` has been reporting `NotObservable` for that reason, and it
was right to.

The skills door made retrieval possible; this makes it a delivery arm.
`index` sends each pinned skill's name, description, `when_to_use` and the
uri that returns its body, and the agent fetches what it judges relevant.
`inline` is unchanged and stays the default.

An A/B rather than a switch, because `index` can only cost Compliance: under
`inline` the procedure sits in front of the model whether or not it noticed
it applied. Trading a measured axis for an unmeasured regression in another
is not an improvement, so both arms stay runnable and the arm is recorded on
the mission row.

Three things the mechanism refuses to do:

- `index` without a door falls back to `inline`. An index names bodies and
  says how to fetch them; with no `clawmates_skills` server reachable that is
  a list of dead ends, and it fails as an agent ignoring its skills rather
  than as a missing config. `install_skills_door` now returns whether it
  installed, because the caller needs the answer and not just the log line.

- The scorer reads the arm off the recorded PROMPT, not off the mission row.
  The row says what the mission is configured to do now; the score is being
  computed against a turn that ran then.

- Under `index`, a skill that was offered and never read is a Fail, not the
  inline arm's `NotObservable` — but only where the skill had a checkable
  consequence in that phase. Reusing the inline text would have said "this
  skill was inlined into the prompt" about a skill whose body was never sent,
  and scoring a real miss as a structural blind spot is the failure this
  measurement already made once.

The arm is per mission (`config.skill_delivery`), not only per deployment.
Both arms run against one server process; restarting between them would put a
confound in the comparison that the numbers would not show.

829 tests, 108 binaries, green.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 07:17:55 -05:00
Omar SobhandClaude Opus 5 b58f0347e6 fix(testkit): stop leaking a database per test
`test_pool` creates a database per test and nothing ever dropped it.

Invisible on the testcontainer path — the container dies with the process
and takes them with it. But `CM_TEST_DATABASE_URL` points at a SHARED
server that outlives the run, and that is the path CI uses and the path
`.cargo/config.toml` sets for local development. So on both, every
database ever created is still there, growing with every `cargo test`.

Measured before writing the fix: **3,546 databases, 38 GB** on one
developer machine. After: 391 and 4.3 GB — the remainder being today's,
still inside the window. The docker volume went 42.3 GB to 5.7 GB.

Age comes from the NAME, not the catalogue. Postgres records no creation
time for a database, but the names are `test_<uuid-v7>` and UUIDv7 puts
its millisecond timestamp in the first 48 bits — the same property
`mission_runtime::container_name` already relies on.

Three things the tests pin down:

  - a database created just now must read as NEW, or the reaper deletes
    one a parallel test binary is still using;
  - only names we minted are reapable — `test_scratch` and `clawmates`
    survive;
  - the window outlasts any test run.

`WITH (FORCE)` because a single leftover session pins a database and the
drop otherwise silently does nothing. Best-effort throughout: a test must
never fail because housekeeping could not run.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 13:45:34 -07:00
Omar SobhandClaude Opus 5 72eda8b3d2 docs: handoff reflects the pushed state
22 commits pushed, CI green, deployed. Items 1-3 of the previous list are
done: staffing, attribution, and the door. Trigger is measured and
red-first turned out observable from run outputs rather than from the
diff — the previous list was wrong about that, and the skill says why.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 13:30:02 -07:00
Omar SobhandClaude Opus 5 7525be3791 test(orphans): the destructive sweep test is opt-in
deploy / test (push) Successful in 5m44s
deploy / build (push) Successful in 5m59s
CI mounts /var/run/docker.sock into the test container and the runner is
gw04 — the host that runs production missions. So `cargo test --workspace`
there has full access to the production docker daemon, and this test
REMOVES containers.

`adopt_existing` protects everything already present, but it cannot
protect a mission container created in the seconds between that call and
the sweep. On a laptop that race is nothing; on gw04 it is somebody's
mission.

So the destructive case now requires `CM_TEST_ORPHAN_SWEEP=1` and CI
simply does not run it. The read-only probes still run everywhere — they
create fixtures and inspect them, and never sweep.

This is the second time this test's blast radius has bitten: it reaped
two real local mission containers on its first run, and this would have
been the same mistake with production's daemon. The sweep is not the
problem — a sweep is global by nature — the harness around it is.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 13:23:58 -07:00
Omar SobhandClaude Opus 5 5220f3bfea feat(skill-use): red-first is observable from what the RUNS reported
The open item said this needed the repository diff rather than tool
order. That was wrong, and the skill says why: "Commit the RED-to-GREEN
pair as one commit." The failing test and its fix land together by
instruction, so the diff and the commit history are as blind as the tool
ordering already was — in Rust one `Edit` adds the implementation and its
`#[cfg(test)] mod tests` in the same call.

The only remaining witness is what each test run itself printed, and the
tap was throwing it away. Claude Code's PostToolUse payload carries
`tool_response` — verified against the real binary, keys
stdout/stderr/interrupted, plus `duration_ms` and `tool_use_id`.

So `Observed.response` now keeps it, for COMMANDS only: a `Read`'s
response is the file it just read and a `Write`'s restates its own
argument — both already knowable, both large, and storing them would
double the biggest write path in the system for nothing.

`bounded_response` keeps the **end** of the output, which is the opposite
of `bounded_input` and deliberately so. An argument's meaning is its verb,
at the start. A command's meaning is its verdict, at the end: `cargo test`
prints hundreds of lines and then `test result: ok` or `FAILED`. A
head-biased truncation would keep the noise and discard the only thing
being stored for — negative-controlled with a 400-line fixture.

`red_before_green` now falls through to the run outcomes:

  failing run, then a passing one  → Pass, red then green observed
  every run failed                 → Fail, the loop ends on green
  every run passed                 → NotObservable, and the reason says
                                     why: a test that never failed is
                                     equally what a correct implementation
                                     written first looks like
  no outputs recorded              → NotObservable (pre-capture missions)

Read from the runner's verdict line, not an exit code — the payload
carries none.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 12:45:20 -07:00
Omar SobhandClaude Opus 5 b47ae7fa6b feat(skill-use): Trigger is observable — score it
The door made retrieval possible; this makes it *measured*. A skill that
arrives by retrieval leaves a recorded tool call, and until now the scorer
ignored it entirely — so the one axis the whole door was built for stayed
`NotObservable` even on a mission where three agents demonstrably reached
through it.

Taken from what run 8 actually recorded, not from the shape I imagined:

    ReadMcpResourceTool {"uri":"skill:global/workspace-repo-commit-protocol",
                         "server":"clawmates_skills"}

`retrieved_skills` reads those URIs through `mcp_skills::parse_uri` — the
function that WROTE them — rather than a second matcher, because two
implementations of one format drift and the drift shows up as a skill
silently scoring nothing.

Trigger is now `Pass` for a skill the agent reached for, and
`NotObservable` for one that was inlined — with a reason that names the
fix rather than the transport: being handed a skill is not failing to
reach for one.

`score` also had to stop reading only the prompt. A skill retrieved and
never inlined is invisible to `skills_in_prompt`, and under progressive
disclosure that is EVERY skill — so the scorer would have reported zero
for the delivery model this axis exists to measure.

Listing the catalogue is browsing; reading a body is the reach. Only
`ReadMcpResourceTool` counts.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 12:37:49 -07:00
Omar SobhandClaude Opus 5 4b160c5a1b test(orphans): prove the sweep against real containers, both directions
`sweep_orphans` force-removes containers and had never run against a
daemon — only its pure decision logic was covered. The two Docker-touching
seams are exactly the ones worth exercising for real: what it can see, and
whether a checkout holds work no remote has.

Three fixtures, three outcomes, one sweep:

  - unpushed commits, no remote ref  → SURVIVES
  - every commit on a remote ref     → reaped
  - inside the grace window          → survives anyway

Negative-controlled: making `unpushed_commits` return `None` for a dirty
checkout fails with "the probe said a checkout with an unpushed commit
holds nothing — this is the exact answer that destroys work".

Two real hazards the test surfaced, neither of them in the sweep:

1. **The tests raced each other.** The sweep is global — it reaps every
   orphaned mission container on the daemon, including fixtures another
   test in this file just started. A `FIXTURES` mutex serialises them.
   Found the honest way: the reap test deleted the listing test's fixture
   and the listing test reported a container it could not see.

2. **The test destroyed real local state.** The sweep asks the DATABASE
   whether a container is known, and `test_pool()` knows nothing — so on
   a developer machine it classified the live stack's mission containers
   as orphans and reaped two of them on the first run. `adopt_existing`
   now gives every pre-existing mission container a row before sweeping,
   which makes the test safe AND covers the one case the other
   assertions missed: a container the platform still knows about is never
   touched.

   Negative-controlled both ways with a bystander container: without
   adoption REAPED, with adoption SURVIVED.

Skips cleanly with no Docker, so a runner without one reports "not run"
rather than failing — the placeholder-as-result shape
`scripts/verify-mission-delivery.sh` was written to avoid.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 12:26:23 -07:00
Omar SobhandClaude Opus 5 42e014976d docs: confirm the door from inside a mission, and correct a count I took from a transcript
Run 8, all three agents, per-agent attributed:

  Pedro / Ebele / Ahmad — ListMcpResourcesTool, ReadMcpResourceTool

The agent's own report: 53 resources from `clawmates_skills`, and
`skill:global/workspace-repo-commit-protocol` read back as
`# Mission repo + commit protocol`. So the wiring works end to end, not
just the mechanism.

And a correction to the commit before this one. It recorded "58 MCP
resources" as a measurement. That number was the model's paraphrase in a
probe transcript, not an observation. `resources/list` returns 53 and
`select count(*) from skills` is 53.

Noted in the doc rather than quietly changed, because it is the same
error this project keeps making — a model's self-report treated as
evidence — and I made it in the very document arguing for measuring
things.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 12:09:45 -07:00
Omar SobhandClaude Opus 5 02d5f5a8c9 docs: the door is deployed, and what it does not buy
Proven against the real binary in the runtime container — connect, list
(58 resources) and read (`# Mission repo + commit protocol`, the correct
first heading). That probe is a two-minute loop; I reached for the
ten-minute rebuild-and-run-a-mission one first, and it would have found
the container-name bug sooner.

No `--allowedTools` change was needed. Recorded because the guess would
have been wrong in an expensive way: with no config read on the daemon,
"adding" the MCP tools meant overwriting the seed's `tools` list and
stripping Write and Bash from every mission agent — to solve a problem
that does not exist.

The §3 claim that this was "config, not code" is corrected in place: it
needed a credential narrow enough to leave in a container an untrusted
agent reads, and the measured proof that the credential IS narrow (same
token: 58 skills from /mcp/skills, 401 from /api/missions).

And what it does not buy, stated plainly: Trigger is still unmeasured,
because delivery still inlines. The door makes retrieval possible; making
Trigger real means switching to progressive disclosure, which could
regress Compliance and so wants an A/B rather than a flip.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 11:59:34 -07:00
Omar SobhandClaude Opus 5 3aeee070b8 fix(missions): the door read a field that is not set yet
`install_skills_door` took the container name from
`mission.runtime_container_name`, and `on_launch` loads the mission at the
top — before `ensure_container` runs and binds that field. So it was
always `None`, and the early return had no log, so the door simply never
installed and said nothing about it. Verified against a live mission: no
log line, no file in the container.

That is the same shape as the three hook bugs before it, which is a poor
excuse for repeating it. The name is derived from the mission id
(`container_name`) instead, guarded on `mission_gateway` being Some —
which is exactly the signal that `ensure_container` ran and that this
mission has its own container rather than the shared runtime.

Every remaining early return now logs.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 11:51:33 -07:00
Omar SobhandClaude Opus 5 73f5d71c55 feat(missions): install the skills door, with a credential it is safe to leave
The capability has been built and undeployed since `88eef99d4`:
`claude_cli` accepts `mcp_config` and passes `--mcp-config
--strict-mcp-config`, so Claude Code's own MCP client can reach our
skills server. What was missing was the config document and, underneath
it, a credential that could be left in a container an untrusted agent
reads.

Now both halves happen together — the document goes in, and the daemon is
told to pass it — because doing one without the other leaves a door
installed and unreachable, which looks exactly like a door nobody walked
through. That is the same shape as the hooks that shipped installed and
inert three bugs running.

The API origin defaults to our own `HOSTNAME` rather than a container
name. Mission containers share `clawmates_core` with the server, and the
server's name differs between deployments (`clawmates-server-1` locally,
`clawmates_server_1` on gw-04); docker's embedded DNS resolves a
container id on a user-defined network, so this is self-configuring.
Measured from a sibling container: both the id and the name return 200.

`--allowedTools` is deliberately NOT touched. The provider passes it only
when `tools` is set and the seed already sets it — without it `claude -p`
stops mid-turn asking for write permission. Whether MCP tools also need
naming there is undocumented in anything we control, and the daemon
exposes no config read to merge into the list safely; overwriting it
would take `Write` and `Bash` from every mission agent, and that failure
would look like agents that stopped working rather than a config that was
replaced. So the question gets answered by running a mission with the
door installed. Guessing is how the last three defects in this file got in.

Every failure degrades to "no door", never to a failed launch.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 11:47:13 -07:00
Omar SobhandClaude Opus 5 2668191e30 feat(auth): a credential narrow enough to hand to an agent
`docs/TOOL-CALL-ARCHITECTURE.md` §3 calls deploying the MCP door "config,
not code". It is not, and the reason is authentication.

`/mcp/skills` authenticates with `AuthService::authenticate`, which
returns a full `AuthedUser` carrying the user's role. There is no
narrower credential in the system. So pointing a mission container at the
door means writing a bearer token into a file inside that container — and
mission agents run arbitrary `Bash` with egress and no read gate, which
is this platform's own documented security posture. An owner-scoped token
there turns "the agent runs commands in a sandbox" into "the agent drives
the whole ClawMates API as the owner".

Checked before building this rather than assumed: no such credential is
in a mission container today. The runtime's config.toml has no
`[mcp.servers]` block and no bearer, so the door would have been a NEW
exposure, not an existing one.

So: `auth_sessions.scope`, defaulting to `full`. `authenticate` now
delegates to `authenticate_scoped(token, SCOPE_FULL)`, which means **every
existing caller rejects a narrow token** and a route must opt in by naming
the scope it accepts. `/mcp/skills` is the only opt-in.

Fail closed on purpose. The likely mistake here is adding a scope and
forgetting to wire its check; this way that mistake grants nothing rather
than granting everything.

`mint_scoped` refuses to mint a `full` token — a caller reaching for it
wants a narrow credential, and handing back a full one because an
argument was wrong is exactly the failure the column exists to prevent,
and it would be invisible because the token would work.

The test that matters is not that the door accepts the token, it is that
nothing else does. Negative-controlled: removing the scope comparison
fails `a_scoped_token_is_refused_by_every_unscoped_caller`.

`.sqlx` regenerated — `authenticate` is a compile-checked query and CI
builds with SQLX_OFFLINE=true.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 11:43:25 -07:00
Omar SobhandClaude Opus 5 8591585e60 feat(missions): attribute a phase's tool calls to the agent that made them
`record_vm_tools` wrote `agent_id: None` for every call. The container
tap is per-CONTAINER and every role in a phase shares one, so a phase
arrived as one undifferentiated stream: every Skill-Use score was
per-mission rather than per-role, and the World's per-agent view got
nothing from this tier.

One `claude -p` invocation is one turn is one agent, and Claude Code
stamps each invocation with a `session_id` the tap was discarding. So the
distinct sessions, in order of first appearance, are the phase's turns in
the order they ran — and `prompt.composed` already records the agent of
each turn in that same order, written by the tier as it sends each turn,
so it IS the running order rather than a reconstruction of it.

**It attributes nothing rather than guessing.** Only when the counts
match exactly. A phase whose sessions and turns differ has something this
correlation does not model — a retry, a turn that called no tool, two
genuinely concurrent agents — and a plausible-looking wrong attribution
is worse than none here: it puts one agent's `git push` on another
agent's record, and a person later reasons from that. One call missing a
session id refuses the whole batch, because a hole shifts every later
session onto the wrong turn.

The microVM call sites pass no turn agents and so keep today's
behaviour exactly. Resolving a graph node to an agent uuid is the fix
there, it cannot be tested while the fleet is offline, and guessing would
put one node's actions on another node's record.

Also restores the `#[cfg(test)]` gate on `repo_less_text_tests`, which my
own insertion had taken — those tests would have compiled into release
builds.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 11:33:20 -07:00
Omar SobhandClaude Opus 5 3f26dfeaca docs: suite is 796 tests across 107 binaries after this pass
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 10:39:16 -07:00
Omar SobhandClaude Opus 5 19c4de36e4 docs: the staffing fix, measured
Run 5 is run 3's task against the new staffing: 5 roles → 3, 14 skill
deliveries → 4, 50KB of prompt → 24KB, and 1 of 9 delivered skills
applicable → 4 of 4. The agents produced exactly the structure the new
team's task specifies — questions.md, evidence.md, REPORT.md — with zero
writes outside /mission/repo.

The baseline says plainly that the SCORES barely moved, because they did:
run 5 is one `pass` and three `not_applicable`. What changed is what
`not_applicable` means — "no machine-checkable consequence" rather than
"this skill had nothing to do with this phase". Halving the prompt is real
but incidental. The finding is that the denominator was wrong: seven of
run 3's nine skills were never applicable, so any ratio over them measured
staffing, not skill use.

Handoff item 1 is closed and the orphan-container section now records what
was actually in it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 10:35:12 -07:00
Omar SobhandClaude Opus 5 6af1149e45 feat(missions): reap orphaned runtime containers — unless they hold work
`sweep_once` selects `FROM missions`, and `teardown_container` is only
ever called with an id from that query. So a container whose row is gone
is invisible to every reaper: nothing enumerates docker, nothing errors,
and the only symptom is disk.

Found on gw-04 today — `cm-runtime-mission-019ff5b1…`, Up nine days,
2.5G, against a `missions` table with zero rows.

`list_mission_containers` is the piece that never existed: without it
"which containers exist" is a question the platform cannot ask, and a
container the database has forgotten is not merely unreaped, it is
unseeable.

**The sweep refuses to reap work that exists nowhere else.** That
container's checkout held ten commits on a branch that had never been
pushed — +3451/-30 across 30 files, eighteen INT items including
AES-256-GCM, Ed25519 signing and HNSW batch insert. A reaper that deleted
on sight would have destroyed all of it silently, as its designed
behaviour. `unpushed_commits` asks the checkout (`git rev-list --all
--not --remotes`) and leaves the container alone, loudly, every tick,
when the answer is not zero.

Every failure path returns `SomeOrUnknown`: a container we cannot
question is not a container we may delete. Same for one docker will not
date — including a future `Created` from clock skew, which would
otherwise underflow into an age past any grace period.

Grace is 24h, long on purpose. The row-driven sweep already handles
everything the platform knows about, so anything reaching this path is
already unexpected.

The container above was handled by hand first: bundled, verified,
branch pushed to git.redclaw.dev, confirmed on the remote at the branch
tip, then removed. 59G free, up from 57G.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 10:30:17 -07:00
Omar SobhandClaude Opus 5 ceec0423ad feat(teams): staff research phases with a research team
`research_only` is repo-less, one research phase, "produce a markdown
artifact" — and it defaulted to `rust_sdlc`. So it was staffed with a
planner, a coder, a tester, a reviewer and a committer, four of whom had
nothing to do, each carrying the code-and-commit skills its role is bound
to. Measured 2026-08-21: 9 distinct skills across 5 role prompts, ~50KB,
one applicable. That is what "most skills score not_applicable" in the
Skill-Use baseline has been measuring all along — the skills were
correctly bound to their roles; the roles were wrong for the workflow.

None of the three existing research templates fit, so this adds
`topic_research`: frame the brief into answerable questions, gather
evidence with the URL and the quoted passage, check every claim against
its source, write the report. Three roles, four skills, each checked
against its own `when_to_use` before binding — and two obvious candidates
deliberately NOT bound, because `executive-summary-writing` tells the
writer to discard any item not tied to a named project and
`signal-to-noise-ranking` scores relevance the same way. On a standalone
topic report that discards the deliverable.

`default_phase_teams` lets a recipe staff each phase PURPOSE separately,
resolved into `config.phase_teams` at create. A multi-phase recipe does
not have one job: `research_and_code`'s research phase spends a paragraph
of `task` telling its team not to change source files, because
`rust_sdlc` gave that phase a coder and a committer and they did what
coders do — mission 01a00c57 shipped both INT items during RESEARCH and
the coding phase then delivered +0/-0. Prose was the only lever
available; staffing is the actual one.

Also fixed in the three existing research templates, all verified rather
than inferred:

  - `papers_research` bound `arxiv-daily` to its DOMAIN SCOUT. That
    skill's entire content is "Do not search arXiv yourself — the harvest
    already ran", and its `when_to_use` names Continuous Research
    missions, which are the only ones the platform writes a harvest
    manifest for. The role whose job is searching was bound a skill
    forbidding it.
  - Its PAPER READER was told to "fetch the PDF, extract text". The
    runtime image has no pdftotext, no mutool and no pypdf — checked in
    the container. Every paper would have hit the `[read: abstract only]`
    fallback, which reads identically to the fallback working as designed.
  - `insight_research` cross-referenced "our repos'" history. A mission
    binds ONE repo (`missions.repo_id`).
  - `codebase_research` wrote to "the Obsidian vault"; no vault is
    mounted, and both it and `papers_research` were committing in "PRs",
    which the platform does not open.

And `research_only` itself had neither `task` nor `done_when` — the same
defect `benchmark`, `security_hardening` and `research_and_code` were each
fixed for, and it was left out. A phase with no `done_when` is never
judged. It also still asked for `pdf`, a format nothing generates.

Two new guards, both negative-controlled: every team a recipe names must
exist (a typo currently only logs, and the mission is staffed by the
fallback crew looking deliberate), and every `default_phase_teams` key
must be a purpose `purposes_for` actually emits.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 09:35:33 -07:00
Omar SobhandClaude Opus 5 4f4ce34203 fix(teams): the wrong repo path was in the TEAM templates too
The `/workspace/repo` guard was written on 2026-08-19 against `skills/`
only. The same wrong path had been sitting in four team templates the
whole time, and nothing looked.

`rust_sdlc` is the default team for five of the six workflow recipes. Its
CODER was told "your working directory is /workspace/repo. All edits
happen there." Its COMMITTER was told to `cd /workspace/repo`. The
platform mounts /mission/repo — `stamp_workspace_paths` pins it there.
Same for the frontend, three.js and mobile coders.

The guards now walk ONE corpus — skills, team templates and workflow
recipes together — because the rule is a property of what an agent is
TOLD, not of which file it was written in. A guard covering one corpus
and not the other reads exactly like a guard covering the problem.
Negative-controlled: widening it failed on all four templates before they
were fixed.

Two more defects in the same committer prompt, both found by reading it:

  - `git push` unconditionally, while the `workspace-repo-commit-protocol`
    skill bound to that same role says push only when the task says to,
    because most missions deliver by diffing the checkout. The role prompt
    and its own skill contradicted each other in one prompt.
  - `git commit -m "<INT-NN> <title>\n\n<rationale>"` — inside a
    double-quoted shell string `\n` is a literal backslash-n, so the
    "paragraph" was never on its own line.

And the committer now says what advances the mission loop: the marker in
the turn output, not the id in the commit subject.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 09:28:41 -07:00
Omar SobhandClaude Opus 5 6f2b0a8f43 docs: record the verified suite numbers in the handoff
107 test binaries, 792 tests, zero failures across the workspace — run,
not estimated from the cm-api figure.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 08:52:23 -07:00
Omar SobhandClaude Opus 5 9560aaec41 test(skill-use): the coding run, and the parsing bug it found
Run 4 (`research_and_code`, real repo) is the first mission that could
have violated the TDD and commit checks. It exercised both, and found a
bug in one.

Claude Code writes a multi-line commit message as a heredoc inside a
command substitution:

    git commit -m "$(cat <<'EOF'
    INT-01 Add slugify function to src/lib.rs
    …
    EOF
    )"

`commit_subjects` read the first line of the `-m` value, which is the
heredoc OPENER. Every commit check was scoring `$(cat <<'EOF'` — a string
the agent never wrote. It reported no violation only because that string
is not one of the never-merge messages, which is luck rather than a check.
Regression test built from the exact command in `mission_events`.

The TDD verdict came back `not_observable`, which is the honest answer and
also a real limit worth stating: the agents edited `src/lib.rs` once —
implementation and `#[cfg(test)] mod tests` in the same write — then ran
`cargo test` five times. In Rust the unit test lives in the file under
test, so that ordering is exactly what following the skill precisely looks
like from outside. The check detects "wrote source, never ran a test" and
cannot confirm red-first. Confirming it needs the diff, not the tool order.

Every one of run 4's 33 tool calls stayed inside /mission/repo.

Handoff and baseline updated: production has never run a mission (both
tables empty), a mission container has leaked since 2026-08-12 that no
reaper can see, and `research_only` staffs a five-role Rust SDLC crew on a
repo-less markdown mission — which is what "most skills score
not_applicable" has been measuring all along.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 08:43:37 -07:00
Omar SobhandClaude Opus 5 c209e654d9 fix(skill-use): a research phase writing markdown is not a TDD failure
The first live scoring of run 3 reported `cargo-test-driven-development`
and `tdd-red-green-refactor` as compliance=FAIL: files were written and no
test ever ran.

Wrong, and wrong in the way this module exists to prevent. The phase wrote
fifteen markdown notes and a helper script; there was no code to
test-drive. Reporting it as an agent failure is a system defect wearing an
agent's name — and it would have buried the actual finding, which is that
a repo-less `research_only` mission is staffed with a Rust SDLC crew whose
coder, tester, reviewer and committer have nothing to do.

The check is now scoped to files with a source extension in the languages
the skill itself names. Shell is deliberately excluded: a helper script
written during a research turn is not behaviour-adding code, and the false
failure costs more than the missed one.

Recorded in SKILL-USE-BASELINE.md as finding 8 rather than quietly
corrected. A measurement that hides its own false positives cannot be
trusted about anyone else's.

Also in the doc: the Trigger reason is half false now (the transport can
surface a tool call; we simply still inline), and the architecture doc's
observe/gate table said the container tier was ungated and unobserved,
which shipped work has made wrong.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 08:37:15 -07:00
Omar SobhandClaude Opus 5 4a6d0dfe01 test(skill-use): keep the harness that runs the measurement
The first baseline was produced by a throwaway script that no longer
exists, so the second measurement could not be run the same way as the
first — which is most of what makes two numbers comparable.

Local stack only, because production auth is Clerk and a mission cannot be
launched from a terminal there. `--score <id>` re-scores a finished run
without spending another one, and every run is held for 90 days so it
stays re-scorable when the scorer changes again.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 08:30:15 -07:00
Omar SobhandClaude Opus 5 d0b657a24b fix(skills): two more skills that contradicted the platform
Same class as the `/workspace/repo` path and the ZeroClaw tool names: the
skills were written alongside the platform and never compared to it again.
Both found by reading the source of truth before writing a check against
it.

1. `decompose-int-items` showed `PLAN_COMPLETE: INT-01..05`. An id is
   strictly `INT-<digits>`, so the range form is rejected outright — the
   plan pass records nothing while every item stays open. A live planner
   emitted exactly that line. Now one id per line.

2. `workspace-repo-commit-protocol` said the task-card parser advances
   mission state on the INT id in the commit subject. Nothing in the
   platform reads commit messages; the parser reads `run_events` — the
   agent's turn output. An agent that believed it could commit with the id
   and never emit `COMPLETED: INT-NN`, leaving the mission open on an item
   it had finished. The convention is kept, the mechanism corrected.

`no_skill_shows_a_marker_the_parser_would_reject` runs the real parser
over every marker in every skill's fenced blocks, negative-controlled
against the range form.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 08:24:01 -07:00
Omar SobhandClaude Opus 5 1a6fdfc0e6 feat(skill-use): score Compliance and Boundary from actions, not narrative
The scorer read the concatenated `reasoning` text — the agent's own
account of its turn, written by the thing being measured and silent about
anything it did not think worth mentioning. `Evidence` now carries the
recorded tool calls alongside that text and every check prefers them.

What that changes, concretely:

- `workspace-repo-commit-protocol` Boundary was a substring search for
  `/workspace/repo` in the narrative. An agent that wrote to the wrong
  root without narrating it scored a clean pass. It now reads the `Write`
  and `Edit` paths, and gained the skill's other hard prohibition —
  force-push — which leaves no trace anywhere else once it succeeds.
- `arxiv-daily` Boundary reads the `curl` that ran rather than a URL in
  prose, which may be the agent explaining that it did NOT fetch it.
- `tdd-red-green-refactor` and `cargo-test-driven-development` gain their
  first Compliance check: files written with no test command anywhere
  cannot have been red-green under any reading of the loop.
- `small-focused-commits` gains a Boundary check on the exact subjects the
  skill names as never-merge, read out of `git commit -m`.

Two verdicts changed for honesty rather than coverage. Silence used to
score `Pass`: a mission with no evidence scored identically to one checked
and found clean. It is now `NotObservable`. And a test that ran AFTER the
first write is `NotObservable`, not a failure — a Rust unit test lives in
the file under test, so that ordering is what following the skill most
precisely looks like from here.

Every tool-backed check is one-sided: it reports a violation it can see
and never infers compliance from silence, because the recorded stream is
capped per phase.

The negative controls earned their keep — they caught `-f` inside a commit
message scoring as a force-push, and `git commit -am` yielding no subject
at all.

Trigger stays `NotObservable`, and half its stated reason is now wrong.
"`claude_cli` cannot surface a tool call" is false; we simply still
inline. The blocker moved from the transport to the delivery model, and
the module says so.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 08:24:01 -07:00
Omar SobhandClaude Opus 5 8cb38d1320 feat(missions): keep the tool's arguments, not just its name
The container tier's first measured mission recorded `Bash × 6` and not
one of them said what it ran. Every behavioural question about the phase
— did it run the tests, did it commit, did it call an API a skill forbids
— was unanswerable from a record that looked complete.

`vm_tool_tap::parse` already read `tool_input` to pull the path out of it,
then dropped the rest on the floor. It now keeps it, bounded: file bodies
(`content`, `new_string`, `old_string`, `edits`) become a byte count, and
any other over-long string is truncated with a marker saying so. Bounded
rather than whitelisted, because a whitelist silently loses the one
argument that matters the first time a tool grows a field.

`file.touch` keeps the absolute path in `detail.abs` alongside the
repo-relative `target`. Normalising is what the map needs and exactly what
destroys "did this write land outside the checkout".

`tool.call` also gains `detail.path`, which the World's SSE has been
reading and getting a null from on every container-tier call.

`mission_events::tool_evidence_for_mission` is the reader — the
counterpart to `narrative_for_mission`, and the reason it exists: the
narrative is what an agent SAID it did.

Host-side only. No image rebuild: the arguments were always in the tap
file, the first parse threw them away.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 08:23:42 -07:00
Omar SobhandClaude Opus 5 0b4d91889a docs: hand-off refresh — container-tier work shipped, stale guidance corrected
deploy / test (push) Successful in 4m46s
deploy / build (push) Successful in 1m1s
TOOL-CALL-ARCHITECTURE.md said "switch claude_cli to stream-json" as the
cheapest fix. That was wrong and is now marked so, with what actually
happened: zero tool.call events with the parser working perfectly, because
TurnEvent::ToolCall only fires for tools ZeroClaw itself executes. Hooks
sidestep that entirely, and the doc now leads with the resolution rather
than the theory. A fresh session is pointed at this file, so leaving the
wrong recommendation on top would have sent it down the same path.

NEXT-SESSION.md: state header, and the ordered list rewritten — items 1-3
are done or superseded. "Give the direct-session tier a tap" is dropped with
its reason: that tier is dormant (CLAWMATES_MISSION_EXECUTOR unset), and
checking before building saved the work. New top item is watching the first
production mission, since the gate and tap are proven locally and unproven
in prod.

Added an operational section for the things that cost the most time: the
403 actions-log API, gw-04's legacy docker-compose, the socket proxy, disk
contention between manual builds and CI, and Clerk-only prod auth.

Also flagged that SKILL-USE-BASELINE.md's Trigger column is now stale in a
good way — tool calls are observable on the container tier, so Trigger can
be scored from behaviour instead of prose. That is the highest-value
follow-up.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 07:22:01 -07:00
Omar SobhandClaude Opus 5 5a11fae0d6 docs: container-tier gate and telemetry shipped; CI failures were disk
deploy / test (push) Successful in 4m39s
deploy / build (push) Successful in 1m0s
Records the verified result (10 tool.call, 4 file.touch on a real mission),
how hooks succeed where stream-json could not, the production state and its
rollback, and the three same-shaped bugs the live test found.

Also records that CI's build failures were disk pressure from my own manual
runtime builds on gw-04 — not code — and that a docs-only commit was the
first casualty, which made it look like a regression.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 07:11:02 -07:00
Omar SobhandClaude Opus 5 f6e6037aa0 ci: make the build job readable too, and reclaim the disk that broke it
deploy / test (push) Successful in 4m45s
deploy / build (push) Successful in 2m6s
Four runs failed at `build` with nothing readable — the actions-log API
returns 403 for our token, so "failure" was the whole message. The first
casualty was a DOCS-ONLY commit, which made it look like a code regression
and cost a cycle chasing one.

It was disk. I had been building runtime images on gw-04 while CI ran on the
same host; the frontend image build lost the race. Reproduced afterwards
with space free and it builds clean, and `docker builder prune` reclaimed
34GB (22G free → 57G).

The build job now writes its breadcrumb and a `df -h` snapshot to
/tmp/ci-logs on the runner host, and records which services actually got
pushed. That last one matters: the failing runs had built and pushed
`server` and then aborted on `frontend`, so the registry held a partial set
and `:latest` never moved — which presented as "the deploy did not happen"
three steps later, nowhere near the cause.

Operational note for the next person, me included: building images by hand
on gw-04 competes with CI for disk on the same 150G volume.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 07:00:43 -07:00
Omar SobhandClaude Opus 5 e84413d437 fix(missions): the container tier now records its tool calls — verified live
deploy / test (push) Successful in 4m53s
deploy / build (push) Failing after 5m30s
Ran it end to end on a real mission. First time the container tier has ever
been observable:

  tool.call   10    Bash 6, Read 3, Write 1
  file.touch   4    research/tapproof.md
  reasoning    5
  prompt.composed 5

Three defects found by running it, each of which left every other link
looking correct:

1. The settings document pointed PostToolUse at {TAP_DIR}/tap.sh while the
   installer wrote {HOOK_DIR}/tap.sh. Claude Code does not complain about a
   hook command that does not exist — it records nothing. Asserting the
   script "mentions tap.sh" had passed; the PATHS have to be compared, and
   a test now does that for every hook the document names.

2. The mission container runs CLAWMATES_RUNTIME_IMAGE, not the shared
   runtime container I had swapped. It was still on an image whose daemon
   schema has no `settings` field, so set_claude_cli_settings returned
   404 path_not_found — which the error message said plainly, and which is
   the only reason this was quick to spot.

3. The sweep used connect_with_local_defaults(). The server reaches Docker
   through a socket proxy (DOCKER_HOST), so that connector fails there — and
   my code returned Ok(()) on the error, silently. The tap filled up, the
   query matched rows, and nothing ran. Now uses container_exec::connect and
   logs the failure; a test pins the choice.

All three are the same shape as the bug they were chasing: installed,
inert, indistinguishable from working. The tests added for each compare the
two ends rather than asserting a string appears somewhere.

Full workspace suite green: 107 binaries, 412 lib tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 06:29:59 -07:00
Omar SobhandClaude Opus 5 cd59e4798d feat(missions): collect the container tier's tool calls
deploy / test (push) Successful in 5m5s
deploy / build (push) Failing after 5m20s
The hooks from the previous commit write a tap file that nothing reads —
which is the same shape as the gate that is installed and inert: everything
looks wired and no evidence ever appears.

The microVM tier records its tools from inside the loop watching the VM. A
container turn is driven asynchronously by topology_worker, so there is no
such loop and something has to come and collect the file.

`drain_finished_container_phases` does, on the same tick as the benchmark
baseline and the security scan, reusing `record_vm_tools` so container tool
calls land as the same TOOL_CALL / FILE_TOUCH events the World already
renders. One shape, two tiers.

Idempotent by TRUNCATION, not a marker or a cursor column: `drain` clears
the file it read, so a second pass finds nothing. Read-then-clear happens in
one exec, and only for phases that have FINISHED — the agent is no longer
appending, so the gap between read and clear cannot lose an event. A cursor
would have needed a migration and a column that means nothing to anyone
else.

Two tests exist because the failure is silent either way: the drain must
clear what it read (otherwise every tick re-records the same calls and a
phase's early files end up weighted by how long the sweep ran), and the tick
must actually call the sweep (otherwise the hooks write a file nobody
collects).

Full workspace suite green: 107 binaries.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 05:53:37 -07:00
Omar SobhandClaude Opus 5 b89606fcf1 feat(missions): gate and observe tools on the container tier
deploy / test (push) Successful in 5m6s
deploy / build (push) Failing after 5m31s
The container tier is the one that actually runs missions in production, and
it had neither a tool gate nor tool telemetry. The microVM tier has had both
since yesterday; the tier that matters had neither.

Both gaps have one cause. `claude_cli` runs claude as a subprocess, claude
runs its tools inside that subprocess, and those calls never pass through
ZeroClaw's executor — the only thing that emits TurnEvent::ToolCall and
therefore the only thing the gateway turns into a frame ClawMates can see.
Recovering the calls from the CLI's stream-json output did not help: a real
mission produced zero tool.call events with the parser working perfectly.
The transport was never the problem.

Hooks are the way in, and they are proven. Claude Code reads
hooks.PreToolUse / PostToolUse from the document given to `--settings` and
honours them under `-p` — measured yesterday against the real binary, where
the gate blocked a Bash call, recorded the payload, and got its refusal
reason back to the model.

So the same hook scripts the microVM tier uses are now written into the
mission's container, and the provider is pointed at the settings document
(`--settings` added to claude_cli in the fork, be9c34b1c).

Composed in ONE script for one document: two writers of one settings.json is
a silent clobber, and the microVM tier already learned that expensively.

Installed on BOTH container paths — created and reused. A hook that exists
only on first creation quietly disappears after a server redeploy, and the
container outlives the server process.

Everything degrades to "no hooks", never to a failed mission: a phase that
runs unobserved still delivers; one that fails to start because telemetry
could not be installed delivers nothing.

Four tests, including two that exist because the halves are inert alone: the
installer and the provider prop must both be wired (hooks nobody reads, or a
document nobody wrote), and nothing may be written under /mission/repo,
where it would arrive as part of the agent's delivered diff.

Full workspace suite green: 107 binaries.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 05:33:25 -07:00
Omar SobhandClaude Opus 5 930c7e0b67 docs: the PreToolUse gate is verified end to end
deploy / test (push) Successful in 4m55s
deploy / build (push) Failing after 1m28s
Ran it against the real claude binary with the real settings document and
the real hook script. Both halves.

It blocks: asked to `curl -X POST`, the agent attempted the Bash call, the
hook fired FROM --settings, the call was refused, and denied.jsonl recorded
the payload with hook_event_name PreToolUse and the exact command. The agent
relayed the reason accurately — the text from vm_tool_gate::RULES reached
the model, which is the point of writing reasons rather than bare refusals.

It allows: `echo` and a harmless `rm -rf ./scratch-nonexistent` both ran and
denied.jsonl stayed empty. A gate that blocked everything would have passed
the first test; this is the half that rules that out — and two of this
gate's four bugs produced exactly that failure.

So the last unproven link in the chain is closed, and the gate is real in
production rather than plausibly real.

One finding worth keeping: asked to `git push --force`, the model refused on
its OWN before ever calling Bash, so the hook never fired and the test was
inconclusive. A gate test must use a command the model will actually attempt.
The model's judgement is not the gate, and testing against something it
already refuses measures nothing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 00:19:37 -07:00
Omar SobhandClaude Opus 5 0be932fd83 test(gate): a fixture emitter for the live PreToolUse check, and what it proved
deploy / test (push) Successful in 4m41s
deploy / build (push) Successful in 5m46s
Tried to close the last open question — does the PreToolUse gate actually
fire in a guest — and got most of the way.

Established:
  - the generated script blocks and allows correctly under DASH, not just
    macOS sh: force-push and `cd /tmp && rm -rf /` return 2, while
    `grep -rn 'rm -rf /' docs/` and ordinary work return 0
  - without node it allows and writes the `inert` marker, so a gate that
    cannot parse is distinguishable from one that matched nothing
  - `claude` in the runtime image supports `--settings` (SETTINGS-OK)
  - PreToolUse DOES fire under `claude -p` in this image — measured by an
    earlier session and recorded in vm_stop_gate.rs:36

Unproven, and now precisely scoped: whether Claude Code honours a
PreToolUse hook supplied via `--settings <path>` specifically, with a real
agent turn. The live attempt hit the weekly subscription rate limit, and
`claude doctor` does not report hooks, so there is no non-LLM confirmation
available.

`emit_guest_assets` (ignored by default) writes the real hook script and the
real settings document to /tmp so the check can be run against the actual
binary in one docker command — no microVM, no fleet. The exact command is in
docs/NEXT-SESSION.md.

Worth stating plainly: if that link is broken, the gate is inert in
production and looks exactly like a gate that found nothing — which is the
failure mode this whole session has been about.

Full workspace suite green: 107 binaries.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 14:45:48 -07:00
Omar SobhandClaude Opus 5 afb1e29bf3 docs: streamjson2 built but deliberately not deployed
deploy / test (push) Successful in 4m31s
deploy / build (push) Successful in 57s
The corrected runtime image exists on gw-04 and stays there. It delivers no
observability until TurnEvent::ToolCall can be emitted for observed calls, so
deploying it alone would be a provider output-format change carrying risk for
no benefit. Production stays on the known-good :v084.

The harmful v1 image was deleted from both hosts so it cannot be redeployed
by accident.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 09:55:33 -07:00
Omar SobhandClaude Opus 5 536adddd0f docs: stream-json tested live — it does not deliver observability, and v1 was harmful
deploy / test (push) Successful in 4m52s
deploy / build (push) Successful in 59s
Deployed the amd64 build to gw-04 and drove a real mission. The agent used
Bash and the standard tools; no tool.call events appeared, and the gateway's
unmatched-frame histogram still showed only session_start.

The reason is structural: TurnEvent::ToolCall is emitted from
tool_execution.rs, only for tools ZeroClaw itself runs. Claude Code runs its
tools in its own subprocess, so the event never fires. A provider that knows
about the calls changes nothing by itself.

The first version was also harmful — it returned the observed calls as
tool_calls, so the loop tried to execute Claude Code's tool names and fed
"Unknown tool: Bash" back to the model. Fixed in the fork; both runtimes
rolled back to the known-good image in the meantime.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 09:47:05 -07:00
Omar SobhandClaude Opus 5 ac4fa0b8f7 docs: CI green and deployed — record the verified production state
deploy / test (push) Successful in 4m37s
deploy / build (push) Successful in 56s
Run 498 passed and deployed. Confirmed on gw-04: 53 skills, 11 templates,
zero unresolved bindings, self-authoring announced ENABLED, the new
gateway_preflight answering, and migration 0080 applied.

Also records that run 497 was cancelled by the concurrency guard rather
than failing, and that the stream-json runtime image is still NOT shipped by
this pipeline.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 08:06:24 -07:00
Omar SobhandClaude Opus 5 689a5e14a3 docs: CI root cause was an apostrophe, not any of the three theories
deploy / test (push) Successful in 4m31s
deploy / build (push) Successful in 6m4s
Records both real causes (run 490 stomped by an overlapping run; 491-496
killed by an apostrophe closing a single-quoted sh -c block), the guard that
now catches the second class locally, and what to check when the in-flight
run settles — including that a successful build is the FIRST time these
commits reach production.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:45:02 -07:00
Omar SobhandClaude Opus 5 8f988739ec fix(ci): an apostrophe in a comment killed six runs
deploy / build (push) Canceled after 0s
deploy / test (push) Canceled after 42s
Runs 491 through 496 failed on one character.

The Rust step is a `docker run … sh -c '…'`. A comment inside that
single-quoted block read `cm-api's vm_tool_gate`, and the apostrophe closed
the quote. Bash died with "unexpected EOF while looking for matching quote"
BEFORE running anything — which is why no log ever appeared, why the
breadcrumb showed the step entered and produced nothing, and why three
separate theories were floated to explain an empty failure.

I introduced it in the commit that installed nodejs, so the fix for run 490
broke every run after it.

Run 490 itself was the stomping: it overlapped run 491, which began by
removing the shared `cm-ci-pg` container out from under it. That is fixed
too, and was a real defect — it was simply not the cause of 491+.

`bash -n` answers this in milliseconds and nothing was running it: a
workflow is not compiled, not linted, and its only feedback is a red build
with a log this deployment cannot read. `tests/workflow_shell_syntax.rs`
now extracts every `run:` block and syntax-checks it, so the failure shows
up before the push rather than six runs later. Gitea's `${{ … }}` is
replaced with a placeholder first — the point is to check OUR quoting, not
to evaluate their templating. Negative control: restoring the apostrophe
fails the test with the file and line.

The block also carries a standing NO APOSTROPHES warning, because the next
person to write a comment there will not be thinking about quoting.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:44:18 -07:00
Omar SobhandClaude Opus 5 d23f30e929 docs: record the CI investigation honestly, including what is still unknown
deploy / test (push) Failing after 7s
deploy / build (push) Skipped
Establishes what is verified (the code passes on the runner host, with
cargo's real exit code), what is narrowed (493/494 die inside the Rust step
before cargo starts; 495 died before step 1), the three theories that were
wrong, and the cheapest next experiment.

Also records the two things that made this expensive: the actions-log API
returns 403 for our token, and my first reproduction piped cargo into `tail`
and reported tail's exit code.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:33:10 -07:00
Omar SobhandClaude Opus 5 c02dbe2266 ci: capture the Rust step's own output, not just cargo's
deploy / test (push) Failing after 8s
deploy / build (push) Skipped
The breadcrumb narrowed run 494 to the Rust step, and rust.log did not
exist — so cargo never started. Whatever failed (apt-get, git config, or
docker itself) wrote to the job log, which the actions-log API will not
give us.

The docker run's stdout and stderr now land on the host too, and the step
exits with docker's status.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:20:38 -07:00
Omar SobhandClaude Opus 5 24393819bd ci: breadcrumb which step dies
deploy / test (push) Failing after 7s
deploy / build (push) Skipped
The host-log change proved the job never reaches `cargo test` — rust.log is
absent while /tmp/ci-logs exists. But TWO steps create that directory, so
"the directory exists" does not say how far the job got, and that ambiguity
cost a debugging cycle on its own.

Each step now overwrites /tmp/ci-logs/STEP on entry, so the last value names
the step that died. The postgres step also runs under `set -x`.

Verified manually on gw-04 in the meantime: the postgres step's exact
commands succeed there (STEP_RC=0), as does the whole Rust container
command with cargo's real exit code, as do the three frontend commands. So
the failure is something the job does that running its steps by hand does
not reproduce — which is precisely what a breadcrumb answers and guessing
does not.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:19:29 -07:00
Omar SobhandClaude Opus 5 a864f2ccc7 ci: make a failed run readable, and stop laundering cargo's exit code
deploy / test (push) Failing after 8s
deploy / build (push) Skipped
Three runs failed and I debugged all three blind: Gitea's actions-log API
returns 403 for the token we have, so the only evidence was the word
"failure". I twice inferred a cause from that and was twice wrong — first
node, then dash — and a third theory (two runs stomping each other) was
right about a real defect but not about these failures.

Worse, my own reproduction lied. It ran `cargo test ... | tail -80`, so the
reported exit code was TAIL's. A green pipeline over a red suite is exactly
the trap this repo already documents, and I walked into it while hunting a
red build.

  - every step writes its full output to /tmp/ci-logs on the RUNNER HOST,
    which outlives the container, so a failure can be read afterwards
  - the Rust step captures cargo's status in a variable and exits with it,
    with the grep and tail in between — no pipe anywhere near the status
  - the frontend step runs npm ci / typecheck / vitest separately, keeps
    each status, prints all three tails, and fails if any is non-zero.
    Previously a `set -e` abort meant later steps produced no output at all

What is now known, verified on the runner host itself with cargo's real
exit code: `cargo test --workspace` PASSES on gw-04 in the CI container
against a CI-shaped Postgres (CARGO_RC=0), and `npm ci`, `typecheck` and
`vitest` all pass there too. So the failing step is not one of those, and
the next run will say which it is instead of leaving it to be guessed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:17:39 -07:00
Omar SobhandClaude Opus 5 72ba4ba523 fix(ci): two runs stomped each other, and the logs blamed the tests
deploy / test (push) Failing after 8s
deploy / build (push) Skipped
Runs 490 and 491 both failed `test`. Neither failure was in the code.

Runs 490 and 491 started 16 minutes apart and a full suite takes longer
than that, so they overlapped. The first thing a run does is
`docker rm -fv cm-ci-pg` — a name every run shared — so the newer run
deleted the older run's database mid-suite. Both failed, and the failures
read as test failures.

Verified before changing anything: the exact CI command, on gw-04, against
the same warm cargo volumes and a Postgres started exactly as CI starts it,
passes on 128b423 — as do `npm ci`, `typecheck` and `vitest` on that host.
The code was never the problem.

  - `concurrency: deploy-${{ gitea.ref }}` with cancel-in-progress, so runs
    on a ref serialize. A superseded run tests a commit that is no longer
    the tip; finishing it costs 20 minutes to learn something that no longer
    matters.
  - the test Postgres is named per run, so overlap cannot corrupt a run even
    if the concurrency guard is later removed. Impossible rather than
    unlikely.
  - `--shm-size=1g` on it. Docker defaults /dev/shm to 64MB and cm-testkit
    creates a database per test; Postgres exhausts its parallel-query
    segments mid-run and reports `could not resize shared memory segment`
    DURING MIGRATIONS, which reads like a schema fault. Hit locally on
    2026-08-19; scripts/test-server.sh already carries the same flag.

The lesson is the session's own: I twice inferred a cause from a red build
without reading the failure — first node, then dash — and both were wrong.
The answer came from running the job on the runner's own host.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:00:10 -07:00
Omar SobhandClaude Opus 5 128b423205 fix(ci): the tool gate needs node, and an inert gate must say so
deploy / test (push) Failing after 8s
deploy / build (push) Skipped
The first push of the PreToolUse gate failed CI, and the reason is a
property of the gate worth fixing rather than a CI quirk.

The hook parses its JSON payload with `node` — no jq in the runtime image,
and node is guaranteed there because Claude Code is a node program. CI runs
`cargo test --workspace` inside `rust:1.96-slim`, which has no node. The
extraction returned nothing, the gate allowed everything, and the two
"blocks" tests failed.

That is correct behaviour with a dangerous appearance. A gate that cannot
read its input must not block the phase — failing closed on a parse error
denies every tool call, which is what an earlier `case`-syntax bug did. But
allowing silently makes an INERT gate indistinguishable from one that simply
matched nothing, which is this codebase's recurring defect exactly.

So the gate now records `inert` when node is absent, still allowing, and a
test pins both halves: exit 0, and the marker written. The host can check
for that file rather than infer a working gate from an absence of denials.

CI installs nodejs so the shell tests exercise the gate instead of its inert
path. Verified in a rust:1.96-slim container: without node the force-push
payload returns 0, with node it returns 2.

Also confirmed the generated script behaves under dash — Linux /bin/sh —
not only under macOS sh. An earlier apparent dash failure was invalid JSON
in the probe command, not the gate.

Full workspace suite green: 106 binaries.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 06:04:56 -07:00
Omar SobhandClaude Opus 5 b653dbfe72 docs: hand-off note for the next session
deploy / test (push) Failing after 1m43s
deploy / build (push) Skipped
Records the state of the tree, the one step not taken (the stream-json
runtime image is built and never deployed, so no mission has confirmed
tool.call rows end to end), the ordered next steps, the decisions that are
the operator's, and what was deliberately left undone with reasons.

Also records the two corrections made this session — "missions can't call
tools" was wrong, and raw test counts are a bad coverage metric — because
both were confidently stated here before being checked.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 05:24:43 -07:00
Omar SobhandClaude Opus 5 547b5d9987 feat(missions): a pre-execution gate on mission tool calls
The second half of the tool-call research. Until now a mission agent's
Bash call was gated by nothing, anywhere.

WHY THERE WAS NO GATE

vm_tool_tap is a PostToolUse hook: it fires after the tool has already run
and exit-0s unconditionally, because a non-zero PostToolUse talks back to
the model. It is telemetry and says so. GatePolicy — the §15 door — has one
enforcement site, the chat loop, and its approvals key on
(session_id, message_id), which no mission phase can produce. Meanwhile the
solo tiers run `claude -p --permission-mode acceptEdits` with Read, Edit,
Write and Bash pre-approved.

PreToolUse fires under `claude -p` in this image — measured by vm_stop_gate,
which also proved the exit-2-plus-stderr contract — and had zero callers.
This is that hook.

WHAT IT IS, AND IS NOT

A deterministic policy gate: a short deny list of actions with no legitimate
form inside a mission, blocked before they run, with the reason handed back
so the model can choose differently.

It is NOT the §15 human approval gate, and the module says so. A hook blocks
the agent's process while it runs and a human decision takes minutes to
hours; waiting inside the hook would wedge the turn. This closes the gap
between nothing and something.

The deny list is short on purpose. A gate that blocks legitimate work is
worse than none: the agent cannot ask a human, so it either works around the
block — doing something stranger than what was denied — or burns the turn.

FOUR BUGS THE TESTS FOUND, IN ORDER

1. Substring matching denied `grep -rn 'rm -rf /' docs/`. Searching for a
   string is not running it. Now rules anchor to the start of a shell
   segment, with a separate flag-style match that exempts text tools.
2. The `case` patterns were unquoted, so a needle containing a space made
   the whole script a SYNTAX ERROR — which as a PreToolUse hook exits
   non-zero and denies EVERY call. Every text assertion passed while the
   script was in that state; only running it under a real `sh` found it.
3. The hook receives JSON, not a command, so "starts with" could never
   match — `case` saw `{"tool_name":"bash",...` every time. Now extracts
   tool_name and tool_input.command with `node` (no jq in the image; node is
   guaranteed because Claude Code is a node program).
4. `IFS='\n'` in POSIX sh sets IFS to backslash and the letter n, not a
   newline. Nothing split, so only commands with no separator were ever
   tested and `cd /tmp && rm -rf /` sailed through. Now a literal newline.

Every failure path allows. A gate that fails closed on a parse error blocks
the whole phase, which is exactly what bug 2 did.

Wired through vm_tool_tap::guest_settings, still the single writer of the
guest settings document — a third hook makes the clobber it prevents more
likely, not less, and a test asserts all three survive one document and that
PreToolUse points at the gate's own script rather than the tap's.

Honest limit, stated in the module: a determined agent defeats any
string-matching gate. This is aimed at accidents and obvious cases; the real
isolation is the container and microVM boundary.

Full workspace suite green: 106 binaries, zero build errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 14:50:01 -07:00
Omar SobhandClaude Opus 5 ea0b989b3f docs(research): missions DO call tools — the claim was wrong, and the truth is worse
Deep research into "missions can't call tools at all", which I wrote and
which is false. docs/TOOL-CALL-ARCHITECTURE.md has the full findings.

WHAT IS ACTUALLY TRUE

Three of the four mission paths end in `claude -p` with Claude Code's own
toolset and permissions PRE-ACCEPTED:

  solo microVM      Read Edit Write Bash Agent   --permission-mode acceptEdits
  composed microVM  same, per node               same
  direct session    Read Edit Write Bash         acceptEdits

So the position is not "no tools". It is: mission agents run Bash and Write
with permissions pre-accepted, and nothing in this platform can gate them.
That is a stronger finding than the one it replaces — "can't call tools"
sounds like a missing feature; "calls tools freely, ungated, and mostly
unobserved" is a security posture, and it is ours.

Observe and gate are different and both are partial. vm_tool_tap is a
PostToolUse hook: it fires AFTER the tool ran and exit-0s unconditionally,
so it is telemetry and structurally cannot gate. The direct-session tier has
no tap at all. GatePolicy has exactly one enforcement site — the chat loop —
and its approvals key on (session_id, message_id), which no mission phase
can produce.

WHY THE CONTAINER TIER LOOKED TOOL-FREE

`claude_cli` runs `claude -p --output-format json`, which returns a single
final result object, and the provider hardcodes `tool_calls: Vec::new()`.
The calls happen; the transport discards them. The comment reading that
emptiness as "§15 by construction: agents are provisioned tool-free" was
inferring a design property from a serialization choice.

Verified against the deployed Claude Code 2.1.228 rather than assumed:
`--output-format stream-json --verbose` emits `tool_use` blocks with the
tool name and `tool_result` blocks. The calls are fully observable; we ask
for the wrong format.

THE DOOR WE ALREADY BUILT AND NEVER PLUGGED IN

claude_cli.rs is OURS — upstream zeroclaw-labs/zeroclaw has no such file —
and so is 88eef99d4 "claude_cli --mcp-config + allow/disallow tools (act via
door)". The provider already accepts mcp_config (claude's own MCP client
reaches our door), tools, and disallowed_tools (lock out the natives so the
gated door is the ONLY actuator). agent.config.example.toml documents the
whole shape.

In the live runtime: clawmates-mcp.json does not exist, there is no
[providers.*] block, and every mission claw binds to claude_cli.default
which sets none of it. My earlier "claude_cli cannot reach MCP, therefore
the skills server is unreachable" was wrong in its reasoning — the
capability is built, documented by us, and never deployed.

Related: we set `agents.<alias>.mcp_bundles`, which configures ZeroClaw's
OWN MCP client for its native loop. A claude_cli agent's actuator is the
claude subprocess, which reads `mcp_config` on the PROVIDER. We were turning
a knob wired to a loop that does not run.

UPSTREAM

218 commits behind. No upstream work on claude_cli (the file is ours). ACP
already exists in the fork; the three new commits are workspace-default and
localization fixes, not new capability. The one item worth pulling is
"feat(plugins): add shared egress policy foundation (#9137)" — a network
guard with DNS pinning and metadata-address blocking, defence for the egress
problem we have not solved.

Stale claims corrected in place, in topology_exec.rs and the runtime config,
so the codebase stops asserting the thing that is false.

Recommended order, cheapest first: stream-json for observability; the
PreToolUse hook for a real gate (it FIRES under claude -p per vm_stop_gate,
and has zero call sites); then deploy the door. The executor swap is NOT
recommended — the blockers are structural, not wiring, and the cheap fixes
deliver what it was wanted for.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 13:06:59 -07:00
Omar SobhandClaude Opus 5 771092b165 fix(skills): a pinned skill contradicted the platform inside the same prompt
Extending the Skill-Use mechanical checks, per the baseline's own next step,
found something bigger than a missing check.

THE DEFECT

`workspace-repo-commit-protocol` told agents that `/workspace/repo` was "the
ONLY path where source-modifying edits belong". The platform mounts and
advertises `/mission/repo` — 26 references in the code; `/workspace/repo`
appears in none of them.

The skill is bound on 29 role bindings and was delivered TWICE in the run
already measured, so an agent received the real path in its tool preamble
and a skill contradicting it a few hundred tokens later, in one prompt. An
agent that obeyed the skill wrote source into a directory nothing collects
— the phase then delivers nothing, and looks like an agent that did no work.

The same skill instructed `file_read` / `file_write` / `shell`: ZeroClaw's
names, the exact ones `phase_task_text` was fixed to stop advertising after
five agents on a single mission spent 7.4k tokens describing the mismatch
instead of working. The prompt was corrected and the skill kept saying it.

Rewritten against what the code actually does, including the repo-less case
(`/mission/repo` exists, is collected as artifacts, has nothing to push).

THE CLASS, AND THE GUARD

The skills were never checked against the platform they describe. Nothing
compared them, so a skill could contradict the prompt it ships inside and
stay that way indefinitely — the same shape as PLAN_COMPLETE being
documented and never implemented.

Two tests in `skills_loader::contradiction_tests` now hold it: no skill may
name a repo path the platform does not mount, and none may instruct a tool
the agent's subprocess does not expose. The second matches backticked
instructions and skips corrective lines, so a skill may still WARN against
the wrong names — as this one now does. Both negative-controlled by
restoring the old wording.

AND THE CHECK THAT STARTED IT

`workspace-repo-commit-protocol` now has a Boundary check: writing outside
`/mission/repo` fails, and the message names the consequence — a phase that
delivers nothing — rather than just the wrong path.

docs/SKILL-USE-BASELINE.md records this as the fourth defect the
measurement found, and corrects the "next unit of work" note now that this
one is done.

Full workspace suite green: 106 binaries, zero build errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:51:43 -07:00
Omar SobhandClaude Opus 5 113de610ec fix(security): a signed Slack request could be replayed forever
Phase 5. The headline is not the coverage work — it is what looking for
coverage found.

A CAPTURED SLACK REQUEST AUTHENTICATED INDEFINITELY

`slack_signature_valid` verified the HMAC correctly, and nothing anywhere
checked how old the timestamp was. The timestamp is an input to the
basestring, so an old request's signature verifies exactly as well as a
fresh one — meaning anyone holding a single captured signed request (a
proxy log, a mirrored packet, a leaked webhook body) could replay it
forever, and every replay would authenticate.

Slack's documented 5-minute window is now enforced IN THE BROKER, not the
caller: the broker does not trust its caller (§15), and a check the caller
can forget to make is one that will eventually be forgotten. Symmetric, so
a far-future timestamp cannot mint a request valid for as long as the
attacker chooses.

Seven unit tests over the pure function with the clock injected, and the
HTTP-level test now asserts an hour-old but validly signed request is
refused. Negative control: removing the window fails the stale and
future cases specifically.

The existing slack_inbound test used the literal timestamp "12345" — a 1970
date — which passed only because nothing checked freshness. That is the
shape of the whole finding: the fixture could not have failed, so it never
told us anything.

COVERAGE, RE-EXAMINED

The review ranked crates by raw test count. That metric was misleading and
found the wrong crates: cm-safety's seven tests already cover the decide
CAS, grant double-consume, expiry and the approved/rejected split, and the
audit_log immutability trigger is tested over in cm-db.

Reading the API surface against the tests found the real gaps —
verify_slack_signature above, and `credits_for_tokens`, pure pricing
arithmetic that every existing billing test went through the database to
reach without ever checking directly. Now pinned: the round-up contract,
the deliberate one-credit floor, and that an absurd token count cannot wrap
into a negative charge (a refund granted by an overflow).

Still genuinely thin: cm-brain, where 6 of 9 tests need live
clawbrainhub.com. Stubbing it means reproducing an external registry
protocol we have no spec for — its own piece of work, not a coverage chore.
Recorded rather than faked.

GATEWAY PREFLIGHT

ZEROCLAW_GATEWAY_URL and ZEROCLAW_TOKEN have no defaults and are read at
FIRST USE, so a deployment missing them boots clean, serves every page, and
fails the first time someone presses run. Third sibling of runtime_preflight
and validator_preflight, same stance: a report, not a gate. The message
names the consequence — "container-tier missions cannot run" — rather than
only the unset variable.

One process note: `cargo test -p cm-secrets` passed while the LIBRARY build
was broken, because `time` is a dev-dependency there and my reference to it
only resolved under cfg(test). Switched to std. Checking `cargo build
--workspace` as well as the test profile is the guard.

Full workspace suite green: 106 binaries, zero build errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:24:09 -07:00
Omar SobhandClaude Opus 5 5c2c63f8e8 feat(missions): a human can finally reach the plan/roster review gate
Phase 4 of the plan, plus the PLAN_COMPLETE decision and the gitea_forge
cleanup from Phase 5.

THE REVIEW UI

mission_plan and mission_roster have been complete and reachable by curl
since they shipped, with zero frontend. That matters more than a missing
screen usually would: the decide step is not a convenience, it IS the
safety mechanism. Approving a plan replaces the mission's phases; approving
a roster flips it to the composed engine. A gate nobody can reach is a gate
that is always open or always shut.

MissionProposalDrawer, modelled on LevelUpDrawer which already does
load → review → decide. Reached from a mission's SETUP tab. Verified end to
end against the live backend, not just compiled: a model proposed a roster,
approval flipped the mission to `composed`, and approval on a non-draft
mission was refused.

The plan view shows each phase's done_when, and says plainly when one is
absent — a phase without a completion condition is never judged and reports
completed whatever it did, so its absence is the thing worth seeing.

AND THE DEFECT BUILDING IT FOUND

Every refusal path computed a precise reason — "the mission is running, not
a draft", "no node can boot that backend any more" — logged it to stderr,
and returned a bare {"error":"bad request"}. The person who needed the
sentence was the one clicking Approve; they got two words, and the reason
went to a server log they cannot read.

ApiError::Refused(String) carries it now. Same argument ApiError::Unavailable
was added for ("a 500 with 'internal error' sent them looking for a bug that
was not there"), one status code down. Live: the 400 now reads "this mission
is completed — a roster can only be approved while it is a draft, because
approving one rewrites how the mission will run".

PLAN_COMPLETE, decided

The Skill-Use measurement found that int-xx-marker-protocol documents
PLAN_COMPLETE and task_card_parser never implemented it, so an agent
following the skill exactly was silently ignored. Implemented rather than
removed from the skill: the planner needs a way to say it is done
specifying, and agents already emit it.

Marker ids are now strictly INT-<digits>. `starts_with("INT-")` accepted the
range form `INT-01..02` — observed live — which parsed into an id matching
no real item, so a task card appeared for something that did not exist while
the two items it covered stayed open. Rejecting is right: an ignored marker
is visible, a plausible row is not.

GITEA_FORGE, REMOVED

Named in nine places, defined in none. Harmless while provision_claw ignored
the bundle list; once the list was honoured, an undefined name became a
capability an agent is told it has and does not. Removed from seven team
templates, a workflow recipe, the auto-provision path, and a dropdown a user
could pick it from.

A new test asserts every bundle a template names is defined in the runtime
config — and it immediately found `web_fetch` in two templates I had missed
removing by hand. Same shape as the skill-binding test, one layer up.

Agents reach the forge through git over HTTPS with the ambient GITEA_TOKEN,
which is why nothing ever broke.

Full workspace suite green (106 binaries); frontend builds clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:56:54 -07:00
Omar SobhandClaude Opus 5 91a6b4e304 feat(skills): the first Skill-Use measurement, and the three defects it found
Scored on the paper's three axes against two real missions on the local
stack. docs/SKILL-USE-BASELINE.md has the numbers, the method, and the
limits.

Trigger is reported as NOT OBSERVABLE, never zero

The paper measures progressive disclosure: the agent sees a name and
description and must retrieve the body, and that retrieval is the Trigger
event. We inline full bodies, because mission claws run on claude_cli which
cannot surface a tool call — there is nothing to retrieve with. So the
agent never reaches for a skill, it simply holds one.

Scoring that zero would report a delivery-model property as an agent
failure, which is the same confusion that kept 55 empty bindings invisible
for months. The verdict type carries NotObservable(reason) as a distinct
case from Fail for exactly this.

Compliance is checked by running the REAL task_card_parser rather than a
copy of its rules — a second implementation would drift, and then the score
would pass while the mission loop still stalled. Skills without a
machine-checkable consequence score not_applicable rather than a guess.

WHAT THE MEASUREMENT FOUND

1. The prompt format made its own record unparseable. Skills were
   introduced with `## <name>` and skill bodies are markdown full of `##`
   headings, so run 1 scored "Sizing heuristic" and "The output shape" —
   subheadings inside decompose-int-items — as skills with no catalogue
   row. Now an unambiguous `--- SKILL: <name> ---` marker, with both
   writers sharing one renderer so the reader cannot drift from the writer.

2. A prompt was recorded that was never sent. My own Phase 1 work recorded
   the phase prompt at the dispatch fork, before the tier was chosen — and
   the container tier does not send that text, it sends the bare task and
   appends skills per turn. Every container mission logged a `solo` prompt
   that reached no agent. A provenance record of something that did not
   happen is worse than no record: it is the wrong answer, delivered
   confidently. Recording now happens inside each tier, with a test that
   every launcher records the prompt it actually sends.

3. int-xx-marker-protocol documents a marker the platform never
   implemented. PLAN_COMPLETE is in the skill's ladder and task_card_parser
   has no such kind and never has, so an agent following the skill exactly
   emits a marker that is silently ignored. Observed live: run 2's planner
   emitted `PLAN_COMPLETE: INT-01..02`, which is also the range form — on
   the kinds that ARE parsed that yields the id `INT-01..02`, a task card
   for an item that does not exist while the two real items stay open.

   This is a skill/implementation mismatch, not an agent failure, and it is
   exactly what the measurement exists to find: the agent did what it was
   told and what it was told was wrong. Both shapes now score as failures.
   The reconciliation — implement PLAN_COMPLETE or drop it from the skill —
   is left as a decision rather than guessed at.

The boot log now shows what the plan asked for: 53 skills, 11 templates,
every one `N role skills bound` with NO unresolved clause. Live missions
confirm per-role delivery — the planner receives decompose-int-items, the
coder receives write-rust-current-edition.

GET /api/missions/{id}/skill-use exposes the scores, and says in its
payload whether an empty result means "nothing delivered" or "the evidence
was reaped" — those have very different causes and must not look the same.

n = 2. No spread is reported because two runs cannot establish one, and the
document says so rather than letting the number be quoted as a baseline it
is not.

Full workspace suite green: 106 binaries.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 10:54:58 -07:00
Omar SobhandClaude Opus 5 769e002bb3 feat(skills): deliver on every tier, record what agents receive, let them self-author
Three phases of the approved plan, plus a correction to what the last one
claimed.

CORRECTION: skills reached ONE tier, not all of them

The previous commit said "skills can now reach a mission agent". That was
true only for the container/ZeroClaw tier — the fall-through that queues a
topology_runs row for topology_worker, which drives the executor that was
patched. compose_turn_prompt/pinned_skills_text had exactly one production
caller, and phase_runner's three other paths (composed microVM, solo
microVM, direct session) never called it. CAPABILITY-REVIEW.md said the
broad thing too; both are corrected.

Those three tiers share one task string and have no per-turn alias, so
their skills resolve per PHASE from the mission's crew and are appended
there. The container tier deliberately still injects per turn, with the
running node's own role — appending in both places would put every crew
member's skills in every turn twice.

The behavioural tests prove phase_skills_text and compose_turn_prompt work.
They cannot prove the three launch_* calls pass the composed string, and
that substitution is a one-word edit that would silently return all three
tiers to delivering nothing with every test still green. So there is also a
source-level assertion on the call sites, following the precedent in
mission_events::the_cap_is_enforced_in_one_statement. Its negative control
names the exact tier.

PROVENANCE: what an agent received, and what it said it did

Both were unanswerable. The prompt was never stored anywhere on any tier —
re-deriving it later re-runs the skill lookup against a catalogue that has
since changed, and once agents author their own skills it certainly will
have. The reasoning rows were durably write-only: pushed live once, then
never read from the database again by anything except the GC that deletes
them.

  - prompt.composed records the exact bytes, on all four tiers
  - the session tier writes its checkpoint record and a reasoning row,
    instead of eprintln! and nothing — the same defect the solo microVM
    path was fixed for, in the last tier that still had it
  - narrative_for_mission reads both back

Found while doing it: the 400-event per-phase cap counted EVERY kind, so a
busy phase could push out its own phase.completed and its own provenance.
The cap now counts only the two unbounded kinds it was written for.
Negative control confirms the old behaviour dropped the prompt.

Retention is now a per-mission hold (0080) rather than a raised global —
with a test asserting unheld missions are still reaped, because an
exemption that applies to everything is not an exemption.

SELF-AUTHORING: agents apply their own skill drafts, no human click

By operator decision. level_up has generated complete drafts from a model
since it shipped; only a checkbox stood between propose and apply.

What replaces the gate is not another gate but four properties, each held
by a test:

  - workspace-scoped, so a hand-authored skill can never be modified
  - a draft cannot take a hand-authored skill's name. Ids are scoped and
    bindings resolve by skill_id, so it could not overwrite or shadow one
    anyway — but two procedures under one name means nobody reading a
    transcript can tell which the agent followed, and that ambiguity is
    fatal in a system where the skill is the standard being graded against
  - every revision appends a skill_versions row, so it can be reverted and
    a past run can be read against the text it was actually judged under
  - approved_by = NULL. An agent's decision is never attributed to a person
    who did not make it

Only skill_candidate applies autonomously. identity_refinement and
brain_consolidation still wait for a human: they change what an agent IS
rather than adding a procedure it can consult. State is announced at boot,
because a safety gate that changes silently is one nobody notices changed.
CLAWMATES_SKILL_SELF_AUTHORING=0 restores it.

Also: the test Postgres ran out of /dev/shm mid-suite (Docker's 64MB
default) and surfaced it during MIGRATIONS, which reads like a schema fault
and is not one. --shm-size=1g, and a pointer to the `clean` subcommand that
already existed for the 779 leaked test databases.

Full workspace suite green: 106 binaries, no failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 10:24:35 -07:00
Omar SobhandClaude Opus 5 e3247fee4b chore(runtime): define the skills MCP bundle the templates now ask for
provision_claw honours the template's bundle list as of the previous
commit, but a bundle an agent is assigned and the runtime config does not
define resolves to nothing — so the assignment had to be made to mean
something on the MCP side too.

Carries the caveat that matters at the point of use: this channel only
works for a provider that can surface tool calls, and mission claws run on
claude_cli, which is text-only. Their skills arrive as prompt text instead.
The entry is for tool-capable agents, and so that an assigned name resolves.

gitea_forge is left UNDEFINED on purpose, with a note. Six templates name
it and nothing defines it; a plausible-looking definition pointing at the
wrong URL would turn a name that resolves to nothing into a server that
fails at call time, which is harder to notice rather than easier.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 08:24:45 -07:00
Omar SobhandClaude Opus 5 e4942ce985 fix(missions): skills can now reach a mission agent at all
Repairing the 55 broken skill bindings made the catalogue correct. This
makes it reachable, which it was not — for any skill, on any mission, since
the catalogue was built.

The skills had exactly ONE delivery channel: the `clawmates_skills` MCP
server. A mission claw could not reach it for three independent reasons:

  1. `provision_claw` wrote the constant `["clawmates_door"]` and ignored
     the template's mcp_bundles — which mission_orchestrator had already
     resolved and stored on the team row.
  2. The runtime config defines no `clawmates_skills` bundle. The live
     local config defines no bundles at all, not even the door.
  3. Mission claws run on `claude_cli`, which the runtime's own config
     comments document as text-only: it cannot surface a tool call, so no
     MCP server is reachable from a mission turn regardless of bundles.

And a mission turn's whole system context is two sentences synthesised from
the role slot in topology_exec::build_prompt. The template's role prose is
not used either — mission_orchestrator documents this, and it means the
role prompts describing which procedures to follow were never read.

Two doc comments in cm-runtime describe the mission path as already having
the summary-and-fetch contract. It never did. The belief was written down
twice and checked zero times, which is why nobody looked — and it is why
the Skill-Use measurement this review planned could only ever have returned
a trigger rate of zero. That would have read as a finding about the agents.

  - provision_claw takes the bundles, with clawmates_door always added: a
    template that forgets to list it must not get an ungated agent
  - all 11 templates now request clawmates_skills; web_fetch removed, since
    a list that is honoured must not name a bundle that does not exist
  - the re-provision sweep re-asserts the team's own stored bundles rather
    than a constant, which would have silently stripped a capability
    mid-mission
  - pinned skill BODIES are injected into the mission prompt, bounded and
    with truncation stated. Bodies, not an index: there is no `skills.read`
    tool on this path, so an index would advertise a capability that does
    not exist — the exact failure this whole change is about

Three tests: the body reaches the prompt, an agent with no skills adds no
heading (an empty "Your skills" section announces skills the agent does not
have), and the composition is exercised separately from the lookup, because
`pinned_skills_text` working and `run_turn` calling it are different claims
and the second is the one that was false.

Also adds the three review documents: CAPABILITY-REVIEW (inventory, what
was repaired, what is deferred and why), PROVENANCE-ASSESSMENT (assess
only, per decision — what each store answers and the two candidate paths),
and RESEARCH-SWEEP (the fortnight's papers and what we did about each,
including the ones we deliberately did nothing about).

Full workspace suite green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 08:24:02 -07:00
Omar SobhandClaude Opus 5 18dc0b964b fix(missions): the security scan phase now scans, and task upserts work
Four defects, found by checking the audit's claims instead of trusting
them. Two of the audit's own findings turned out to be wrong, and the
registry that exists to record which config keys are read was itself
inaccurate — so the corrections are part of the change.

upsert_task raised 42P10 on every call, for every caller
  `mission_tasks_external_uniq` is a PARTIAL unique index (WHERE
  external_id IS NOT NULL). Postgres will not match a partial index to an
  ON CONFLICT target unless the statement repeats the predicate, so the
  upsert failed on its first row. Both callers — the task-card parser that
  turns INT markers into tasks, and the security scanner — map the error to
  a string their caller logs. Two features were broken and nothing was red.
  Regression test in cm-db with a negative control: reverting the WHERE
  reproduces 42P10 exactly.

the security scan never ran
  `security_scan::run` was reachable only from an operator button, so
  security_hardening.toml — a workflow whose entire first phase is a scan —
  ran an agent that was never told to scan and never fired the scanner
  either. phase_runner now sweeps finished security_scan phases, mirroring
  the benchmark baseline sweep that was added for the identical defect.
  Guarded on a new completion marker rather than on findings: a clean scan
  writes no findings, so a findings-guard would rescan forever. The marker
  also answers the question an operator actually asks, which is not "how
  many findings" but "was this looked at, by what, and when".

two recipes could not fail
  security_hardening.toml and benchmark.toml carried no `task` and no
  `done_when` on any phase. A phase without done_when never enters
  evaluating, is never judged, and reports completed whatever it did — so a
  security mission could scan nothing and go green, and a benchmark mission
  could record no baseline that the next refactor would then compare
  against. Both now state the work and the condition, with inert keys
  annotated inline rather than deleted, so the gap between what a recipe
  asks for and what a phase receives stays visible.

the config registry was wrong in both directions
  `harness` was listed NOT IMPLEMENTED while benchmark_runner reads it and
  phase_runner runs a baseline through it. `tools` was listed NOT
  IMPLEMENTED while security_scan::run reads it. A registry that exists so
  an operator can trust what a recipe does is worse than useless when it is
  inaccurate. Both corrected, `bench_name` and `cmd` added, and
  `test_command` deleted — it had neither a reader nor a writer, so it
  described a situation that could not arise.

Also: CLAWMATES_JUDGE_MODEL had two different defaults (opus-4-8 in
routes/topology.rs vs opus-5 in cm_runtime::judge_model) and a doc comment
naming a third; topology now calls the one function. GITEA_TOKEN's absence
in mission_plan is stated rather than degrading to the same "could not be
read" string a private repo produces.

BRAINHUB_API_KEY needed no change — hub::push already rejects an unset key
with a named error. That half of the finding was overstated.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 08:08:27 -07:00
Omar SobhandClaude Opus 5 4358964c05 fix(skills): every team-template skill binding now resolves
55 of 85 role skill bindings pointed at skills that were never authored,
so 10 of 11 team templates bound a smaller context bundle than their role
prompts assumed. Three roles bound nothing at all (gpu.bench_engineer,
threejs.shader_author, threejs.perf_engineer) while their prompts described
procedures they had no way to read.

The loader comment at team_template_loader.rs:167 already diagnosed this —
snake_case slugs in TOML against kebab-case skill files — and it was
half-fixed: the kebab names were corrected, the snake_case ones left.

It was invisible because both existing tests assert authored ⊆ referenced
(30/30, green) and the second explicitly declines to check the other
direction. So the failing half was the half nobody asserted.

Resolved every name by one of three explicit choices:

  - 23 skills authored where the role genuinely needed the procedure
    (gpu, threejs, research, analysis, frontend, mobile, backend, platform)
  - renames onto authored skills where one existed in substance, including
    the four-near-duplicate cases that collapse onto one real skill
  - 22 aspirational references deleted — a binding an agent cannot read is
    a promise, not a capability

Two tests now hold it. The unit test checks referenced ⊆ authored against
the files. The new integration test runs both loaders in boot order and
asserts the bindings survive the trip through the database, which is a
different question: resolution goes through skills_catalog rows, so a skill
file that exists but fails to ingest still leaves the role empty.

Negative controls: the unit test failed naming all 55; the integration test
fails naming the exact role when one name is reverted.

threejs.shader_author and .perf_engineer gained a second and third skill
after the collapse — pin_in_context pins idx < 2, so a role left with one
skill silently pins less than the policy intends.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 07:42:48 -07:00
Omar SobhandClaude Opus 5 ba98c29481 fix(podcast): the left rail showed the workforce, not the episodes
deploy / test (push) Successful in 4m20s
deploy / build (push) Successful in 5m56s
The PODCAST tier had no branch in the left column, so it fell through to the
default — the org/company/team roster. Opening the podcast page showed a list of
agents, which is the one thing on that screen that has nothing to do with it.

`PodcastList` now fills the rail with one card per episode (date, title,
duration, size), the same shape `MissionsList` and `RepoList` give their tiers:
objects in the rail, the selected one in the canvas. It selects the newest on
first load so the canvas is never blank, and refreshes on the render sweep's
own two-minute cadence so a new episode appears without a reload.

`PodcastPanel` loses the duplicated list and becomes what a canvas should be:
how to subscribe, and the selected episode with a player. The empty state points
at the Continuous Research mission that produces one rather than just saying
there is nothing.

Verified on the served page: PODCAST renders between AGENT and REPOS.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 12:15:17 -07:00
Omar SobhandClaude Opus 5 fe45f72f09 feat(podcast): a PODCAST tier, a topics field, and a feed a phone can actually reach
deploy / test (push) Successful in 4m28s
deploy / build (push) Successful in 6m25s
Three gaps between "the pipeline works" and "you can use it".

**1. Topics could not be set.** The wizard never sent `config.topics`, so every
mission created through the UI silently fell back to
`library::default_topics()` — a hardcoded list that is somebody else's research
interests. The card now takes one arXiv search per line, and the description
field says plainly that for this template it IS the brief the agents judge
relevance against.

**2. There was nowhere to see or subscribe.** New PODCAST tier in the left rail,
between AGENT and REPOS: the feed URL with a copy button, the episode list, and
an inline player for checking one at a desk. `GET /api/podcast/episodes` and
`/subscription` back it. The panel also reports how many missions produced no
audio, so a missing day reads as a known gap rather than silence.

**3. The feed 404'd for the only client that will ever request it.** Three
layers each assumed a browser:

  - `resolveBearer` is server-only (`next/headers`), so a client component that
    imported it broke the build outright. The panel now goes through the
    same-origin proxy like every other panel, and the backend mints the feed URL
    because the session lives in an httpOnly cookie JavaScript cannot read.
  - The `/api` proxy demanded a session COOKIE. A podcast app has none and
    carries `?token=` instead — the same shape as the existing `hooks/` prefix,
    which is already exempt for exactly this reason.
  - The local autologin middleware 307'd it to `/auth/autologin`. A podcast app
    follows redirects blindly and would have stored an HTML page as the episode.

Neither exemption weakens auth: the backend still validates the token and
answers 401 to a bad one, verified. `episode_audio` accepts the token from
either the query string or an Authorization header, because the app fetches it
one way and the browser player the other, and refusing either breaks one of the
two ways this is listened to.

`CLAWMATES_PUBLIC_URL` matters and was wrong first: the tailnet root proxies to
a different service on :18789, and this frontend is on :8443. A feed advertising
an unreachable origin syncs silently forever, so `/subscription` returns a
`reachable` flag and the panel warns when it is still localhost.

Verified from a phone's point of view: feed 200 application/rss+xml over the
tailnet, enclosure 200 with 6,739,582 bytes of audio at 421s, bad token 401.

367 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 11:55:19 -07:00
Omar SobhandClaude Opus 5 f4adc8d0f9 fix(podcast): stop reading identifiers aloud, and pitch the episode at a teenager
deploy / test (push) Successful in 4m11s
deploy / build (push) Successful in 5m28s
Two things the operator found by listening to a real episode.

**1. Identifiers were spoken as digit soup.** The script genuinely said
"arxiv 2608.12888", which the voice reads as "two six zero eight point one two
eight eight eight". Same for three-decimal values: "0.506" and "0.004" became
long strings of spoken digits. A listener on a treadmill cannot write an
identifier down and does not need a third decimal place.

`speakable()` strips arXiv references and bare identifier-shaped numbers, and
rounds decimals to two places — with a carve-out that matters: 0.004 rounds to
0.00, which would claim the value was ZERO when the whole point was that it
collapsed to nearly nothing, so it says "under 0.01" instead.

Deliberately narrow: it removes identifiers and shortens over-precise decimals,
and does not paraphrase, reorder or summarise. The agents' words are still the
episode. It also preserves the sentence's full stop — swallowing it turned
"…financial retrieval, arxiv 2608.00183. This one's a catch." into one run-on
sentence, and the pause is how a listener knows a thought ended.

Note that `podcast-dialogue-writing.md` ALREADY said "no arXiv ids" and the
writer included them anyway. That is this project's recurring lesson restated:
an instruction is a request, and a listener deserves a guarantee. The prose asks
and the code enforces.

**2. It was written for someone who already knew the field.** The skill and the
script phase's task now target a bright sixteen-year-old: define an acronym in
the sentence that first uses it, describe the mechanism rather than naming it
("a road map with motorways and side streets" instead of "a hierarchical
navigable small world graph"), one idea per sentence. The test offered is
whether the listener could explain the finding to a friend afterwards.

That is not dumbing down — it is the constraint that forces a writer to say what
a thing actually does rather than what it is called.

Tested against the exact lines from the episode that was listened to.
366 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 10:42:48 -07:00
Omar SobhandClaude Opus 5 1f39f642a3 feat(podcast): render finished missions into episodes, and serve them as a feed
deploy / test (push) Successful in 4m29s
deploy / build (push) Successful in 5m22s
The renderer existed but nothing called it. This wires it to the missions and
puts the result somewhere a phone can reach.

**A sweep, not a phase step.** Rendering is not the agents' work and must not be
able to fail a phase that succeeded; a transient API error simply retries next
tick, and a mission already rendered is skipped because its episode row exists.
`podcast_episodes` is that record — without it the sweep would re-render on
every pass and re-bill for it, the same lesson `corpus_items` taught for papers.

**It is racing a reaper.** script.md lives in the mission checkout, and
`mission_runtime`'s sweeper deletes that tree 30 minutes after the mission
reaches a terminal state. So the sweep runs every 2 minutes, leaving ~15
attempts inside the window. When it does lose — as it did for three missions
that had completed hours before this shipped — it now SAYS so and records a
marker rather than skipping in silence, which is how a feed ends up quietly
missing a day. The feed filters those markers out: a zero-byte enclosure shows
a broken episode in a podcast app, where showing nothing is honest.

**Duration is read from the audio, not estimated from the script.** The feed
advertises a length and that length should be the real one — and it is the check
that catches a 6 MB file playing for six seconds.

**The feed authenticates by query-string token**, because no podcast app can set
headers. That is a real trade: the token lands in the app's database and any
proxy log. It reuses `AuthService::authenticate`, so revoking the session
revokes the feed with it rather than creating a second secret to forget to
rotate. Titles are XML-escaped — one raw ampersand makes a client reject the
WHOLE feed, not one episode.

363 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 10:25:24 -07:00
Omar SobhandClaude Opus 5 55b16f25c8 fix(podcast): the episode played for six seconds
deploy / test (push) Successful in 4m24s
deploy / build (push) Successful in 5m33s
Concatenating whole MP3 files does not make a longer MP3. Each TTS clip is a
standalone file: a small ID3v2 tag, then a first frame carrying an `Info`/`Xing`
VBR header that declares THAT CLIP's frame count. Joined raw, a player reads
clip one's header, believes the file is that long, and stops. The 6.1 MB
"episode" played for 6.9 seconds.

Caught by the operator listening to it. I had verified the byte count, the ID3
magic and a >100 KB size floor — every proxy for "this is audio" — and never
that it plays. The assertion I needed was duration, and none of the ones I wrote
could fail on this bug.

Measured on two real clips of 4.86s and 4.68s:

    raw concat                      -> 4.86s   only clip one plays
    strip second clip's ID3         -> 4.86s
    strip both clips' ID3           -> 4.86s   the tag was never the problem
    strip ID3 *and* the Info frame  -> 9.53s   correct

The ID3 tag is ~45 bytes and harmless. The header FRAME is what lies, and my
first attempt at a fix — scanning the joined file for `ID3` — was worse than
useless: it matched those bytes inside audio data and silently deleted half the
stream.

`strip_container` removes both from every clip, leaving pure frames a player
times from the stream itself. Real ElevenLabs output is committed as a fixture
so the test pins the actual wire format, not an approximation of it, and a
junk-input case proves a malformed clip fails the render rather than panicking.

Re-rendered the same script: 380.8s, up from 6.9s.

361 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 08:27:38 -07:00
Omar SobhandClaude Opus 5 656662850d feat(podcast): render the episode from the agents' own script
deploy / test (push) Successful in 4m32s
deploy / build (push) Successful in 5m25s
GenFM is unreachable. `GET /v1/studio/projects` and `POST /v1/studio/podcasts`
both return 403 — "Access to the Studio API requires your account to be
explicitly whitelisted to use it. Please contact our sales team." Measured with
two different keys on the account, so it is an account restriction, not a key
scope. Plain text-to-speech on the same key returns a valid MP3.

That suits the operator's choice better than GenFM would have. GenFM always runs
its own LLM over the source, so the agents' script would have been REWRITTEN;
rendering each line ourselves speaks it verbatim. The agents did the reading and
the judging, and the episode says what they wrote.

`AudioBackend` is a trait because every candidate has a different shape:
NotebookLM documents no programmatic retrieval at all, GenFM needs a sales
conversation, Gemini TTS is a third form. The renderer hands over a `Script` and
gets bytes.

`parse_script` is a parser rather than a `read_to_string` because structure must
not be spoken: headings, rules and block quotes are skipped, a wrapped paragraph
stays ONE turn (splitting per line would stutter at the seam), and a colon mid
sentence does not start a new speaker — "The finding: recall dropped" would
otherwise be truncated to everything after the colon. Voices are assigned by
order of appearance, so a script using names instead of HOST/GUEST still
alternates, and an unexpected third speaker falls back rather than failing.

`from_env` returns None without a key, so a deployment with none produces no
audio instead of failing a mission that otherwise succeeded.

Proven end to end on the real script this morning's mission wrote: 25 turns,
887 words, 5,614 billable characters, 6.1 MB of MP3 in 33 seconds. The live test
drives `parse_script` + `ElevenLabs::render` — the production path — and is
`#[ignore]`d because it spends credits.

359 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 08:19:26 -07:00
Omar SobhandClaude Opus 5 850f11838b fix(research): the manifest belongs to the mission, and a brittle phrase must not mean silence
deploy / test (push) Successful in 4m28s
deploy / build (push) Successful in 5m21s
Two defects from the first on-topic run.

**1. The manifest could never be updated twice in a day.** It was written into
the VAULT at a per-DATE path, but it is per-RUN data. A second mission the same
day rewrites a file that already exists, and `auto_merge` correctly refused the
whole branch:

    diff is not additive (1 non-add change(s), first:
    M ContinuousResearch/2026-08-18/harvest.jsonl); left for a human

So `main` kept the FIRST run's manifest, the next mission cloned it, and the
agents analysed yesterday's papers while every log line reported a successful
harvest. The merge policy was right; the placement was wrong. The manifest now
goes into the mission's own checkout after `ensure_checkout`, which keeps the
vault additive and gives each mission exactly its own papers. The agents commit
it alongside their analysis through the normal delivery path.

**2. A quoted phrase that matches nothing looked like a quiet day.** Phrase
search is precise and brittle: "hybrid retrieval BM25 dense" is a reasonable
topic and appears verbatim in no paper on arXiv — measured, 0 hits — while the
same four terms unquoted return exactly the hybrid-retrieval evaluations the
topic asked for. Harvesting zero because of adjacency is indistinguishable from
a genuinely quiet field, which is the distinction `Harvest::healthy()` vs
`added_anything()` exists to preserve. `search` now retries unquoted when the
phrase finds nothing, and says so in the log.

Proven in one run, all three behaviours at once:

    "approximate nearest neighbor search" -> 5 candidates, 5 already held, 0 shelved
    "hybrid retrieval BM25 dense"         -> no exact phrase match, retrying broad
                                          -> 5 candidates, 0 already held, 5 shelved
    "LLM as a judge evaluation"           -> 5 candidates, 5 already held, 0 shelved
    wrote 5 paper(s) to .../ContinuousResearch/2026-08-18/harvest.jsonl

The seen-set suppressing 10 of 15 is the whole point of a recurring mission, and
the 5 that landed are on topic for the first time: RAG architecture evaluation,
agent-controlled search over chat logs, compute-aware retrieval and reranking,
hybrid retrieval in hyperbolic space, sparse-dense fusion limits.

353 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 07:18:11 -07:00
Omar SobhandClaude Opus 5 2cd0872e50 fix(papers): the arXiv topic was never actually searched for
deploy / test (push) Successful in 4m13s
deploy / build (push) Successful in 5m19s
The operator's topic went RAW into `search_query=`, unfielded. arXiv matched
essentially nothing, and `sortBy=submittedDate` then returned the newest
submissions across the entire archive — so the library shelved whatever had been
posted in the last few minutes and called it research.

A real run for "agentic topology", "retrieval augmented generation" and "vector
index pruning" shelved, among 13 papers: Galois extensions of geometric fixed
point spectra, a bulk path integral for a quantum black hole microstate, blazar
boosted dark matter in IceCube, and colloidal packing. Nothing was broken —
every layer reported success, the notes were written, the seen-set was updated,
the branch auto-merged. The papers were simply unrelated to anything asked for.

Measured against the live API:

    speculative decoding         -> pixel-space diffusion, simplicial actions
    all:"speculative decoding"   -> S2-MoE self-speculative decoding, DARTree

So a bare topic is quoted into `all:` and bound to `cat:cs.*`. The quotes make
it a phrase (unquoted, "vector index pruning" matches any paper containing all
three words anywhere, which is most of cs), and the category bound is needed
because the archive's physics and maths volume dominates any recency sort.

A topic that already starts with a field prefix passes through untouched, so an
operator who knows arXiv syntax keeps control.

The passthrough originally also accepted anything containing " AND "/" OR ", and
the injection test caught it on the first run: `agent" OR cat:hep-th` escaped the
phrase and rewrote the category bound. Only a LEADING field prefix counts now.

348 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 20:17:02 -07:00
Omar SobhandClaude Opus 5 d524107b37 fix(missions): harvest before the checkout, and stop an unreachable judge failing done work
deploy / test (push) Successful in 4m27s
deploy / build (push) Successful in 5m26s
Two bugs from the first real Continuous Research run, both found by running it.

**1. The harvest ran AFTER the checkout.** `on_launch` cloned the vault and then
harvested, so the mission's working copy predated the manifest push. The reader
agent found no `harvest.jsonl` and — being resourceful — queried arXiv itself
and wrote its own. That is exactly what `skills/research/arxiv-daily.md`
forbids: the papers it found are not checked off in `corpus_items`, so the next
run re-offers them, while the 13 the real harvest DID shelve went unread. The
harvest now runs first, so the clone contains the manifest.

The analysis it produced was otherwise very good — it named
`crates/clawhdf5-ann/src/hnsw.rs`, cited the ROADMAP's serial insert loop and
proposed a concrete pre-build probe — which is the behaviour the whole design
is for. It was reading the wrong papers.

**2. An unreachable judge consumed a pass.** `Verdict.error` exists to
distinguish "could not judge" from "judged incomplete" and nothing acted on it.
glm-5.3 returned "transport error: error decoding response body", the phase
counted it as a failed pass, and with two budgeted that single outage failed a
phase whose work was done and committed. The evaluator was right to refuse a
same-family fallback — that would trade independence for availability — so the
fix belongs here: an unreachable judge no longer spends an iteration.

Retrying forever would trade a wrong failure for an invisible hang, so the wait
is bounded by `judge_blocked_since` (migration 0078), mirroring how
`capacity_blocked_since` bounds a phase waiting on a VM slot. Thirty minutes is
many sweep ticks, so a blip recovers inside it; past that the phase FAILS with
the transport reason rather than requeueing, because re-running spends a
container re-doing work that was never the problem.

346 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 18:14:29 -07:00
Omar SobhandClaude Opus 5 a02e0cba69 feat(missions): Continuous Research harvests at launch, and cards launch by clicking
deploy / test (push) Successful in 4m21s
deploy / build (push) Successful in 5m14s
The card shipped in e20b321 could not actually be used. Three things were
missing, each of which failed at a different distance from its cause.

**1. `default_team_template` was parsed and never read.** Every recipe declares
one; `WorkflowRecipe` carries the field; nothing consumed it. A mission created
from a card with no explicitly chosen team was rejected at LAUNCH with "no
team_id, no team_template_id, no config.phase_teams" — one step removed from the
real cause, which is that creation ignored the recipe. Create now resolves it
via `team_templates::get_by_key`, only when the caller named no team of any
kind, so an explicit choice still wins. A test asserts every shipped recipe
names a template that has a `templates/teams/<key>.toml`, because a mismatch
there produces an unlaunchable card.

**2. The harvest ran nowhere.** `harvest_for_mission` existed and nothing called
it. `on_launch` now runs it for `continuous_research` missions, before the
phases start, and threads the blob store through from `main` (the route already
had it on `AppState`; the scheduler needed it). Deliberately non-fatal: a
harvest that fails still starts the phases, because the phase is what reports
whether today was quiet or broken and those must stay distinguishable — but
never silent, so both outcomes log their counts.

**3. Nothing wrote the manifest.** `templates/teams/continuous_research.toml`
has pointed its reader role at `ContinuousResearch/<date>/harvest.jsonl` since it
was authored, and the file did not exist — agents aimed at a path nothing
produced. `run_to_vault` now writes it beside the notes and stages it, but only
for a mission-attributed run. `Harvest` carries the shelved `Paper`s to build
it; re-parsing the notes we had just written would have been a parse of our own
output and one more place for the two to drift.

Also: the blob root. `storage.data_dir` defaults to "./data" and the container's
cwd is `/`, so the server tried to create `/data` as uid 65532 and EVERY shelve
failed with "storage io: Permission denied". The image now creates
/var/lib/clawmates-blobs owned by 65532 so a mounted volume inherits it rather
than arriving root:root. Kept off /var/lib/clawmates-missions on purpose: that
tree is swept, and a paper shelved there would be deleted out from under its own
catalogue note.

Proven end to end on a real mission: 15 candidates, 2 already held, 13 shelved,
0 failed; branch auto-merged as additive-only; manifest on vault `main` with
every documented key. The "already held" counts are the seen-set deduping across
topics within a single run, which is the behaviour the whole design exists for.

The project brief now comes from the mission description — `phase_task_text`
already places it under BRIEF verbatim, so no new field was needed.

346 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 15:05:37 -07:00
Omar SobhandClaude Opus 5 a2d7e3ea92 feat(skills): author the Continuous Research skills, and make its roles honest
deploy / test (push) Successful in 4m4s
deploy / build (push) Successful in 1m45s
The boot log has said `continuous_research — 4 role skills bound, 8 unresolved`
on every start. Those eight roles ran without the instructions their template
promised them, silently: `skills_loader` reports the miss and carries on.

Worse than the missing files was what the prompts described. v1 told the
harvester to sweep "RSS feeds, GitHub trending, HN front page, YouTube /
podcast RSS" — none of which exist. `harvest.rs` searches arXiv and nothing
else. A role prompt describing a machine that was never built is the failure
this codebase keeps paying for, so v2 describes the machine that exists.

Roles now match the pipeline: paper_reader (the harvest already ran; read the
manifest and the papers), signal_ranker (unchanged in spirit), script_writer
(the podcast half, which had no role at all).

Seven skills authored under skills/research/, kebab-case to match the loader —
team_template_loader.rs:177-181 documents the snake_case/kebab-case trap that
already unbinds skills elsewhere:

  arxiv-daily                  what the harvest guarantees, so an agent does
                               NOT re-search arXiv and corrupt the seen-set
  paper-to-project-relevance   name a file or roadmap item, or say "no bearing"
  duplicate-detection          the seen-set catches identity; this catches the
                               same work under a different id
  signal-to-noise-ranking      novelty/relevance/depth, and the two biases to
                               resist (recency up, inconvenience down)
  executive-summary-writing    what it is / why it matters / what to do — decide,
                               do not hedge
  obsidian-vault-conventions   the vault is a human's live workspace; never
                               main, never reorganise, hash the body not the file
  podcast-dialogue-writing     write for someone on a treadmill; the 10-70 char
                               highlight bound is the API's, not a style rule

`web_fetch` dropped from mcp_bundles: runtime_provision.rs binds every mission
claw to `["clawmates_door"]` and never reads that field, so declaring it
instructed roles to use a tool that never arrived. The prompts say `curl` via
Bash, which is what they actually have.

Boot now reports `continuous_research — 11 role skills bound`, with no
unresolved clause. 344 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 14:44:03 -07:00
Omar SobhandClaude Opus 5 e20b321055 feat(missions): Continuous Research is a mission type, not just a team checkbox
deploy / test (push) Successful in 4m6s
deploy / build (push) Successful in 5m32s
`templates/teams/continuous_research.toml` has existed with three well-written
roles since it was authored, but no workflow recipe pointed at it — every
recipe in templates/workflows/ defaults `default_team_template = "rust_sdlc"`.
So the only way to reach it was as a checkbox under Advanced. It is now a
Step-1 card: the registry loads it at boot and `GET /api/workflows` serves it,
with no frontend change (MissionWizard renders whatever the endpoint returns).

Both phases are kind `research`, deliberately, rather than new `read`/`script`
kinds. An unrecognised kind falls through `purposes_for`'s `_ => ["mission"]`
and is absent from `PRODUCING_KINDS`, so it would get the generic directive AND
be exempt from the empty-delivery rule — a phase that produces nothing and
still passes. That is the shape this codebase keeps paying for; two `research`
phases differentiated by `task` keep both guards.

`commit_policy = "always"`, not `on_green_tests`: the vault is prose with no
suite, so a test gate would find nothing to run and land every branch `-wip`.

The harvest is NOT an agent phase. `continuous_research.rs` calls the existing
`library::run_to_vault` — arXiv search, seen-set check, PDF shelf, vault note,
attributed by `mission_id` — because that path is deterministic, takes seconds,
and owns the `corpus_items` seen-set that is the whole reason a recurring
mission knows what it already covered. An agent redoing it would be slower and
would lose that.

The manifest path is not invented either: the team template has told
`signal_harvester` to write `ContinuousResearch/<date>/harvest.jsonl` all along.
This makes the code produce what the prompt already promised, and a test pins
the path and every documented key so the two cannot drift into an agent reading
a file nothing writes.

DEFAULT_CORPUS / DEFAULT_VAULT_URL exported rather than duplicated, so the
route and the launch hook cannot disagree about which vault.

344 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 14:18:35 -07:00
Omar SobhandClaude Opus 5 f87853ecf9 fix(missions): scheduled missions never fired — nothing read missions.schedule
deploy / test (push) Successful in 4m25s
deploy / build (push) Successful in 5m10s
The wizard has collected a cron since `0047_missions.sql` ("schedule JSONB
carries the trigger config (cron | one_shot | on_event)"), the frontend posts
`{kind:"cron", cron}`, and the API persists it faithfully. Nothing has ever read
it back: the only due-work enumerator in the codebase was `routines::claim_due`.
So every scheduled mission ever created sat in `draft` forever while the UI
reported it was on a schedule.

Proven before fixing, on the shipped build: a mission with `* * * * *` sat in
`draft` for 4m34s and started ZERO topology runs. After this change the same
mission launched on its next occurrence and recorded one `fired` row.

Two pieces were missing, and they are the two `routines` already had:

  - `missions.next_run_at` — schedule STATE. `schedule` is user intent and stays
    untouched; without somewhere to record which occurrence is owed there is
    nothing to put a `<= now()` predicate on, which is why no enumerator could
    be written against the JSONB alone.
  - `mission_fires` — one row per (mission, occurrence). 0063_routine_fires.sql
    called this exact case: "For a scheduled *mission* it costs a container, a
    repo checkout, and real money — which is why this lands before mission
    scheduling does."

`mission_schedule.rs` deliberately mirrors `cm-scheduler`'s shape rather than
inventing a second one: atomic `FOR UPDATE SKIP LOCKED` claim, reschedule
BEFORE dispatch so a failing launch cannot stall the clock, claim the slot
before launching so a crash mid-launch is retried rather than dropped, and a
fan-out cap. The cap is 5, not the scheduler's 25, because a mission firing is
a container and a checkout where a routine firing may be one turn.

The claim skips `status = 'running'`: a daily cron on a mission that takes
longer than a day must skip the occurrence, not stack a second crew on the same
workspace. Launch goes through `mission_orchestrator::on_launch` +
`missions::set_status`, the same path as the draft→running transition, so one
code path mints a crew. An unattended launch acts as the workspace owner
(`users::owner_of_workspace`) since missions carry no creator column; a
workspace without one settles the occurrence `failed` with the reason rather
than dropping it silently.

Backfill blast radius was MEASURED, not assumed: prod has zero missions with a
cron, this workstation had exactly one — the control created to prove the bug.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 14:10:39 -07:00
Omar SobhandClaude Opus 5 3cc65c22c4 feat(judge): room to analyse — and a panic in the evidence path
deploy / test (push) Successful in 4m4s
deploy / build (push) Successful in 5m11s
Three changes, one of them a live bug.

**The bug.** `phase_summarizer` truncated agent output with `&s[..remaining]`,
a BYTE slice of arbitrary UTF-8. Agent turn output routinely carries arrows,
box-drawing and emoji, so a cut landing mid-character panics — taking down the
evaluation sweep for that phase, triggered by nothing more than an agent
writing a long enough line with a non-ASCII character at the wrong offset.
Replaced with `clamp_to_char_boundary`, tested across every cut offset of a
pure-4-byte string.

It is precisely the bug the clawhdf5 agents found and fixed in
`clawhdf5-migrate/src/validate.rs` this week — in our own code, in the path
that feeds the judge.

**Evidence budget** 60 KB -> 120 KB. Output headroom is worthless if the judge
cannot see the work: the verdict is only as good as what reaches it.

**Judge max_tokens** 2048 -> 16384. glm-5.3 is a reasoning model that spends
most of its budget on a `thinking` block before writing the verdict, and
running out mid-thought truncates it. A truncated verdict parses as empty and
FAILS CLOSED, burning one of the phase's passes on a judge that never answered
— how mission 01a00bbb lost one.

Measured ceiling: z.ai accepts max_tokens up to 131072 on both glm-5.1 and
glm-5.3 (131073 -> 400, "限制数值范围[1,131072]"), so 16384 is chosen for cost
and latency rather than capability, and only emitted tokens are billed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 21:59:01 -07:00
Omar SobhandClaude Opus 5 9c4b0722e8 feat(judge): an independent GLM judge, on the newest model z.ai publishes
deploy / test (push) Successful in 3m58s
deploy / build (push) Successful in 5m29s
Every phase verdict this session was Anthropic grading Anthropic, and the boot
log said so on each start:

    validator_preflight: no CLAWMATES_VALIDATOR_MODEL — phase verdicts are
    judged by the house model, which is NOT an independent check

`evaluator.rs` already preferred a cross-provider judge and refused to call a
same-family one `independent`; the local stack simply had no non-Anthropic
credential. It now carries the same `glm` provider gw-04 has had all along —
`format = "anthropic"` is load-bearing, since z.ai's OpenAI-compatible endpoint
is ToS-throttled for raw SDK access while its Anthropic-format one is not.

Model: glm-5.3, the newest z.ai lists (4.5, 4.5-air, 4.6, 4.7, 5, 5-turbo,
5.1, 5.2, 5.3 as of 2026-08-17). gw-04 still runs glm-4.7.

glm-5.3 is a REASONING model: it emits a `thinking` block before its JSON. Our
SSE parser ignores `thinking_delta` and keeps the text, so the wire shape is
compatible — but on a realistic phase-evidence prompt it spent 819 of the
evaluator's 1024 output tokens. A longer phase would truncate the verdict, and
a truncated verdict parses as empty and FAILS CLOSED, burning one of the
phase's passes on a judge that never answered — precisely how mission 01a00bbb
lost a pass. max_tokens raised to 2048.

Measured before wiring: asked to judge 25 commits claiming INT-01..INT-25 with
tests passing, glm-5.3 returned met=false because the evidence never
established what the brief actually required. That skepticism is the point of
an independent judge.

Boot now reports: `validator_preflight: independent validator glm:glm-5.3
answered`.

The key lives in .env (gitignored), never in this file.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 21:41:45 -07:00
Omar SobhandClaude Opus 5 b5032a732a fix(phase_runner): collect the agent's work BEFORE judging it
deploy / test (push) Successful in 4m24s
deploy / build (push) Successful in 5m15s
`Sandbox::for_mission` builds the judge's verification copy from the HOST
checkout. In copy mode the agents write inside the container, and their work
only reached the host when `sync_out` ran — in the capture sweep, AFTER the
phase closed. So every phase was judged against a tree that did not yet contain
the pass being judged, and the judge truthfully reported nothing there.

Mission 01a00cfa is the proof. Research pass 2 wrote a 434-line
IMPLEMENTATION_BRIEF.md, `cargo test` passed, and it was pushed to a clean
branch (clawmates/mission-01a00cfa-c69f39fd-i2 at 563cdd21). Its verdict:

    failed after 2 pass(es) — met=false — research/IMPLEMENTATION_BRIEF.md
    does not exist anywhere

logged one line BEFORE `captured (+434/-0 across 1 file(s))`. A phase that
succeeded was failed because the evidence had not been collected yet.

This hid because it only bites a phase judged on its OWN pass. The v2 coding
verdict cited real commits (339a5bd, 167671f) — research had already synced
that work to the host in an earlier phase.

`evaluate_finished_phases` now runs `sync_out` first, and on failure leaves the
phase `evaluating` for the next sweep rather than recording a verdict nobody
could stand behind — the same policy the capture sweep already applies, for the
same reason. microVM keeps its carve-out: `microvm_executor` collects out of
the guest over this same path before the VM is destroyed.

Research goes to 3 passes. On 01a00cfa it got no real attempts out of two: one
spent on a fabricated commit claim the judge correctly rejected, one on this
bug.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 17:09:05 -07:00
Omar SobhandClaude Opus 5 a582dea4fc feat(workflow): research plans, coding builds — the split was a fiction
deploy / test (push) Successful in 4m1s
deploy / build (push) Successful in 1m36s
`rust_sdlc` gives the research team coding roles and a writable /mission/repo,
so research implemented what it found and the coding phase then opened a clean
tree, produced +0/-0 and failed on the empty-delivery rule. Mission 01a00c57
ended exactly that way: research shipped both INT items itself (+276/-57),
coding delivered nothing.

Worse than the wasted phase is WHERE the code landed. Research ran under a gate
that does not check tests, so its two source changes reached a branch with
`tests_status: null` — never compiled by the gate, never run. Keeping
implementation in the coding phase is what puts it behind `on_green_tests`.

Research now carries a `task` that scopes it to the brief and says plainly that
editing crates/ is not its job this phase, plus a `done_when` describing what
the brief must CONTAIN. The no-source-edits constraint deliberately lives in
the prose and NOT in `done_when`: "and nothing else" phrasings measurably make
a judge invent requirements it was never given.

Coding gets the counterpart `task`: implement the brief's items, one commit
each, tests green. Stated explicitly because a phase that finds a clean tree
and no instructions has historically written a REPORT about the work instead of
doing it — four documentation commits and one implementation, on the run that
became the haiku baseline branch.

Research also gets `commit_policy = "on_green_tests"` as a safety net, so
source it writes anyway still has to pass the suite.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 16:48:05 -07:00
Omar SobhandClaude Opus 5 d341640255 fix(mission_fs): drop build output when collecting work back from a container
deploy / test (push) Successful in 4m25s
deploy / build (push) Successful in 5m39s
`pack_dir` (host -> container) skips `transport_excludes`; `copy_out`
(container -> host) is the raw Docker archive API and carries the whole tree,
`target/` included. The asymmetry was invisible for as long as the runtime
image had no cmake — nothing could compile, so no `target/` existed.

The moment missions could actually build, every collection died on a build
artifact:

    failed to unpack `…/repo/target/debug/build/ahash-…/build_script_build-…`

`phase_runner` then correctly refused to capture, rather than record a stale
tree as an empty diff — so mission 01a00c57's coding phase, which had done the
work, delivered nothing and retried forever. A fix that let missions compile
created a delivery failure one layer down.

`unpack_into` now skips excluded entries by NAME at any depth (a workspace has
a `target/` per crate) and logs how many it dropped.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 15:09:24 -07:00
Omar SobhandClaude Opus 5 99dd29cc8a fix(worker): the stuck-run reaper was killing healthy sonnet-5 turns
deploy / test (push) Successful in 5m14s
deploy / build (push) Successful in 5m42s
REAP_STUCK_AFTER_SECS was 15 minutes; the runtime grants a single turn
`timeout_secs = 3000` (50 minutes). A run journals its first step record when
its first step COMPLETES, so a turn still legitimately in flight is
indistinguishable from a wedged container — and with a window shorter than the
turn timeout the reaper does not detect stuck runs, it kills slow healthy ones.

The old value was calibrated on haiku, where "healthy first-step latency is
typically 5-60s" held. Moving mission agents to sonnet-5 made first turns
longer than the window: mission 01a00c41's research phase was reaped at 900s
having already written +402/-39 across 13 files. We only know it was healthy
because the delivery path captured and pushed that work anyway, to branch
clawmates/mission-01a00c41-421200ee at b08df7b6.

Raised to 60 minutes, above the turn timeout, with the invariant written down
so the next person changing either number sees the relationship.

Generalises: a liveness timeout calibrated against one model becomes a
correctness bug when the model changes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 13:48:03 -07:00
Omar SobhandClaude Opus 5 b31a79f650 fix(llm): the subscription 429s were a malformed request, not a rate limit
deploy / test (push) Successful in 5m18s
deploy / build (push) Successful in 5m14s
On the OAuth path Anthropic requires the Claude Code identity to be its own
first system BLOCK. We concatenated it with the caller's prompt into a single
string, so EVERY server-side call that set a system prompt was rejected — and
the rejection arrives as `429 {"type":"rate_limit_error","message":"Error"}`,
which reads as throttling and is not.

Measured on one token, seconds apart:

    "PREAMBLE"                    (string)  -> 200
    "PREAMBLE\n\nJudge the …"     (string)  -> 429
    "PREAMBLE"                    (string)  -> 200   (control)
    ["PREAMBLE"]                  (blocks)  -> 200
    ["PREAMBLE", "Judge the …"]   (blocks)  -> 200

while the account reported `5h utilization 0.07, 7d 0.11, overage 0.0`, every
window `allowed`. A Max 20x subscription at 7% was being read as out of
capacity.

What this was breaking, silently, for as long as it has been there:
  - every `done_when` verdict on the subscription judge. Mission 01a00bbb
    pass 2 returned "could not evaluate the completion condition this pass"
    and BURNED one of the phase's three passes on it.
  - the boot preflight, which reported `claude-opus-4-8 throttled (configured,
    no capacity now)` on every start — a diagnostic that was itself the bug.
  - mission_refiner, phase_summarizer, swarm planning.

The `claude` CLI was unaffected throughout, because it sends its system prompt
as blocks. That divergence is what made this look like an account problem: the
agents worked while everything server-side "throttled".

After the fix the preflight reports opus-5, sonnet-5 and haiku all `ok`.

The API-key path keeps sending a plain string — it never had this constraint.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 13:15:27 -07:00
Omar SobhandClaude Opus 5 69c294addc fix(models): coding runs on sonnet-5, judging on opus-5, haiku only as last resort
deploy / test (push) Successful in 7m38s
deploy / build (push) Successful in 6m55s
Operator model policy: haiku ONLY for genuine yes/no questions; anything
requiring thinking is opus-5; coding is sonnet-5.

The mission AGENTS were running haiku, and nothing in the product said so.
`provider_alias_for` maps every `claude-*` binding onto the single alias
`claude_cli.default`, so a crew whose `model_binding` reads `claude-sonnet-5`
— as this deployment's does — still ran whatever that alias pointed at, which
was `model = "haiku"` in the runtime config. The binding is cosmetic; the
alias is the truth.

Measured consequence on mission 01a00bbb: the coding agents claimed six INT
items complete and had committed three, and the done_when judge caught it by
auditing git history against the claims.

Model assignments, by what the component actually does:
  evaluator (done_when judge)  haiku  -> opus-5   reads evidence, audits it
                                                  against the repo, writes
                                                  guidance. The verdict is a
                                                  boolean; the work is not —
                                                  and this is the one component
                                                  whose failure mode is passing
                                                  work that was never done.
  judge_model                  4-8    -> opus-5
  mission_refiner              4-8    -> opus-5   composition
  phase_summarizer             4-8    -> opus-5   composition
  swarm planner                4-8    -> opus-5   planning
  subscription preflight head  4-8    -> opus-5
  fallback chain head          4-6    -> sonnet-5 haiku stays BELOW it as a
                                                  last-resort link, never a peer

Every value stays env-overridable; only the shipped defaults move.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 12:36:19 -07:00
Omar SobhandClaude Opus 5 53da4d7e6d fix(runtime): a mission could not build the repo it was given
deploy / test (push) Successful in 4m20s
deploy / build (push) Successful in 5m21s
`clawmates-runtime` shipped with `gcc` and `make` but no `cmake`, no `g++` and
no `python3-dev`. Measured on clawhdf5, three probes:

  no cmake        → "is `cmake` not installed?"        exit 101 after 13s
  no python3-dev  → "cannot find -lpython3.11"          exit 101 at link
  with both       → cargo test PASSES                   exit 0 after 69s

This is not only the delivery gate. The AGENTS run in this image, so a coding
phase was writing Rust it had no way to compile or test — which reframes the
last run's 11 agent commits as unverifiable by construction.

`images/agent-toolchain/Dockerfile` (the microVM path) has had `cmake
build-essential` all along, and its own header warns about precisely this:
"if `cargo` is present in one image and absent in another, the same mission
passes or fails depending on which backend it landed on, and nothing says why."
Both images now install the same set — it was missing `python3-dev` too.

`images/runtime-toolchain.Dockerfile` is a thin local overlay so the laptop can
run today without recompiling zeroclaw from the fork; it is meant to be deleted
once a runtime image built from the corrected deploy/ Dockerfile is published.

Also: a build failure is no longer reported as a red suite. Both are cargo exit
101, and `verify_tests` mapped every non-zero to `Failed(code)` — so a missing
toolchain was recorded as the USER's tests failing. It now returns
`CouldNotRun` with the reason when the output shows a compile or link failure.
Deliberately narrow: a failing `assert!` still reads as red, because letting
broken code past `on_green_tests` is the expensive direction to be wrong in.
Both directions are pinned by tests built from today's two real samples.

And the coding phase finally has a loop: `research_and_code.toml` declared
`loop = "until_no_more_int_items"`, which `phase_config.rs` lists as
DECLARED_BUT_UNREAD. Iteration is driven by `max_iterations` + `done_when`, and
with `max_iterations = 1` and no `done_when` the phase ran ONCE and was never
judged — reporting `completed` whatever it produced. Now 3 passes against a
stated goal, wording per the measured rule (say what the tree must CONTAIN).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 11:02:07 -07:00
Omar SobhandClaude Opus 5 10341cf7fe fix(missions): a retry's work is no longer silently destroyed
deploy / test (push) Successful in 4m12s
deploy / build (push) Successful in 5m22s
Two independent bugs, either of which loses everything a retried phase
produced, and neither of which reports a failure.

1. Capture is suppressed forever on a retry. Both
   `capture_finished_coding_phases` and the sweeper's last-chance
   `capture_outstanding_phases` skip any phase that already has a
   `code_diff` artifact. That guard is right for a phase that ran once and
   catastrophic for a retried one: the artifact from the FAILED attempt
   suppresses capture of the new attempt, the container is reaped on its
   normal grace, and everything the agents committed inside it is gone.
   The UI keeps showing the old diff, so the mission reads as delivered.
   `retry_phase` now clears the reopened phases' captures in the same
   transaction that reopens them, which is what makes its own doc comment
   ("the phase card starts fresh on the retry") true of the artifacts too.

2. `git add` exits non-zero over a gitignored path while staging correctly.
   Measured: with a populated `target/`, `git add -- . :(exclude)target`
   exits 1 and stages the right files; `-c advice.addIgnoredFile=false`,
   `--ignore-errors`, `-A` and `:/` all behave identically. Propagating
   that with `?` aborted the commit AFTER a successful staging — no branch,
   no commit, no push — for every Rust repo an agent has built in.
   `capture_phase_diff_at` already treats the same command as advisory;
   the commit path now does too, and the staged index decides.

Mission 01a00538 hit both: it completed research and coding on the retry,
11 agent commits and all, delivered a patch dated the previous day, and
lost the commits when the container was reaped. The remote was never
touched — its HEAD still equalled the mission's own base_sha.

Covered by a test that drives real git and asserts the files are staged
regardless of the exit code.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 09:41:36 -07:00