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]>