Measured on prod: 7 of 9 verdicts ran to the 12-check cap. Every round
resends the whole history, and each check's output is bounded at 12 KB — so
by the last round the judge was paying for ~144 KB of outputs it had already
read, on top of up to 120 KB of evidence, and it paid that on every round.
That is the quadratic term in a verdict's cost, and the reason a single
blocked phase could empty a weekly plan.
Before this round's results go in, every earlier tool result compacts to an
800-byte head plus a marker saying the rest was shown when the check ran.
The round that just ran stays whole; a result already carrying the marker is
left alone. The budget of checks is unchanged — each one is cheaper to
remember, not fewer to run.
Also: docs/NEXT-SESSION.md rewritten for the state as of today.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
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
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
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
`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]>
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]>
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]>
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
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
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
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>