TestTogglingKeepFinishedAgentsPersists "reloaded" by calling the setter
again, which sets the value on its returned config, so it passed even if
nothing reached disk; it now decodes the file after each toggle. The two
completion-model tests called setModel directly, so deleting the
completion handler's model update or its non-empty guard left them green;
they now send specialistCompleteMsg through Update.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The stall watchdog was armed when the plan runner began, before the
executor resolved the manifest, built the arguments and prompt file and
recorded start accounting, so the parent's own preparation was charged as
the child's silence and a slow launch could be killed as "stalled" before
the child existed. That is how TestAChattyChildOutlivesItsStallTimeout
failed on a loaded Windows runner with the child never called. The
watchdog now holds until the executor's new TaskRunOptions.OnLaunch hook
fires immediately before launch; a child that launches and never speaks
is still caught.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
shellStartsUnderEngine discarded the CommandPlan it built, so the shared
lease that planning takes on the workspace's sandbox runtime directory
was never released: each probe left an open, locked file for the rest of
the process, keeping the runtime root "in use" to other Zero processes'
cleanup and holding a handle under the cache directory on Windows.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Without a standalone helper, the sandbox launches os.Executable() with a
hidden __windows-command-runner / __windows-sandbox-setup /
__sandbox-helper subcommand. Tests that run the cli test binary as the
zero CLI (TestLocalAdoptionSavesAndReloadsEligibleModel) therefore made
the test binary the helper, and it ran the whole package suite instead.
With #829's sandboxed shell probe at prompt build, every `zero exec` in
that child hit it: two 15s probe timeouts, then the 30s test kill on
Windows. TestMain now routes the hidden subcommands exactly as
cmd/zero/main.go and runWithDeps do.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The card re-decoded the raw orchestrate arguments with its own decoder,
which accepted only JSON numbers, while admission also accepts numeric
strings: a plan asking for "500000" tokens was approved as "no token
limit". It also looked for background inside the budget object, where no
call puts it, so a background plan (inline or saved) was never flagged.
The card now uses specialist.DecodePlanBudget and PlanRunsInBackground,
the same decoders the tool runs, and shows a budget admission will refuse
as invalid with its reason.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
TaskOutput published len(summary.Tools) as the child's tool count, but
Tools holds distinct names while the AGENTS panel counts every call live,
so a background agent that ran read_file forty times showed "1 tool". The
stream summary now counts calls (StreamResult.ToolCalls), TaskOutput
publishes that as Meta["tool_calls"], and the TUI reads that key. The
meta test now drives TaskOutput itself instead of only the summary.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
SessionBudget.admit incremented before judging, so each refused spawn
was counted as a start: Started() over-reported and every later refusal
quoted a larger "has started" figure. Admission is now one
compare-and-swap that counts only a start it admits, so the limit also
holds exactly under concurrent spawns.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
taskIdentity sorted depends_on as if it were a set, but the briefing
walks it in declared order and spends one shared budget, so reordering
decides which dependency outputs a task receives. A reordered task kept
its old fingerprint and resume replayed a result built from a different
briefing. The identity now hashes depends_on in declared order (tools
stay sorted: the grant is a set). A plan recorded before this change
re-runs its multi-dependency tasks once on resume, which is the safe side.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The isolator prepared a fixed plan-<name> worktree under a PID-less lease
and its Release did nothing, so the lock outlived the run and every rerun
of the plan was refused as "locked by another active run". Each execution
now gets its own tree (timestamp + random suffix), the lease names this
process like `zero exec --worktree`, and Release drops the lease while
keeping the tree: Clean never removes an unlocked tree with uncommitted
work, so the unreviewed output is retained without a lock nobody clears.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The live read-root supplier was wired into the orchestrate tool only: a fresh
ordinary Task got no read-only roots, and BuildResumeArgs emitted only write
roots, so a child could read a granted external path inside a plan but not when
launched normally or resumed. Executor now has an ExtraReadRoots supplier like
ExtraWriteRoots, wired from the CLI for both TUI and headless runs, and both
argument builders emit it only as --add-read-dir (skipping roots already passed
as write roots), under the same within-turn lifetime rule as write grants.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Scope.ExtraRoots returned AddTemporaryWrite roots like permanent ones, so a
turn-scoped request_permissions grant left in a child's argv as --add-dir and
stayed writable for the child's whole life after the parent released it.
Semantics now: a child inherits a turn-scoped grant only when it is certain to
exit inside the turn, i.e. a foreground Task or resume that the tool call waits
on (TaskRunOptions.WithinTurn). Background Tasks, plan tasks (a plan may run in
the background) and swarm members get only grants that outlive the turn, via
the new Scope.SessionExtraRoots. The zero value is the conservative one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
appendExtraWriteRootArgs skipped a root only when it was byte-equal to the
child's --cwd, so a parent write root that is an ancestor of the parent
workspace (--add-dir ~/dev, config additionalWriteRoots) or lies inside it
followed a write-capable plan task into its isolated worktree and let it write
into the parent checkout. The executor now knows the parent workspace, and a
child whose --cwd lies outside it gets no write root that contains or is
contained in that workspace (canonical paths, separator-bounded, case-folded on
Windows and macOS); unrelated roots are still forwarded.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Restart, bare resume and named resume staged generated plans under fixed
names in the user's plan storage. Exclusive publication stopped them
replacing a user's plan, but refused instead, and Zero's own staged file
then blocked every later restart or resume in the project. Staging now
publishes under the preferred name or the first free variant, tracks
the bytes it wrote, and removes that file once its plan is admitted
unless someone edited it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
removeCompletedLater waited CompletedRetention and then called Remove,
which only drops the live process entry; the immutable record in the
completed store survived until 64 newer completions evicted it. remember
now schedules each record's own expiry when it publishes it, stamped with
a generation so a timer only removes the publication it belongs to, and
expiry also frees the record's slot in the FIFO order.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
budgetLeft was decremented on every harvest and never read after its initial
"bounded" check; dispatch is decided by the per-pool harvested spend and the
report by TokensUsed. Three comments still called it the authoritative number
that decides dispatch. The variable is removed and the comments now name what
actually enforces the budget. No behaviour change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
terminalStatus returned PlanCancelled whenever nothing succeeded and anything
was cancelled, so a real task failure followed by a user stop surfaced as a
deliberate stop in Meta["plan_status"] and the durable plan event. Cancelled
now also requires that no task failed; such a plan reports PlanFailed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
runTaskWithRetries bounded stall retries with the attempt number, so the
one-shot decline, provider and model-fallback retries each consumed a stall
retry: a decline followed by a stall got zero of the documented one. Stall
retries are now counted on their own, and the exhaustion message reports the
number of stalled attempts rather than every attempt.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
runTaskWithRetries passed the full cap on every attempt and NewPlanRunner
started each attempt's meter at zero, so a task that stalled after spending
most of its cap could spend (max_retries+1) times it. Each attempt now carries
TaskTokensSpent, the runner seeds its meter with it, and a retry whose task has
already spent the whole cap is not launched.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
req.Spend.add ran as a side effect inside the second switch case's condition,
and Go stops at the first true case, so the event that tripped
max_tokens_per_task and every event after it never reached the plan meter.
Downstream dispatch, gated on limit - upstream, then ran on an undercount. The
add is hoisted above the switch so both limits judge a meter that saw it all.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
refuseUnreachablePerTaskCap multiplied taskCount by max_tokens_per_task with
no overflow check; past math.MaxInt the product wrapped negative, every
max_tokens compared >= it, and the unreachable cap was admitted. A product
that cannot be represented now refuses up front, matching the rule
refuseUnrepresentableSeconds already applies to timeouts.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
planIntSet truncated JSON floats with an unchecked int() and returned
"absent" for any other type, so max_wall_seconds 0.9 disabled the wall bound,
max_workers 1.9 ran one worker, and max_tokens "500000" silently erased the
spending limit (and dropped it from a saved plan's Args()). planIntField now
decodes every integer budget field: whole finite numbers and numeric strings
(the intArg convention) are accepted; fractions, NaN/Inf, out-of-range values,
null and other types are refused; absence still means absence.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
planStringsStrict returned (nil, nil) whenever args[key] was not a []any, so
a present string, object, number or null depends_on/tools was admitted exactly
like an omitted key: the task lost its declared dependency, or a malformed
tools narrowing became the default inherited grant. The parser now separates a
missing key from a present wrong-typed one and refuses the latter; omission and
an explicit empty array keep their meaning. Covered inline and via a saved plan.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
harvest recorded every completion to the pad before its outcome was
decided, so a failed task's diagnostic output was listable by unrelated
tasks while the dependency briefing deliberately refuses to pass it on.
Recording now happens per branch once the outcome is final, only for
succeeded and cancelled tasks, matching the briefing policy.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
ParsePlan's uniqueness check was exact-string, so "Alpha" and "alpha" were
admitted as distinct tasks while sharing one scratchpad file on
case-insensitive filesystems, serving one task's answer as the other's.
Task ids are user-visible identifiers (event log, plan panel, depends_on),
so admission now refuses case-variant ids with an error naming both.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Record used os.WriteFile, so a symlink or hard link planted at a
not-yet-recorded task's path redirected the unsandboxed executor's write.
It now creates each file with O_EXCL and refuses anything already at the
destination. The package comment claimed no task could write to the pad;
write-capable tasks can, so it now states what is and is not guaranteed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The watcher armed a timer for the pre-pause remainder and the pause credit
was applied only after WaitWhilePaused returned, so a pause longer than the
remaining budget fired the timer mid-pause and cancelled the plan. The clock
now owns hold/release and in-flight state under one lock, the executor holds
it before blocking, and the watcher decides expiry under that lock. Only time
with dispatch held AND no task in flight is credited, so a task running
through a pause (max_workers > 1) still spends the budget.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
#909 now admits only the runner's package-level result as timing
evidence, because test2json cannot prove a per-test event came from the
runner. The agent and plan measurement tests claimed a per-test figure,
which is now (correctly) silent, so they claim the package figure instead.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The copies vendored from #897 (3eae423c) and #909 (8fd02228) had fallen
behind both heads. Reviewers accepted carrying them only while they stayed
byte-identical to their owning PRs, so bring them back in line. The only
difference left is the Gitlawb -> Twigpine module path (plus the gofmt
realignment it causes).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Brings gnanam1990's 195-commit zeromaxing/orchestrate branch (head
6916f853) onto main e034a938, with every original commit unchanged.
All conflict resolution lives in this merge commit.
Resolutions that combine both sides:
- module path: Gitlawb/zero imports rewritten to Twigpine/zero
- config: project maxTurns stays tighten-only and records maxTurnsSet
only when it applies; the partial (no-provider) config keeps Profiles
- config writer: SetKeepFinishedAgents takes main's config file lock
- exec: execTurnBudget applies before the zeromaxing refusal/delta notice
- exec_command: tty-gated interactive guard (main) plus the
sandbox-selected shell preflight (PR); the Windows description
guidance is the <environment> pointer, kept paired with
hostExecCommandShellGuidance for the token ratchet
- specialist: LifecycleHooks (main) alongside the PR's executor fields;
main and the PR both fixed resume --model, so it is emitted once
- cli: registerSpecialistTools keeps the PR's signature and wires main's
lifecycle hooks bridge into the executor
- tui: main's file-view refresh, run-details clicks, notify fields and
locked model selection, combined with the PR's plan messages, chip,
background-plan /new guard and spinner-tick ordering
- resume model tests: both add/add files kept (resume_parent_model_test.go)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* perf(tui): bounded file read, LRU rendering cache, and size caps for full-file view
- Bound full-file view reads (4000 lines, 1 MiB total, 4 KiB per line) with async loading and an LRU render cache capped at 64 entries and 8 MiB.
- Refresh both BTW surfaces when a tool result (such as write_stdin) carries both exact changed paths and an unknown-scope or aggregated directory summary outside Git.
- Gate Run details FILES mouse hit-testing on Run details being the visible foreground overlay so higher-priority modals (speech API-key prompt, leader help) block clicks behind them.
(cherry picked from commit 100d847aaf)
* fix(tui): gate full-view read errors on snapshot validity
renderFileViewFull returned renderedContent whenever hasError was set,
before the snapshot-validity check that governs every other completed
snapshot. A /btw fork of a full view that had settled on a read error
invalidates the inherited snapshot and clears renderedContent, but the
error latch survived, so the side pane rendered an empty body instead of
the loading placeholder until its own load landed. Reloads of an errored
view (resize, refresh, theme) likewise kept showing the stale error.
Serve the error snapshot through the same validity predicate as content
snapshots, so a pending or invalidated load falls through to "Loading…".
Error displays still do not count as cache hits.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit a1887f4b03)
* fix(tui): keep file-view revisions monotonic and revalidate on git sweeps
Shared path revisions reset to zero on every cache purge, while each surface
kept its own required revision. Three sites could also raise that revision
past the shared one by incrementing it locally. After a purge, an exact-path
edit made on the other BTW surface could produce a shared revision no higher
than this surface's own. recoverInvalidatedFileView then saw nothing new, and
the view stayed stale. Revisions now come from one cache-wide clock that
survives purges. A purge raises the epoch floor to that clock instead of
resetting it, and each surface adopts the revision the cache returns.
Every git sweep invalidated each swept path and forced a full refresh. The
swept list is cumulative since the baseline, and each live sweep is launched
beside the tool-result, turn-end or BTW-return handling that already reloads
or rechecks the open view. The sweep that followed therefore reread and
re-highlighted an unchanged file and flashed "Loading…". A sweep now
revalidates the open view instead. It waits for an in-flight load to land,
then rereads the bounded source in the background and compares it with the
displayed snapshot's identity (hash and truncation flags). The view reloads
only if the bytes changed. A mutation the swept list omits is still caught.
Regression tests (all fail on 311290c0 and pass here):
- TestFileViewCrossSurfaceExactEditAfterPurgeReloadsOtherSurface
- TestFileViewGitSweepRevalidatesWithoutDuplicateReload
- TestFileViewGitSweepDuringLoadDefersToThatLoad
- TestFileViewStaleRevalidationDoesNotReloadNewerSnapshot
- TestFileViewCachePurgeResetsPathRevisions (now asserts monotonicity)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* refactor(tui): return source identity from loadAndRender directly
1ce4f96e added loadAndRenderSource and left loadAndRender as a two-value
wrapper. After that change only tests called the wrapper, so make deadcode
newly reported it. Fold the wrapper away: loadAndRender now returns the
rendered text, the source identity, and the error, and its callers take the
value they need. Behavior is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(tui): close three file-view staleness gaps from review
- fileViewSourceHash joined lines with separators, so an empty file (no
lines) and a file holding one newline (one empty line) hashed alike.
Sweep revalidation compares identities without the file size, so adding
or removing that blank line went undetected. Every line is now
terminated, which keeps the encoding injective.
- A load cancelled inside the bounded read returned errFileViewSuperseded
with no lines. loadAndRender then treated it as a read error and evicted
the path, discarding an entry a newer load had already committed. The
sentinel now returns before the eviction branch.
- A cancelled run keeps going until it finishes, and agentRowMsg ignores its
late tool results. Its completion only flushed session events, so a write
that finished during cancellation left an open full-file view stale. The
flush completion now runs the unknown-scope refresh.
Regression tests (each fails on ca1cdaf3 and passes here):
- TestFileViewGitSweepDetectsBlankLineAddedToEmptyFile
- TestFileViewSupersededReadKeepsNewerCacheEntry
- TestFileViewCancelledRunCompletionRefreshesOpenView
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(tui): gate Run details Enter on the visible overlay
The Run details Enter drill-in keyed off the raw runDetailsOpen flag,
while mouse FILES picks use runDetailsVisible(). The flag outlives the
overlay: clicking a specialist card beside Run details enters the
subchat, which hides the overlay but leaves runDetailsOpen set, and
Enter then opened the parent's file view from inside the subchat.
Use runDetailsVisible() and the shared runDetailsInnerWidth() geometry
on the keyboard path, matching the mouse path. The branch's existing
Esc/Ctrl+B and key-swallow behavior is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: hazyhaar <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(hooks): dispatch specialistStart/Stop and allow matchers
These events were documented and accepted by hooks add, but never fired.
Wire advisory dispatch from specialist accounting and allow specialist
name matchers so EXTENDING.md matches runtime behavior.
* fix(hooks): unlock before specialistStop hooks and fire once
Release accountingMu before running lifecycle hooks, and dispatch
specialistStop only when the stop event is newly recorded so concurrent
finishers cannot re-run arbitrary hook commands.
* fix(hooks): fire specialistStop without relying on session append
Decouple stop-hook dispatch from parent-session event append so swarm
members (empty parent) and TaskOutput still get exactly one hook via a
shared LifecycleHooks claim, and wire that bridge into TaskOutput.
* fix(hooks): claim specialistStop durably and gate hooks on real launches
Address the review findings on specialist lifecycle dispatch:
- specialistStop is now claimed in the parent session's event log via a
record-once specialist_stop_hook event, sharing the accounting run-ID
equivalence (empty runId is a catch-all). A later reaper in another
process (TaskOutput poll, recovered TaskStop, restart) no longer re-runs
the hook. The in-process claim remains only as fallback for swarm
members with no parent session and for session-store errors.
- Recovered TaskStop records the terminal transition after Kill, so an
explicitly stopped recovered task emits specialistStop exactly once.
- specialistStart fires only after the child process actually started
(onStarted after Start()); a launch that fails before exec records
attempt accounting but no public start/stop hooks. Reapers derive the
same eligibility from the durable stop event's childStarted flag.
Adds a CLI startup wire-up test asserting both real entry points
(interactive TUI, headless exec) attach the lifecycle bridge to the same
LifecycleHooks the TaskOutput tool holds, fixes the stale import path on
the rebased branch, and documents the once-per-run semantics.
* fix(hooks): gate background exit accounting behind the start decision
A background child that exits immediately can run its launcher exit
callback before Executor.LaunchBackground returns, so the terminal
accounting could claim and dispatch specialistStop before runBackground
dispatched specialistStart, and the recorded stop missed the pid that
SetPID had not stored yet.
The exit callback now waits on a settled channel closed when runBackground
returns (after the start hook and SetPID), from its own goroutine so a
synchronous injected launcher cannot deadlock waiting for a signal only
the launch return can close.
Regression test: TestRunBackgroundDefersExitUntilStartDecisionCompletes
drives a synchronous fast-exit launcher and asserts start-before-stop hook
order, the recorded stop pid, and no deadlock. It fails on the previous
code with hooks "specialistStop,specialistStart".
* fix(hooks): record specialistStop only after the orphan kill succeeds
When SetPID fails after a background child has started, runBackground kills
the unrecorded child and records the stop. If TerminateProcess fails, the
child may still be running, but the branch still marked the task errored,
recorded the stop, took the durable specialist_stop_hook claim and fired
specialistStop. The hook could then run while the sub-agent was alive, and
the one-shot claim suppressed the hook on the child's real exit. Marking the
task non-running also let a TaskOutput or TaskStop reap claim the stop
before the launcher's Wait observed the exit.
On a failed kill, surface the joined error and return without the status
update or the stop. The task stays running, so reapers remain no-ops, and the
launcher's exit callback marks it exited and records the single stop when the
child ends. A successful kill keeps the immediate stop as before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(hooks): keep the prompt on a failed orphan kill and share one stop claim
When SetPID fails and terminating the unrecorded child also fails, the child
may still be running and the task stays running. That branch still deleted
the --file prompt, so a child that had not yet read it failed at startup.
The launcher's exit callback already removes the prompt once the child
really exits, and Runtime.Close removes tracked prompts at shutdown, so the
failed-kill branch no longer removes it.
dispatchSpecialistStopHook made two independent dispatch decisions: the
durable specialist_stop_hook claim, and the in-process claim used when the
session store errors. A won durable claim never recorded the in-process
claim, so on one bridge a transient store error before or after a durable
claim dispatched the same stop twice, breaking the documented at-most-once
per process. A won durable claim now also takes the in-process claim
(including for an ineligible stop), and the hook runs only if both are free.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Ayush7614 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(plugins): add CLI enable/disable for plugin.json
Add zero plugins enable|disable so installed plugins can be toggled
without removing them, matching hooks/MCP parity and EXTENDING docs.
* fix(plugins): serialize toggles with installs and preserve manifest bytes
Address review findings on the enable/disable CLI:
- Take the same cross-process installtxn lock plugins add/remove take on
the plugin's install root before the final lookup and read, and publish
plugin.json with installtxn.WriteFileAtomically while holding it.
- Re-resolve the plugin under the lock so a concurrent install can never
be overwritten with manifest bytes read before it committed, retrying
when precedence moves to the other root; a plugin removed under the
lock reports ErrNotInstalled instead of being recreated.
- Edit the enabled value in place (a targeted token walk) so field
order, formatting, and numeric literals survive; the old
decode/re-encode dropped precision (9007199254740993 became
9007199254740992) and sorted the keys.
- Fail closed on manifest stat errors, refresh a clean plugins.lock hash
after a toggle, and leave pre-existing drift in place instead of
absorbing it into the lockfile.
- Redact the text-mode output, reject an empty plugin id as a usage
error, and document project-over-user precedence in the help text.
* fix(plugins): recover interrupted install transactions before toggling
SetEnabledByID takes the plugin root's installtxn lock but went straight
to reading and rewriting the tree. installtxn.Recover requires every
holder of that lock to resolve interrupted transactions first and to
abort on its error, as Install and Remove already do.
Without it, a toggle after an install killed between its tree swap and
its lockfile publish edited the unpublished tree, and the next install
or removal restored the retained backup over it, silently undoing the
enable/disable. A transaction recovery could not resolve also failed to
stop the toggle.
Call installtxn.Recover with the plugins reconciler right after taking
the lock, and add regression tests for every interrupted state that
leaves the plugin discoverable, the unresolved-transaction abort, and a
plugin removed while the toggle waits on the lock.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(plugins): say when a plugin toggle takes effect
Zero reads plugin.json "enabled" only when it activates plugins at
startup (runExec and the interactive TUI bootstrap), and nothing reloads
them afterwards. A running interactive session therefore keeps the
tools, hooks, and skills of a plugin disabled from another terminal.
State that in the plugins docs and in the enable/disable help so the
documented activation rule matches what the command does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): pin toggled manifests, redact toggle errors, honor last enabled key
Address the review findings on the plugin enable/disable toggle.
Pin the plugin directory before editing its manifest. The install lock
only serializes cooperating installers, so swapping the plugin directory
for a link after discovery redirected the read and the atomic publish
through it, overwriting another manifest. The toggle now opens the
directory under its install root with pathjail, refuses a link or
reparse point, checks that the handle is the directory entry itself,
and reads, stats, creates the temporary file, and renames through that
handle.
Redact every stderr message the toggle writes. Parser and not-installed
usage errors echoed the raw argument, so a mistyped credential reached
stderr unredacted while the toggle's other output was redacted.
Validate only the last "enabled" value, as the loader does. The loader
keeps the last duplicate key, but the toggle rejected an earlier,
overwritten non-boolean value, so a plugin discovered as enabled could
not be toggled.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): keep the toggle on the pinned plugin directory throughout
The previous fix pinned the manifest read and publish to a handle on the
plugin directory but still hashed the tree for plugins.lock by path, and
reported success without checking that the path still named the pinned
directory. If the directory was replaced after it was opened, the edit
landed in the moved-away original while the lockfile check and refresh
read the replacement, and the command reported a state the installed
plugin did not have.
Hash the tree before and after the edit through the pinned handle, and
confirm that the plugin's directory entry is still the pinned directory
before refreshing the lockfile or reporting any result, including a
no-op. hashTree now delegates to the same rooted walker, so path and
handle callers hash identical bytes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): hash plugin trees through native os.Root calls
20b484f moved hashTree onto an os.Root handle but walked it through
handle.FS(). That adapter accepts only fs.ValidPath names, which must be
UTF-8, so a plugin holding a Linux-valid name such as byte 0xff failed
with "lstat ...: invalid argument". copyTree and the previous walker
accept those names, so Install, the toggle's lock refresh, and Info's
drift check all regressed for such plugins.
Walk the handle with its own Open/Lstat methods instead. The walk stays
pinned to the directory, and the hash bytes match the merge-base
implementation (checked on a fixture with nested and non-UTF-8 names,
an exec bit, a symlink, .git and a FIFO).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Ayush7614 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(aimlapi): shared service key and attribution for aimlapi.com services
Adds the pieces the Toolbox and MCP integrations share:
- internal/aimlapi: X-AIMLAPI-Source (agent/zero), the fixed Toolbox and
MCP URLs, and an exact-hostname check (https, default port, no
userinfo) for api/tools/mcp.aimlapi.com before the key or attribution
headers are attached. The partner id still comes from ResolvePartnerID.
- internal/config: AIMLAPIServiceKey picks the key from the aimlapi
profile (or AIMLAPI_API_KEY without one), reading the credential
store, and withholds it when any aimlapi-keyed profile points at a
proxy base URL or AIMLAPI_INFERENCE_URL is overridden. Keys that are
not a single token are refused.
* feat(mcp): AI/ML API MCP server as an opt-in built-in default
Seeds `aimlapi` (https://mcp.aimlapi.com/mcp, streamable HTTP) next to
Exa as a built-in MCP server that ships disabled: its tool runs and
generations are billed to the user's AI/ML API balance. The disable is
marked as explicitly set, so a project config cannot lift it; the user
turns it on with `zero mcp enable aimlapi`, which now writes
`"disabled": false` for a default that ships off (and `zero mcp add
aimlapi` does the same). `zero doctor` reports the untouched default as
a pass rather than a warning.
The MCP client attaches the AI/ML API key and the X-AIMLAPI-Source /
X-AIMLAPI-Partner-ID headers in one place (internal/mcp/aimlapi.go),
called from the three header-application points. Both apply only to a
request whose own URL is exactly https://mcp.aimlapi.com on the default
port, whatever the server is named. The key is resolved for every
request through config.LoadAIMLAPIServiceKey, so switching the aimlapi
provider to a proxy, or a key that is not a single token, means no
Authorization header on the next request. It is never put in config,
the server identity used for OAuth token binding, list output or errors.
A user Authorization header, `"auth": "oauth"`, or a project-touched
server goes without it. A 401 from that host explains how to add a key
or sign in with `zero mcp oauth login`.
* fix(aimlapi): validate whitespace and preserve store errors
* fix(mcp): tailor unauthorized hints to credential policy
* docs(mcp): clarify automatic credential eligibility
* test(mcp): cover edited disabled built-in defaults
* fix(aimlapi): report store reads after proxy eligibility checks
* docs(aimlapi): document MCP key precedence and clarify 401 hints
Operator copy now matches normalizeProvider resolution (inline key,
apiKeyEnv, then credential store) and warns that AIMLAPI_API_KEY can
bill MCP before a stored profile key. SECURITY and service-key godoc
stay aligned. 401 hints no longer imply a proxy-only failure mode.
Adds a regression test that resolved profile keys win over the store.
Co-authored-by: Cursor <[email protected]>
* fix(mcp): keep the aimlapi key off redirects and error text, persist enable
Redirect hops: net/http copies the first request's headers onto every
redirect hop without calling the header hooks, so the automatic AI/ML API
key and attribution followed a same-origin hop that fails the exact-URL
rule (userinfo) and an eligible hop reused the first hop's key. The
redirect guard now restores the server's own values for those headers and
applies the per-request rule again.
Error text: httpStatusError copied up to 1 KiB of the response body into
the error, so a body echoing the injected key (or its prefix, when cut by
the read limit) reached CLI output. When the request carried the automatic
key, the body is now omitted instead of matched.
Enable persistence: MCPServerConfig had no serializer and Disabled is
omitempty, so any read-modify-write of config.json (SetTheme,
SetActiveProvider, ...) dropped `"disabled": false` and the shipped
disable turned aimlapi off again. MarshalJSON now writes the flag whenever
it was explicitly set; `zero mcp add` keeps writing only a disable.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Stan <[email protected]>
Co-authored-by: Hugo <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(web-fetch): AI/ML API Toolbox as an opt-in web_fetch backend
With ZERO_WEBFETCH_PROVIDER=aimlapi, web_fetch fetches the page as Markdown
through the Toolbox tool search1api/crawl instead of a direct GET. A key
alone never selects it; unset or unknown values keep the direct fetch.
- The URL checks (pre-permission and resolved host) run before the URL is
sent, so loopback, private and metadata URLs never reach the crawler.
- The key is resolved on every call through LoadAIMLAPIServiceKey; a
missing, proxied or invalid key is an error with no request and no
fallback. A failed run (is_error, failed status, no content) is also an
error.
- Requests go only to aimlapi.ToolboxBaseURL, checked with
IsAttributedURL before the key and attribution headers are attached;
redirects are refused. The client uses the default transport, so the
proxy environment applies and the target-site SSRF pin does not.
- Every run carries max_cost_usd 0.005, wait_ms 30000 and a fresh
idempotency key. 202 runs are polled (hint clamped to 250 ms..5 s,
120 s overall). 429/502/503/504 are retried with the identical body,
honouring Retry-After (capped at 10 s); max_cost_exceeded is not.
- Output keeps the result shape with a Backend line, respects max_bytes,
refuses format raw, and scrubs the key and redacts all Toolbox text.
* fix(web-fetch): honor poll retries and reject raw before permission
* docs(web-fetch): explain the bounded run response
* test(web-fetch): rename the Toolbox fake that collides with web_search tests
#1189 added a fakeToolbox type with a recorded method to the tools test
package. The web_fetch Toolbox tests declared their own fakeToolbox with a
different shape, so the package's tests no longer compiled once both were on
main. Rename the web_fetch one to webFetchFakeToolbox, matching the file's
webFetch prefix. Test-only; no behavior change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(web-fetch): describe the aimlapi backend in the tool schema
With ZERO_WEBFETCH_PROVIDER=aimlapi the max_bytes and format descriptions
still promised direct-fetch behavior: raw body bytes before conversion and
HTML conversion with a raw option. The Toolbox backend returns crawled
Markdown, applies max_bytes to that Markdown and refuses format "raw", as
the README says. Say so in both descriptions so the model sees the same
contract.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(web-fetch): keep the AI/ML API key out of Toolbox errors and output
The Toolbox backend scrubbed the key with a literal string replace. A key
echoed back split by an invisible character (NUL, ZWSP) passed it on every
path the model sees: error bodies, run errors, page content and the run_id
meta field. scrubWebFetchKey now uses scrubAIMLAPIResult, the same
split-aware redaction the web_search backend uses.
A failed Toolbox request also returned the *url.Error from client.Do. That
error quotes the request URL, and a polling URL carries the run id
percent-escaped, a form no key scrubbing can match. send now reports the
cause without the URL, as web_search does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(web-fetch): state only established billing for Toolbox runs
The README said every fetch is a billed Toolbox run, while the next
sentence says a run over max_cost_usd is refused and not charged, and a
code comment said a 404 comes back as an unbilled is_error run. The
AI/ML API Toolbox documentation says you pay for runs but not whether a
failed run is billed. Both now say only that Toolbox runs are billed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Stan <[email protected]>
Co-authored-by: Hugo <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(aimlapi): shared service key and attribution for aimlapi.com services
Adds the pieces the Toolbox and MCP integrations share:
- internal/aimlapi: X-AIMLAPI-Source (agent/zero), the fixed Toolbox and
MCP URLs, and an exact-hostname check (https, default port, no
userinfo) for api/tools/mcp.aimlapi.com before the key or attribution
headers are attached. The partner id still comes from ResolvePartnerID.
- internal/config: AIMLAPIServiceKey picks the key from the aimlapi
profile (or AIMLAPI_API_KEY without one), reading the credential
store, and withholds it when any aimlapi-keyed profile points at a
proxy base URL or AIMLAPI_INFERENCE_URL is overridden. Keys that are
not a single token are refused.
* feat(web-search): AI/ML API Toolbox as an opt-in web_search backend
ZERO_WEBSEARCH_PROVIDER=aimlapi runs the Toolbox tool web/search at the
fixed https://tools.aimlapi.com/v1/tools URL. It is selected only by that
setting; an AI/ML API key alone never enables it or registers web_search.
- The key comes from config.LoadAIMLAPIServiceKey on every search; a
missing, proxy-profile or invalid key fails before any request.
ZERO_WEBSEARCH_BASE_URL and ZERO_WEBSEARCH_API_KEY are ignored.
- The key and attribution headers are attached only after
aimlapi.IsAttributedURL accepts the URL, and redirects are refused.
- Every run carries max_cost_usd 0.025 and a fresh idempotency key;
limit maps to max_results and domains to include_domains, with the
local domain filter kept.
- A queued run (202) is polled until it ends, honouring
next_poll_hint_ms clamped to 250 ms..5 s. This backend gets a 30 s
budget with wait_ms 20 s; the generic backends keep 10 s.
- Non-2xx responses, is_error runs, failed/stopped/timed_out runs and
malformed bodies are errors carrying the error code and request id,
with the key scrubbed.
* fix(aimlapi): preserve store errors and isolate Windows config tests
* fix(web-search): redact results and preserve domain errors
* docs(web-search): clarify minimum search charge
* fix(aimlapi): reject Unicode whitespace in service keys
* fix(aimlapi): report store reads after proxy eligibility checks
* fix(web-search): keep plain no-results for unscoped backends
Only a backend that applied the domain allowlist itself (the AI/ML API
Toolbox) should turn an empty result into the "no results matched
domains" error. A generic backend never saw the allowlist, so an empty
result from it is an ordinary "No results for query", as before this
branch.
Also clear ZERO_WEBSEARCH_PROVIDER in the unconfigured registry test:
ZERO_WEBSEARCH_PROVIDER=aimlapi alone now registers web_search, so an
exported value flipped the test's oracle.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(aimlapi): resolve the Toolbox key without an active provider
A user config that lists several providers but marks none active (or
names a removed one) makes Resolve return ErrNoActiveProvider together
with the normalized profiles. The app keeps running on that partial
config, but LoadAIMLAPIServiceKey stopped on the error, so web_search
failed even with an aimlapi profile or AIMLAPI_API_KEY set. Look the
key up in the partial config instead; the proxy check still scans every
resolved profile, and all other resolve errors are still returned.
Also state the key rules in the README as implemented: user config
only (project .zero providers are not used), which aimlapi profile is
used, and that any aimlapi profile or any profile reading
AIMLAPI_API_KEY on a non-canonical base URL withholds the key.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(aimlapi): withhold the Toolbox key for dropped proxy profiles
Resolve drops a non-active profile it cannot normalize (an unknown or
unsupported catalog entry, a missing base URL, ...), so the proxy check
never saw it. A dropped profile that reads AIMLAPI_API_KEY from a proxy
still shows the key belongs to that proxy, yet the key was sent to
tools.aimlapi.com. LoadAIMLAPIServiceKey now also checks the configured
user profiles, merged the same way Resolve merges them, and withholds
the key when a dropped profile carries it and is not explicitly on the
canonical base URL.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* test(web-search): pin the generic backend route in BASE_URL registration tests
defaultSearchBackend now checks ZERO_WEBSEARCH_PROVIDER=aimlapi before
ZERO_WEBSEARCH_BASE_URL. TestWebSearchRegisteredInCoreNetworkTools and
TestCoreNetworkToolsExposeSafetyMetadata set only the base URL and inherit
the provider from the process environment, so with an exported
ZERO_WEBSEARCH_PROVIDER=aimlapi they took the Toolbox route and still passed
even if generic base-URL registration broke. Clear the provider so they
exercise the generic route they describe, matching
TestCoreNetworkToolsOmitWebSearchWhenUnconfigured.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(redaction): redact caller-supplied secrets split by invisible characters
RedactString replaced Options.ExtraSecretValues only as contiguous text,
and the gap-tolerant pass from #1067 covers only the built-in credential
shapes. An opaque key such as the AI/ML API service key, echoed by a
service with a NUL or zero-width space inside it, matched neither pass.
Both fragments then reached Toolbox web_search errors (before clipping)
and successful results sent to the model and transcript.
Match each caller-supplied secret with runs of the same invisible
separators allowed between its characters, right after the contiguous
replacement. Separators inside the match are covered; those beside it
stay. Text without a separator takes the old path unchanged.
Regressions: split explicit values in the redaction package, and split
keys in the Toolbox HTTP error, run error and successful-result paths.
Raised by Copilot on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(redaction): claim each caller secret whole or split before shorter ones
RedactString replaced every ExtraSecretValues entry contiguously, longest
first, and only then ran the split match. A shorter secret inside a
longer one that was split by an invisible character was replaced first,
so "abc\x00def" with secrets abcdef and abc became "[REDACTED]\x00def"
and the rest of the longer secret stayed visible.
Run the contiguous and split match together per value, longest first,
so a longer value is claimed in either form before any shorter one.
Regressions cover a shorter secret at the start, end and middle of a
split longer one, and alongside it. Raised by CodeRabbit on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(redaction): merge overlapping caller-secret occurrences before replacing
ExtraSecretValues were replaced one occurrence at a time, so each
replacement could consume bytes another occurrence needed. With secret
abcabc, "abc\x00abcabc" became "abc\x00[REDACTED]". A key whose first and
last characters match lost all but one character the same way
("xY7q[REDACTED]"), and two partly overlapping values left the
rest of one visible. Reordering the passes cannot fix this: the
contiguous and regexp replacements are both non-overlapping.
Find every occurrence of every value, whole or split by invisible
separators, in one separator-free copy of the text (linear KMP, all
overlapping starts), map the spans back, merge overlapping ones, and
replace each merged span once. Occurrences that only touch stay
separate replacements; separators beside a match stay; tab, newline
and carriage return are still not gaps. This honours the existing
TestRedactString_OverlappingExtraSecrets contract for both forms.
Raised by CodeRabbit on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(web-search): keep the request URL out of Toolbox transport errors
When http.Client.Do failed, the backend scrubbed err.Error() from the
*url.Error, which quotes the request URL. A polling URL carries the run
id the Toolbox returned, percent-escaped, so a key echoed in that id
appeared as "Lp9%00Xr4" or "Lp9%E2%80%8BXr4". The key scrubber matches
the key whole or split by raw invisible separators, not percent-escapes,
so the key stayed recoverable in the web_search error.
Report only the cause inside the *url.Error. The message prefix, the
redirect refusal text and key scrubbing are unchanged. The regression
fails a polling GET after a queued run whose id carries a NUL- or
ZWSP-split key and checks the output has neither the polling URL nor
the key. Raised by CodeRabbit on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(redaction): always redact a caller secret as written
Once the text held any invisible separator, redactExtraSecretValues
matched each caller secret only with separators removed from both. That
replaced the contiguous match instead of adding to it: a secret made
only of separators compacted to nothing and was skipped, and separators
at either end of a secret fell outside its match. RedactString("a\x00b")
with secret "\x00" returned the text unchanged, where main replaced it.
In the separator path, also record every occurrence of the secret as
written, then merge spans as before. Text without a separator takes the
unchanged path. Raised by CodeRabbit on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Stan <[email protected]>
Co-authored-by: Hugo <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Sandboxed tool runs in the agent tests keep runtime state and lease files
under os.UserCacheDir, so go test ./internal/agent/ left
zero/runtime/v1/*.lease files in the developer's real cache. TestMain
now points every per-user root at its temp dir via
testutil.IsolateUserDirs, as internal/tui does, and a guard test pins it.
Refs #1103
Signed-off-by: ShryukGrandhi <[email protected]>
* fix(agent): mark headless guardrail stops incomplete
The empty-turn and repeated-tool-failure guards returned the run as a
successful final answer even with RequireCompletionSignal enabled, so
zero exec reported exit 0 and a success run_end for work it had just
abandoned.
Set Result.Incomplete and a reason at both stops when the headless gate
is on. Thresholds, stop answers, retry behavior, interactive runs, and
--no-completion-gate are unchanged.
Fixes#1154
* fix(agent): count consecutive empty turns in stop reason
The headless no-output stop reason used result.Turns, which counts every
turn in the run, so a run with an earlier tool-call turn claimed "stopped
after 4 turns with no output" for a 3-turn empty streak. Use the guard's
emptyTurns counter and say "consecutive".
Pinned by a case with a successful tool turn before the empty streak.
* fix(chatgpt): refresh subscription defaults and compatibility version
Replace retired preset suggestions with vendor-documented Sol, Terra, Astra, and Luna IDs. Update the Codex discovery compatibility floor for Terra and verify that saved model selections are retained.
Refs Twigpine/zero#1177 (defaults portion)
* test(chatgpt): assert discovered model in compatibility probe
Verify the live response contains exactly gpt-future-coder in addition to checking the Codex compatibility query value.
Refs Twigpine/zero#1177
* docs(chatgpt): align Terra description with vendor guidance
TestScanSSEDataWithContextContentResetsOnData wrote a data line every 30ms against a 100ms idle timeout, so a ~70ms scheduling stall on a shared runner tripped the idle watchdog. Use a 500ms idle timeout (600ms content window) and 25 writes (~750ms), which keeps every gap far under the idle timeout while the run still outlasts the content window.
Signed-off-by: ShryukGrandhi <[email protected]>
* fix(sessions): capture freeform apply_patch checkpoints
Freeform apply_patch arguments are raw patch text, but both checkpoint
paths passed them to json.Unmarshal and dropped the error, so they found
no mutation targets and recorded no checkpoint. The tool still executed,
leaving rewind unable to restore the edit.
Resolve targets through a shared agent.CheckpointTargets helper that
prepares freeform input on the registered built-in apply_patch, exactly
as executeToolCall does. This keeps absolute patch headers
workspace-relative (rewriting them and setting cwd) instead of handing
absolute paths to the checkpoint layer. Non-freeform calls now use the
same tolerant decoder as execution, so the two cannot disagree.
Covers the CLI, spec-draft, and TUI capture paths.
Fixes#1162
* test(sessions): canonicalize freeform absolute-header fixtures
t.TempDir returns the symlinked /var spelling on macOS and short names on
Windows, while the apply_patch tool resolves the physical root. The
absolute-header cases must build the header from the resolved root, as the
repo's cross-platform test guidance requires.
A finishing member's task reads Done before its slot is freed: the
supervisor completes the task, removes the member and only then frees
the slot in afterExit. The test waited for Done == 5 and checked the
running count straight after, so a busy runner could see one slot still
held. It now waits for the drain itself.
TestExecCommandTTYSkipsInteractiveBlock runs ssh to a bogus host under
tty with a 250ms yield. When ssh is still resolving the host after the
yield, the session keeps running in the test's directory and nothing
stops it, so on Windows t.TempDir's cleanup fails with a sharing
violation. It failed Smoke (windows-latest) on a README-only PR.
Stop the session in a cleanup, and build the tool on execTestRoot,
which retries the removal while Windows still holds the directory right
after the stop, as the other exec tests that leave a session do.
Adds Vercel beside GMI Cloud in the Partners table of both READMEs,
with Vercel's official logotype in light and dark variants, taken
unchanged from its brand page apart from a trailing newline.
@jatmn's six findings, taken at the two root causes he named rather than as six
phrase-specific patches. Three are closed at the root; two are not, and this
message says which and why rather than implying six.
## Closed: a duration is read whole or refused (F1)
Three unanchored regexes each hunted for their own suffix with no shared left
boundary, so a failed outer match restarted inside the same token. Measured
before:
.86s -> 86s 1,200ms -> 0.2s
.5m -> 300s 1m10ms -> 0.01s
1h1m500ms -> 0.5s
Every one turns an honest claim into a fabricated correction, which is the single
failure this package exists to prevent.
One scanner now recognises a token whole or not at all, with explicit left and
right boundaries, and BOTH callers use it — parseClaimedDuration and the clause
scan. They were separate heuristics, so a token the parser refused could still
bound a clause; the two disagreeing about what a duration is was its own defect
class. Ambiguity is still silence rather than a second-best reading.
## Closed: every timed mention is checked (F4)
claimedSecondsFor returned at its first successful occurrence, so an agreeing
mention shielded every later one: "TestFoo took 1.00s; TestFoo later took 9.00s"
reported nothing against a recorded 1s. Extraction now returns every value and
the caller compares, which is why "later" needs no special case. Per-value
dedupe applies within a call as well as across calls, so repeated equivalent
spellings are one finding and two distinct wrong values are two.
## Closed: a package is a measurement subject (F5)
The unrecorded-neighbour guard knew test-shaped names but recognised packages
only when that exact package had been recorded, so a truthful
"github.com/x/first passed github.com/x/unrecorded took 4.20s" charged the
neighbour's figure backwards. Both classes now live in the same subject layer.
## NOT closed: threshold ownership (F3)
"TestQuick stayed under the 10s timeout and completed in 0.86s" still reports
10s. A clause carrying two durations is now ambiguous, which fixes the wordings
where both figures share a clause — "well under the 10s budget" and "against a 5s
baseline" are silent now. It does not fix this one, because " and " is already a
clause separator, so the two figures are in DIFFERENT clauses and the first
clause owns the threshold before any ambiguity rule sees it.
Fixing it properly means the clause boundary and the ownership model have to be
decided together, which is exactly the single model jatmn asked for and is more
than this change carries. Reported rather than patched.
## NOT closed: postfix qualifiers (F6)
"TestFoo passed, 9.90s elapsed" still reports nothing where the same sentence
without "elapsed" is caught.
I implemented the suggested fix — recognise a subject rather than any letter,
using the same measurement-name layer — and it reopened the case that check
exists for. All six following-subject tests failed: "TestFoo passed; 4.20s was
the whole suite." went back to charging the suite's figure to the test. That is a
FALSE ACCUSATION where the current behaviour is only a miss, so it was reverted.
"the whole suite" and "elapsed" are both ordinary words. Separating them by
vocabulary is the qualifier allowlist jatmn explicitly ruled out and would reopen
at the next synonym. Closing this needs an ownership model reading structure
rather than words; the code now says so where the check lives.
## Housekeeping
Six symbols died with the three regexes — claimedDuration, claimedMinuteDuration,
claimedHourDuration, bareUnitIsAmbiguous, startsFirst, compoundPart — plus the
scalar claimedSecondsFor. All removed, and make lint-static run BEFORE pushing
this time: 0 issues. That obsolete-helper lint failure is what broke Windows CI
on #911.
Three mutations, each caught by its own test: dropping the left boundary accuses
2 honest claims, returning at the first mention breaks 4 mention cases, and
demoting package paths mis-charges the neighbour's figure.
Rebased onto ad34dc8d, 0 behind. go test -race ./internal/measurements/ -count=3:
clean. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
Origin-Session: local-c962d7 | Claude Code | 17 prompts
Origin-Snapshot: a599377c09e0
Reported by @jatmn.
keepLocalScopePrivate treated a local/.gitignore containing "*" as proof that
later writes could not enter git. That holds for an UNTRACKED path and for
nothing else: git applies no ignore rule to a path already in the index. A clone,
an earlier `git add -f`, or a hand-authored repository restores both the ignore
AND local/<note>.md as tracked, and the atomic rename then replaced a tracked
file with a body the user was told stays on this machine — sitting in git status,
one `git commit -a` from being shared.
refuseTrackedLocalStore asks git what its index actually holds under the store,
and gates BOTH branches. Refusing only where an existing ignore is accepted would
leave the same leak reachable by deleting the ignore from the worktree: O_EXCL
would create a fresh one, report a clean first write, and overwrite the tracked
note anyway.
The regression jatmn asked for is there: a repository with a committed ignore and
a committed local note refuses the write and leaves nothing index-visible.
## A linked worktree carries .git as a FILE
Found by a verification pass, and it was the real gap. gitDirName's comment says
a file counts as much as a directory — that is what a linked worktree and a
submodule carry — and nothing tested it. Changing the check to
`err == nil && info.IsDir()` compiled and all nine tests still passed, so the
whole linked-worktree and submodule class was resting on a comment.
TestALocalWriteIsRefusedInsideALinkedWorktree builds the real article with
`git worktree add` rather than a stand-in, so it exercises the same code a user's
linked worktree does. That mutation now fails it.
## Two branches that are deliberately unreachable
The same pass reported the EvalSymlinks-failure branch and enclosingGitRepo's
unreadable-ancestor branch as untested fail-open points. Both mutate cleanly and
break nothing, and I could not build a fixture that reaches either — because
neither is reachable through Write. refuseReparseChain has already refused every
link in the chain, and opening the rooted handle has already failed on an
unreadable ancestor: a self-referential store link is refused as a link, and a
000 ancestor fails at mkdir. Measured both.
They stay, and the code now says why: reachability is a property of the CALLERS,
not of this function, whose contract is that an unanswerable privacy question is
a refusal. A future caller that skips the earlier guards would otherwise inherit
a silent fail-open. Writing a mock seam purely to reach dead code would have
tested the mock.
Rebased onto ad34dc8d. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Both reported by @Vasanthdev2004.
## memory_forget destroyed what read and write refuse to touch
Same hand-authored findings.MD, project scope, three calls at 6df0c097:
1. Read -> a differently spelled note already occupies this name
2. Write -> a differently spelled note already occupies this name;
rename or remove it first
3. Forget -> ok
4. on disk: nothing left
Read and Write both consult storedEntryName and refuse when the stored spelling
differs, because on a case-insensitive filesystem findings.md and findings.MD
are one file. Forget did not, so the one operation with nothing left to inspect
afterwards was the one that ignored the guard. The tool layer gates only on
ErrNotFound; a clash returns ErrNameClash, falls through, and handle.Remove
takes the occupant with it.
This is not a regression: forget behaved this way before the change too. It
matters more now because the change establishes a contract that forget
contradicts, and because the refusal the other two doors return said "rename or
remove it first" while memory_forget is the obvious way to remove. The message
routed the reader from a refusal to the destructive door.
The guard now lives in memory.Forget rather than in the tool, so every caller
gets it and not just this one. Absence is still not an error: a name nobody has
stored has no occupant to clash with, and the documented idempotence is
unchanged. All three doors share one wording, which names the occupant and asks
for a rename in the store directory — the only step that actually resolves the
clash, and not a suggestion to delete the file being described.
## The named listing order applied to nothing anyone could see
ResolveScopes("") returns [local project], so memory.List's len(scopes) == 0
branch was unreachable from the product and listingScopeOrder never ran. The
variable, its comment, and TestListingShowsBothScopesWithoutShadowing all
described a project-first listing that no user was getting.
Wired up rather than deleted, because the distinction it draws is real:
resolution order decides which scope WINS a name and must be local-first;
listing order decides only what a reader sees first and shadows nothing. The
listing tool now hands List no scopes when the caller named none, which is what
asks List to choose. A caller who names a scope still gets exactly that one, and
the unknown-scope refusal is unaffected.
Three mutations, each caught by the test written for it: dropping the Forget
guard deletes the note; forwarding the resolved order lists local first;
restoring the "remove it first" wording fails the assertion that a refusal must
not route the reader to the sharpest door.
Pre-existing on this branch and on its merge-base, in this environment only:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider both exit 3.
Origin-Session: local-8cd239 | Claude Code | 11 prompts
Origin-Snapshot: 365efe3045f2
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
CodeRabbit's review of the current head. Three real, one declined with reasoning.
THE SCOPE FIX WAS INCOMPLETE. List kept its own project-first literal, so making
ResolveScopes authoritative fixed the read round trip and left the listing
walking the other way — two copies of one decision, which is exactly how they
drift. Correctly flagged.
BUT THEY ARE NOT ONE DECISION, and following the suggestion literally would have
changed behaviour a test asserts on purpose:
resolution order decides which note WINS when both scopes hold the name, and
must be local-first or an unscoped write cannot be read back
listing order decides only what a reader sees FIRST — both scopes are shown
and neither shadows the other, so nothing is being resolved
Project first in a listing puts the checked-in, team-facing notes at the top of a
long one. So the listing order is now NAMED rather than left as a literal, with
the difference from the resolution order written down — the duplication CodeRabbit
saw was real, and the fix is to state the distinction rather than erase it.
DefaultScopeOrder now returns a copy through an accessor, with the backing slice
private: handing out the slice let any caller reorder the resolution every other
caller depends on.
EACH GODOC BACK ON ITS OWN ERROR. Inserting two sentinels above the block left
every comment attached to the wrong error — ErrNotPrivate's text sat above
ErrNameClash, and so on down. Cosmetic, and the kind of cosmetic that misleads
whoever reads the package docs next.
Also removed a duplicate uncreated-store test I added without noticing the
existing one, and added CRLF cases to the ignore-gate table: a .gitignore written
on Windows carries them, git does not care, and if the gate ever stops stripping
them every local write on a Windows checkout fails for a file doing its job.
Not done: reusing List's ReadDir entries to skip storedEntryName per note. It is
a real saving, but it adds a second read path next to the one that just fixed a
correctness bug, and the listing is bounded by the store size.
Origin-Session: local-8cd239 | Claude Code | 1 prompt
Origin-Snapshot: 627f6f8664c6
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Two findings from @Vasanthdev2004, both reproduced before changing anything, plus
the corrections he asked for on the previous commit.
SAVE A NOTE, READ IT STRAIGHT BACK, GET SOMEONE ELSE'S. memory_write and
memory_forget default to local while an unscoped read resolved project FIRST, and
scope is optional in every schema:
memory_write{name, content} -> Saved "findings" (local).
memory_read{name} -> memory "findings" (project) ... not what was written
memory_forget{name} -> Forgot "findings" (local).
memory_read{name} -> memory "findings" (project) ... still there
Every message is accurate about the scope it acted on and the round trip is still
broken. A checked-in project note is the ordinary way it happens, since it
arrives with a clone and the model has no reason to expect it. The tool's own
approval text is "Saves a note that future sessions will read and believe", which
is exactly what fails. There is now ONE order, local first, matching where an
unscoped write lands; a project note is still found when nothing local shadows
it, so the ordering decides which wins rather than what exists.
A NOTE THE LISTING DENIES EXISTS WAS DESTROYED BY THE NEXT WRITE. List matches
the extension exactly; Read reopened name+".md", which on a case-insensitive
filesystem is the same file as a hand-authored "findings.MD". So the listing
reported an empty store, writing was the reasonable next move, and a checked-in
file was silently overwritten — verified: the note came back holding
"OVERWRITTEN". Read now agrees with List, and the write refuses with ErrNameClash
naming the occupant rather than taking its place.
AND THE CORRECTION HE ASKED FOR. My previous commit was written as a junction
fix. It is not one: he re-measured and every junction placement is already
refused a layer earlier, because os.Root.Lstat reports a junction as
ModeIrregular and RefuseReparse rejects it on the first component. The comment
now says what the branch actually covers — a store present but unopenable for an
ordinary reason, which used to surface as "no such memory" — and records the race
he spotted, where a store being created concurrently reads as refused rather than
absent.
The chmod arm cannot build its refusal on Windows, since os.Chmod there only
toggles the read-only attribute — the opposite of what my comment claimed. A
junction companion covers that platform, so between the two arms the branch is
exercised everywhere and neither passes vacuously.
Mutation-checked: project-first breaks the round trip, and removing the clash
guard destroys the hand-authored note again.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
My own defect, and it broke the run on the platform the test exists to stand in
for. The skip-check opened the blocked directory to find out whether this
environment enforces directory permissions, and discarded the handle without
closing it. Windows will not remove a directory while a handle is open, so
t.TempDir's cleanup failed and the smoke job went red — after the test had
correctly decided to skip.
escape_test.go:404: this environment ignores directory permissions...
testing.go:1464: TempDir RemoveAll cleanup: unlinkat ...\.zero\memory:
The process cannot access the file because it is being used by another process.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
@Vasanthdev2004 measured why my last attempt did not take, and he is exactly
right. The branch was keyed on the component being ABSENT to the handle, and a
junction is not absent to it:
presentOnDisk(root, ".zero\\memory\\project") = true
componentExists(handle, ".zero\\memory\\project") = true <- Lstat succeeds
Lstat does not traverse a reparse point, so it reports the junction as being
right there. !exists was false, presentOnDisk was never consulted, and the walk
continued past the very component it was meant to refuse — the later open failed
as ErrNotFound and List dropped it, exactly as before the "fix". The reasoning in
the comment was right and the condition guarding it was wrong.
OPENING IS WHAT TRAVERSES, so opening is the question. A path the confined handle
will not open while it is plainly present on disk has been REFUSED; a path absent
from both is simply not there yet, which is what a first write is for.
A Windows junction is not a symlink and does not answer like one — os.ModeSymlink
misses it, EvalSymlinks returns it unchanged, Lstat says it is there. Only the
open disagrees. That is the third time this shape has bitten this branch.
ErrUnreadable is its own sentinel rather than ErrIsSymlink, because this path
cannot identify what it refused: a junction and a directory the process may not
enter are indistinguishable here, and claiming "is a link" for the second would
be a guess stated as fact.
THE TEST RUNS ON THIS PLATFORM NOW. A directory that is present but cannot be
entered produces the same disagreement as the junction — Lstat sees it, Open
refuses it — and unlike the junction it is constructible on darwin and Linux. It
skips where directory permissions are not enforced rather than passing vacuously.
Reverting to the Lstat condition fails it. Absence is asserted separately: a
clean workspace must still read ErrNotFound, list empty without error, and take
its first write.
Still unverified by me: the junction itself, which remains @Vasanthdev2004's
measurement on Windows rather than mine.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
@Vasanthdev2004 and @anandh8x both, and @Vasanthdev2004 named the real problem
with my last attempt: I answered it by loosening the test rather than changing
what the caller is told, and the commit title said so. He is right. This changes
the behaviour.
@anandh8x supplied the detail that made it tractable — the junction's target is
OUTSIDE the workspace. So the confined handle reports the component absent while
it is plainly present on disk, the chain read it and everything under it as
missing, the later open became ErrNotFound, and List drops ErrNotFound. A
tampered store presented as an empty one, which is exactly what memory.go's own
contract forbids:
A refused link, an oversized file or a permission error is a problem the
caller needs told about rather than a note that silently vanishes.
The harm is specific: the model concludes its notes are gone and writes over
whatever is really there.
Absence and refusal are now different answers. A component the handle will not
open, while it is present on disk by ordinary pathname, has been refused and
returns the reparse sentinel — which Read propagates and List reports, because
neither suppresses anything but ErrNotFound. presentOnDisk stats and never opens,
so nothing is traversed on the strength of it; it decides only which error the
caller is given.
An uncreated store is still absence, and that is asserted: a clean workspace must
read ErrNotFound, list empty with no error, and accept its first write.
NOT VERIFIED, and this is the limitation to weigh. I am on darwin, where a
symlink is caught by the reparse check one step earlier, so the branch this adds
is unreachable locally — the junction path is your measurement, not mine. What IS
verified here is presentOnDisk itself: it reports a directory, a nested
directory, a reparse point and a dangling link as present, a missing path and a
blank root as absent. If either of you can re-run the junction case on Windows I
would rather have that confirmation than my inference.
Origin-Session: local-ae96ee | Claude Code | 4 prompts
Origin-Snapshot: 701aaeb8081a
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
My test, not the production code — it turned Windows red while the behaviour was
correct. linkDir builds a JUNCTION on Windows, which is a directory, so the ignore
path became a directory and the create failed with "is a directory" rather than
reaching the reparse check that returns ErrNotPrivate. The write still failed
closed; the assertion was simply about the wrong thing.
Split in two, so each platform exercises the case it can actually build:
file symlink what a .gitignore would really be; Windows needs a
privilege for these, so it skips there
directory reparse point junction on Windows, symlink elsewhere; the refusal
arrives differently by platform, so what is asserted is
the property that holds on both — THE WRITE DOES NOT
SUCCEED
Still mutation-checked: removing the reparse refusal makes the write succeed and
both subtests fail.
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Three findings, all reproduced before changing anything, and the first two are
the same mistake: certifying a privacy rule that git does not actually apply.
GIT DOES NOT READ A SYMLINKED .gitignore. @anandh8x's P1. keepLocalScopePrivate
saw O_EXCL return EEXIST and then read through the link, accepting the target's
"*" as proof of a rule git warns about and ignores. An in-workspace RELATIVE link
is followed by os.Root; an ABSOLUTE one is refused, which is exactly why my own
review of this looked clean and was not — I attacked it with an absolute target
and reported the refusal as the general answer. The ignore path now goes through
the same no-reparse rule as a note before its contents are read.
THE GATE DISAGREED WITH GIT IN BOTH DIRECTIONS. @Vasanthdev2004's finding,
re-verified here against git 2.55.0 rather than taken from the spec:
" *" gate said covered; git does NOT ignore, because LEADING whitespace
is part of the pattern — so a note the user was told stays on this
machine was picked up by a routine `git add -A`, which is precisely
what this function exists to prevent, and it failed silently
"*" gate said not covered; git strips a UTF-8 BOM and honours the rule
— so an ignore written by an ordinary editor failed every local write
Both came from validating a pattern language by trimming and comparing strings.
Leading whitespace is significant and is no longer stripped; trailing whitespace
is not and still is; a BOM is stripped once at the start of the file, as git does.
REFUSED IS NOT ABSENT. Also @Vasanthdev2004's: a junction at an ancestor made the
store read as empty, so the caller was never told, and for this feature that means
the model concludes its notes are gone and writes over what is there. A component
cannot exist inside one that does not, so where absence does not hold all the way
down the handle is refusing to traverse rather than reporting an empty path, and
that now surfaces as a refusal.
NOT VERIFIED, and this is the limitation to weigh: the junction behaviour itself
is Windows-only and I am on darwin. The anomaly the check keys on is
platform-independent, but that it is the shape a junction produces is his
measurement, not mine.
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
CodeRabbit's catch. The ancestor-link regression called os.Symlink directly, so
on Windows it skipped — and a junction is precisely the case that guard is built
for: a reparse point that is NOT a symlink, which is why RefuseReparse tests
ModeIrregular alongside ModeSymlink. The Windows behaviour went unasserted while
the run reported green.
linkDir already existed in this file for exactly this reason and creates a
junction there. Using it is the whole change.
Same shape as the sandbox helper that reached for /Users/Shared and skipped off
macOS: a skip is not a pass, and a guard whose platform-specific case is never
exercised is a guard nobody has tested.
Origin-Session: local-abff1c | Claude Code | 5 prompts
Origin-Snapshot: dddd3415c4e0
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Both P1s from @anandh8x, both reproduced exactly as he described before any
change was made.
A LINK ABOVE THE STORE REDIRECTED EVERYTHING BELOW IT. The comment on openScope
claimed this class was closed by routing every operation through os.Root, and it
is only half closed: os.Root refuses to traverse OUT of the workspace but
follows a link that resolves back INSIDE it, and the store path is checked in.
A repository shipping ".zero -> redirected" served notes from a directory nobody
asked for — Read returned the planted note — while every individual check
passed, because the only component ever inspected was the note file at the end.
Every component of the store path is now checked, outermost first, composed from
pathjail.RefuseReparse so the Windows-junction and trailing-separator handling
stays in one place rather than being restated here.
THE PRIVACY PROMISE FAILED OPEN. keepLocalScopePrivate read every failure of its
O_EXCL create as "a previous run wrote the ignore", and O_EXCL cannot tell that
from "something else got there first": precreating an empty .gitignore made every
later local write succeed with the store fully tracked. The old comment argued
best-effort was right because failing a write would trade privacy for an outage —
that reasoning does not hold for this function, because the promise IS the
feature. A note the user was told stays on this machine, sitting in git status,
is worse than a refused write: the refusal is visible and the leak is not. The
ignore is now installed or verified, and a store that cannot be made private
returns ErrNotPrivate naming the file.
The effectiveness check recognises a bare "*" — what this store writes — and
nothing narrower, because guessing at the effect of an arbitrary pattern set is
how such a check ends up agreeing with a file that protects nothing. A single
re-inclusion line fails the file wherever it sits.
Both mutation-checked: removing the ancestor chain lets the redirected read
through again, and ignoring the verify result lets an empty ignore pass. The
ancestor test derives its components from Paths rather than naming them, so it
keeps covering every ancestor if the layout moves — and writing them by hand is
how the first version of it tested a path that was not a store ancestor at all.
Origin-Session: local-abff1c | Claude Code | 3 prompts
Origin-Snapshot: 07900397c62e
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Four gaps CodeRabbit found in the tests, each covering behaviour that only a
comment held in place:
- a truncated description now has to END WITH the ellipsis, not merely fit the
cap: a silent cut reads as the author's own words stopping there
- ResolveScopes is asserted on the VALUE it resolves to, not just the count —
" local " returning one scope proves nothing if that scope is the project store
- the exact ".md" match, which decides that List and Read agree about what a
note is
- List returning readable notes BESIDE the error, which is the contract the
memory tool depends on to avoid rendering one bad file as an empty memory
Both of the last two were mutation-checked, and the extension test only half
holds: loosening the extension check alone does NOT fail it, because the
case-sensitive TrimSuffix then leaves "shouty.MD" whole and the name pattern
refuses it, so nothing reaches the listing to catch. It fails on the refactor
that does reach it — extension check and suffix strip made case-insensitive
together. That limit is written into the test rather than left for the next
reader to discover.
Origin-Session: local-abff1c | Claude Code | 1 prompt
Origin-Snapshot: 0e7ed28981cb
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Both raised by @Vasanthdev2004 against the cap added in the previous commit, and
they compound: the read path is where an unbounded description shows up, and the
read path was not where the bound ran.
THE CAP WAS QUADRATIC. It dropped one rune per iteration and re-encoded the whole
slice each time to measure it:
1 KiB -> 1.6ms 16 KiB -> 381ms 64 KiB -> 13.3s
maxNoteBytes is 64 KiB, so a 64 KiB description is a note the write path accepts
— the cap was at its slowest on exactly the input it exists to handle, and
multi-byte runes were worse per byte. Ranging over the string yields rune
boundaries with their byte offsets, so the cut point is now found in one pass
with no intermediate string.
THE CAP ONLY RAN ON WRITE. A project-scope note arrives with a clone and never
passes through this process's write path, so a checked-in note with an enormous
single-line description was unbounded in the listing. The bound now runs where
the description is PARSED, which covers both paths.
Both regressions fail against the old behaviour: the quadratic loop takes ~5s on
64 KiB, and bounding only on write lets a 23,999-byte checked-in description
reach the listing.
Origin-Session: local-abff1c | Claude Code | 1 prompt
Origin-Snapshot: 0e7ed28981cb
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Split 2/3 of #829, stacked on the pathjail primitive from #891 (1/3).
- internal/memory: a durable note store confined to a pathjail handle, so every
read, write, rename and delete stays on the confined handle rather than being
re-resolved by pathname. Writes publish through an O_EXCL temporary file.
- internal/tools: memory (read/list), memory_write (save), memory_forget
(delete).
Reads are confined by the same rule as writes, the size ceiling holds on the way
out as well as in, scope is resolved in one place, and deletion is its own tool
so its approval can disclose it — memory_write's says only that it saves, and an
omitted or blank content used to fall through to Forget under that sentence.
PARTIAL SUCCESS IS PRESERVED END TO END. The earlier fix for "errors reported as
absence" overshot in two places, and both are corrected here:
- The listing returned an error INSTEAD of the notes whenever any note failed,
so the library's careful partial success became total failure one unreadable
directory entry away from an empty memory. The failure is now appended to the
rendered notes rather than substituted for them.
- The named read returned on the first non-ErrNotFound failure, and project is
searched before local. So one unreadable project note called "findings" made
the user's own local "findings" unreachable — the shared, externally-supplied
scope masking the private one. Failures are carried and reported only when
nothing readable turns up.
A description is bounded separately from the note. The listing prints every
description, so the field is shared screen space: the total-size check alone let
one note carry a 60 KiB single-line description and consume the listing everyone
else has to fit in.
Origin-Session: local-de382f | Claude Code | 9 prompts
Origin-Snapshot: 92ae33c95cd1
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
determinism assertion
@Vasanthdev2004's three remaining points. His blocking finding — a zero-value
Ledger panicking on l.raised — was already closed at 0c1b1bc7 and is confirmed
still closed: a declared Ledger now survives a CONTRADICTORY claim through both
Conflicts and ConflictsAcrossRuns, which is the path an agreeing claim never
reaches.
## The determinism assertion was vacuous by construction
He was right that it looked armed and was not. The merge walked its runs in Go
map order, so "identical between identical passes" could only be asserted about
something that had no stable order to compare. sort.Strings on the merge keys
gives it one, and TestTheRunOrderTheMergeWalksIsStable now fails without it —
the key comes back as a real run key instead of the expected first one.
## A neighbouring number charged to this test
"The subject rule charges a neighbouring number to the current test whenever the
subject follows its number rather than preceding it." Reproduced: six ordinary
report shapes, all mis-charged —
TestFoo passed; 4.20s was the whole suite.
TestFoo passed - 4.20s was the package total
TestFoo passed | 4.20s for the whole package
TestFoo ok: 4.20s across every package
TestFoo passed (4.20s for the suite)
TestFoo was fine, 4.20s covered every package
The clause scan looked for a word BEFORE the figure and never after it, so a
figure whose subject trails it read as belonging to the test named earlier. It
now scans the figure's own segment on both sides. Removing the trailing half
mis-charges all six.
The presentation forms still read, which is what stopping at the end of the
figure's own segment buys: "| TestFoo | 9.90s | passes |" and
"TestFoo passed, 9.90s. The suite took 34.249s." both still catch a fabricated
9.90s. Cutting at the first word instead would have silenced them.
## An "m" that is not minutes
"TestParseCorpus handled 5m rows in 0.86s" read the count of rows as five
minutes and accused a truthful report of claiming 300s — the one failure this
package must never produce. A compound form ("1m10s") cannot be a count, and a
bare figure with nothing after it ("took 2m", "(9h)") has no noun to count, so
the bare-figure-plus-word shape is the whole ambiguity.
An ambiguous token now yields nothing rather than a second-choice reading:
reaching past it to a later figure would answer the same question by guessing.
The cost is stated plainly in the code — "TestSlow took 2m to finish" is now
unreadable, which is the safe direction for this package.
The clause scan refuses exactly what the parser refuses, or the two disagree
about where a clause ends. TestTheClauseScanRefusesWhatTheParserRefuses pins
that agreement and catches its own mutation on four inputs.
Four mutations, each caught by the test written for it: widening the minute
pattern past its word boundary, dropping the merge sort, removing the trailing
subject scan (6 cases), and disabling the bare-unit ambiguity guard (4 cases).
Rebased onto ad34dc8d. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Reported by @jatmn, and it is a hole in my own previous fix rather than a new
defect.
That fix made a declared-but-not-constructed Ledger survive Record by
initialising the two maps Record touches. It left `raised` nil. Only Conflicts
and ConflictsAcrossRuns write that map, and they write it exclusively on the
contradiction path — the dedupe that stops the same wrong number being reported
twice — so nothing in the Record path could reach it.
The test I wrote could not catch this. It asked an AGREEING claim:
ledger.Conflicts(Run{}, "TestSomething took 1.25s") // against a recorded 1.25s
which returns early with no conflict and never reaches the write. A contradicting
claim panics:
var ledger Ledger
ledger.Record(Run{}, "--- PASS: TestSomething (1.25s)\n")
ledger.Conflicts(Run{}, "TestSomething took 99s")
-> panic: assignment to entry in nil map
Centralised into ensureMaps rather than adding a third `if` beside the other two.
The field list now sits beside NewLedger's, so a fourth map added later is a
visible omission in one place instead of a panic in whichever entry point forgot
it — this is the second time these lists have drifted apart.
TestAZeroValueLedgerSurvivesAContradiction covers both conflict entry points and
also asserts the dedupe actually works, since that is what `raised` is for.
Honest note on the mutation checks: removing `raised` from ensureMaps panics the
new test, so the real defect is covered. Removing the ensureMaps call from
ConflictsAcrossRuns does NOT fail anything — neither conflict path can write
`raised` without data having been recorded first, and Record initialises. Those
two calls are defence-in-depth for a future entry point, not independently
reachable today.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Both raised by CodeRabbit on the full review.
## "1.5m" read as five minutes
The minute and hour components accepted integers only, so neither pattern could
match at the digit the number starts on. The leftmost match began after the
decimal point instead:
"took 1.5m" -> 300s (want 90)
"took 0.5m" -> 300s (want 30) half a minute read as five
"took 1.5h" -> 18000s (want 5400)
"took 10.25h" -> 90000s (want 36900) ten and a quarter hours read as
twenty-five
This is the failure this package exists to prevent, occurring inside its own
parser. A model that truthfully restated a recorded 90s as "1.5m" was reported
as contradicting the transcript, and the nudge quoted 300s back at it — a number
nothing in the run ever produced. The file already carries two comments about
exactly this shape of mistake, one for minutes and one for hours.
compoundPart parses with ParseFloat, so the fraction only had to be allowed into
the capture for the match to start where the number does. The word boundary that
keeps "1.5ms" out of the minute pattern is unchanged, and the positional rule
that makes "took 0.86s (package total 1m20s)" read 0.86 is unaffected.
## A zero-value Ledger panicked on its first Record
The nil receiver is handled; a Ledger that was declared rather than constructed
got past that guard and panicked with "assignment to entry in nil map". Both
maps are now allocated lazily, which costs nothing on the NewLedger path where
they are already non-nil.
Both mutation-checked. Restoring the integer-only minute pattern reports the
honest 1.5m and 0.5m claims as conflicts at Claimed:300, and reads a fabricated
4.5m as 300 rather than 270 — the regression asserts the reported value, not
merely that something was reported, because a whole read and a lucky one are
otherwise indistinguishable. Removing the lazy allocation panics.
Unrelated to this change, in this environment:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 here and on the merge-base.
TestResolveSandboxEnabledIgnoredFromProviderCommand failed once under full-suite
load and passes 3/3 in isolation; internal/config has no dependency path to
internal/measurements, so this change cannot reach it.
Origin-Session: local-8cd239 | Claude Code | 12 prompts
Origin-Snapshot: dd397730a138
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
CodeRabbit's finding on the current head, verified before changing anything.
separatorBreaksClause locates the next duration to decide whether a separator
introduces a new subject, and it knew the seconds and minute patterns but not the
hour one added a commit earlier. So it read the "h" of "9h" as the first letter
of a new subject, turned the punctuation into a clause boundary, and cut the
test's own number away from its name:
"TestVerySlow - 9h" missed
"TestVerySlow passed - 9h" missed
"TestVerySlow (9h)" missed
"TestVerySlow: 9h" missed
"TestVerySlow took 9h" caught, because no separator was involved
A duration this package can PARSE has to be one this scan can SEE, or the two
disagree about where a clause ends — and the disagreement is silent, because the
answer it produces is the same shape as an honest bound.
Both bounds still hold: an hour figure belonging to another subject
("…passed - the whole suite took 9h") stays that subject's, and a truthful
1h10m0s restatement of a recorded 4200s is not a conflict.
NOT DONE, with the reason. The review also asked for a real ParseGoTest call site
from internal/agent or internal/specialist to clear an unreachable-function
finding. ParseGoTest is called by Record at measurements.go:213, and the
integration that calls Record lives in #829 — this split branch deliberately has
no caller, which is the same for Run.key and Run.Label. Adding one here to quiet
a reachability scan would put the wiring in the wrong PR.
Mutation-checked: dropping the hour pattern from the scan misses all four
separator spellings again.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
CodeRabbit's findings on the current head, all verified before changing anything.
HOURS COUNT, for the reason minutes did one round earlier. Without an hour form
"1h10m0s" matched only its minute remainder and read as 600s, so a truthful
restatement of a recorded 4200s was reported as a fabrication — the accusation
this package exists to avoid, one unit further up.
The hour form is its OWN pattern rather than an optional prefix on the minute
one. Making every part optional lets the expression match the EMPTY string, which
regexp then finds at offset 0 ahead of any real duration: that version read
"1h10m0s" as 0s, which is worse than the bug it was fixing. I shipped it that way
for one iteration and the round-trip table caught it.
A TEST THIS PR HAD DISARMED. The stability assertion recorded the SAME name under
both runs, which makes the report a merged one — and the merged-attribution fix
earlier in this same PR drops the label for merged reports. So it watched an
always-empty string and could not have failed. A production change silently
disabled a test guarding a different property, which is the fourth time in this
series that a green test was asserting nothing. It now uses one name per run, so
a single run stands behind the conflict and a label exists to be stable, and it
fails outright if the label is ever empty again.
Also from the review: ConflictsAcrossRuns is asserted nil-safe alongside the
other two entry points, the dedupe doc now says name AND VALUE rather than name
alone, and the unused outer ledger is gone from the honest-reporting table.
The plain-seconds fall-through now checks for nil rather than relying on the
branch conditions above it to guarantee non-nil. That was provably safe and
provable-by-argument is what this file has already paid for once.
Mutation-checked: disabling the hour branch reports 1h10m0s as 600s and 1h2m3s as
123s, and recording one name under both runs fails the stability test outright.
Origin-Session: local-79d7a0 | Claude Code | 5 prompts
Origin-Snapshot: c175cabb9d50
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
@Vasanthdev2004 found the ASCII hyphen still leaking. Walking the same nine
shapes here found FIVE that leak, not one — the finding is a class, not an
instance:
ACCUSED "TestChattyChild passed - the suite took 34.249s."
ACCUSED "TestChattyChild passed — the suite took 34.249s."
ACCUSED "TestChattyChild passed – the suite took 34.249s."
ACCUSED "TestChattyChild passed (the suite took 34.249s)"
ACCUSED "TestChattyChild passed | suite 34.249s"
The review reported the em dash, en dash, parenthetical and pipe as bounding
correctly. They do not at 36c79cdf; each is quoted above from a run against that
head. This does not change his recommendation, only its size: clause punctuation
is still a closed set, and it is now enumerated.
PUNCTUATION ALONE IS NOT THE BOUNDARY. Adding the missing separators outright
cost real detections, because the same marks are how a test's OWN number gets
written:
"TestChattyChild (9.99s)" "TestChattyChild passed - 9.99s"
What makes a mark a break is a SUBJECT named after it, so the test is whether any
word appears between the separator and the next duration. That rule also recovers
two detections the pre-existing comma and colon separators were already costing
silently — "TestChattyChild passed, 9.99s" and "…passed: 9.99s" were both MISSED
at 36c79cdf and are caught now.
Measured on the widened set: 11 bleed shapes bound, 10 own-number shapes read,
none lost. Mutation-checked both halves — removing the new separators leaks 7
shapes, and removing the subject rule loses 8 detections.
Reviewed adversarially before committing: hyphenated test names and import paths
still parse, the minute and millisecond forms are read after a separator and
bounded before one, and a truncated "(" or trailing "-" is inert.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Both reproduced before changing anything.
A FULL STOP ENDS A CLAUSE. @Vasanthdev2004's blocker. clauseSeparators had no
sentence terminator, so when the next sentence's subject was an ordinary noun
phrase — not a recorded name, not name-shaped — nothing bounded the clause and
its number was charged to the previous sentence's test:
"TestNested is green. The full run took 34.249s."
-> [{Name:TestNested Claimed:34.249 Recorded:[0.03]}]
Both sentences are true, both numbers were really measured, and the writer
attached each to the right subject. Accusing a correct report is the failure this
package's own doc says gets the tripwire switched off.
A decimal point is not a terminator and neither is the dot in an import path: a
terminator is followed by whitespace or the end of the line and never sits
between two digits. A colon is now a separator too.
One correction to the review, since it changes the scope rather than the fix: he
reported "TestChattyChild ok. Package total 34.249s." as NOT leaking, and it does
leak at this head — I measured it. The bound covers it either way, but the shape
was not as narrow as it looked.
A MERGED RESULT NAMES NO SINGLE COMMAND. @anandh8x's P1. ConflictsAcrossRuns
merges every run's values for a name, and labelling that union with one run said
that command reported a number it never printed:
`go test ./a` in this session reported 0.1s, 0.2s
where 0.2s came only from ./b. Choosing the run deterministically fixed the
reshuffling and left the attribution just as untrue. With more than one run behind
the values the label is dropped and the nudge names the session; with one run it
is kept, which is the useful case because the model is told exactly what to re-run.
Both mutation-checked: removing the terminator bound brings back three bleeds, and
removing the attribution guard brings back two false attributions.
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
CodeRabbit's catch, and it is a real one. ConflictsAcrossRuns merged each name's
values by ranging over the per-run map, and Go randomises that order — so the run
it picked, and therefore the dedupe key built from it, varied between identical
calls. A name recorded under two commands was re-reported on a later pass 50
times in 200. The agent loop feeds this back to the model as a correction, and
the dedupe exists precisely so that cannot loop.
The same randomness reached the message: the nudge named whichever command the
map happened to yield first, so identical passes produced different text — the
problem the sorted output in this function already exists to avoid.
THE SORT IS THE FIX. Selecting the lowest run key makes both the quoted run and
any key derived from it stable, and reverting it alone fails the new test at the
first attempt. Repeated 200 times in the test, because a defect that depends on
map order passes a single run and would be called fixed.
The cross-run dedupe now also has its own key namespace rather than borrowing the
per-run one. That is a SEMANTIC choice, not part of the nondeterminism fix, and
it has a visible consequence worth stating: asking both questions on one ledger
reports the same number twice, once per question. They are different questions
and neither should silence the other. No caller asks both — the agent loop and
the specialist each use the cross-run form — and the behaviour is pinned by a
test so the split is deliberate rather than discovered later.
Found by running the review protocol over my own pushed head, after CI had passed
it. Mutation-checked: reverting the sort makes the quoted run vary between
passes, and reverting both makes the same conflict raise twice.
Origin-Session: local-abff1c | Claude Code | 6 prompts
Origin-Snapshot: b59446f78949
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Found by self-review of the previous commit, not by CI. That commit claimed
clauseEnd gained three bounds. It gained two.
nextNameShaped compared against LOWERCASE prefixes, and nothing in this package
lowercases the claim — unlike the guardrails, it compares against recorded names
and must preserve their case. So "test" never matched "TestUnrecorded" and the
function returned -1 for every realistic input. Lowercasing the input would not
have saved it either: the rule that the next character must not be lowercase then
rejects "testunrecorded". It could not fire on anything.
THE TESTS CERTIFIED THE WRONG MECHANISM. Every case added for the name-shape
bound contained a comma, a semicolon or an " and ", so the clause-SEPARATOR bound
caught them all and the dead branch looked alive. A claim with no separator at
all went straight through:
recorded: TestFoo 0.10s
claim: "TestFoo passed TestUnrecorded took 4.20s"
-> [{Name:TestFoo Claimed:4.2 Recorded:[0.1]}]
which is the exact fabricated conflict the commit said it had closed.
The prefixes are capitalised, and the character after one must be uppercase, a
digit or an underscore — how `go test` spells these names, and what separates
TestFoo from the ordinary words "test", "testing" and "tested". The new cases
carry no separator, so only this bound can satisfy them, and two more assert that
an ordinary word beginning with a prefix does not cut the clause short.
Mutation-checked: restoring the lowercase prefixes brings back all four bleeds.
Origin-Session: local-abff1c | Claude Code | 5 prompts
Origin-Snapshot: dddd3415c4e0
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Both from CodeRabbit, both verified against the current head first.
A NEIGHBOUR BOUNDS THE CLAUSE WHETHER OR NOT IT WAS MEASURED. Cutting only at
names the ledger knows left the unrecorded neighbour holding the number and the
name before it taking the blame:
recorded: TestFoo 0.10s
claim: "TestFoo passed; TestUnrecorded took 4.20s"
-> [{Name:TestFoo Claimed:4.2 Recorded:[0.1]}]
That is the same fabricated conflict @anandh8x reported, reached through a name
the session never ran — and whether a number belongs to a name cannot depend on
whether some OTHER name happened to be measured. The clause now also ends at a
name-SHAPED token (Test/Benchmark/Fuzz/Example, or an import path) and at a
clause separator, which catches the neighbours that are not name-shaped at all
("TestFoo passed, the suite took 4.20s"). All three bounds only ever shorten the
search, so each can cost a detection and none can invent one — the right
direction for a check whose worst failure is accusing a correct number.
THE PREFIX FIXTURE COULD NOT FAIL. TestAPrefixNameDoesNotAccuseAnHonestClaim
recorded the parent at 0.03s and the subtest at 0.01s, which sit INSIDE
tolerance of each other — so a subtest claim matching the parent's entry read as
agreement, and the test passed whether or not the prefix boundary worked. It
certified nothing. At 5.00s against 0.01s the two cannot be confused, and
breaking the boundary now fails it: the mutation reports
`{Name:TestNested Claimed:0.01 Recorded:[5]}`.
NOT DONE HERE, and it is real: CI runs `go test ./...` while the Makefile's test
target runs `-race -count=1`, so the concurrency test in this package has never
been exercised under the race detector in CI. That is .github/workflows/ci.yml —
repo-wide, outside this PR's files, and affecting every PR rather than this one.
It wants its own issue. This package is race-clean when run that way locally.
Origin-Session: local-abff1c | Claude Code | 5 prompts
Origin-Snapshot: dddd3415c4e0
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Follow-up to the provenance change, and the thing that makes it usable. The
agent loop checks a FINAL ANSWER, which may summarise several commands, so it
has no single run to hold the claim to — and requiring one would have forced it
to pick arbitrarily.
ConflictsAcrossRuns asks the question that caller can actually answer: does this
number match nothing this session printed, anywhere. A value one of the commands
really did print is not invention, and accusing the model of fabricating it would
be the false accusation this package exists to avoid.
Conflicts keeps the strict, per-run meaning for callers that DO know the command,
where a claim about an ordinary run is not answered by a value only
`go test -race` printed. Two functions rather than one with a flag, because the
difference is not a preference — it is how much the caller knows, and a flag
would let a caller that knows the run quietly ask the weaker question.
A conflict raised across runs is deduped against the same (run, name, value) key
as the strict path, so the two cannot report the same thing twice.
Origin-Session: local-abff1c | Claude Code | 3 prompts
Origin-Snapshot: 07900397c62e
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
All three from @anandh8x, all reproduced before changing anything. The package
has no callers yet, so the data model could be fixed rather than worked around.
A DURATION BELONGS TO THE NAME BESIDE IT. claimedSecondsFor searched the whole
remainder of the line, so one name took another's number:
recorded: TestFoo 0.10s, TestBar 4.20s
claim: "TestFoo passed; TestBar took 4.20s"
-> [{Name:TestFoo Claimed:4.2 Recorded:[0.1]}]
Every word of that claim is true. This is the same failure as reading a package
total as a test's own timing, reached through the name binding instead of the
pattern order — and it is the one this package must never produce, because a
missed conflict is silence while this is a confident wrong accusation. The clause
now ends where the next recorded name begins; the ledger knows those names, so
they are passed in rather than guessed at from punctuation.
TIMINGS FROM DIFFERENT COMMANDS ARE DIFFERENT MEASUREMENTS. Everything pooled
into map[name][]seconds, losing which command produced what, so a claim about an
ordinary run was satisfied by a value only `go test -race` ever printed — and
-race is routinely several times slower, which is the size of discrepancy this
exists to catch. Record and Conflicts now take the Run, and the ledger is keyed
by run FIRST so a future caller cannot reintroduce the pooling by forgetting to
pass it. A zero Run is still a legitimate "this caller does not distinguish
runs", but the call site now says so out loud instead of it being the only thing
the type could express. The nudge names the command, so the model is told which
run to repeat.
A SECOND WRONG NUMBER IS A SECOND THING TO SAY. Suppression keyed on the name
alone switched the check off for that test permanently: after one bad 4.20s, a
later and differently bad 9.90s was silent. It keys on the claimed value too, so
re-reading the SAME answer still says nothing — which is all the dedupe was for,
and what keeps a correction fed back to the model from looping.
Each mutation-checked: unbinding the clause, pooling the runs, and suppressing by
name alone each fail the test that covers them.
Origin-Session: local-abff1c | Claude Code | 3 prompts
Origin-Snapshot: 07900397c62e
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
CodeRabbit's catch: the assertion table carried TestNested/subcase but not
TestNested, leaving the parent side of the prefix-trimming unpinned. A change
that stopped parsing parent lines, or folded the parent's time into the child,
passed every assertion in this test.
Mutation-checked: requiring indentation on the case-line pattern makes
TestNested read 0 instead of 0.03 and this test fails.
Origin-Session: local-abff1c | Claude Code | 1 prompt
Origin-Snapshot: 0e7ed28981cb
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Raised by @Vasanthdev2004 against the minute support added in the previous
commit. Trying the minute pattern over the whole tail before the seconds pattern
let it reach past a nearer figure to claim a later one:
"TestChattyChild took 0.86s (package total 1m20s)"
-> [{Name:TestChattyChild Claimed:80 Recorded:[0.86]}]
The claim is the test's own 0.86s; the 1m20s is the package total that `go test`
prints after it. Reading the far number as the claim invents a conflict against a
number the model got RIGHT, then quotes it back as a correction — the one failure
this package exists to avoid, and worse than the miss it was fixing, because a
missed conflict is silence while this is a confident wrong accusation.
Both patterns are now located with FindStringSubmatchIndex and position decides:
the minute form wins only when it starts no later than the seconds form. Group 2
is optional, so a bare "1m" reports index -1 rather than an empty span, which is
why the check is `>= 0` and not a string test.
Every case in TestAMinuteDurationIsReadWhole put the minute figure first, so it
passed against this. The new test fails without the fix on both a trailing
package total and a trailing budget ("450ms, well under the 2m budget" -> 120),
and still catches a fabricated 5m00s when a seconds figure sits nearby.
Origin-Session: local-abff1c | Claude Code | 1 prompt
Origin-Snapshot: 0e7ed28981cb
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Split out of #829 as an independent package, per @Vasanthdev2004's review asking
for the small self-contained pieces to arrive separately.
A measured run finished a benchmark and reported a table of test timings that no
command in the session had produced: the same test read 0.86s in one paste and
4.20s in the next, a -race overhead moved from +3.7% to +133% between two
tellings of the same result, and the column summed to an exact total no real
transcript lands on.
A prompt rule — "re-run every command before you paste it" — is the obvious
answer and the weak one, because a model willing to write numbers it did not
measure is equally willing to say it re-ran them. The harness is not: every
command's output passed through this process and was written to the session log,
so this package reads the run's real numbers back and compares them against what
the answer claims.
Deliberately loose: a 50% band, so ordinary variation passes and 0.86s reported
as 4.20s does not. A tripwire that cries wolf gets turned off and then catches
nothing.
Two ways it cried wolf, both found in review and both fixed here:
The name match is now bounded on both sides. A raw substring search called an
HONEST report a fabrication whenever one recorded name is a prefix of another,
which go test -v guarantees — it prints the parent above every subtest and the
ledger records both, so a truthful "TestNested/subcase took 0.01s" matched the
entry for TestNested. Package names collide with no subtests at all:
internal/agent is a prefix of internal/agentinit.
Durations with a minute component are read whole. The pattern was ms-or-s only,
so "1m10s" failed on "1m" and "10s" won: a truthful restatement of a recorded 70
seconds became a conflict, and the nudge quoted 10s back at the model — a number
its answer never contained.
The fixture now carries the parent line that go test -v really prints. Omitting
it is what hid the whole class: with only the subtest present no ledger name was
ever a prefix of another, so the substring match looked correct.
No importers yet by design — internal/agent and internal/specialist adopt it with
the orchestration work.
Origin-Session: local-de382f | Claude Code | 9 prompts
Origin-Snapshot: 92ae33c95cd1
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
internal/memory a refused ancestor junction reported as an empty store rather
than a refusal, which is the outcome the package's own
contract forbids
internal/agent a failure state inside a NEGATED proposition was read as the
reported outcome, so a successful negative finding was marked
incomplete
Origin-Session: local-ae96ee | Claude Code | 4 prompts
Origin-Snapshot: 701aaeb8081a
Five punctuation shapes still let a following clause's number be charged to the
name in front of it — the plain hyphen, both typographic dashes, a parenthetical
and a pipe. A separator now ends a clause only when a new subject follows it,
which also recovers two detections the comma and colon were already costing.
This branch is where the package is called from, so the nudge those bounds
protect is the one the agent loop and the specialist feed back to the model.
The linked-ignore regression asserted ErrNotPrivate, but linkDir builds a
junction on Windows — a directory — so the create failed with "is a directory"
before reaching the reparse check. The write failed closed either way; the test
now asserts that property rather than which error carried it.
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Eighth sync. The three split branches moved on this round's review:
internal/memory the ignore gate disagreed with git in both directions,
and a symlinked .gitignore was accepted as proof of a
rule git does not apply
internal/measurements a full stop did not end a clause, so the next
sentence's number was charged to the previous
sentence's test; and a merged cross-run result named
one command as having printed all of it
internal/agent a recognised absence object outranked an explicit
statement that the work was unverified
All 17 files the five split branches touch are byte-identical to their split
heads, verified per file. Full suite, fmt-check, vet, release build and smoke
pass.
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
ConflictsAcrossRuns picked the run it quoted — and the key it deduped on — by
map iteration order, which Go randomises, so the same conflict was re-reported
one pass in four and the nudge named a different command between identical runs.
This branch is where that function is called, from both the agent loop and the
specialist, so the loop-safety it provides is relied on here.
Origin-Session: local-abff1c | Claude Code | 6 prompts
Origin-Snapshot: b59446f78949
Self-review of the previous sync found that clauseEnd's name-shape bound was dead
code — lowercase prefixes compared against a claim this package deliberately does
not lowercase — and that every test for it contained a clause separator, so the
separator bound caught them and the dead branch looked alive.
Origin-Session: local-abff1c | Claude Code | 5 prompts
Origin-Snapshot: dddd3415c4e0
Fifth sync today. The three split branches moved again on review feedback:
internal/memory the ancestor regression skipped on Windows, so the
junction case the guard exists for went unasserted
internal/measurements an UNRECORDED neighbour still let one name take
another's number; the prefix fixture's two durations sat
inside tolerance of each other and could not fail
internal/agent an admission escaped by putting its consequence in a
second sentence, and "the work is blocked" matched no
marker at all
All 17 files the five split branches touch are byte-identical to their split
heads, verified per file. Full suite, fmt-check, vet, release build and smoke
pass.
Origin-Session: local-abff1c | Claude Code | 5 prompts
Origin-Snapshot: dddd3415c4e0
Sync of all five split packages after @anandh8x's P1s were fixed on them, plus
the integration work his measurements finding forced here — this branch is where
that package is actually wired in, and the split branch that owns it has no
caller at all.
Adopted from the split heads:
internal/memory a link at any store ANCESTOR redirected reads and
writes; the local-scope privacy promise failed open
internal/measurements a name borrowed the next name's duration; timings from
different commands were pooled; one bad number silenced
every later one for that test
internal/sandbox a permanent grant was revoked by a temporary holder's
release; one holder's undo could consume another's
reference
internal/agent "the objective is met" read as failure; a tool caveat
excused blocked work; "find any solution" passed as a
successful absence finding
WIRED HERE: Record now carries the call that produced the output. The agent loop
has the ToolCall in hand at exactly the point it reads the output, and the
specialist has the tool, its arguments and the cwd on the stream event — so a
timing is recorded against the command that printed it instead of being pooled
with every other command's. That pooling is what let a claim about an ordinary
run be answered by a number only a `-race` run produced.
Both call sites check ACROSS runs rather than against one: a final answer
summarises several commands, and holding each number to a single run would
accuse the model of inventing a figure another of its own commands really
printed. The per-run check exists for callers that know the command; neither of
these two does, and saying so at the call site is the point.
Full suite, fmt-check, vet, release build and smoke pass.
Origin-Session: local-abff1c | Claude Code | 3 prompts
Origin-Snapshot: 07900397c62e
#911 and #912 both moved when CodeRabbit's findings were fixed, so this branch
was behind again in two more packages:
internal/sandbox the concurrency test was not concurrent — instrumented over
200 runs, 194 peaked at ONE simultaneous holder — and its
helper skipped outright on Windows
internal/agent "the objective" and "the assignment" were bare nouns, so a
finished answer reporting success was read as admitting
failure; and a tool caveat excused blocked work
Same check as before: all 17 files the five split branches touch are
byte-identical to their split heads. Full suite, fmt-check, vet, release build
and smoke pass.
Origin-Session: local-abff1c | Claude Code | 2 prompts
Origin-Snapshot: d2f269b81f33
Follow-up to the sync commit: #897 and #909 each gained tests after it, so this
branch was behind again by four assertions — the ellipsis on a truncated
description, the scope ResolveScopes actually resolves to, the exact ".md"
match, List returning readable notes beside its error, and a parent test's own
duration.
Re-verified the same way: all 17 files the five split branches touch are
byte-identical to their split heads. Suite, fmt-check, vet, release build and
smoke pass.
Origin-Session: local-abff1c | Claude Code | 1 prompt
Origin-Snapshot: 0e7ed28981cb
@Vasanthdev2004 found this branch still carrying its own older copies of
internal/measurements and internal/memory. Checking the rest of the split for the
same drift found four more packages behind, so the reviewer fixes lived only on
the split branches and merging this would have reintroduced every one of them:
internal/measurements the minute pattern reached past a nearer seconds figure
("took 0.86s (package total 1m20s)" -> 80), inventing a
conflict against a number the model had right
internal/memory the description cap was quadratic (64 KiB -> 13.3s) and
ran only on the write path, so a cloned note was unbounded
internal/tools/memory a list error discarded the notes that WERE readable, and
an unreadable project note masked the user's own local one
internal/tools RecordEdit branched on the raw whole flag while SeenWhole
derived it, so a file read whole in two chunks lost that
status after one edit and write_file refused the overwrite
internal/sandbox AddTemporaryRead took no reference when a TEMPORARY write
root covered the path, so the writer's cleanup revoked a
live reader
internal/agent the incompletion allowance fired on blocked work
Two sweeps were wrong before this one, and both failed in the direction that
reports trouble as fine:
- The first reported all three unflagged splits in sync. zsh does not word-split
an unquoted parameter, so the loop had compared one bogus path and found no
diff. Rerun per file, every one of them was behind.
- The second synced only the files I guessed at, which missed that each split
keeps its regression in a SEPARATE file — scope_temp_refcount_test.go,
edit_preserves_reads_test.go, memory_test.go. The fixes would have arrived
with nothing here to hold them in place.
So the check is now every file each split branch touches, enumerated from the
branches themselves rather than named by hand: all 17 are byte-identical to their
split head. Full suite, fmt-check, vet, release build and smoke pass.
Origin-Session: local-abff1c | Claude Code | 1 prompt
Origin-Snapshot: 0e7ed28981cb
Vasanth asked for this to be measured rather than argued, so it was.
The successNegationTails list lets an absence-establishing sentence through, so
a finder reporting "I could not find where X is set" is not marked incomplete
for doing its job. But the stems it added — "find the", "reproduce ",
"confirm any", "observe any" — also head the most ordinary way of admitting
defeat, and the allowance fired on the tail prefix alone regardless of how the
sentence ended.
Measured against eleven genuine admissions, TEN passed the detector undetected:
"I could not reproduce the crash, so the fix is unverified."
"I could not find the root cause; someone else will need to pick this up."
"I could not locate the source of the regression and have run out of ideas."
That is the guard's whole purpose defeated — it is the last thing between a
stalled run and a report that reads like success.
The allowance now yields when the sentence ALSO says the work is blocked
(blockedWorkMarkers). Re-measured: 3 of 11 still pass, all single-clause
sentences carrying no blocked-work signal at all ("I failed to reproduce it
locally."), and false positives on legitimate findings stayed at zero — the
motivating audit case, "I could NOT find where AllowManifestToolAutoApproval is
set to true in production code", still passes as a finding.
Deliberately not tuned further: the remaining three are genuinely ambiguous
without more context, and fitting the list to them would be over-fitting to the
eleven examples rather than measuring.
Origin-Session: local-de382f | Claude Code | 5 prompts
Origin-Snapshot: 8d8cced3f645
Vasanth's review found this branch carrying OLDER copies of code under active
review elsewhere, so merging it would silently revert those reviews:
internal/pathjail/pathjail.go 135 lines here, 177 on #891's head
internal/memory/memory.go 333 lines here, 539 on #897's head
The memory copy was not merely older. Its name allow-list was
^[a-zA-Z0-9_-]+$, which lets "Findings" and "findings" collide into one file on
Windows and default APFS — the case-collision bug #897 fixed, where a write
silently replaced the other's body and a delete removed a note the caller never
named. The reserved-device-name refusal was absent too, so a note called "con"
or "nul" would make `git add -A` stage nothing and leave the repo
un-checkoutable on Windows.
Those three packages are now taken verbatim from #897's reviewed head (which
contains #891's) rather than kept as a fork. Two consequences had to be handled:
- internal/tools/memory_tool_test.go asserted the OLD destructive behaviour
(TestOmittingContentForgetsTheNote) — a test encoding the very defect #897
fixed, which is what would make a careless resolve look correct. Replaced with
one asserting deletion goes through memory_forget and that a save-shaped call
with no content is refused.
- internal/cli/app.go registered the read and write tools but not the new
memory_forget, which would have left notes undeletable. Registered.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
livePlanProvider fell back to the captured profile on two conditions, a resolve
error and an empty provider name, and neither covers the case that actually
happens on a machine with no config file.
DefaultResolveOptions leaves both config paths empty when the files are absent.
config.Resolve then SUCCEEDS rather than failing, falling back to a built-in
default provider: anthropic, claude-sonnet-4.5. That name is not empty and there
is no error, so both guards pass and the default is treated as a deliberate
switch. The captured profile is discarded.
That profile is the one carrying --provider, --base-url and an inline --api-key,
applied by the caller on top of Resolve. Losing it points discovery at the
default endpoint with none of the caller's credentials, which is this function's
own failure mode arriving from the other direction: it exists because discovery
and child execution disagreed about the provider, and here it manufactures that
disagreement itself. A headless run in a fresh container hits it every time.
So the third condition is the one that was missing: when neither config path nor
a provider command gives resolution anything to read, there is nothing to
re-resolve from and the captured profile stands. Read off the options rather
than by stat-ing again, since DefaultResolveOptions already computed it.
ZERO_PROVIDER is deliberately not part of that test even though it looks like it
belongs. With no config file there are no profiles to select from, so a
ZERO_PROVIDER naming one resolves to ErrNoActiveProvider and the existing error
branch already returns the captured profile. Including it would read as a
meaningful case while changing no outcome, so the comment says why it is absent.
The failing test was already in the tree, written against the intended behaviour;
this makes the code match it. Verified by mutation: disabling the new guard
reproduces the original failure exactly, and the two tests guarding the opposite
directions, following an in-session switch and keeping flag overrides when the
provider did not change, both still pass.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Reported by anandh8x. Both stores checked their own directory and file with
Lstat and then handed those same strings to MkdirAll, CreateTemp, Rename and
Remove. Every one of those re-resolves the ANCESTORS, so a link anywhere above
the store redirected the operation while the checks passed, because the
components they looked at genuinely were not links.
Reproduced before fixing, with .zero as a junction to an outside directory:
SavePlan and memory.Write both landed their bytes outside the workspace, and
memory.Forget deleted a file outside it. The delete is the sharpest of the
three, since a write leaves a file somewhere and a delete removes somebody
else's.
There is a second, independent failure on Windows that the report did not
reach, and it changes what the fix has to be. A junction is a reparse point but
NOT a symlink, so os.Lstat returns ModeIrregular with ModeSymlink clear and the
guard answered "not a link" when pointed straight at one. Applying the same
predicate to every ancestor, which is the obvious repair, would therefore have
passed all of them. Junctions also need no privilege to create, unlike Windows
symlinks, so this was reachable by exactly the unprivileged workspace owner the
guard exists to contain.
So the fix stops naming things by path. New internal/pathjail opens a handle on
a declared root once and addresses everything below it relative to that handle,
which is what os.Root gives us: each component is resolved against an object
that cannot be swapped underneath, and a reparse point is refused rather than
followed. It is the same answer internal/sandbox already uses for ACL
materialization, reused rather than reinvented.
Both stores now carry that root. memory.Paths gains Root and specialist
PlanPaths gains ProjectRoot and UserRoot, all set by their DefaultPaths
constructors from the workspace, and SavePlan takes the root it must stay
inside. The root itself is resolved normally, because it is the boundary the
caller chose and may legitimately sit under a link; only what is below it is
confined.
Reads are confined too, not just writes. A note read through a link is an
exfiltration primitive in a tool the model can call by name, which is the same
hole with the arrow reversed.
Tests: pathjail covers the ancestor link, the junction the old predicate missed,
an absent name still being creatable, targets outside the root, and O_EXCL temp
names. Each store has an end-to-end test that the escape is refused and an
ordinary workspace still round-trips. The link helpers use a junction on Windows
on purpose, since a test written with os.Symlink skips on an ordinary account
and proves nothing there. Verified by mutation: restoring the ModeSymlink-only
predicate fails the junction test, and removing the containment fails the
ancestor tests in all three packages.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The AGENTS panel toggle wrote its preference to disk and then lost it on every
launch. mergeConfig copies FavoriteModels, RecentModels, Recaps and Theme field
by field and never touched keepFinishedAgents, so Resolve always answered false:
app.go passed KeepFinishedAgents:false into tui.Options, the panel seeded
showDoneAgents:false, and finished sub-agents were dropped after the linger
every session. The key sat in the config file doing nothing.
Copied as a pointer like Recaps, so an explicit false stays distinguishable from
an absent key.
The reason this survived is worth recording, because the test that should have
caught it is green and stays green. TestTogglingKeepFinishedAgentsPersists
round-trips through SetKeepFinishedAgents and reloads the file, which proves the
WRITE. The broken half was the read on the next launch, and the only thing on
that path is Resolve, which that test never calls. Its comment claimed the
preference survives a restart, which was not a property anything checked.
So the new coverage sits in internal/config, next to the merge it is about, and
goes through Resolve: an explicit keep, an explicit drop (the case the pointer
exists for), an absent key staying absent so an unconditional assign cannot pass,
and the neighbouring theme and recaps values, which are what localise a future
break to this merge rather than to the unmarshal or the writer. Verified failing
with the fix removed, where the older test still passes.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
detectShellRuntime probes PowerShell by starting it in this process, which is
unrestricted, and the command then runs inside the sandbox, which is not. On
Windows the sandbox wraps commands in a WRITE_RESTRICTED token, and PowerShell
is a .NET program whose crypto initialization fails there:
System.DllNotFoundException: Unable to load DLL 'BCrypt.dll'
or one of its dependencies (0x8007045A)
So PowerShell answers "usable" when asked in the parent, is selected, and is
then unable to run anything at all: exec_command and bash could not execute so
much as `echo` under the sandbox. The probe was asking a different environment
than the one that had to answer.
buildBashCommand is the single execution site for both tools, so it now resolves
the shell through the engine that will wrap the command. When PowerShell cannot
start there, detection falls through to cmd.exe, which does survive the token
and was already the documented fallback -- no new fallback path, just an honest
probe reaching the existing one. The sandboxed answer is cached separately from
the unsandboxed one because the two environments genuinely disagree; sharing one
cache would let whichever question was asked first answer both.
This is the same restricted-token incompatibility class already recorded for
Schannel TLS and MSYS2/Cygwin in internal/sandbox/windows_command_runner_windows.go.
Verified by running the feature on all three platforms against the real binary,
same commit, A/B: Windows 6/7 -> 7/7, macOS 7/7 and Linux 7/7 unchanged. The
added test pins the contract the fix relies on -- that a failed probe reaches
the cmd.exe fallback -- but not the wiring itself, which would need lookPath
injected into buildBashCommand; the cross-platform run is what covers that.
Known gap, not addressed here: the bash/exec_command tool descriptions still
advertise PowerShell syntax, because they are built in NewScopedBashTool, which
receives no engine. Sandboxed Windows now runs cmd.exe while the model is told
otherwise. shellGuidanceForRuntime already contains the correct cmd.exe wording;
reaching it means threading the engine through tool construction.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
#867 landed on main while this branch was being rebased, reshaping
read_file's schema to the canonical path/offset/limit contract and
rewording its description. The golden hashes the tool DEFINITION bytes,
so a description change on main moves it — the same way #838's rewording
did before.
The new value is what the fixture's tools actually hash to on this base,
and it is the value CI computed independently: the macOS and Ubuntu smoke
jobs both reported got ea7b4e64… against the old constant, which is the
figure recorded here now.
This is main's schema change, not a posture leak from this branch. The
byte-identical guarantee is unaffected: what the guard protects is that a
posture-off run's definitions match a binary built from the same main,
and both sides moved together.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Rebasing onto main flattened the two merge commits this branch carried,
and in doing so replayed an older value of postureOffDefinitionsFingerprint
over the current one — reverting it to the pre-#838 hash and dropping the
comment recording why it had moved.
The constant is a frozen golden over the tool DEFINITION bytes, and main's
#838 reworded four tool descriptions (glob, grep, list_directory,
read_file), so the correct value on this base is the one the branch
already had. TestPostureOffToolDefinitionsMatchGolden proves it: with the
reverted constant it fails with got 51ffc34f… want 3693b93a…, and the
'got' is what the fixture's tools actually hash to now.
Restored with its rationale intact, since that comment is the thing that
tells the next reader the move was main's schema change rather than a
posture leak from this branch.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The fix for "cancel must not stop a background plan" over-corrected into
its own opposite. The guard wrapped BOTH cancelRunning calls in
BackgroundPlanLive, and the specialist tracker holds foreground and
background children TOGETHER — so one live background plan left every
FOREGROUND sub-agent spinning forever: still specialistRunning, still
showing its current tool, still counted live in MODELS, over a process
that died with the run context. Nothing else settles it, because a late
completion is dropped by the stale-run guard once cancelRun has zeroed
activeRunID. That is verbatim what TestCancelSettlesEveryRunningAgentAndTask
exists to prevent, and the background test missed it because it had no
foreground child.
Children now carry the distinction themselves. specialistInfo gains a
background marker, set from the two places that can know: a background
plan's task-start message carries the flag, and a specialistRebindMsg is
only ever emitted for a background Task spawn. cancelRunning skips marked
rows and settles the rest. The orchestrate panel keeps its whole-panel
guard, because one plan runs at a time and there is no mixed case there.
The new test is the mixed state neither existing test covered: a
foreground child and a background child under a live background plan,
asserting the first settles and the second does not. The background test
now marks its row the way production does, rather than passing because
nothing settled at all.
specialistInfo's new field is added to specialistCacheFingerprint, as the
render-cache guard test requires — without it a card that changed through
the field would keep serving its previous render.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two regressions the review found in the previous two commits, plus the
hole that made one of them reachable.
THE CAP WENT INERT ON ANY PRICED CATALOGUE. Exempting priced catalogues
from the cut fixed the cost inversion by removing the bound entirely, and
catalogueIsPriced is an ANY test — so one priced model among three
hundred disabled the cut for all three hundred, planModels.topModels
contradicted its own documentation, and the router prompt lost its only
bound (measured 2,194 -> 22,985 characters on a 300-model catalogue, paid
once per plan on the strongest model).
The cut now keeps BOTH ENDS of the ranking instead of exempting anything:
a third from the least-capable end, the rest from the most-capable. That
satisfies every reader of the list at once — the cheap tier reads
eligible[0], the strong tier reads the last, and the router gets a
bounded shortlist that still spans the real range. It also preserves the
unsized models without a special case, since an id whose size cannot be
read sorts at the cheap end.
VERSION TEXT WAS READ AS A PARAMETER COUNT. The suffix boundary was
[^a-z], which a DIGIT satisfies, so "deepseek-r1t2-chimera" matched
"1t" followed by "2" and reported a trillion-parameter model — on a
free provider that would have made it the strong tier every judgement
task was routed to. The b/m suffixes always had the same hole
("r1b2-x" read as 1B); adding t is what made it reachable on a real id,
so the boundary excludes digits for all three rather than for the one
that was reported.
Both mutation-checked, and the boundary now has a test naming the exact
reported id — reverting it alone previously failed nothing.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The ranking sorts an unparseable id as size 0 — the least-capable end —
so taking a plain tail deleted exactly the models whose names do not
state a parameter count. On a real ollama-cloud account that removed
kimi-k2.6, glm-5.2 and deepseek-v4-flash from routing while gpt-oss:20b
survived, and the operator's own router guidance calls the first two the
strongest reasoners on that machine.
Dropping a model because its name is uninformative is a parsing
accident, not a capability judgement — and applyMinSizeFloor in this same
file already follows exactly that fail-open rule. The cut now applies to
SIZED models only: every unknown-size model is kept, and the top N of the
ones that can actually be compared join them, with order preserved so
cheap/balanced/strong still read off the ends.
On the fourteen-model catalogue this was measured against, the shortlist
now drops only phi4:14b — small AND sized — and the cheap tier becomes
deepseek-v4-flash rather than gpt-oss:20b.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
CANCEL CLOBBERED A LIVE BACKGROUND PLAN. cancelRun settled the trackers
unconditionally, with none of the BackgroundPlanLive guard beginRun makes
a few lines above — so stopping a foreground turn marked a background
plan's tasks and sub-agents cancelled while they kept spending tokens and
writing files. The mirror image of the defect the background-status tests
exist to prevent. Foreground children still die with the run context and
are still settled.
A RESUME BLIND-PINNED A STALE SESSION MODEL. runResume applied
session.ModelID with no served-check, so a sub-agent recorded on one
provider and resumed after a switch was spawned with a model that
provider has never heard of and died at spawn — the failure autoTaskModel
already guards against. The recorded model is now REJECTED ON EVIDENCE
rather than requiring proof, the opposite direction to autoTaskModel and
deliberately so: this restores a model the session demonstrably ran on,
so no discoverer, a failed listing or an empty one all leave the recovery
in place.
THE PROVIDER RETRY FIRED ON PERMANENT FAILURES. Exit code 3 means "the
provider failed", but internal/cli returns it for every agent-run error
— an expired key, an unknown model, a quota wall — so an expired key cost
a ten-task plan twenty spawns to produce the same ten auth errors. The
budget is now PLAN-WIDE (two, mutex-guarded because tasks dispatch
concurrently) instead of per-task: a genuine blip is still retried, a
systemic failure costs two extra children for the whole plan.
THE SESSION BUDGET CHARGED FOR RESUMES AND REFUSED CALLS. admit() ran
ahead of prompt validation and ahead of the resume branch, so iterating
on one sub-agent's answer thirty times consumed thirty of the session's
slots while spawning nothing. It now runs on the fresh path only, after
the checks that can refuse the call outright and still before every side
effect.
NON-LATIN LABELS WERE REJECTED AS NON-WORDS. hasNameLetter tested ASCII
ranges, so every CJK, Cyrillic, Greek and Arabic token was dropped and
the sidebar row lost its label — a silent degradation for anyone not
writing in English. unicode.IsLetter/IsDigit is the same question asked
correctly.
DRACULA'S SUCCESS COLOUR COLLIDED WITH ITS BLUE. The recolour set green
to #8be9fd, byte-identical to that palette's blue, collapsing two of the
four series colours modelMixPalette cycles through so two models in the
MODELS bar could render indistinguishably. Success moves to a teal in the
same cool family; the registry-wide contrast tests still pass.
Also drops TestZeromaxingSkinPreview, a visual preview with no assertion
that could not fail.
Each fix is mutation-checked: gutting any one fails its own test.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two more defects in the auto-appended verification task, both of the same
shape: a task the author never wrote changing the outcome of the plan
they did write.
A THIRD BUDGET CEILING. The headroom check covered
refuseImplausibleBudget (max_tokens vs 50,000 per task) but not
refuseUnreachablePerTaskCap (max_tokens vs max_tokens_per_task x
taskCount), so the extra task raised that bar by one whole per-task cap.
A 4-task plan with max_tokens 400,000 and max_tokens_per_task 100,000
parsed fine and was then refused outright — naming a task count and a
budget its author never chose. Any plan whose per-task cap exceeds the
50,000 floor falls in that window, so the floor check alone never
covered it.
THE VERIFIER'S FAILURE FAILED THE WHOLE PLAN. It runs last, on the
strongest tier, with the largest dependency briefing — the task most
likely to stall or exhaust its provider retry — and terminalStatus
counted it like any other, so three succeeded author tasks plus one
stalled verifier returned StatusError and the orchestrating model re-ran
a plan whose work was already done. When the appended task is the ONLY
thing that did not succeed, the call now stays OK and says plainly that
the claims were not independently verified and that re-running would
repeat completed work. The report stays honest either way: Failed still
counts it and the summary still names it; only the verdict on the
author's plan is left to the author's tasks. appendVerifyStage returns
the id actually used, so a suffixed id cannot match the wrong task.
Both mutation-checked.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two defects a high-effort review found in the top-ranked shortlist.
THE SHORTLIST INVERTED COST ON PRICED PROVIDERS. The ranked list is
sorted least-to-most capable, but "capable" is measured by SIZE on a
free provider and by COST on a priced one — so taking the tail meant
"keep the biggest" in the first case and "keep the DEAREST" in the
second. Measured on a 12-model priced fixture: the cheap tier moved from
cost 1 to cost 3, and on a real 30-model account every scan-role task in
every auto-assigned plan silently moved an order of magnitude up in
price, reported only as "scan -> <model>".
A priced catalogue is now never narrowed. The asymmetry is the point: on
a free provider the small models cost nothing and buy nothing, so
dropping them is pure gain; on a priced one the cheap end is exactly what
the cheap tier exists to spend. catalogueIsPriced is extracted as one
authority so the ranking and the shortlist cannot disagree about what a
free provider is.
A TRILLION-PARAMETER MODEL PARSED AS SIZE ZERO. modelSizeToken accepted
only the b/m suffixes, so "kimi-k2:1t" read as unknown and sorted BELOW
gpt-oss:20b on a free provider — the largest model on the account became
the cheap tier every scan task was routed to, and the first thing the
shortlist discarded. The suffix class now covers t.
Both mutation-checked: re-narrowing a priced catalogue, and dropping the
t suffix, each fail their own test.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A measured ten-task run lost one task to a bare "Internal Server Error"
after 6 tool calls and 42,524 tokens, and everything depending on it went
with it. Nothing about the task produced that.
Two layers each declined to retry, and only one of them was right to.
The transport does not replay a 500/502/504 on purpose: unlike 429/503/
529 they do not guarantee the request had no effect, so replaying the
same POST could pay for the same completion twice
(providerio.ShouldRetryStatus). That is correct for a replay. The plan's
own loop then returned on "anything other than a stall is an ANSWER,
including a failure" — true of a task that read the code and concluded
wrongly, which will conclude the same thing, and not true of an HTTP 500,
which is a coin flip. A fresh child is not that replay; it is a new
request the provider is very likely to answer.
So the loop grows a third carve-out beside the two that already exist for
"not the task's fault" (ModelRejected, Declined): one more attempt when
the child died on the provider, recognised from its exit code the way
Declined is — a flag, never a message match.
READ-ONLY TASKS ONLY, and that bound is the whole safety argument. A
write task may have applied part of its change before the provider died
and no exit code says how far it got, so re-running it could apply that
change twice. A read-only task re-reads, which costs tokens and nothing
else — the same asymmetry the no-tool-call check rests on.
Bounded at exactly one by its own flag (a provider failing twice is
having an outage), gated by the same cancellation and wall-clock stops as
every other retry here, and NOT gated by max_retries, which bounds stall
thrashing rather than this. Both attempts' spend is reported.
Five mutations, five tests: removing the branch, dropping the write-task
gate, dropping the one-retry bound, never setting the flag from the exit
code, and drifting the duplicated constant each fail. The flag test is
separate on purpose — gutting the classification left the four executor
tests green, which is the layer-join defect this package keeps
relearning, so one test drives the real runner. The constant is now
pinned on BOTH sides; pinning only cli's left specialist's free to move
silently.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Ranking spanned everything the provider serves, so buildModelTiers read
the cheap tier off the SMALLEST model on the account and the router was
handed every candidate to choose between. On a provider listing twenty
models that is a toy running a scan while nineteen better ones sit
unused — the reason 20b-class models kept turning up on tasks that had
a 120b available.
rankedEligibleModels now keeps the most capable N (default 10) after
exclusions and the size floor, so the tiers span the good models rather
than the full catalogue and the router chooses from a shortlist worth
choosing from. Configurable as planModels.topModels for anyone whose
provider warrants a wider or narrower field.
THE TAIL, NOT THE HEAD: the list is sorted least-to-most capable, so the
shortlist is its end — taking the front would keep exactly the toys this
drops. Fail-open throughout: a provider offering fewer than the cap keeps
all of them, and a zero or negative cap means the default rather than
none, because a zero value must never leave a plan with nothing to
assign.
A PIN IS UNAFFECTED. Pins are validated against what the provider serves,
never against this list, so naming a model outside the top N still routes
to it — an explicit instruction outranks a heuristic. Tested, along with
the fail-open cases; gutting the shortlist, taking the head instead of
the tail, or reading a zero cap as none each fails its own test.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan fans out, every task reports, and the report carries what they
said — under zeromaxing that is five or more sub-agents' claims, each
written by a model that cannot see the others' work and will never be
asked about it again. The bundled research plan ends in a refute step
for exactly this reason and planTaskSystemPrompt's evidence rules exist
to make one possible, but both are habits: a plan whose author forgot a
verifier finished green with nothing checked, and a wrong "looks fine"
is indistinguishable from a right one once it reaches the report.
Under the posture, a multi-task plan that names no verification now gets
one appended — a task depending on every other, told to REFUTE and to
default to refuted when uncertain, because a verifier that sets out to
agree always does. It classifies as verify, so the user's verify pin
routes it to their strongest model, and it works on the ARGS before
ParsePlan, so it is validated by the same constructor as a hand-written
task, round-trips into a saved plan, and re-admits on resume.
THE APPEND MUST NEVER BE THE REASON A PLAN IS REFUSED, and two ceilings
can do that. The size tier caps task COUNT. The budget floor is the one
the existing suite caught: refuseImplausibleBudget requires max_tokens
to fund 50,000 per task, so one more task raises the bar by 50,000 and a
plan whose budget exactly funded its own tasks was rejected outright —
for a task its author never wrote. Both have a guard and a test.
It also names NO tools: planToolGrant then resolves the read-only
intersection of the parent's grant, so it can neither modify anything
nor flip the plan into worktree isolation (isolation is plan-level, the
measured failure plan_workspace_reach.go exists to catch). It defers
entirely to a plan that already verifies, moves its id aside on a
collision, leaves a malformed task list to ParsePlan, and reports itself
in the run's output so a task nobody wrote never appears unexplained.
Each guard is mutation-checked: gutting the posture gate, either ceiling,
the already-verifies check or the id probe fails its own test.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
NTFS ignores trailing dots, so Lstat("…/streamer.go.") succeeds for the
file named "…/streamer.go" — the exact-hit branch returned the
sentence's full stop as part of the path, and the reach check compared
the wrong spelling. When the trimmed spelling names the SAME file, the
canonical name wins; a POSIX file genuinely named with a trailing dot
keeps its exact spelling, which the new cases pin (dotted-only, distinct
dotted sibling, and the plain trim), with the unconditional-rewrite
mutation killed by them.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
make deadcode (-test=false) flags production functions whose only
callers are tests: ExecutePlan, withDependencyBriefing and
autoAssignModels in specialist, markDone and joinColumns in tui. Each
was a deliberate convenience seam with documented rationale; they keep
that rationale and move to export_test.go, the repo's home for test-only
helpers, so the production binary carries none of them. SortedPlanSizeNames
had no callers anywhere and is deleted.
Also clears the three staticcheck findings the advisory lint reported in
PR-owned code: a tagged switch in plan_model_assign, time.Since in a
test, and a self-comparison rewritten as two named renders.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
absolutePathPattern only matched POSIX-style paths, so on Windows the
reach check silently never fired: prompts naming C:\ paths contributed
no candidates, and four tests showed it. The pattern now takes both
separators and an optional drive letter, plus '~' for 8.3 short names
(RUNNER~1), which real CI paths contain. pathInsideRoot keyed on the
single platform separator, answering false for every /-separated pair on
Windows; both separators are boundaries on every platform now.
Also makes the completed-session retention test cross-platform (cmd /c
on Windows) instead of assuming /bin/sh — the behaviour under test is
OS-agnostic, so it runs everywhere rather than skipping.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A saved plan's prompts are model-authored text read back from disk — the
same trust level as a live task's — and planTaskSummaryLine only cut at
the first newline, so control characters (ESC included, the byte that
starts an ANSI sequence) reached the display. It now routes through
sanitizeCardText, the chokepoint the live path already uses.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Entering the posture through /effort zeromaxing is guarded by the
idle-session rule; leaving it through /effort auto reached
revertExecProfile — moving the turn budget, the self-correct setting and
the shared orchestrate gate — with no guard at all, leaving one turn
running under two different budgets. The same rule now guards both
doors. Plain /effort auto outside the posture keeps working mid-run: it
only clears the effort selection, which mutates no shared budget.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The exec wiring handed children execScope.Roots() — the parent workspace
root included — as extra write roots. For a worktree-isolated plan that
defeats the isolation outright: the worktree exists so a write-capable
plan cannot touch the parent tree, and this line was handing the parent
tree back as a writable --add-dir. The TUI has always passed only
scope.ExtraRoots; the two surfaces now agree, through a named helper the
regression test drives with a real sandbox scope.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The status card stacked three renderers — a labelled list, the resolved
posture line, and the delta sentence — so the profile name appeared
twice and the effort and turn budget three times each. It is now a
single sentence of key-value clauses: each fact once, the transitions
as clauses on the row they qualify ("was 80", "raised by the posture",
"NOT raised — high is unsupported on this model"), no section header
and no hint advertising the command the user just typed.
Every honesty guarantee the old card's tests enforced survives on the
one line: the refused effort level is named, the self-correct claim
tracks live state ("your /selfcorrect off overrides the posture"),
exactly one effort-transition claim exists, and the escalation row keeps
the headless-only asymmetry. /effort still carries the shared delta
sentence verbatim, so the switch notice and that surface cannot
disagree. Also learned and recorded: compactCommandOutputText collapses
every whitespace run, so padded column alignment can never survive this
renderer.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The stock #50fa7b green and #ff5555 red are the highest-chroma signals
in the whole registry, and in a zeromaxing session — success ticks and
error rows arriving constantly — they read as alarms, not status. The
success and failure roles now wear Dracula's signature cyan and pink,
which sit naturally beside the purple accent and stay clear of amber's
permission meaning. The palette token names stay green/red (every
consumer binds to them as success/failure); diff colours keep their
conventional hues — a diff is a diff on every theme. The registry-wide
contrast floors still hold.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
While the posture is on, the workspace wears it; off, every surface is
byte-identical to before (each gate mutation-checked).
MODELS panel: the sidebar grows a section showing the live mix of models
the fleet runs on — a proportional mix bar plus a row per model with
live counts, colours stable per model name. Absent until an agent runs
on a known model, so a plain session's layout is untouched.
The skin: an electric gradient (theme blue blended into the accent —
deliberately two-tone) paints the always-on surfaces: the composer box
on all four edges, the chat|sidebar divider as a full-height rail, the
sidebar section headers, and the Working ripple. The gradient sweep is
mirrored, so no seam ever reads as "half painted". No new timers:
every walk reads the spinner clock that already runs during a turn, and
reduced motion pins all of it.
State-aware animation: thinking drifts, writing races (4x), and
orchestrating gets a scanner — a bright band sweeping the composer bar
while plan tasks are actually running. The Working ripple's wavelength
follows the same states.
Plan bars join the skin: done work fills with the gradient, the running
head burns accent, failure stays findable in calm dimmed red, and the
update_plan checklist gets a progress bar of its own (the orchestrate
bar's renderer, so the two cannot drift). Click offsets for plan steps
account for the inserted bar row, test-pinned against the rendered
sidebar.
Settled cancels: cancelling the run kills the children but the trackers
were never told — agents kept their spinner, ticking clock and "live"
mark in MODELS beside "Run cancelled." in the transcript. cancelRun now
marks running specialists and orchestrate tasks cancelled (not failed —
stopping a run is not a defect), freezing their clocks and dropping the
live counts.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A bare Task delegation inherited the parent's model unconditionally, so
under zeromaxing every sub-agent ran on the session model no matter what
it was for. A Task that names no model is now routed to the configured
role pin — the plan path's own classifier and pins, the grant outranking
the prose — gated on the posture AND the autoAssign config, so a
posture-off spawn stays byte-identical.
Four defects found and closed while verifying the seam end to end:
- A resumed task drifted back to the parent's model. runResume applied
neither the call's model nor the assigned one; it now recovers the
model the session actually ran on from the session store, with an
explicit model on the resume call winning exactly as on a fresh
launch.
- A plan task could be second-guessed at dispatch. Plan tasks flow
through the same executor, and a model the plan tool deliberately left
empty (a reported decision, or auto_assign:false) would have been
re-assigned blind — including to a pin the plan level had passed over
as not served. autoTaskModel now refuses plan-authored manifests,
keyed on the provenance the manifest already carries.
- Naming an uncurated model killed the spawn. applyTaskModel forwarded
the posture's raised reasoning effort unconditionally; the child only
clamps efforts for models the registry knows, so providers that reject
the parameter refused the whole request — a real orchestrator lost
three children to it and retreated to inherit-everything. The effort
now forwards only where the registry can vouch, the same gate the plan
path already applies.
- A stale pin after a provider switch killed the spawn. Pins named
Ollama models while the session ran on xai, and three children died
with not-found. A pin now fires only when the provider's own listing
carries both the pin and the session's model (the plan path's
provider-mismatch guard); anything uncertain inherits. One listing is
cached for two minutes, so a five-agent fan-out costs one probe.
Every load-bearing line is mutation-checked: the pin lookup, the
manifest-model guard, the posture gate, the session-model recovery, the
explicit-resume-model apply, the plan-provenance skip, the effort vouch
gate and the served-set guards each have a test that goes red when the
line is gutted.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Review feedback: Done() and Remaining() were only ever called by their own
tests — the resume path narrows through RemainingPlan and never touched either,
and the comments falsely claimed orchestrate_saved consulted Done() first.
They are also unsafe to wire in as suggested: Done() is id-based, so with the
identity-aware resume a plan whose ids all succeeded but whose task was edited
would be reported "done" and its re-run silently skipped. Removed both; the one
test that asserted "a failed task is remaining" now asserts it through the real
gate (RemainingPlan) instead of the dropped helper.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Independent improvements to the plan/orchestrate subsystem, built and verified
together on this branch. All are posture-gated: the posture-off cold-start
request stays byte-identical.
- Identity-aware resume. A plan resumes from where it stopped; a completed task's
bounded output survives into the tasks that depended on it; and an edited task
(plus everything downstream) re-runs instead of replaying a stale result.
Bare "/plans resume" continues the last cancelled run.
- Read-grant propagation to sub-agents. A plan's tasks inherit the parent's
request_permissions READ grants through a new --add-read-dir flag applied with
scope.AddRead, so a read-only audit of a granted external path no longer fails
"outside the workspace". A read grant is never emitted on the write channel, so
it can never become writable in the child.
- Size-aware per-task model routing. Free providers (no prices) tier by the model
size parsed from the id instead of falling through to alphabetical; the router
is shown explicit size labels; a configurable plan_models.min_size floor drops
models known to be too small; and `providers models` shows the classification.
- Plan-run observability. Per-worker token and model display, a worker view
folded from the accounting events, background-agent completion/status, and
durable per-task numbers that survive resume.
Every production change is covered by a regression test; mutation-checked.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
BuildFinalResult prefixes "session_id: <id>" so a Task caller can continue the
child it started. A plan task has no such caller: the plan owns its children's
lifetimes, and TaskResult.SessionID already carries the id structurally.
Its output, though, is quoted into the report under "result:", pasted into the
dependency briefing every downstream task reads, and rendered in the plan
panel — so the line surfaced a raw child session id in the middle of a
user-facing answer three times over, and told every dependent task to treat it
as a finding.
Removed at the plan boundary, keyed on the id we were handed rather than on
the pattern: the first line goes only when it is exactly the prefix followed
by that id, so a child whose own prose opens the same way comes through whole.
The Task path keeps the line, which is what it exists for.
Asserted at the caller as well as the helper — a real task through the real
executor seam — because a helper that works while nothing calls it is the
defect this repo keeps finding.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A completed two-task plan drew
AGENTS 2 done
no agents spawned
sidebarSpecialists drops a finished agent once it is past its linger, so the
section had no lines to render; doneAgentCount counts exactly those agents, so
the header reported two. The header and the body were describing the same run
and disagreeing about it.
The count is the true one — two agents ran. The placeholder was reporting the
emptiness of a filtered list as the emptiness of the session, so it now says
what is actually there and names the toggle that brings them back. "no agents
spawned" survives for the case where it is true.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The hit test scanned every row footerView renders and took the first one
containing the label. But footerView is not only chips: it renders the plan
panel, the idle hints, a queued-message preview and the composer.
So typing "zeromaxing" into the composer put a matching row above the chip's,
and the hover target became the composer — a click meant for the chip landed
on text the user was still writing, and the chip itself stopped responding. A
plan task prompt carrying the word did the same through the panel.
The chip renders in statusLine and nowhere else, so the search is narrowed to
the rows the status line occupies: rendered separately and taken off the end
of the footer, which is where statusLine writes on every branch.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A manual run emitted budget.max_tokens_per_task: 5000. Admission refused it
for sitting below minimumPlausibleTaskTokens, correctly — and the model spent
a turn and a call discovering a rule that was written down only in prose, in a
description it had already read.
Every bound here was already enforced; none was declared. A schema minimum is
checked by the provider before the request is made, so the invalid call is
never emitted rather than being caught and retried.
Each bound mirrors its enforcement constant rather than repeating the literal,
because two spellings of one limit drift. A test pairs them and admits the
declared floor while refusing one below it: a declared bound that does not
match enforcement is worse than none, since it teaches the model a rule the
code does not hold.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
research.json read "Find where THE SUBJECT is DEFINED" — prose describing a
hole rather than a parameter declaring one, so nothing refused it and nothing
filled it. Run from the documented path it dispatched five child agents to
search the repository for the literal words "THE SUBJECT".
A stand-in that is not a ${parameter} is invisible to expandPlanParams, which
is the one thing in this package that would otherwise have caught it. So the
plan now declares ${subject}, and a test asserts no builtin plan carries a
prose stand-in — the next bundled plan gets the guarantee without anyone
remembering to ask for it.
That alone would have made the plan unrunnable from the path that names it:
/plans run took a name and nothing else. It now takes the words after the
name, and fails closed in both directions — a plan declaring a parameter will
not run without one, and words handed to a plan declaring none are refused
rather than dropped. A single parameter is resolved by the command, since
everything after the name is the value; several are named to the model so it
fills those and invents none.
Refused BEFORE the turn is dispatched. expandPlanParams refuses at admission
too, but only after a model call has composed the plan and the run has begun.
For a five-task plan the difference is five sub-agent runs.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Separated from the code it wires because every capability in the previous commit
is inert without this, and because app.go touches all four - so folding it in
would have made three of the four commits unbuildable on their own. Bisecting a
branch whose middle does not compile is worse than one commit that does two
things.
ExtraRoots replaces Roots for a child's write roots, so an isolated plan task
stops being handed the parent's tree back. One SessionBudget for the whole
session, shared by every run and plan in it - a per-run counter would reset on
each message and bound nothing a conversation does over time. The keyword gate
and the memory tools are read from resolved config, so both stay off unless
asked for.
The memory tools are registered HERE rather than in the core tool set on
purpose: adding them there changes the advertised tool set for every run, which
is what this branch guarantees the posture does not do when it is off. Measured
by watching the posture-off request body diverge, and reverted.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Each is inert until the wiring commit that follows, so this one adds no
behaviour on its own.
SCOPE.EXTRAROOTS. A write-capable plan runs in a worktree so it has "somewhere
to write that is not the user's tree" - one branch, one diff, a discard path.
Scope.Roots returns the workspace root AND anything granted beyond it, which is
right for "everything this run may write" and wrong for a field documented as
"the directories THIS RUN may reach outside its workspace". Wiring the first
where the second was meant handed an isolated child the parent's tree straight
back. ExtraRoots answers the second question.
NOT the fix I first proposed: workspaceWriteAutoAllowed looked like the culprit,
but an outside-workspace path is blocked earlier and that branch was never
reached - the path was not outside. The sandbox was doing its job with the roots
it was given.
SESSIONBUDGET. Depth caps nesting, maxPlanWorkers caps concurrency, a session
shows one plan at a time, and a run is bounded by spend. All bound ONE run or
ONE plan; a conversation that keeps starting more work was bounded by nothing.
200, because across 75 recorded sessions the most any one started was 22. A
COUNT, NOT A COST - the failure that shaped the token budget was one run
spending 35.8M tokens, which a count would not have seen. Counted on admission
and never decremented: the question is how much work this conversation set
going, not how much runs now.
REQUIRE-PLAN-KEYWORD. Once the posture is on, orchestrate exists for the rest of
the session, and everything the model reads shares context with the user's
instructions. An imperative sentence in a file, a PR comment or MCP output reads
like an instruction to the tool that spends the most. So a session may require
that a plan was asked for in the user's OWN words, checked at admission rather
than requested in the prompt - a prompt rule is advice to the same model the
injected text is talking to. That needed the turn's raw user text to reach a
tool, which nothing carried; RunOptions.UserMessage does now. An empty message
is NOT consent, or the gate would be off in exactly the headless and scheduled
paths most exposed to untrusted payloads.
MEMORY. A session ends and what it worked out goes with it. internal/memory
reuses plan_store's answers verbatim - an allow-list name that cannot spell a
traversal component, symlink refusal on directory and file, an O_EXCL temp file
renamed into place - because a note store is a write primitive pointed at a path
the model chooses, the same shape as "save my plan". Reading is free; writing
prompts, because a note is read back and believed in every future session. Local
scope by default. Plan tasks reach NEITHER tool: a stale note could steer a
whole fan-out, and twenty tasks racing to describe one finding is the update_plan
problem again.
Both new config flags default off, which is what keeps a build carrying this
byte-identical to one without it. A project may ENABLE the keyword gate - asking
to be handled carefully is not a privilege escalation - but never switch it off.
A project may not enable memory at all: memory_write writes into the workspace
and project config is not trust-gated.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two constants contradicted each other:
maxEmptyPollYield = 5 * time.Minute // a poll may wait this long
defaultCompletedRetention = 30 * time.Second // ...but we forget after this
Zero prints "Use write_stdin with session_id N ... to poll", accepts a
five-minute poll, and forgot the answer after thirty seconds. In a measured run
a 60-second test was started, polled with yield_time_ms 40000, and the id was
already gone - so the SAME 60-second test ran again to recover a result the
first had produced. The reply told the caller "do not guess or probe session
ids" about an id this manager had issued and instructed it to poll.
Retention is now DERIVED from the poll bound rather than chosen beside it, and a
test pins the relationship rather than the number, so moving the poll bound
cannot silently reopen the gap.
Past that window a finished session is still remembered - id, command, exit code
and the output TAIL, which is where a result, a failure and an exit summary
live. A late poll gets that instead of an error. An id this manager never issued
still returns ErrProcessNotFound, so a real probe is refused and
UnknownExecSessionError stays id-invariant for the repeated-failure guard that
keys on it.
Recorded when the process exits, not only on the polling paths. The common case
is that NOBODY polled while it ran - the reason to background a command is to go
and do something else - and that is the case that was measured. It is also the
case my first six tests did not cover: deleting that capture left them all
passing, which is why a real-process test for it exists now.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two things a plan run lost, both found in one measured plan.
A TASK'S OWN COMMANDS ARE THE CHECK ON ITS OWN NUMBERS. The parent's
measurement tripwire compares the parent's answer against the parent's tool
output; a plan task's commands run in the CHILD's session, so a figure a task
invents was caught by neither. The child's tool results do stream to the parent,
which is what makes the check possible at all: each task now keeps a ledger of
what its own commands printed, and its answer is checked against it. A conflict
is REPORTED, not retried - the parent's version sends an answer back for another
turn, and doing that here would spend a whole task to re-ask a question the
report can simply carry the answer to. It lands above the fold because it is the
one finding a reader cannot check for themselves: the command ran inside a
child's session and its output is not in the report.
A CUT-SHORT TASK MUST HAND SOMETHING ON. withDependencyBriefing already carries
a cancelled task's output to its dependents labelled INCOMPLETE, so the
machinery for a follow-up to pick the work up existed - and was fed nothing. A
task killed mid-edit-loop has written no prose, so Output was empty: a measured
run lost 858,231 tokens of real work with no record of what it had touched.
The handoff is built from the child's own ChangedFiles tool results, not from a
model call. By the time the meter trips the child's process is already gone, so
a summary turn would mean resuming a task that just overspent - paying more for
the report than the report is worth. The file list is free, exact, and cannot be
invented: a file is on it because a tool reported changing it. Prose the task
did write is kept and the list appended, never the reverse.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The posture's evidence contract rides the turn loop's reminders, and a plan
task runs as a child process that BuildArgs never hands --exec-profile - so a
task starts with the posture OFF and the contract never reaches it. The tasks
that fan work out are exactly the ones whose claims land in the plan's report
unchecked.
Most of the substance was already in this prompt: claims backed by what was
read, quoted file:line, an honest "not found" preferred to a plausible guess.
Two rules were not, and both are ones a task can violate straight into the
report a reader will trust:
A passing test is not proof that a property holds. A measured submission
scored a property top marks in five places on the strength of "the test
passes" - while the test consumed the lazy iterator it was meant to prove
lazy, discarded the value whose absence was the bug, and reported avg x 2.5
as a p99.
A number must come from a command actually run. The same submission reported
a table of timings no command in the session produced, with the same test
reading 0.86s in one paste and 4.20s in the next.
Stated in this file rather than shared with agent.ZeromaxingEvidenceNotice
because this package CANNOT import agent - agent's own test binary imports
specialist, so the reference would compile and then break `go test
./internal/agent` with a cycle, the same trap PostureReasoningEffort exists to
avoid. The tests pin the RULES rather than the wording, so the text stays free
to change and a deletion still fails.
NOT FIXED HERE, and worth naming: the measurement tripwire still has a blind
spot for plan work. It runs in the parent's loop against the parent's own tool
output, and a plan task's commands run in the child's session - so a number a
task invents is caught by neither. Closing that means either a ledger inside
each child or feeding the child's streamed tool results into the parent's, and
both cross the agent/specialist boundary this comment is about.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
320 turns was cutting real work short. A measured run spent every one of them
on legitimate sequential edit-verify work and was forced into a summary before
it could write up what it had found - and the check that this was not simply
wasted turns is in the data: of 49 turns that looked batchable, 43 were repeat
reads of the SAME file, where the next call's arguments depend on the previous
result. The realistic saving from perfect batching was 4 turns out of 321.
So the ceiling moves to 480, which is triple thorough's rather than double.
The multiple stays exact so the relationship remains something a reader can
check, and the doc comment moves with it.
NOT HIGHER, and the reason is the token curve rather than caution. In that run
the prompt grew monotonically from 35k to 186k tokens per turn and had not
flattened, so turns near the end cost five times what early ones did. 640 would
land near 360k per turn and push the run into compaction - which is where
"paste the actual terminal output" quietly becomes a summary of the output,
the exact failure the evidence contract exists to prevent.
AND THE TWO MOVE TOGETHER. Raising the turn ceiling alone would double the
worst case while still ending runs at an arbitrary place: that run spent
35,781,390 tokens reaching 320, and 480 on the same growing prompt reaches well
over 100M. Profile.MaxTokens bounds spend, and only the posture sets it -
every other profile stays unbounded, so nothing outside it gains a ceiling
nobody chose.
50M is anchored on measurement, not preference: the largest observed legitimate
run spent 35.8M and needed roughly 45M to finish, so this lets that work
complete while stopping a run that has stopped making progress. TWO RUNS IS A
THIN BASIS - the mechanism is what matters, and the number is the part most
likely to be wrong.
Deliberately NOT exported to the environment the way the turn budget is: a
child inheriting the parent's ceiling would be handed the whole budget each, so
a ten-task plan could spend ten times it. The parent's bound is the parent's.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
MaxTurns is a proxy for cost that does not track cost. A measured heavy run
reached its 320-turn limit having spent 35,781,390 tokens; a cheap run reaches
the same limit at roughly a tenth of that. The turn count bounds round trips.
Nothing bounded spend.
Options.MaxTokens does. Zero is unbounded, which is every caller that does not
set it, so nothing existing moves. Spend accumulates from what the provider
actually reported rather than from an estimate, at the same point the compaction
estimator is calibrated - past error recovery and past any reactive compaction
that re-sent the request, so it counts the exchange that happened rather than
one that was abandoned.
Checked at the TOP of a turn, for the same reason the turn count is: the
previous turn's tool calls have run and their results are in hand. Stopping
mid-turn would discard work the run has already paid for, which is pinned by a
test.
Crossing it takes the same ending MaxTurns takes - one final call asking for a
summary - so a budget expiry produces a written report of what was done instead
of a run that simply stops. Reusing that path rather than inventing a second
ending also means there is one place where a run can end early.
WHICH BOUND FIRED NOW REACHES THE READER. A turn-count stop still says
"max-turns limit"; a spend stop says "token budget", in IncompleteReason and in
the prompt the model is given. Reporting the first for the second is how someone
comes to raise a turn limit that was never what ran out - which is exactly what
happened when the 320-turn stop was read as a turn problem.
This is the mechanism only. Nothing sets MaxTokens yet, so the posture-off
request body stays byte-identical; wiring the profile's budget and choosing its
value is a separate change against a more delicate path.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A saved plan is stored args re-admitted through ParsePlan, which makes it
exactly repeatable and completely fixed. "Audit internal/tui" cannot be run
against internal/cli without opening the file and editing the prompt of every
task, so in practice a plan gets copied per target and the copies drift from
the one that was reviewed.
Task prompts may now contain ${name}, and `params` fills them in.
WHAT MAY BE SUBSTITUTED IS DELIBERATELY NARROW: prompts and the plan
description. Not `tools`, which is the authority grant; not `id` or
`depends_on`, which are the graph; not `model` or the budget. A parameter that
could reach the grant would turn "run the sweep plan with scope=x" into a way
to widen what the plan may do, and one that could reach an id would let a
single argument silently detach a dependency edge. Those are fixed when the
plan is saved and reviewed, and they stay fixed.
Expansion happens BEFORE ParsePlan, so the plan that runs is the plan that was
validated - same tool-grant narrowing, depth cap and budget checks - rather
than a template of it.
It fails closed in both directions. A placeholder with no value would leave a
literal "${scope}" in a prompt, which reads to the model as a directory that
does not exist. A value matching no placeholder is almost always a typo, and
ignoring it runs the plan against the wrong target while reporting success.
Both are refused, naming the offender and what the plan actually takes.
The stored plan is never mutated: LoadPlans caches by path, so rewriting it
would leave the next run carrying the previous run's arguments. A plan with no
placeholders is untouched, so every plan saved before this is inert to it.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Family-specific prompt guidance was selected by matching the model id against
a list of prefixes - gpt, o1, o3, o4, gemini, claude - and nothing else. Of
the providers in the catalog, only a handful ship a default model that
matches any of them. The rest, including the recommended default and every
gateway a user is most likely to reach for, matched nothing and silently
received no guidance at all. Nobody decided that; the switch simply fell
through.
OpenAI's own OAuth entry was correct for one reason: "gpt-5.5" happens to
begin with "gpt". OpenAI has already shipped o1, o3 and o4, each of which
needed a new arm added here to keep a first-party provider working, and the
next id is under no obligation to help. The provider knows the answer without
being asked twice.
So the catalog carries the fact. Descriptor.ModelFamily is set ONLY where a
provider is first-party and single-family - openai, chatgpt, chatgpt-proxy,
anthropic, google - and left empty everywhere else, which is the honest
answer for a gateway: Ollama Cloud speaks openai-compatible and serves Qwen,
and reading the wire format as a family would hand GPT-shaped guidance to a
model that never asked for it. Empty means "ask the model id", and the id is
genuinely the only thing that separates openai/gpt-4.1 from z-ai/glm-4.6 on
one endpoint.
Options.ModelFamily carries it to prompt assembly, which prefers it over the
guess. In the TUI it is read from the LIVE profile inside the per-run
assembly rather than resolved once at startup - /model can switch provider
mid-session, and a family fixed at launch would keep describing the provider
the session began on, which is the staleness that had plan discovery
assigning from the wrong provider's model list.
What this does NOT do: the providers that declare nothing still receive no
addendum. Whether GPT-shaped rules help Qwen or GLM is untested, and
inventing guidance on a guess is the failure this replaces rather than a fix
for it. The difference is that the silence is now a declared position, and a
family can be added in one line when there is evidence for it.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
TestConcurrentSavesOfOnePlanDoNotCorruptIt required all eight concurrent
saves to succeed. SavePlan publishes with os.Rename, which on Windows is
MoveFileEx with REPLACE_EXISTING, and concurrent replaces of one destination
can come back as a sharing violation there. That is a platform property no
other OS can verify, so requiring it was asserting something unchecked - and
it would have surfaced as an intermittent failure on the one CI job that
cannot be reproduced locally, in the same change that exists to fix that job.
The property the test is for is unchanged and still enforced everywhere: no
corruption, no temp file left behind, and the saved plan still parses. What
is relaxed is only the claim that nobody loses the race, and only on Windows,
where a lost rename is logged rather than failed.
Deliberately not adding a retry to SavePlan: writing Windows-specific
recovery that cannot be exercised from here is worse than not writing it.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A run halted after six consecutive edit_file refusals and lost its work. The
cause was its own successful edit: RecordHash drops every recorded read range
when the content hash moves, and edit_file called it on the way out. That is
right for a change we did not make - we cannot say which lines still hold
what was read. After our own edit we can say exactly.
A 371-line file had been read in three pieces (40-45, 85-260, 260-371). One
two-line edit at line 92 erased the credit for all three, so the next six
edits - into regions that HAD been read, that the edit never touched, in a
file whose line count had not changed - were each refused as content "not
read in this session", and the repeated-failure guard stopped the run. The
error told the model to re-read, which its next successful edit would have
undone again.
RecordEdit computes the changed span from a common prefix and suffix of the
before and after content, then keeps what the edit provably did not disturb:
ranges before the first changed line at the same numbers, ranges after the
last changed line shifted by the delta, and ranges SPANNING the edit split
around it. That last case is the one that matters and the one I got wrong
first - dropping on overlap left the defect exactly in place, because a read
almost always contains the line it is about to edit, containing it being why
it was read. Byte intervals get the same treatment.
The guard is unchanged in what it guards: content never read is still
refused, and a file changed outside Zero still invalidates everything, both
pinned by their own tests. An external edit through sed remains a legitimate
refusal - that half of the original cascade was correct.
Verified against the observed run: seven consecutive edits across a
partially-read file now succeed where the second used to fail, and both wrong
implementations fail the tests.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Three failure classes from a measured benchmark run, each answered where the
harness can hold the line rather than where a prompt asks nicely.
A property was scored top marks in five places on the strength of "the test
passes" - while the test consumed the lazy iterator it was meant to prove
lazy, discarded the value whose absence was the bug, and reported avg x 2.5
as a p99. Told its code had a defect, the run agreed; the claim was wrong,
and it revised a correct answer down with a proof contradicting its own
preceding step. So the posture now states what counts as evidence, on the
first turn of every run: name what a passing test would still pass with,
report only numbers this session produced, and refute or confirm a defect
claim from the code before agreeing - agreeing without a line-level trace
being wrong even when the claim turns out to be right.
Once per run and not once per posture change, because turn 1 is where the
user's message sits, which is where a claim arrives and where a report gets
written. Announcing it only on the flip would leave every later run of a long
session without it, and the long sessions are the ones that produce reports.
The third class was worse than an oversight: a table of benchmark timings no
command in the session had produced, the same test reading 0.86s in one paste
and 4.20s in the next with nothing said, summing to a total no real
transcript lands on. A prompt rule cannot catch that - a model willing to
write a number it did not measure is equally willing to say it re-ran it.
The transcript can. internal/measurements reads every tool result for go test
durations and checks the final answer against them; a number that contradicts
what ran sends the answer back once, naming both values. Deliberately loose -
a 50% band, same-line pairing only, benchmarks not parsed - because a
tripwire that cries wolf gets ignored, and then it catches nothing.
Posture-gated and nil-safe throughout: a posture-off run takes exactly the
path it took before, pinned by its own test.
Last, the orchestrate tool teaches conflicts and relaxations apart. A run
built to a specification, listed its requirement conflicts cleanly, and filed
not one relaxation - having lowered a million-record bound to ten thousand,
cut a sixty-second soak to five, and excluded a latency class from its own
numbers. All three reached the reader as cells in a results table. A conflict
is a question the spec failed to answer; a relaxation is an answer the work
failed to reach, and merging them is how the second disappears.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The Windows job failed on one test, not on the deadcode noise filling its
log: plan_live_provider_test redirected config with XDG_CONFIG_HOME, which
os.UserConfigDir ignores on Windows in favour of %AppData%. The file was
never found, resolution fell back to the captured profile, and the switch
could not be followed. Its two siblings passed there by accident, both
expecting that same fallback. The helper now sets the variable the platform
actually reads and FAILS if the redirect lands outside the temp root, so a
wrong variable can no longer read and write the developer's real config
while the tests pass for the wrong reason.
The rest are the open review findings, each verified against the code first
and each pinned by a test that fails when the fix is reverted:
- a wall budget that a paused plan no longer burns. It was a fixed
context.WithTimeout, so pausing for twenty minutes took twenty minutes off
a thirty-minute budget and pausing past it meant the plan was already dead
on resume, stopped by time in which nothing ran.
- a task that failed on its own merits stays failed. The harvest relabelled
anything reaching it after a stop as cancelled, so a real compile error
became "the run was stopped" and the one result worth reading was
overwritten. Structural signals decide it now, never the message.
- max_wall_seconds past ~292 years wrapped int64 to a NEGATIVE duration,
which reads as no bound at all. Refused instead: it failed OPEN.
- the model probe fan-out is bounded in width, not only in time. Measured a
200-wide burst on the session's own key, where the provider answers 429,
which classifies as Unknown - paying for every request and learning
nothing.
- plan discovery filters through the same allow-list providers.New enforces,
so a scoped provider stops offering models that die at client creation.
`providers models` already filtered; the plan path was the odd one out.
- project config sets no plan models at all. Exclusion was allowed on the
premise that removing a candidate can only lower spend; it does the
opposite just as easily, since the selector promotes the next model up.
- SavePlan writes through an O_EXCL temp file with an unpredictable name,
closing both the check-then-write window and two concurrent saves sharing
one <name>.json.tmp.
- PlanProgress.Done derives from Remaining instead of counting alongside it,
so the two cannot disagree in the direction that silently drops work.
- the specialist card's cache key covers tokenCount, model and result. All
three are written AFTER the first render, so the key never moved and a
finished task kept showing none of them.
- a return no longer mutates the model it returns in the same statement. Go
does not specify the order, and all five call sites relied on it.
- panel width and the posture chip's hit-test measure COLUMNS, not runes or
bytes. A styled glyph is 22 runes of one column, and the chip's span sat
two columns off whatever multi-byte text preceded it.
Five tests that could not fail are repaired rather than deleted: an
unwired runner, an unseeded saved plan, a watchdog that never ticked inside
its child's life, a skip hiding a modal guard, and a budget case asserting
only that parsing returned no error.
Verified: 83 packages pass, vet and gofmt clean, -race clean, cross-compiles
for windows and linux, and the posture-off request body stays byte-identical
to origin/main across all five modes.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Six runs of one seven-task audit, none of which produced a report. Each failure
had a different immediate cause and the same shape: the plan spent its budget and
returned nothing.
RESERVE, IN THREE LAYERS, because fixing one exposed the next.
Feeders consuming the whole budget left nothing to dispatch verify, sweep or
synthesis with, so work that others wait on now stops early. A ceiling alone did
not hold: four tasks in flight each overshoot by about one usage event, and with
max_workers 4 that overshoot was the size of the reserve itself, drawn from the
same account it was capping. Two pools fixed that — upstream cannot touch
downstream's share however far it overruns. Then a sticky budgetExhausted flag
undid it anyway: the moment ANY task was skipped for budget, every task after it
was too, including work drawing from a pool nobody had touched. Per pool now.
SIZED BY THE PLAN, NOT THE BUDGET. A flat quarter gave three downstream tasks
125,000 between them; verify and sweep used 149,769 and synthesis was skipped.
Three of seven tasks downstream reserves three sevenths: a plan that is mostly
synthesis keeps most of its budget for synthesis, one that is mostly finding
keeps little, and a plan with no dependencies keeps all of it.
THE SCHEMA DECLARES ITS SHAPE. tasks was a bare array with a paragraph of
English, so a model composing a plan had to invent the field names — and read
"an optional read-only tool subset", which names no tool, as tools:
["read-only"]. Twice. The item schema now types every field and the tools enum
comes from PlanGrantableToolNames, which admission already validates against and
which was exported for exactly this ("ONE list ... so the grant and the validator
cannot come to disagree"); the schema was the one caller not using it.
AND THE NUMBERS ARE EXPLAINED WHERE THEY ARE CHOSEN. max_tokens bounds the whole
plan and max_tokens_per_task bounds one task; setting both so the cap can never
be reached is now refused with the arithmetic rather than silently resolved in
favour of the smaller. An undersized budget is warned about BEFORE the first task
rather than in the output afterwards — the estimate was computed correctly on
five consecutive runs and read by nobody, because it rode a result that arrives
once the run is already over.
Estimates are weighted by position: a task that reads a repository has measured
510k-1,017k, one that reads its dependencies' findings 62k-87k. Pricing both
alike over-estimated a correct plan by 60%, and a warning that cries wolf is
worse than none.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two runs of the same seven-task audit, 3.6 million tokens, and no report at all.
Four finder tasks were stopped at their token budget — three of them holding
substantial partial findings — and the verify, sweep and synthesis tasks that
depended on them were every one skipped. The evidence to write the report was
sitting in the results map the whole time.
TWO SEPARATE LOSSES, and both had to go.
WHAT THE CHILD WROTE WAS DISCARDED AT THE BOUNDARY. The kill path returned an
empty Result, so a task stopped mid-investigation handed on nothing however much
it had already found. summary.Text held it; nothing carried it. Status stays
StatusError — the task did not succeed and must not read as a finished answer —
but the text now travels with it.
AND A DEPENDENT WAS SKIPPED IF ANY DEPENDENCY FELL SHORT. Now only if EVERY one
did. A task with some evidence can do useful work and name what it lacked; a task
with none would produce a confident answer drawn from nothing, which is worse
than the gap. That is the line, and it is where the old rule sat one step too far
to the left.
LABELLED, NOT LAUNDERED. Partial input arrives under an INCOMPLETE heading, the
briefing opens and closes by saying which inputs were cut short, and the reader
is told plainly that an unfinished input's silence is not evidence of anything —
nothing was said about X must not become X is fine.
A FAILED dependency is still not partial evidence. It ran and did not deliver;
its output is a harness diagnostic or an answer already judged wrong, and passing
that on as a finding would launder a failure into a source. Only a CANCELLED
task — one interrupted while working — carries anything forward.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A cloned repo could raise the per-run tool-turn budget, increasing cost for
whoever opened it. Project config now mirrors the rule PlanSize and
DisableZeromaxing already follow three fields below; user config stays free to
raise, being the user's own file and their own money.
COMPARED AGAINST THE EFFECTIVE VALUE, NOT THE FIELD, and that is the whole of
it. Zero does not mean "no limit" here, it means "fall back to defaultMaxTurns"
— and that fallback runs AFTER this merge. The obvious guard,
if src.MaxTurns > 0 && (dst.MaxTurns == 0 || src.MaxTurns < dst.MaxTurns)
reads as tighten-only and is not: with no user config dst.MaxTurns is 0, the
first clause holds, and the repo's 500 is taken. That is the common case — every
user who never wrote a config — and the one the rule exists for. Both the
original defect and that near-miss are pinned by tests.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan that names no max_tokens has nothing to meter against — and on a provider
that reports no usage, max_tokens would not have bounded it either: the meter
sums the children's own usage events, and a provider emitting none leaves it at
zero forever while the work runs. Nothing then stops the plan but the work
finishing.
SO A PLAN THAT ASKED FOR NOTHING GETS A WALL, and only that plan. An explicit
wall wins, and a named token budget is left alone entirely — defaulting a wall
over it would put a new ceiling on a plan deliberately running long inside a
bound its author chose. An hour is far beyond the 3-5 minutes measured plans on
this repo take; it exists to catch a runaway, not to discipline a slow plan.
ONE SOURCE FOR THE WALL. Two sites read it — the context timeout that bounds
children in flight, and the deadline that stops dispatching new tasks — and only
one was reading the new value, so a backstop would have killed running work while
still dispatching more. They now read the same function.
AND THE REPORT SAYS WHEN SPEND COULD NOT BE MEASURED, because zero tokens after
two successful tasks is not a cheap plan, it is an unmeasured one — and a
max_tokens set against that number bounds nothing while reading like a guarantee.
Verified while doing this and NOT built: update_plan already cannot reach a plan
task. Named explicitly it is refused at admission with the tools it may use
instead; inherited from the parent's grant it is filtered out. The reasoning is
recorded at planReadOnlyTools and holds.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
TWO RUNS DECIDED THE FIRST ONE. The same audit of this repo, once ending in a
verify task and once not: the verified run dropped five overclaims the other
passed through to its report, including an inference the unverified run stated as
fact. Fan-out multiplies whatever a finder produces, mistakes included, and
nothing in this tool attacks a claim once it has been made.
So the shape is TAUGHT, not built. No verdict is parsed and no claim is
filtered — a plan author adopts find/verify/synthesize with the tasks they
already write, or does not, and the only place that can live is the description
they read. The prompt rules that make a verifier worth its tokens are named
there too: refute first and confirm only when the code forces it, quote what the
code says rather than guessing a refutation, judge each claim independently of
the finder's confidence, and re-read the cited locations because a briefing
carries a claim and not a trace.
AND THE SILENCE BEFORE A PLAN. Auto-assignment runs ahead of admission: a
/models call, a probe of every candidate, and when routing is on a full child run
on the strongest model. Tens of seconds with no plan, so no panel and no rows —
a foreground run looked frozen at exactly the moment it was doing the most.
Reported as a STATUS, not a plan row, through an optional recorder interface
alongside the others: the plan does not exist yet and admission may still refuse
it, so putting a task on screen would show work that never runs. Empty clears it,
and the stale-run guard applies as it does to every other plan message.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Each lands with the test that fails without it.
A MALFORMED DEPENDENCY WAS DROPPED, NOT REFUSED. planStrings skips an entry it
cannot decode — right for a display label, wrong for an edge: "depends_on": [42]
decoded to no dependency at all, so the task was admitted as dependency-free and
ran BEFORE the precondition it declared. Silently; nothing failed and nothing
warned. That defeats this file's own rule that an unknown edge is rejected and
never skipped. depends_on and tools now go through a strict variant that names
the offending index, and the lenient one stays for the fields where dropping is
harmless.
Tasks() SHARED ITS SLICES. copy() duplicates the structs and shares their
backing arrays, so plan.Tasks()[0].Tools[0] = "bash" rewrote the validated grant
in place, from outside, after every check had passed — the widening this file
exists to prevent, reached through the accessor rather than around it.
SavePlan GUARDED THE DOOR AND LEFT THE WALL OPEN. Symlinks were refused on the
directory and on <name>.json, and the bytes go to <name>.json.tmp, which nothing
checked. A repo shipping that name as a symlink turned "save my plan" into the
file-overwrite primitive the other two checks exist to prevent.
A SAVED PLAN LOST THE CALLER'S EXECUTION FLAGS. resolveSavedPlan replaces the
caller's arguments with the stored plan's, which is right for plan content and
wrong for the two flags that say HOW to run rather than what:
{"saved":"sweep","background":true} passed the refusal list and then ran in the
foreground, because background was read from the map that had replaced it.
Plan content is still refused.
SHUTDOWN RAN BACKWARDS. Defers are LIFO, so registering planLaunch.Close()
beside the launcher it belongs to — which reads naturally — meant it ran AFTER
closeSpecialistRuntime. Close cancels AND WAITS, so a background plan was being
waited on with the specialist runtime it needs already torn down. The ordering is
invisible at the point where the next defer would be added, and a comment did not
prevent it happening once, so a source-order guard asserts it.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The surface a person reads was the last to learn what the plan already knew.
THE MODEL THAT RAN, NOT THE ONE THAT WAS CHOSEN. A card's model is set once at
dispatch from the assigned model, and a task whose model the provider refuses is
re-run on the session's — so the row went on naming a model that never executed
it. The plan report the MODEL reads was corrected first; this is the half a
person looks at. A refused model is still named, because it stays in the
provider's list and the next plan will pick it again.
WHY WORK REMAINS, on resume. A plan that ran to the end with failures and one
stopped partway both leave tasks behind, and they call for different decisions:
re-running things that already failed, or finishing work that never started.
PlanProgress.Complete distinguished them and was recorded, never read.
WHAT A BUDGET CUT SHORT, in the headline rather than in per-row text. A measured
run came back missing a third of its questions and nothing above the fold said
which — an incomplete answer that does not announce itself gets used as a
complete one.
Also removes a workers count that rode planAdmittedMsg for planRunningCardKey,
which was deleted when per-task progress began carrying its own task id. The
field outlived its only consumer and its comment still sent readers to a
function that no longer exists.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The hooks specialist declares, supplied from the two surfaces that own a
provider and a sandbox.
DISCOVERY FOLLOWS THE LIVE PROVIDER. It was bound once at startup, so a session
that switched provider with /model kept probing the one it began on: a run on
xai/grok-4.5 had its plan assigned from a nineteen-model Ollama list and the
provider refused every task. It now re-resolves through the same path a freshly
spawned child takes, config plus ZERO_PROVIDER, so discovery and child execution
read one source of truth rather than two kept in sync. The captured profile
still wins when the provider is unchanged, because --provider, --base-url and an
inline --api-key are applied on top of Resolve and re-resolving would discard
them.
A CHILD STANDS ON THE SAME GROUND AS ITS PARENT. A run granted a directory
outside its workspace could create it, populate it, and then watch every plan
task be refused at that same directory — a child rebuilds its sandbox from --cwd
alone and the grant lived only in the parent's engine. The run's roots now reach
the child as --add-dir, read at launch so a mid-session approval lands, and on
the resume path too: this file has already seen the resume path forget what the
fresh path carried.
PROVING, wired to the provider stack and exposed as `providers models --verify`
— which model this provider will actually run, as opposed to which it lists.
That is the difference the exclude list was being maintained by hand to express.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan ran every task on the session's model and metered a budget it could not
enforce. This makes both real.
CHOOSING. A task may name a model; auto_assign picks one per task from what the
provider serves, ranked by price, with an optional routing call that reads the
task text rather than its verbs. Resolution goes through ResolveWithFallback,
not ResolveID, because the latter misses regex aliases and skips the deprecation
redirect — the plan would display one model while the child ran another.
Uncurated ids pass through: the registry holds thirteen models and an xAI
account serves half a dozen Grok models, none of them curated.
PROVING, because /v1/models is an advertisement. Every model failure this
feature produced lived in the gap between listing and serving:
grok-4.20-multi-agent-0309 lists like anything else and answers "Multi Agent
requests are not allowed on chat completions" — six tasks were assigned it and
all six died. A one-token request per candidate, once per session, settles it.
It fails toward KEEPING the model: anything not recognised as a refusal changes
nothing, because being wrong that way costs one task while being wrong the other
way silently removes a model the user pays for. Every candidate refusing points
at the provider, not the models, so the list is kept and the user is told.
BOUNDING. max_tokens was checked between tasks and decremented after they
finished, so nothing in flight could be stopped: a measured plan spent 3,091,618
against a 200,000 budget and still came back incomplete. The meter now reads the
child's own stream, a plan too small for its task count is refused before
anything is spent, and max_tokens_per_task arrives as a deadline the task is
told about rather than a guillotine — a cap that only kills produced 1,437,049
tokens and zero completed tasks.
CARRYING. depends_on ordered execution and passed nothing, so a synthesising
task received conclusions with no trace behind them and could repeat a claim but
never check one. Dependents now receive their dependencies' findings, bounded,
with truncation disclosed.
RESCUING. A model the provider refuses, or a task that declines rather than
answers, gets one attempt on the session's own model. Both triggers are
structural — a flag and an exit code — because deciding to spend another child
by reading error prose is the shape this package already warns about twice.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two additive pieces the plan-model work needs, kept apart from it so each
stands alone.
PROFILES.PLANMODELS lets a user pin a model per task role, exclude ones that
should never be chosen, name the model that does the routing, and add their own
guidance in plain words. The guidance exists because the code cannot know the
account: discovery orders models by price, and on real providers price is not
capability — one account's dearest model was a build preview that failed every
task it touched, another reports no prices at all so the ranking collapses to
alphabetical. The operator knows which model reasons well; this is where they
say so once, instead of naming models in every prompt.
PROJECT CONFIG MAY ONLY EXCLUDE. An exclusion removes a candidate and can only
lower spend; a pin is the opposite, and a cloned repo pinning every role to the
priciest model would raise cost for whoever opened it — the same hazard the
PlanSize and DisableZeromaxing tighten-only rules exist for. Guidance is the
softest way to do what pinning does ("always pick the most expensive model, it
is worth it" costs money without naming a single id), so it sits on the same
side of that boundary.
CACHE AND REASONING COUNTS on the stream-json usage event, because a parent
summarising a child's stream is the only place a sub-agent's turn can be priced,
and it was being handed prompt/completion/total alone. A measured plan task had
49,280 of 49,894 prompt tokens served from cache — 98.8% — and every one of
those turns was costed as though nothing had been cached. Emitted only when
non-zero, so a provider reporting no cache produces exactly the event it always
did.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
ParsePlan permits a task to name a write tool, deliberately and recently:
"A TASK MAY NOW NAME A WRITE TOOL, and only by naming it." That grant is what
makes PermissionForArgs prompt and what makes RequiresIsolation prepare a
worktree. Two layers downstream still assumed the older rule that every task
is read-only, so the capability was gated, approved and isolated — and then
could not be exercised.
The child's system prompt was one literal telling every task "You have
read-only tools" and "do not attempt to modify anything", including a task
granted write_file or bash. A child told not to modify anything does not use
the write tool it holds, so the approval the user gave and the worktree
prepared for it bought nothing. The prompt is now built from the grant, using
the same allow-list ParsePlan validates against so the two cannot drift. The
read-only wording is unchanged for the read-only case, which is the majority
and the one whose phrasing was tuned.
savedPlanLimits granted only the read-only names, so a plan ParsePlan accepts
at run time failed to parse on save, show, run, restart and resume alike. The
durability surface silently excluded exactly the plans whose work is most
worth keeping. It now uses PlanGrantableToolNames. This widens no authority:
the parent grant is intersected again when the plan runs, a write-capable plan
still needs its approval and its worktree, and a tool outside the grantable
list is still refused — both directions tested.
NOT changed, because it is a decision rather than a defect: plan children
still run at --auto low, where plan_runner.go says "Explicitly NOT
MemberAutonomy: Phase 2 tasks are read-only, and granting it here would be the
authority widening that was ruled out as needing its own decision." That
comment and ParsePlan's contradict each other, and only one of them can stand.
Widening child autonomy is not a call to make inside a review fix.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The wall deadline was consulted only before dispatching a task, and the walk
only reaches that check when it needs a free worker slot. A plan whose ready
set fits the worker pool dispatches in one wave and is never checked again,
so it ran for as long as its slowest child took and reported "completed"
with nothing skipped.
Measured before the fix, four 2s tasks under a 1s wall:
max_workers=4 -> 2.0s elapsed, status=completed, 4 succeeded, 0 skipped
max_workers=1 -> status=partial, 1 succeeded, 3 skipped
So the bound existed only on the sequential path, and asking for parallelism
silently deleted it. Nothing cancelled an in-flight child either — the plan
context was WithCancel, and the only deadline reads were the two dispatch
gates.
The plan context now carries the wall as a real deadline, which reaches the
children through the same machinery a user stop already proves works. The
walk keeps its skip-the-rest behaviour for tasks not yet dispatched.
This is the defect max_tokens' cap was removed for, in the commit that said a
number which neither bounds spend nor lets a plan finish "is worse than no
number, because it reads like a guarantee". max_wall_seconds was the worse
case: the token check at least fires between tasks, while the wall deadline
fired zero times in a fan-out — and the approval card shows "300s wall" while
never showing max_workers, the parameter that disabled it.
Both cancellation paths now say WHICH reason stopped a task. A wall expiry is
the plan spending what it was allowed; a cancel is a person deciding. Both
read "the run was stopped", which sent the reader looking for who stopped it.
The tests bound their own waits rather than blocking on ctx.Done(). An
unbounded wait there wedged a full run for ten minutes when the fix was
mutated out — a test that hangs on a regression is worse than one that fails.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
planInt returns 0 for both an absent key and a present-but-negative one, so
`seconds > 0` read a model-supplied max_wall_seconds of -60 as "unset" and
ran the plan unbounded with no error. Every other numeric in the budget
refuses a negative — max_retries and max_tokens explicitly, and
max_stall_seconds even refuses positive values under its floor — so this was
the one invalid input silently accepted, and the failure mode is the worst
kind: a bound the caller asked for that is not applied. Absent and explicit
zero keep their meanings, which is tested, so the stricter parse cannot turn
"unset" into an error.
The orchestrate approval card said "read-only specialist sub-agents".
PermissionForArgs prompts ONLY when argsCanWrite is true, so every card a
user actually sees is asking about a plan that CAN write — the description
was exactly inverted on the one screen where it matters. Description() said
the same thing to the model. Both now say tasks inherit the parent's grant
and may hold write tools granted by name.
The posture-off identity test claimed Zero is byte-identical to a build
without the feature when the posture is off. It is not: system_prompt.go
drops the ~5 KB confirmation policy for any run that cannot mutate, and
runCanMutate is evaluated regardless of posture, so an all-read-only run
gets a smaller prompt either way. The drop is deliberate and fails closed —
the claim was just wider than the proof, and the comparison could not catch
it since both registries take the same branch. The comment now states the
property actually proved: registering the tool changes nothing while the
posture is off.
Left alone deliberately, as design calls rather than defects: PlanProgress
Done/Remaining have only test callers, and research.json ships with an
unsubstituted "THE SUBJECT" placeholder plus dependents that assume upstream
output they never receive. Both need a decision (wire in vs drop; add
argument substitution vs unship the plan) that belongs to the author.
Co-Authored-By: Claude Opus 5 <[email protected]>
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Three guards that were missing beside siblings that have them.
/effort zeromaxing delegated to handleProfileCommand with no idle check.
That is the same mutation /profile refuses mid-run — turn budget,
self-correct, and the shared orchestrate gate — and /profile refuses it
because the budget propagates to sub-agents spawned later in the same run,
so changing it mid-turn leaves one run straddling two budgets. Reaching the
mutation through the effort namespace skipped the guard the profile
namespace applies. Idle behaviour is unchanged and tested, so the guard
cannot quietly disable the feature.
orchestrateHeaderAtMouse had no modal guard while orchestrateTaskAtMouse
directly above it does, and sidebar.go states the rule: sidebarAvailable
deliberately does not suppress the sidebar for the `/` palette, because a
palette must not reflow the layout, so each hit-tester carries its own
suggestionsActive() guard. Without it, clicking the PLAN header while the
palette is open toggled the section behind the overlay.
The posture chip in mouse.go is checked BEFORE the surface switch, so the
modal guards in those cases do not cover it. With the /model picker or a
provider wizard open, clicking the chip swapped in a fresh effort picker and
discarded whatever the open one had loaded or the user had typed.
The header test first used a picker and passed against the unguarded code:
sidebarAvailable already suppresses the sidebar for pickers, so it proved
nothing. It uses the `/` palette now, which is the case the guard exists
for, and that mutation fails it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
releaseTemporaryRead deleted the refcount, dropped the lock, then stripped
the root in a second acquisition. Between those two holds the root was still
in readRoots but no longer in tempReads, and AddTemporaryRead reads exactly
that pair to decide what a caller is holding: it finds the root covered,
finds no refcount, concludes the cover is PERMANENT, and returns a no-op
undo. The release then strips the root and that caller has silently lost
access it believes it holds.
Reproduced by driving the two mutations apart and stepping a second caller
through the gap:
after add: readRoots=[.../shared] tempReads=map[.../shared:1]
in window: second caller tracked=false (a no-op undo)
after release: validateRead -> "requires access outside the workspace"
Both mutations now happen under one hold, and releaseTemporaryWrite gets the
same treatment. removeReadRoot/removeWriteRoot existed only to re-acquire the
lock and are gone.
ON THE TESTS, because they are weaker than they look: neither reproduces the
window on demand. The concurrent one cannot hit a gap that narrow reliably —
it survived the unfixed code even with an injected scheduling point — and the
sequential one holds two references, so the refcount never reaches zero and
the delete never runs. They assert the invariant the bug broke (a live grant
stays readable, the root retires only with its last holder) and are worth
having, but the evidence for the bug is the reproduction above, not them.
Two earlier versions of these tests were worse than weak: they used
t.TempDir() with NewScope, which adds the system temp directory as a write
root, so AddTemporaryRead returned early and never touched the refcount at
all. The scope is built directly here for that reason.
Co-Authored-By: Claude Opus 5 <[email protected]>
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Three from the review on #829.
RESUME NARROWED BY THE WRONG PLAN'S PROGRESS. ReducePlanEvents keeps only
the LAST admitted plan's state and records whose it is in PlanProgress.Name,
but resumeSavedPlan narrowed whichever plan the user named by that progress
without checking the two match. Run plan A, run plan B, then /plans resume A:
every A task whose id sits in B's succeeded set is dropped from the remainder
and never runs. Ids like "tests" or "lint" collide across plans constantly, so
this is the ordinary case rather than a contrived one, and it fails silently —
the remainder validates and runs, just without the work.
The comparison is against plan.Name(), not stored.Name: plan_admitted records
the plan's own name, which is independent of the name it was saved under. A
test asserting the saved name would have passed while breaking every
legitimate resume, so both directions are pinned.
TASK SUMMARIES CARRIED CONTROL BYTES. planTaskSummary cut at the first
newline and left every other control byte intact, and these strings are
model-authored — the realistic path is indirect prompt injection, poisoned
file or web content echoed into orchestrate args and painted into the inline
panel, the sidebar rows and the detail pane. It now goes through
sanitizeCardText, which permission_detail.go already applies to exactly this
class of data.
BACKGROUND PLANS LOST THEIR PANEL. beginRun cleared the orchestrate panel
unconditionally, but background plans are built to outlive the run that
launched them and every message they post carries the background flag
precisely to pass the stale-run guard. After the clear those messages passed
the guard and no-oped against an empty byID, so the PLAN surface vanished for
the rest of the plan's life while it kept running. BackgroundPlanLive gates
the clear, consulting background alongside cancelPlan for the same reason
RunningPlanName does — the launcher sets one synchronously and the goroutine
sets the other later, so reading either alone leaves a window.
Co-Authored-By: Claude Opus 5 <[email protected]>
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The test already redirects HOME, USERPROFILE and GOOGLE_APPLICATION_CREDENTIALS
so host credential stores cannot leak into the golden. It missed the variable
that decides where the config directory actually is:
credentialDenyReadPathsForEnvironment reads XDG_CONFIG_HOME FIRST and only falls
back to $HOME/.config.
Every ubuntu GitHub runner sets it. So redirecting HOME left configHome pointing
at the real /home/runner/.config, and the golden gained
denyRead: ["/home/runner/.config/zero"]
the moment that directory existed. Other packages in the same `go test ./...`
create it — zero/config.json from internal/config, zero/trust.json from
internal/workspacetrust, zero/specialists from this package's own specialist
test — and `go test` runs packages in parallel, so whether this golden saw it
was a race nobody was arbitrating.
That is why it only ever went red on ubuntu: macOS and Windows runners leave
XDG_CONFIG_HOME unset, so those hosts fell back to the redirected HOME and the
path could not exist.
Verified against the exact CI condition — XDG_CONFIG_HOME set AND $XDG/zero
already present — passing with the fix and failing without it.
The wider issue is untouched and belongs to its own change: tests write into the
user's real config directory, which is what made this reachable at all.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
TestIndependentTasksRunConcurrently asserted that four tasks reach a peak
concurrency of four. effectivePlanWorkers deliberately caps a plan at
runtime.NumCPU()-2, so on a four-core runner a plan asking for four workers
correctly gets two — and the assertion could never hold.
Not flaky. Deterministic on any host with fewer than six cores, which is every
CI runner: green on a ten-core laptop, red on macos, ubuntu and windows alike.
The test was measuring the machine and calling it the scheduler.
It now waits for effectivePlanWorkers(requested) — what this host will actually
run. The minPlanWorkers floor of 2 is what keeps it honest: want is never below
two, so it still fails against a scheduler that runs tasks one at a time, which
is the only thing it exists to catch. The failure message now reports the
request, the effective count and the host's capacity, so the next person reads
the cause instead of rediscovering it.
Verified by forcing machinePlanWorkers() to 2 to simulate the runner and
checking both directions: the old assertion fails with CI's exact message, the
new one passes alongside the five sibling concurrency tests.
This was the single cause of all four failing checks — the three Smoke jobs run
go test ./... directly, and Zero Review's blocker gate fires on its test step
(diffcheck, build and smoke were already green there).
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two findings from the review pass, and one root cause: the surface that shows a
plan can hold exactly one, and only the path a USER drives enforced that.
/plans restart already refused to start under a running plan, with a comment
saying precisely why — "the panel is keyed by task id, the second would
overwrite the first's rows". The orchestrate TOOL had no such gate, and the tool
is the reachable path: a background plan returns immediately by design, so the
model's very next call lands while it is still running.
What that cost, both confirmed against the code:
A foreground plan finishing cleared the shared background flag and cancel. The
foreground plan was announced to the model as "the background plan finished" —
for a plan that had already returned its summary inline — /plans stop then
reported no plan running while the real one kept spending, and when the
background plan genuinely finished its own completion saw the flag already
false and told nobody. Spend nobody sees and no result, which is the failure
mode that code's own comment names.
And cardByTask is keyed by task id, unique WITHIN a plan and not between two.
Two plans sharing an id as ordinary as "tests" would have one plan's dispatch
overwrite the other's entry; the first completion closes the wrong row, and the
card left behind can be closed by nothing at all — the forever-spinning agent
from this morning's screenshot, back by another door.
PlanSurfaceBusy is the optional CONCURRENCY half of a recorder, type-asserted
like PlanController beside it, so a recorder that cannot answer — the headless
path, which runs one plan per process and has no surface to contend for — is
treated as free rather than blocked.
The gate is checked AFTER parsing, so an invalid plan is still called invalid
rather than blamed on the plan already running, and BEFORE admission, so a
refused plan leaves no record of having started.
The bridge reports busy on `cancelPlan != nil || background`, not on the cancel
alone. The launcher sets background synchronously before starting the goroutine;
the cancel arrives only when the executor reaches its first task. A gate reading
the cancel alone would wave the next plan through the gap between them — which
is exactly where the model's next call arrives.
Both preconditions are now written where the code rests on them, so relaxing the
gate cannot silently reintroduce either. Five compile-time assertions added for
the optional interfaces: each is reached by a type assertion, so a signature
that drifts does not fail the build — it stops being found, and the behaviour
disappears with no error anywhere. This branch has shipped that defect seven
times.
Four mutations, all caught.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The column clips to height-1 and pins the token readout at that last row, but
the selectable tables are built from the full section heights. Only
fileRowAtMouse checked for this, inline; the plan, orchestrate and agent tables
did not — so a click on the token readout opened whichever row had been pushed
underneath it, and hovering it highlighted the readout as though it were one.
Expanding an agent is what makes it reachable in a single click. On an 11-row
terminal with a four-step plan:
before after one click on the agent row
6 |PLAN 2/4| 6 |PLAN 2/4|
7 | ✓ alpha | 7 | ✓ alpha |
8 | ✓ bravo | 8 | ✓ bravo |
9 | • charlie | 9 | • charlie |
10 | ○ delta | 10 | 0 tokens | <- clicking here
opened "delta"
The rule now lives in one predicate and is applied to the TABLES rather than to
each hit-test, so hover and every future consumer inherit it instead of each
having to remember. fileRowAtMouse's inline copy is gone with it: two statements
of one rule is how they drift apart.
It fails OPEN on an unmeasured terminal. Filtering the whole table before the
first WindowSizeMsg changes nothing in production — every consumer already
requires a live sidebar — but it made the offset tables untestable in isolation,
which is how the existing TestSidebarPlanSelectablesOffsets caught me.
Also repairs a gap the shared PLAN section opened: sidebarOrchestrateSelectables
returned nil whenever update_plan had steps, so the running plan's rows rendered
but could not be clicked, and its base ignored the checklist, the naming line and
the bar above them. The prefix is now MEASURED off the renderers rather than
re-derived from the conditions that produce them — the bar already had a
hand-written correction term here, and a second one for the naming line would
have been a second chance to disagree with what was drawn.
Found by an adversarial review pass, with a reproduction. Four mutations.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan's finished tasks ARE its result, and they vanished a second and a half
after each one landed. A nine-task run that succeeded showed an empty AGENTS
section, with no way left to ask what any of them did.
The header carries the control: "AGENTS 1 · ▸2 done". Clicking it keeps the
finished agents for the session; clicking a finished agent opens it.
AGENTS 1 · ▸2 done AGENTS 3 · ▾2 done
⠋ d-report ✓ a-reltime
You are auditing package
10s · 22.8K tok
pkg/reltime: 3 findings.
reltime.go:41 — Parse ign…
…
✓ a-fsutil
⠋ d-report
WHAT IT PRODUCED is new data, not a new rendering. TaskResult.Output has always
held the child's full answer verbatim and the TUI never saw it: planTaskDoneMsg
carried the outcome, the spend and the error, so a finished row could report
everything except the thing the user ran the agent for. It is bounded to 2000
bytes at the bridge — the report task in a nine-task plan returned a hundred
thousand tokens of output, and carrying that through the event loop to show four
lines would be paying megabytes for text nothing reads. The whole answer is
still in the child's session, which the row's drill-in opens.
The toggle is registered independently of the agent rows. Hung off them it would
have been unclickable in exactly the state it exists for: when every agent has
finished, there are no rows.
Six mutations, all caught. The output is tested bridge → message → Update rather
than by calling setResult, because testing the setter would pass with the bridge
sending nothing at all.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Four of nine tasks dispatched, none finished, and the bar was 44% filled with
the same solid block a completed task gets — sitting directly beside its own
"0/9". The bar contradicted its own number, and the number was the one telling
the truth.
done, failed, skipped and cancelled are TERMINAL: the task is not coming back
and the bar can spend a full block on it. Running is not a fraction of done.
It now draws as a half-shade, which ranks correctly between the settled blocks
and the pending track and cannot be misread as either.
nothing started ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0/9
4 running, 0 done ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░ 0/9
3 done, 2 running █████████▓▓▓▓▓▓░░░░░░░░░░░░░ 3/9
all 9 done ████████████████████████████ 9/9
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The screenshot showed the footer line still there, on a session that had been
running for five minutes. The gate I added stands the inline panel down only
when the sidebar's PLAN section is showing the orchestrate plan — and it never
was, so the gate never fired and nothing had changed.
update_plan and orchestrate are not alternatives. A zeromaxing turn routinely
runs an update_plan checklist whose middle step IS "run this orchestrate plan",
so both are live for the whole run. Every piece of orchestrate rendering in the
sidebar — the task list AND the progress bar — was gated on update_plan being
empty, so the running plan had no sidebar surface in the one configuration it
actually appears in, and got pushed back into the footer panel it was supposed
to have replaced.
The section now draws update_plan's checklist and, beneath it, the running
plan's own tally, bar and tasks. The outer plan is above because it is the outer
plan. The running plan names itself, because the section header is already spent
on update_plan's count and two stacked tallies otherwise read as one list.
The bar moves inside sidebarPlanLines with everything else. It was drawn beside
it and carried a correction term in sidebarFileSelectables to compensate — a
standing invitation to the exact offset drift that function's comment warns
about. One function now owns every line the section draws.
sidebarOwnsOrchestrate keys on the section being COLLAPSED rather than on
update_plan's presence: a section collapsed by a click shows a count and a hint,
which is not a surface, so the plan goes back to the panel until it is reopened.
Four mutations. M18 and M19 missed first: the file-offset loop iterated over a
model with no touched files, and nothing asserted the bar at all. Both were
passing for want of a fixture rather than for correctness.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
"" is the zero value on BOTH sides of the expansion test: expandedAgent when
nothing is open, and childSessionID for a specialist keyed by a tool-call id the
provider left blank. Compared bare, such a row is permanently expanded — nobody
opened it and nobody can close it, because the hit that would toggle it is
already guarded on a non-empty key and never records one.
The consequence is not local. The sidebar renders AGENTS first and clips the
whole column to its height, so four uninvited lines at the top push PLAN, FILES
and ACTIVITY down and off the bottom. The mutation makes it plain: PLAN moves
from line 2 to line 6.
The guard now matches the hit's. A row that cannot be clicked open must not be
able to open itself.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The collapsed row says which agent and which tool it is in. The three things it
cannot say are what the agent was actually asked to do, what it has cost, and —
when it did not simply finish — what happened to it. A click now opens those
under the row, and closes them again.
In place, not a drill-in. A specialist row is keyed by its CARD, and a plan
task's card key is not a session id until the task finishes and reconciles one,
so there was nothing to drill into during the exact stretch where the detail is
most wanted. Swarm rows keep their drill-in; only specialist rows toggle. The
expansion follows the card→session rename at completion, or a row the user had
open would collapse at the moment it gained a result to show.
Four lines is the cap. This section shares its column with PLAN, FILES and
ACTIVITY, and an expansion that pushes those off has traded three sections for
one.
The spend line drops segments that do not fit rather than truncating the joined
result. These are figures: prose ending in an ellipsis still reads as prose, but
"3,4…" is three thousand or three million with equal authority. Elapsed uses the
footer's own 1m10s and tokens the column floor's own 3.4K, so a 30-cell sidebar
shows "1m10s · 3.4K tok" whole instead of all three cut off.
Six mutations, all caught — including the offset hazard sidebarPlanLines warns
about, since PLAN and FILES derive their click targets from the agent section's
rendered height and every added line moves them.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The right column already carries the plan: progress bar, task list, live detail,
and an agent row per task. The inline panel above the composer predates all of
that, and keeping both meant the same plan announced itself three times on one
screen while taking footer height from the conversation to do it.
It now draws only when the sidebar is not the plan's surface. That is a real
set of states, not an empty one: the sidebar needs a wide enough terminal, is
suppressed under every full-screen overlay, and hands its PLAN section to
update_plan whenever that has steps. In all of them the panel is still the only
place a running plan appears, so it still draws — a fallback, not dead code.
Two supporting changes:
An admitted plan now counts as sidebar content on its own. It did not, so the
column waited for the first task to spawn an agent row, and the inline panel
flashed on screen for exactly that gap before standing down. visible(), not
isEmpty(): a finished plan's tasks are kept for the record, and holding the
column open for those would mean the sidebar never auto-hides again once a
session has run a plan.
Ctrl+G moves off sidebarActive() onto the same predicate, so it acts on
whichever surface is on screen. The two disagree precisely when the sidebar is
up but update_plan owns its PLAN section — where the old key cycled a selection
in a list that was not visible while the panel the user was looking at ignored
it. Mutation M7 missed until that case was in the test.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The screenshot: five agents in the sidebar, every one of them spinning, on a
plan whose tasks had long since finished. The footer said 6 running and it
stayed 6 running.
finish() resolved the card with planTaskKey(bridge.dispatched) — the LAST
dispatched task's key. With one worker that was indistinguishable from a
lookup: a task could only finish after it was dispatched and before the next
one was, so the last card was always the finishing task's card. Fan-out opened
six at once, completions started arriving in whatever order the tasks finished,
and the counter then marked a task that was still running as done while the one
that actually finished spun for the rest of the session.
cardByTask has existed since fan-out landed and was written on every dispatch.
Nothing read it here. Seventh instance this branch of a field populated at one
site and consumed at none.
The map is also the authority on whether a task was DISPATCHED, which the
outcome cannot answer. TaskCancelled is emitted both for a task stopped
mid-flight (it has a card) and for one cancelled before it ever ran (it does
not); reading the first as undispatched opened a second card and closed that,
leaving the real one running forever. That is the "even after cancelling it
keeps spinning" half of the same report.
Read-and-delete, so an entry exists exactly while its task is in flight and a
later plan reusing a task id cannot resolve against a dead card.
Three tests, three mutations, all caught. The existing tests only ever completed
tasks in dispatch order, which is precisely why this survived.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
LOWERCASE, like every label beside it — "ask", "high", the model name. Shouting
was the badge's job and the badge is gone; the word earns attention by moving,
so caps were doing the same work twice and losing to the row around them.
THE HOVER FLOWS. A three-letter band travels left to right through the word and
wraps, so hovering reads as motion rather than as a second static state — which
is what an underline across the whole label had been. Three letters is the
width that reads as a band: one blinks, and all ten is a rule.
IT NEEDED A TICK, and the bound is the whole justification. The resting word
walks its colours on the spinner already running during a turn and holds still
otherwise, but a band under a resting cursor has no frames to move on. So the
hover starts the existing tick loop and stops it the moment the cursor leaves —
ensureSpinnerTick's rule is "an idle plain session schedules no timer", and a
cursor held on a control is a direct interaction rather than an idle session.
Reduced motion pins it, like every other animation here.
Wall clock rather than a frame counter, so the band crosses at the same speed
whatever cadence the ticks arrive at.
Seven mutations. F7 initially MISSED and it is the sixth instance today of one
shape: the test asserted the PREDICATE (zeromaxingChipAnimating) and not that
the scheduler consults it, so removing the hover from ensureSpinnerTick's gate
passed — the predicate would have stayed right while the band never moved. The
test now drives ensureSpinnerTick itself, including that it declines to schedule
a second timer over a live one.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Corrects the last change rather than extending it. I read "no yellow, colourful
hover" as "same badge, different fill, spectrum on hover" — the ask was for the
WORD to animate, and for the box to go entirely.
NO BACKGROUND FILL AT ALL. The badge signalled "on" by being a solid slab; the
word signals it by MOVING, which the eye catches at least as well and which
costs the footer none of its calm. A footer is a row of quiet labels and a lit
block in it is a shout; letters that walk their colours are noticed without
being loud.
The ramp rotates on the pulse the chip already rides, so it costs NO TIMER. It
walks while a turn is in flight and holds a static rainbow when idle —
deliberately, and not a compromise: an idle session schedules no timer, which is
the rule this pulse was built around, and a footer animating forever would be
CPU nobody asked for. The word is alive exactly while the posture is doing
something, which is also what it means.
HOVER IS AN UNDERLINE. The chip opens /effort so it must say it is pressable,
and a background fill is the one thing this deliberately does not have — so it
cannot be the hover signal either.
Colours come from the palette, never hex, so the word stays coherent on dracula
and on light themes. postureBadge is DELETED rather than left unused: a style
nothing renders is a knob someone later trusts.
One real consequence, checked before touching anything: per-letter colouring
means the label is no longer a contiguous run in the styled output. Both
production hit-testers already strip ANSI first, so clicking is unaffected — but
a footer test was asserting Contains on the RAW string, which was asserting an
accident of how the chip happened to be styled. It strips now, as it should
always have.
Five mutations, all caught, including the box returning and the animation
running on an idle session.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two changes, and the first is about meaning rather than taste.
IT IS NOT AMBER ANY MORE. Amber fills the PERMISSION badge, so in this UI it is
the colour of "something needs your attention". The posture is a standing MODE,
not a caution, and sharing the fill made it read as the wrong sentence — a
warning that never resolves. It sits on the brand accent now, still filled, so
"is it on?" is still answerable from across the room.
THE HOVER IS A SPECTRUM, not another flat fill. A flat hover on an
already-filled chip is two solid blocks differing by a shade, which is close to
invisible; letters that each take their own hue cannot be mistaken for the
resting state, and they say "this does something" louder than any single colour
can. The chip opens /effort, and a clickable thing that looks like a label never
teaches anyone it can be pressed.
EVERY COLOUR COMES FROM THE PALETTE, never hex. The ramp is built in buildTheme
from accent/green/blue/amber/red/gitAdd, so the effect stays coherent on
dracula, on a light theme, and on whatever is added next — the same reason the
permission card stopped tinting its body with an amber wash.
The shimmer rotates on the pulse the chip already rides, so it costs NO TIMER:
an idle session still schedules none, which is the rule the pulse was built
around, and reduced motion pins it still like every other animation here.
Width is unchanged, which is what keeps the chip clickable — colour adds ANSI
bytes and no cells, and the span finder strips ANSI before locating the label.
Asserted directly: the hit region must be identical resting and hovered.
Six mutations, all caught, including the two that would look fine in a
screenshot and be wrong: the spectrum collapsing to one hue, and the shimmer
running on an idle session.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A defect I introduced two commits ago and did not notice until a stale build
plan made me look at the permission layer again.
permissionSupportsPersistentDecision refuses "always allow" for bash,
exec_command, write_stdin and apply_patch — deliberately, because a remembered
approval for those is too broad a standing grant. orchestrate fell through to
the default and was offered it.
THE COMPOSITION IS THE PROBLEM. A read-only plan does not prompt at all, so
every prompt orchestrate produces is for a plan that CAN WRITE — and a
write-capable plan may name bash, which is in planWriteTools. So "always allow
orchestrate" is a strictly broader standing grant than the one the name list
exists to refuse, reached by a different route. One keystroke would have
permanently disabled the write-plan approval gate that the whole three-step arc
was built to justify.
DECLARED BY THE TOOL, NOT LISTED BY NAME. A name list cannot describe a tool
whose reach depends on its arguments, and it cannot cover a tool that RUNS the
ones already refused — the next such tool would be silently persistable too.
tools.PersistentPermissionRefuser lets a tool state its own reach, which is the
same reason ChildProgressStreamer exists and the same mistake `call.Name ==
"Task"` was.
The name list is unchanged and still decides for tools that declare nothing, so
this adds a rule rather than replacing one. A nil registry falls back to it
rather than refusing everything.
The plan stays approvable for THIS call and for the session. Only the permanent
form is withheld — exactly how bash behaves.
Four mutations, all caught, including the two that would be silent: orchestrate
declaring false, and the agent ignoring the declaration. The specialist-side
test also asserts bash is still in PlanWriteToolNames, because the argument
above stops holding if it ever leaves — and then this rule needs rethinking
rather than quietly persisting.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The second of §5.1's three prerequisites — and it turns out NOT to be a
prerequisite for plan fan-out at all. It is a live defect on the parallel
tool-call path, which is a different thing, and worth fixing on its own merits.
A CORRECTION TO WHAT I SAID EARLIER. I claimed this blocked combining
max_workers > 1 with write-capable tasks. It does not: plan tasks are separate
PROCESSES (osexec.CommandContext), each with its own sandbox Engine and Scope,
so concurrent plan tasks never share this state. What does share it is
internal/agent/parallel_tools.go, which runs up to eight read-only tools
concurrently in ONE process — and a read-only tool blocked outside the
workspace prompts and takes a temporary read grant.
THE DEFECT. AddTemporaryRead appended the root and returned an undo that
removed it, with no notion of who else needed it. The second caller to ask for a
root already present got a NO-OP undo, so when the first caller finished its
cleanup removed the root out from under the second — which was still running.
Two read-only tools in one parallel batch, both blocked on the same directory,
is exactly that shape.
Reproduced before fixing: two holders, then A's cleanup, and the root goes
present -> absent while B still holds a grant.
Temporary roots are now reference counted and removed only when the last holder
releases. A caller whose request is COVERED by an existing temporary root takes
a reference on the covering root rather than a no-op undo, so the broader-root
case — the same defect one level up — is closed too. A permanent root is never
refcounted and never removed by a temporary holder, which is asserted.
Write roots get the identical rule, or a write grant would keep the bug reads no
longer have.
THE TEST SETUP IS THE INTERESTING PART. A first version used t.TempDir() and
passed against completely unfixed code: /tmp and $TMPDIR are default write
roots, so every grant for a path under one is a no-op and every assertion held
vacuously. That is RULES §2.3 in its exact documented form, met head-on. The
helper now builds its directories outside any default root and skips when it
cannot, rather than quietly proving nothing.
Five mutations, all caught. Race detector clean at -count=20 with eight
concurrent holders of one root.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The last item from the fan-out plan, and the one I had not closed: does the
resume reducer still narrow correctly when events come from tasks running at
once? It does, and the reason is a property worth pinning rather than assuming.
THE EVENT LOG STAYS SINGLE-WRITER. Tasks run on their own goroutines; the five
lifecycle events do not. Dispatch is recorded by the walk before the goroutine
starts, and every terminal event by the harvest the same walk performs. So the
log is written by one goroutine in a defined order however many tasks overlap —
which is why ReducePlanEvents can rely on Sequence and why none of that
bookkeeping needs a lock. Asserted by recording which goroutine each recorder
call arrives on.
RESUME AFTER A FOUR-WIDE CRASH. Several tasks can be in flight when a process
dies, so the reducer must report EVERY unfinished one rather than the last, the
remainder must re-run all of them, a joining task must keep the edges to the
predecessors that did not finish and lose the ones that did, and the remainder
must still be concurrent — narrowing a fan-out into a chain would be a silent
downgrade.
Four mutations. R4 — dropping the lock around the bridge's card map — initially
MISSED under -race, and the test was at fault: each task's goroutine made five
quick calls and finished before the next dispatch, so the reader and the writer
never actually met. Made to spin until signalled, the race is found immediately.
A concurrency test whose threads do not overlap is not a concurrency test, and
"passes under -race" meant nothing here until the overlap was forced.
Also adds the combination nothing else exercised: a real bridge recording a
concurrent plan into a real session store while per-task progress arrives from
every task's goroutine. Race-clean at -count=10.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A child's stream events carry no task identity. The agent loop's progress
callback holds the parent's TOOL-CALL id, which is identical for every task in a
plan, so a display could only guess which task an event belonged to — and the
guess it made was "whichever task was dispatched last". Sound while exactly one
task ran at a time; a lie the moment two could.
THE IDENTITY NOW TRAVELS WITH THE EVENT, from the only place that has it. The
recorder opened the cards, so the recorder knows which card belongs to which
task; the runner tags each event with its task id on the way out and the
recorder resolves it. Nothing at the display end reconstructs anything.
SO THE GUESS IS DELETED, not conditioned. planRunningCardKey and the
planConcurrent flag that briefly protected it are both gone. The previous commit
turned attribution OFF for concurrent plans because a card that says nothing is
honest while one that says something false is not; this makes that compromise
unnecessary, and leaving the field behind would have left a second, wrong way to
answer the same question.
BOTH CALLBACKS FIRE, and they are not alternatives. My first version routed only
to the recorder, and a test caught it: a plan task's child must stream under the
same contract a Task sub-agent's child does, and restoring that was a fix in its
own right (finding: plan children streamed to nobody). Sending only to the
recorder would have quietly undone it for every caller without one — the
headless case. The recorder gets the event WITH the task id; the caller's own
callback still fires as it always did.
Only tool calls reach a card. A message per streamed token would put the event
loop under a load for a display that shows neither the tokens nor the count of
them.
Seven mutations, all caught, including the two that would look right on screen
and be wrong: all tasks sharing one card, and progress routed to whichever card
was newest.
Race detector clean at -count=5 across both packages. Additivity re-proven, five
modes byte-identical.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Admission, scheduler and worker cap in ONE commit, because splitting them the
way the plan suggested would have shipped a regression: relaxing max_workers
while the executor stayed sequential means a plan asking for 8 silently runs
with 1, which is exactly what the old rejection existed to prevent.
ONE SCHEDULER, NOT TWO. With a single worker it walks plan.Order() one task at
a time, applying the same checks in the same order and producing the same
report — so the entire existing suite, written against the sequential executor,
IS the equivalence oracle. It passed unchanged except for the one test that
encoded "max_workers must be 1". A second executor would have been easier to
write and impossible to keep in step; a duplicated rule drifts, and a duplicated
executor drifts faster.
THE WALK IS WHAT PRESERVES ORDER. Rather than keeping a ready SET and picking
from it — which would let task 7 start before task 3 because its dependencies
happened to resolve first — the scheduler walks the validated topological order
and, for each task in turn, waits until that task's dependencies have resolved
AND a worker is free. With one worker the wait is "until the previous task
finished", which is today exactly.
TWO NUMBERS, BOTH REPORTED. max_workers is what the plan asked for; the machine
may carry less. A plan that asked for sixteen and ran six has not been given
sixteen, so the report carries Workers and WorkersRequested rather than letting
the request stand as if honoured. Out of range is REJECTED, not trimmed, for the
same reason the old rule rejected: a trimmed number is one nobody can reason
about afterwards.
THE TUI STOPS ATTRIBUTING RATHER THAN ATTRIBUTING WRONGLY. Child progress events
carry no task identity — the loop's callback holds only the parent's tool-call
id — so with two tasks in flight there is nothing to route them by. Pointing
every child's output at whichever task started last would show one task's tool
calls under another's name. A card that says nothing is honest; a card that says
something false is not. Per-task progress needs the child's identity threaded
through the callback, and that is its own change.
Eight mutations, all caught, including the two that would be invisible in
production: dependencies ignored, and one worker overlapping anyway.
Race detector clean at -count=10.
MEASURED IN THE REAL BINARY, same plan, same build — a five-task diamond:
max_workers=1 WALL 40.0s
max_workers=4 WALL 27.0s
Note what this spends: MaxSpeedup was defined as the theoretical ceiling
measured FROM a sequential run, and it was the evidence the decision to build
this rested on. Once plans run parallel that instrument no longer measures a
ceiling. The 1.67x it reports for this diamond still matches the observed
40→27; on plans that are mostly chain it will not.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Found by the user running a fifteen-task plan: 260 seconds on the first task
with ZERO tool calls. The task was "find every definition and call site and
quote the file:line of each" — and the child answered it by thinking.
The prompt said "You have read-only tools" and stopped there. That states a fact
and asks for nothing, so a model is free to reason from its own weights about
what the code probably says. It now says USE THEM: search before answering,
start with a tool call rather than prose, back every claim with something read
in THIS run and quoted with its file:line, and say plainly when something cannot
be found.
The honest-failure clause is the half that matters most. A guess and a finding
are indistinguishable once they reach the plan's report, so a task that cannot
find something has to be able to say so without that reading as failure.
THE STALL WATCHDOG CANNOT CATCH THIS, and that is worth recording rather than
fixing blind. It keys on SILENCE — deliberately, because a task reading forty
files for four minutes is working — and a model writing prose is not silent. A
task that monologues indefinitely without calling a tool trips nothing. The
bound that would have applied is max_wall_seconds, which the model omitted.
Verified at the wire, and my first probe was WRONG in a way worth noting: it
searched only system-role messages, found nothing, and I briefly concluded the
manifest prompt never reached the child at all. It does — WrapSystemPrompt puts
it in the user message under "## Specialist Instructions". Checking every role
showed all four new phrases arriving. A probe that looks in one place and
reports absence is how a non-defect becomes a rewrite.
Whether this changes glm-5.2's behaviour on a real plan is NOT proven here; the
stub cannot answer it. It needs a run.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The last step, and the only one that changes what a plan may DO. It is safe to
make because the previous two exist: an approval that can show the plan
(b52ed77) and a worktree of the plan's own (b2673f8). Making it first would have
been asking a user to approve something the screen could not describe, into a
tree they had not agreed to have changed.
WRITING IS NAMED, NEVER INHERITED. A task that names no tools still gets the
read-only grant and nothing else; a task that wants to change something says
which tool. Otherwise every unqualified task in every plan becomes write-capable
the day the parent grant widens — and the grant DID widen here, which is exactly
when that would have happened.
AN ALLOW-LIST, not "anything not read-only": write_file, edit_file, apply_patch,
bash, exec_command. The negative form would hand a plan task every future tool
the moment it is registered, including ones nobody considered when this was
written. Every deny-list in this repository has leaked.
BOTH GRANT SITES MOVED TOGETHER. validateTaskTools permits a named write tool
and planToolGrant delivers one; relaxing admission alone would have produced a
task that validated and then ran with less than it asked for, silently. That
pair is the sibling rule this feature exists to respect.
PROMPTING IS ARGUMENT-AWARE because it has to be: Safety() sees no arguments, so
it can only describe the tool, and "this plan writes" is a property of the PLAN.
A read-only plan keeps PermissionAllow — friction with nothing to show for it is
the click-through problem. An unreadable plan, or a `saved` one whose tasks are
in a file the check has not opened, errs toward ASKING: a wrong guess that way
costs one prompt, the other way runs write tasks unasked.
THE ADDITIVITY GUARD THIS NEEDED. Implementing ArgsPermissioner makes
permanentlyDenied return false — deliberately, since only a tool with no
per-argument override can be ruled out from its static permission. But
orchestrate is gated on the POSTURE, which is a session state and not an
argument, so it would have counted as held in every run merely for implementing
the interface, and the confirmation policy would have appeared in read-only runs
that never turned the feature on. tools.PermanentDenier lets a tool say what
ArgsPermissioner cannot, and it is consulted first. Five modes still
byte-identical.
Seven mutations, all caught. Two existing tests encoded the OLD rule and were
rewritten to the new one rather than around it — and TestBothGrantEnforcement
PointsAgree now CALLS validateTaskTools instead of restating it, so the next
time the rule moves the test moves with it rather than failing against correct
code.
Verified in the real binary: a write-capable plan outside a git repository is
refused with the reason, and inside one it gets a worktree named for the plan.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
STEP 2 OF THREE. Step 1 gave the approval prompt something to show; this gives a
write-capable plan somewhere to write that is not the user's tree; step 3 allows
write tools with both as preconditions.
THE REQUIREMENT IS DERIVED FROM THE PLAN, not declared by a caller, and that is
what makes step 3 safe by construction. RequiresIsolation reads the plan's own
tools: a task naming anything outside the read-only set can change something,
which is exactly the condition isolation exists for. Today validateTaskTools
rejects such a task so it is always false; the moment step 3 stops rejecting
them it becomes true, with no second place needing to be updated. Three defects
in this feature have been a rule that lived in one place and a condition that
lived in another.
PER PLAN, NOT PER TASK. Per-task isolation is more isolation and less use: this
phase is sequential and a later task routinely needs an earlier one's output, so
per-task trees would either hide that or need a merge step nobody wrote. A plan
is the unit a user reviews, so a plan is the unit that gets a tree — one branch,
one diff.
FAIL CLOSED, four ways, because a precondition that degrades is not one. No
isolator, a prepare that errors, an isolator reporting success with no path, and
one reporting a path it did not isolate are all REFUSED — never run in the
parent tree with a warning. The third and fourth are what a buggy isolator
produces, and honouring either would put write tasks exactly where this exists
to keep them out of.
BOTH DISPATCH PATHS. Foreground and background resolve the same workspace by the
same rule, and the background path resolves it BEFORE launching so a plan that
cannot be isolated is refused now, with a reason the model reads, rather than
failing invisibly on a later turn.
It reuses internal/worktrees — the package `zero exec --worktree` already uses —
rather than shelling out to git again. Release does NOT delete the tree: a plan
that wrote produced work nobody has reviewed, and removing it would remove the
only copy.
Eleven mutations. I7 initially MISSED and it is the same shape as the Stalled
flag: every test drove ExecutePlanIn with a FAKE runner that reads req.Cwd, so
all of them passed against a real runner ignoring it. Fixed with a test that
drives NewPlanRunner and asserts on the `--cwd` argument the child actually
receives — both directions, since a runner that used only the override would put
read-only children nowhere.
A read-only plan still runs in the parent tree, verified with the real binary:
no worktree is created for one.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
PermissionRequest has carried an Args map since it was written and NOTHING has
ever read it — verified across permission_prompt.go, transcript.go and
rendering.go. So the card names a tool and states a static reason. For a
one-file write that is survivable, because the path already surfaces as the
grant scope. For a plan it is not: a twenty-task plan and a one-task plan
produce the identical card, which is precisely the argument plan_tool.go makes
for NOT prompting on orchestrate today — an approval that cannot show what it
approves trains click-through, and that is worse than no dialog because it also
erodes the prompts that do carry information.
STEP 1 OF THREE, and it is a step rather than a product. Step 2 is worktree
isolation; step 3 flips plan tasks to allow write tools with both as
preconditions. The order is forced: the gate cannot be justified until the
renderer exists, so this lands first and is wired by step 3.
A REGISTRY KEYED BY TOOL NAME, and a tool with no entry renders exactly what it
rendered before. That is what keeps every existing permission prompt
byte-identical, and it is asserted with Args deliberately PRESENT so the test
proves the renderer lookup is the gate rather than the absence of arguments.
THE ARGUMENTS ARE UNTRUSTED. Every value came from the model, so text is
collapsed to one printable line: newlines dropped (or the model writes its own
rows), control characters and ESC dropped (or it repaints the screen around the
card), truncated on RUNE boundaries. The row count is bounded and says what it
hid — a card a plan can make taller than the terminal is a card whose options an
attacker can push off screen, leaving only the key still visible.
A malformed task is SHOWN as malformed rather than skipped: a task the card
silently drops is a task the user approved without seeing. An unbounded budget
is NAMED, because "no token limit" is the thing worth reading on an approval and
omitting it reads as though a limit exists. A `saved` reference says which plan
and where to read it rather than inventing a summary of tasks the card never saw.
Nine mutations, all caught. The one that mattered most was P2 — pointing every
tool at the plan renderer — because the whole no-regression claim rests on that
lookup being the gate.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The last open decision, settled by measuring instead of reasoning — and the
premise I had been carrying was wrong.
I had recorded this as "update_plan blocks the prompt saving; drop it or add a
session-only capability". Driving a real plan and dumping the CHILD request
showed the saving was not reaching plan tasks at all, and the reason was neither
option: update_plan declares readOnlySafety("Updates in-memory planning state
only") and sets NO capabilities, so CapabilitiesOf reports EffectUnknown — the
fail-closed default — while its safety string says read-only. runCanMutate reads
the CAPABILITY. One unclassified tool in an otherwise read-only grant made the
whole child look mutating.
Measured at the wire, same plan, before and after:
child system prompt 18,118 -> 11,639 chars (-36%)
confirmation policy present -> absent
That is per TASK, multiplied by every task of every plan.
DROPPED FROM THE GRANT rather than reclassified, and the distinction matters:
the same capability drives concurrency eligibility, so promoting update_plan to
EffectReadOnly would make it eligible for parallel execution it is not safe for
— it REPLACES the whole plan list on every call, so concurrent callers race and
the last one wins. The safety/capability mismatch is a pre-existing main defect
and is filed as one, not fixed here.
It also has no business in the grant on its own merits. A plan task is an
investigator that reports to the parent; the parent owns the plan list.
WHY NO TEST CAUGHT THIS. internal/agent's readOnlyRegistry enumerated its own
copy of the plan grant, and the copy differed from the authoritative list by
exactly one entry — update_plan, the entry that broke it. So
TestAReadOnlyRunDropsTheConfirmationPolicy asserted the policy was dropped for a
grant that was not the grant, and passed green for the whole period real plan
children carried it. Invariant 5, in a test rather than in production. The
helper now reads specialist.PlanReadOnlyToolNames.
Three mutations. U1 — putting update_plan back — is MISSED by the agent tests
and the fixture now says why in place: a grant name outside the scoped core set
is invisible to it. That half proves "these read-only tools drop the policy";
the other half, that every name in the grant is read-only BY CAPABILITY, is
TestEveryPlanGrantToolIsReadOnlyByCapability against the real registry, where
the same mutation is CAUGHT. Neither alone is the guarantee.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Opt-in via `background: true`, default off, so nothing changes unless asked.
THE CONTRACT IS INJECTION, of the three the report offered. The agent loop
already drains post-edit diagnostics into the conversation tail as a user-role
message — background work reporting into a later turn has a channel here, and a
second one would be a second ordering and a second thing to budget. The
completion goes down the same path, at the same point in the turn.
THE SEAM IS A LAUNCHER, NOT A CONTEXT. A background plan must outlive its tool
call and must not outlive its session, and only the surface running the session
knows where that ends. So the surface supplies a launcher, keeps the context and
drains on exit; the tool asks to be run and is told yes or no. Headless exec's
refusal then falls out of the wiring rather than being special-cased: that
process exits when the turn ends, so it supplies no launcher, and the refusal
says exactly that.
THREE DEFECTS FIXED THAT THE FEATURE WOULD HAVE LANDED ON, all found by sizing
it before writing it:
1. Card keys collided. `dispatched` reset on every Attach, safe only while
every plan lived inside one run; a background plan still dispatching when
the next run attached would restart the counter and hand the new run's
tasks keys the old plan was still using. Now monotonic for the bridge's
life.
2. Progress was silently dropped. The stale-run guard discards anything whose
runID is not the active one — right for a finished run's leftovers, wrong
for a plan still working. The panel would have frozen with no error, no
card, nothing. The guard is now background-aware in all four handlers, and
a test asserts a stale FOREGROUND message is still dropped.
3. The goroutine would have outlived the session. The TUI root was
context.Background(), never cancelled. Now a cancellable session root, and
Close cancels AND WAITS — returning early would let the process exit while
a child was still being torn down, which is how orphans happen.
One at a time, by rule. Two background plans report into one panel keyed by task
id and the second would overwrite the first's rows; refusing the second is
stated, letting the UI silently pick one is a defect.
A background plan returns "NOT finished", never a summary. Returning one would
be reporting work that has not happened, which is this repo's oldest defect
class at the point where it would be least visible.
Eighteen mutations. G7 was a genuine miss — the terminal message's background
mark, which has to be captured BEFORE the flag is cleared and carried down.
Without it a background plan's panel freezes one row from the end: every task
done, the plan never closing. It was caught earlier only because the compiler
complained an unused variable, which is luckier than it deserved to be, and now
has a test. G12 read as a miss and was an inert mutation; re-run as "never
marked at all", CAUGHT.
Verified in the real binary: headless exec refuses it with the reason.
Additivity re-proven, five modes byte-identical. The TUI path needs a user run.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The last of §5.5. It was left out of the control commit deliberately, because
restarting needs the plan's ARGUMENTS and nothing stored them; named plans
(b2f22d2) made the bridge keep them, so this is now a thin layer rather than a
reconstruction.
It stages the plan the BRIDGE holds, not the panel's rendering of it. The panel
has ids, statuses and depths — restarting from that would run something that
merely resembles what ran. Staged as a saved plan rather than replayed as a tool
call, so `/plans show last_run` reads exactly what is about to happen and it
travels the one execution path every other plan travels.
RESTART AND RESUME ARE OPPOSITES and the test asserts them together, because the
whole risk is that one silently becomes the other: restart ignores the session's
recorded progress entirely, resume honours it.
Refused while a plan is running. Two plans reporting into one panel keyed by
task id means the second overwrites the first's rows, so the rule is explicit
rather than letting the UI silently pick one.
Four mutations. Two initially read as MISSED and were bad mutations, not missing
tests — one was inert, and the other hit the FIRST `m.planProgress.LastPlan()`
in the file, which belongs to savePlanText. That is the same wrong-occurrence
trap this project hit once before; re-targeted at the last occurrence, both
CAUGHT.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Found sweeping for siblings of the OrchestrateAvailable defect, and it is the
same family: `OrchestrateTool.Limits *Limits` was read by limits() and set by
NOTHING — not a production call site, not a test — so `if tool.Limits != nil`
could not be true.
It is worse than merely dead. It sat directly beside Size, which is the knob
that actually works, so a reader looking for how a plan's caps are configured
would find the one that does nothing first.
Deleted rather than wired, because nothing needs it: every value it could have
overridden is already derived from something real — the tier for the task
ceiling, the run's grant for the tools, the run's depth for the headroom check.
Wiring it would have been building a second way to set caps that no caller
asked for.
That is three instances of this family in this feature now: ParentTools
(declared, forwarded, consumed, populated nowhere), OrchestrateAvailable (same),
and this. The first two were found by running the binary; this one only by
looking for them on purpose.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Found by the user running the binary: the posture turned on and nothing
orchestrated.
AgentOptions.OrchestrateAvailable was declared, documented, and consumed by the
reminder selector — and set by NEITHER production call site. So the enter notice
that names the tool could not fire, in either surface, ever. The tool was
advertised in the tool list and the conversation never mentioned it, and a model
handed an unfamiliar tool it was never told about does not reach for it.
This is invariant 1 for the SECOND time in this feature — the same shape as
ParentTools, which was likewise declared, forwarded, consumed by two guards, and
populated at neither site. Both were found the same way, and it was not by a
test: a unit test on zeromaxingReminders passes the flag in itself, so it stayed
green for the whole period the field was set nowhere.
Derived from the registry rather than passed as a flag, and set ONCE in the TUI
rather than beside each of the three places it assigns Zeromaxing. A bool a
caller has to remember is how this happened; the registry is the thing that
actually knows.
ALSO FIXED, a regression from b2f22d2: adding the `saved` argument dropped both
tasks and budget from Required, which emits no "required" key at all — so the
model was handed a tool whose every argument looked optional. tools.Schema has
no oneOf, so the constraint that is actually true, "tasks and budget, or saved,
never neither", cannot be spelled there. It is spelled in Description, which is
what a model reads for intent, along with when the tool is worth using at all.
Four mutations, all caught, including the two that ARE the reported bug — exec
not setting it, and the TUI not setting it. They are caught by driving Run() to
the wire and by capturing the tui.Options the TUI is handed, because those are
the only levels at which this was ever visible.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan format nobody has seen an example of is a format nobody writes. `/plans
show research` reads the shape working, `/plans run research` runs it, and
neither needs a file on disk.
IT ENCODES THE HOUSE STYLE ON PURPOSE, because a starter plan that merely
demonstrated the JSON would teach nothing worth teaching. The three searches are
INDEPENDENT — by definition, by call site, by test — because one search angle
finds one kind of thing, and a synthesis over three blind searches is the
argument for fan-out at all. The last task is told to REFUTE and to default to
refuted when uncertain, because a verifier that sets out to agree always does.
That is the verified-vs-refuted distinction the report asked for.
It fits the SMALL tier, asserted by test: a shipped example that a user with the
tightest ceiling cannot run is an example for everyone except the people most
likely to want one.
SHADOWED, NEVER AUTHORITATIVE. Builtin loses to user, user loses to project, so
shipping an example can never override something someone wrote — and the
listing does not count bundled plans as the user's own, or a fresh install would
read as though they already had plans and drop the line telling them how to save
one.
SavedPlan.Project became SavedPlan.Scope. A bool could say "project or not", and
there are three origins now: "not project" would have meant both "the user's"
and "shipped with the binary", which is the one distinction a listing exists to
draw.
Four mutations, all caught, including the ordering one that matters most — a
bundled plan placed after the disk scan would silently replace a user's plan of
the same name on the next release.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The write half put the five lifecycle events in the session log from both
surfaces. This reduces them back into plan state, which is the shape
ARCHITECTURE.md asked for: a deterministic reduction over the five events, with
no second store to disagree with.
A DISPATCH WITH NO TERMINAL EVENT IS UNFINISHED, NEVER DONE. Dispatch is written
before the child runs precisely so a task in flight when the process died is
distinguishable from one that never started, and it is the task most likely to
have been interrupted mid-work. Treating it as complete would silently drop
exactly that one. A failed task is remaining too: unlike a success it produced
nothing to keep.
WHY RESUME NEEDS A SAVED PLAN, and it is a consequence of a decision made two
commits ago rather than an oversight. The event payloads are deliberately small
— ids, counts, durations, terminal status, not prompts — so the log knows which
tasks finished and cannot know what they were asked to do. Rather than growing
the log into a second copy of the plan (a fifty-task plan's prompts, written
again, per run), the plan supplies the work and the log supplies the progress.
Neither has to become the other.
NARROWING REMOVES A COMPLETED TASK AND EVERY REFERENCE TO IT. Leaving the edge
would produce a plan whose dependency names nothing, which ParsePlan refuses —
correctly — so a resume would fail on its own output. Rewriting the edge to the
next task along would invent an ordering nobody declared. A dependency on work
already done is simply satisfied. The remainder goes back through ParsePlan, so
it is re-validated against the CURRENT run: it cannot keep a tool grant this run
does not hold, or a task count the tier no longer allows.
The remainder is STAGED AS A SAVED PLAN rather than taught to the tool as a
"skip these ids" argument. It is a plan — it validates, it runs, /plans show
reads it before anyone spends a token — and it travels the one path every other
plan travels. A skip list would have been a parallel notion of what a plan is,
resolved somewhere other than ParsePlan. It overwrites a fixed name, because a
directory of sweep-resume-1, -2, -3 is a directory nobody can tell apart.
`/plans resume` is overloaded BY ARITY, and the two meanings are the same
intention at two scales: bare, it un-pauses the plan running now; with a name,
it picks up a saved plan where its last run stopped. Bare resume keeps exactly
the meaning it had, which is asserted rather than assumed.
Eleven mutations, all caught, including the two that would have been silent in
production: an unfinished dispatch counted as done, and a second plan in one
session inheriting the first plan's completions.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan that took a real sweep to get right could only ever run once. Saved plans
live at .zero/plans/<name>.json (project, checked in and shared) or the user
config equivalent, with project shadowing user — the same precedence
usercommands and the specialist loader already use.
A PLAN IS STORED AS ARGUMENTS AND RE-ADMITTED THROUGH ParsePlan. Never as a
serialised Plan: the fields are unexported and ParsePlan is the only constructor
precisely so nothing can enter execution having skipped validation, and a stored
object that deserialises straight into an executable would be that hole with a
filesystem in front of it. So a saved plan is re-validated against the CURRENT
run — a plan saved when the tier was large is refused when it is small, and one
naming a tool this run does not hold is refused rather than granted. Proved with
the real binary, not only in tests.
RUNNING ONE IS NOT A NEW EXECUTION PATH, which is the deliberate divergence from
the report. The report wants saved plans to become /name slash commands; they do
not. A plan is not a prompt, and a command that reached into tool execution
would need its own copy of the posture gate, the depth check, the grant
intersection and the recorder wiring — this feature's whole defect history is
second call paths carrying less than the first. Instead orchestrate takes a
`saved` argument, /plans run dispatches an ordinary prompt, and by the time
ParsePlan sees it a stored plan and a model-supplied one are indistinguishable.
A saved plan runs AS IT WAS SAVED: `saved` alongside tasks/budget/name/
description is refused rather than merged, because a half-overridden plan is not
the plan whose name is still in the transcript.
The name is the PATH GUARD as well as the name guard, and it is an allow-list:
no separator, dot or traversal component can be spelled with those characters,
so "../../etc/passwd" is refused by the same rule that refuses a space. Symlinks
are refused on the file AND the directory, on write and on read — without it
"save my plan" is a file-overwrite primitive. A malformed plan file is reported
by name rather than skipped, and a lookup past one says the file was unreadable
instead of "you have no plan by that name".
Save-from-run reads the bridge, not the panel. The panel holds a RENDERING of a
plan — ids, statuses, depths — and saving that would write something that merely
resembles what ran.
Thirteen mutations. N10 could not be killed and the code now says why rather
than implying otherwise: the re-admission on the SAVE side is unreachable,
because those args came from a Plan that was already admitted. It is handled
because ignoring a returned error is how a reachable version goes wrong later,
not because it guards anything today. The re-admission that DOES fire is on the
way back in, and a hand-edited file that is valid JSON and an impossible plan is
now refused there by name.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The large plan-size tier allows fifty tasks. Three surfaces truncated a long
plan and all three took the FIRST N rows, so task 40 would be running while the
user watched tasks 1 to 12 with "+38 more" underneath. A progress display that
cannot show the thing in progress is not one, and the previous ceiling of twenty
is the only reason this was survivable before.
The window FOLLOWS rather than scrolling. Keeping the anchor — the running task,
or the one the user selected — in view means the list moves itself through the
surfaces that already exist, clicking and ctrl+g, rather than needing a scroll
offset and another chord in a keymap where seventeen ctrl letters are taken.
A running task OUTRANKS the selection. Someone who clicked a task to read its
detail is still watching the plan move, and pinning the list to their last click
would hide the work; the detail pane shows the selection either way.
ONE window function, three callers. The footer panel, the sidebar list and the
sidebar's HIT TABLE each derived their own slice, and they had already drifted —
two took the head, one took the tail once everything faded. The renderer and the
hit table are two doors onto one state, so that drift is a click landing on a
different task from the one drawn under the cursor. The test asserts EQUALITY
between them, which is the relationship two doors have.
Both directions are named. "+38 more" under a list whose first row is task 30
reads as though the plan has 68 tasks.
Seven mutations, all caught. The bounds are exercised directly across every
count/limit/anchor shape rather than only through a rendered plan, because that
is the part that panics when it is wrong — and it found the degenerate case
where a zero-height window reported no hidden tasks at all instead of all of
them.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Abandoning a twenty-task plan meant Ctrl-C, which cancels the whole run and
takes the conversation with it. "Stop this plan" and "stop this turn" are
different intentions and now have different acts.
THE CANCEL IS DERIVED IN ExecutePlan, not in the orchestrate tool. The first
version put it in the tool, which would have given per-plan cancellation to
exactly one call path — the recurring defect of this codebase, written on
purpose. ExecutePlan is the function that owns a plan's lifetime, so the seam
belongs where the lifetime is, and every caller gets it.
Control arrives through the SAME seam the display does: PlanController is
type-asserted off the recorder exactly like PlanLifecycleRecorder, so nothing
that only records is affected and no existing signature changes. The surface
that shows a plan is the one a user asks to stop it.
PAUSE HOLDS AT A TASK BOUNDARY, and the message says so. A child already talking
to a provider cannot be suspended; a pause claiming to be immediate would be
claiming tokens had stopped being spent when they had not. The gate is consulted
BEFORE the cancellation check so stopping a paused plan is not a wait for a
resume that will never come, and resume is a CLOSED channel rather than a signal
so a waiter arriving after the resume never blocks on a send nobody makes.
The handle is dropped when the plan ends. A stale cancel would let a later stop
cancel a context that has since been reused — the PostureGate lifetime mistake
in another costume — and a new plan never inherits the last one's pause.
A SLASH COMMAND, not the reference's bare p/x/r. Bare letters go to the
composer, seventeen ctrl chords are already bound, and — the actual reason —
only commandPrompt is queued while a run is pending, so a slash command is
dispatched immediately. That is the entire requirement for "stop the plan that
is running right now". Bare /plans is unchanged.
NOT BUILT, both from this gap item, both with reasons. RESTART needs the plan's
arguments and nothing stores them; the panel holds a rendering of a plan, not a
plan, and re-running from that copy would produce something that merely
resembles what ran. That is what named plans (§5.7) is for. FILTER solves the
second problem first: a 50-task plan needs SCROLLING, which is the row cap.
Eleven mutations, all caught after one repair. The miss was the same shape as
the last commit's: the comment on StopPlan claimed clearing the pause is what
releases the parked executor. It is not — WaitWhilePaused selects on ctx, so the
cancel does that on its own. What the clear actually fixes is the reported
STATE, which otherwise goes on offering "/plans resume" for a plan being
abandoned. The test now asserts both, because they are not one thing.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The watchdog file said retrying needed its own argument before it was built.
Here it is.
ONLY A STALL IS RETRIED, and that asymmetry is the entire justification. A task
whose child ran and reported an error produced an ANSWER; running it again
spends another child to receive the same one. A stall is the opposite — it is
the ABSENCE of an answer, so there is no result to disbelieve and nothing
partial to lose. Every other failure behaves exactly as it did before.
That is why the retry keys on TaskResult.Stalled, set by the runner from the
watchdog's own verdict, rather than on "the task failed" or on a phrase in the
error text. Deciding to spend another child by matching a message is the same
class as comparing errors with == instead of errors.Is, which is what silently
disabled every stall retry in the prototype.
THE DEFAULT IS ONE EXTRA ATTEMPT, and that is a judgment, not a measurement. It
costs a genuinely wedged task twice the stall timeout before the plan moves on —
six minutes at the default — in exchange for surviving a transient provider
condition without losing the task's dependents with it. Under a posture whose
premise is spending more for a better answer, six minutes of patience is the
cheaper mistake. A caller who disagrees sets max_retries to 0, which is why an
explicit 0 has to be distinguishable from an absent key: planInt returns 0 for
both, so the budget parses PRESENCE. An unset value reading as an explicit one is
the invariant that made an empty tool grant expand to the full read-only
category.
THE RETRY LIVES IN THE EXECUTOR, not the runner. The executor owns the budget,
the wall deadline, cancellation and the record; a retry hidden inside the runner
would spend a second child's tokens with none of them counting. Duration and
tokens come back as the TOTALS across attempts, so a plan's reported spend is
its real spend. Every reason to stop is checked before launching another child —
a cancelled run, an expired wall deadline, an exhausted attempt budget. The
prototype's equivalent loop retried a cancelled task, which turned Ctrl-C into
another spawn.
One dispatch per task, so the panel still shows one card and the transcript
keeps one result per tool call. The count reaches the summary, the session
events and the task detail, because a retried task's totals otherwise read as one
extraordinarily expensive attempt.
Seventeen mutations. The one that MISSED is the reason this note exists:
TaskResult.Stalled — the flag the whole policy turns on — was produced by the
real runner and asserted by nothing, because every retry test supplies it from a
fake runner. Same shape as the budget meter fed by a counter NewPlanRunner never
populated. A fake that fabricates its own inputs cannot test the producer.
Driven through the real binary, not only the tests: a wedged child with
max_stall_seconds 30 produced two child requests and "stuck [failed] 1m10s (2
attempts)", the independent task still ran, the dependent was skipped, exit 4.
With max_retries 0, exactly one child request and 35s.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan's task count was bounded by ONE hard-coded number with no way to move
it. Twenty was too many for someone on a metered provider and too few for a real
sweep, and the only way to discover it was to read plan_tool.go.
profiles.planSize now names the ceiling: small 5, medium 20, large 50,
unrestricted. NAMED rather than numeric so the config file says what it means
and so "no ceiling at all" is expressible without a sentinel integer.
TWO DELIBERATE DIVERGENCES FROM THE REPORT, both worth stating.
It stays a HARD CAP, not the advisory warning the reference implementation has.
Under this posture every plan task is a child inheriting a 320-turn ceiling, so
fifty tasks authorise 16,000 child turns from a single tool call. A warning the
model emits to itself is not a bound on that. What the report actually wanted —
that the number be discoverable and movable — is delivered by the rejection
naming the tier and the setting instead of printing a bare integer.
And medium is 20, not the reference's 15, because 20 is the number
defaultPlanMaxTasks already held. Making a bound configurable and moving it in
the same change hides a behaviour change inside a mechanism change; the tier
mechanism is the deliverable and the number stays where it was.
PROJECT CONFIG MAY ONLY TIGHTEN IT. A cloned repo can ask for a smaller ceiling
and can never raise one, because raising it raises a spend ceiling for whoever
opens the repo. Same privilege boundary as Sandbox.Network's allow/deny rule and
DisableZeromaxing's disable-only rule, and a looser tier is dropped silently the
way an ignored network "allow" is: the project scope simply does not hold that
privilege, which is not a malformed config.
Fail closed throughout. An unrecognised tier resolves to the default and never
to "no ceiling" — a typo must not be the thing that removes a bound — and the
same is true when the early config resolve in exec fails.
The mutation sweep found that rank() returned -1 for an unknown tier with a
comment claiming that made it safe. It does the opposite: the tighten-only
comparison adopts the SMALLER rank, so -1 would have beaten every real tier.
Nothing was reachable through it, because ParsePlanSize rejects the name first —
but a defence-in-depth value that only holds because of the guard in front of it
is not defence in depth.
Verified against the real binary, not just the tests: the same six-task plan is
admitted with no setting and refused at 5 with planSize small, and a project
config asking for unrestricted against a user config of small stays refused.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The report asked for an append-only journal beside the session store. It is not
needed, and ARCHITECTURE.md says why: "Plan state is a deterministic reduction
over the five events. No new store."
The five events already reached the session log under `zero exec`. The TUI wrote
NONE of them — PlanProgressBridge drove the panel and persisted nothing — so a
plan was durable or not depending on which surface you ran it from, and only one
of the two could ever be resumed. That was the actual gap; a journal would have
been a second store papering over it.
Events are appended AS THEY HAPPEN, not batched to run end. Batching would lose
exactly the crash the durability is for. Written from the TOOL's goroutine,
never the Bubble Tea loop — appending is file I/O and the loop must not block on
it. Dispatch is written BEFORE the child runs, so a task that was in flight when
the process died is distinguishable on resume from one that never started; that
ordering IS the guarantee.
THE PAYLOADS NOW HAVE ONE AUTHOR. They were built in internal/cli, and a TUI
copy would have drifted — with the drift only surfacing when someone resumed a
plan recorded by the other surface. specialist.PlanAdmittedEvent and its four
siblings are now the single source both recorders append, and a test compares
the two by round-tripping through JSON, the form they are actually stored in.
Best effort at every level, mirroring execSessionRecorder.append: no store, no
session, or a latched earlier failure and it does nothing. The first failure is
latched and readable, so a surface can say once that the plan was not fully
persisted rather than leaving a user to believe it was.
Five mutations, all caught. The fifth needed a test that drives beginRun rather
than the bridge: a recorder that records perfectly and is never bound records
nothing, which is the shape this feature keeps producing.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
runResume resolved the manifest by session.AgentName and ignored
params.Manifest entirely, while the fresh path honoured it. That is not merely
"inline-manifest children are unresumable", which is how it was filed — it is a
WIDENING.
A plan task launched under a parent holding only grep carries the inline grant
[grep]. Resuming it reloaded the registered explorer manifest and handed the
child back [glob grep list_directory read_file read_minified_file]. The
parent-grant narrowing that Workstream A made real was undone by resuming.
Measured, not inferred: the registered explorer resolves five read-only tools;
a plan grant under --enabled-tools grep is one.
Both paths now share ONE resolver — an inline manifest wins, validated exactly
as before, and only a caller that supplied none falls back to the registry. The
session's identity still governs: a mismatched specialist name is refused, and
the registry fallback still looks up the specialist the session belongs to.
Not currently reachable from a plan (NewPlanRunner sets no Resume), so this is a
latent widening rather than a live one — and it is the defect that stood in
front of Stage 2c, since plan tasks are exactly the inline-manifest children
that could not resume.
Four mutations, all caught. One test needed fixing first and the failure was
instructive: the fixture set ResolvedTools but not Metadata.Tools, and Validate
re-resolves from Metadata.Tools — so the fixture was re-expanded to the default
read-only category and the test "failed" against correct code. It now uses
planTaskManifest itself, which is what a plan task actually carries.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Removing the token cap left max_wall_seconds — optional and model-supplied — as
the only bound on a plan, and NOTHING bounding a single task. A child that
wedges against an unresponsive provider hung until the user noticed.
KEYED ON SILENCE, NOT DURATION, and that distinction is the whole design. A task
reading forty files for four minutes is working; one that has emitted nothing
for three is not. Killing the first would be worse than having no watchdog,
because a timeout that fires on healthy work teaches users to raise it until it
never fires. The clock resets on every stream event the child emits.
Default 180s, settable per plan via budget.max_stall_seconds, floored at 30s —
below that it fires on ordinary think-time and becomes a random task-killer. The
floor is enforced on MODEL input in planBudget, not at construction, so internal
callers with an already-validated value are not second-guessed.
It cancels the TASK's own context, derived from the caller's, so a wedged task
never takes the plan with it; the dependents skip as they would for any failure.
A stall reports as a stall, not as a cancellation: both surface as a context
error, and a user who stopped a plan and a plan that hung are looking at very
different problems.
A BOUND, NOT A RETRY POLICY. A stall is usually a provider or network condition
that a second identical attempt hits again, and spending another task's budget
to find that out needs its own argument.
The runner now always hands the executor a progress callback, because the
watchdog needs the liveness signal whether or not a UI wants the events.
TestPlanRunnerWithoutProgressPassesNil asserted the opposite and was right until
this existed — with no feed, a child talking happily would be judged silent and
killed. The executor is unaffected: `progress != nil` gates only the call, and
the event is parsed and stored regardless.
Ten mutations, all caught — but four of them only after fixing the tests:
Two were indistinguishable. The wedged-child test's child emits nothing, so
dropping the watchdog's feed and using the parent context both still produced
a stall. A chatty-child test (speaks every 10ms, 60ms timeout, must survive)
and a parent-context-liveness test tell them apart.
One test could HANG rather than fail: with the watchdog disabled, the wedged
child blocked forever and took the whole mutation sweep with it. A test for a
hang must not be able to hang; it carries its own deadline now.
And the sweep itself needed per-run timeouts, for the same reason.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The chip is now clickable — pressing it opens /effort, where the posture can be
turned off or changed — so it highlights under the cursor. A clickable chip that
looks identical to a label never teaches anyone it can be pressed.
Not while a turn is in flight: /effort refuses mid-run, and opening a dialog
that cannot be acted on is worse than opening none.
Its hit region is measured from the RENDERED footer rather than assumed. The
chips before it — permission mode, effort — vary in width with the session, so
a fixed offset would drift the moment either changed.
The sidebar's plan rows highlight too, joining the agent, plan-step and file
rows that already did. The hover is held by TASK ID, not row index, for the
reason the others hold identities: the rendered row set changes as tasks finish
and fade, with no mouse motion in between to re-resolve it, and a cached offset
would light up whatever slid into the slot.
The /effort picker already highlighted its rows on hover — selectGenericPicker-
AtMouse live-previews on motion — so the zeromaxing row needed nothing.
Six mutations, five caught. The sixth is belt-and-braces rather than a gap and
the comment now says so: the posture-off guard in the chip's hit test is a fast
path, not the enforcement — with the posture off the footer carries no chip, so
the span lookup fails anyway.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The posture raises a cost multiplier — 320 turns per run, inherited by every
sub-agent — and it was a word in amber, in the same weight as everything beside
it. "Is it on?" should be answerable from across the room.
The footer chip is now a FILLED badge: the label sits on the amber rather than
in it, which is what reads as lit rather than as another label in the row. Off
still means ABSENT, not dim — the footer is byte-identical without the posture.
It breathes while a turn is in flight, ◦ • ● ◉ ● •, on a 1.4s cycle. Slow on
purpose: this marks a standing state, not activity, and a fast blink beside a
working spinner reads as a second thing happening.
THE PULSE IS FREE, and that constraint shaped it. It advances on the spinner
tick that is ALREADY running during a turn and holds a steady full glyph
otherwise. ensureSpinnerTick deliberately schedules no timer on an idle session
— "an idle plain session schedules no timer" — and a chip is not a reason to
break that. Steady rather than frozen mid-cycle, because a half-lit chip on an
idle session would read as a rendering fault. Reduced motion pins it, like
every other animation here.
The /effort picker marks the posture row too, so what you are about to turn on
looks like what you will see once it is on. Marked with a glyph rather than
merely coloured: the selected row already owns its background, so colour alone
would be invisible exactly when you are looking at it. The marker is display
only — the VALUE handed to the command stays the bare name, or selecting it
would be refused as an unknown effort.
Seven mutations, all caught. Two needed the tests fixed first: the chip test
called the helper rather than the footer, so reverting the footer to plain amber
text passed it; and the value test located the row BY value, so the moment the
marker leaked into the value the row stopped matching and the test passed by
finding nothing.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The overlay was wrong and the screenshot showed why: composited into the
transcript column, a twelve-task plan drew its phase list straight through the
user's own prompt. It is gone.
The right column already owns "what is happening" — agents, files, activity —
and had half a screen padded with blank lines down to the token floor. The plan
lives there now:
PLAN 1/8
█████████░░░░░░░░░░░░░░░░░ 1/8
✓ t-sandbox x
✗ t-agent y
• t-tools Read the Go package at…
○ t-redaction
+2 more
TASK t-tools
running
52 tool calls · 21,400 tok
blocks pair-a
↳ read_file internal/tools/registry.go
Read the Go package at internal/tools
A progress bar that keeps done, failed, skipped and running in their own
colours inside one span, sized to the column rather than to a fixed width, and
never rounding a segment away — one failure in thirty still gets a cell,
because a failure the bar hides is a failure it does not report.
TASK fills what was dead space, and yields first when the column is short: the
list above it is what the section is for. It shows the REVERSE dependency edge
too — what a task blocks — because a failure matters in proportion to what
waits on it and the forward edge alone does not say.
Click a task to select it, click PLAN to collapse the section, ctrl+g to cycle
the selection from the keyboard. The sidebar is not focusable, so a mouse-only
affordance would not be one.
THE OFFSET ARITHMETIC IS THE RISK HERE, not the drawing. The progress bar sits
between the header and the first task, so every clickable row below it moves
down one — in the plan's own hit table AND in the FILES section beneath. Both
are adjusted, and both are asserted against where the row actually RENDERS
rather than against a re-derivation of the sum: the first version of the FILES
test recomputed the arithmetic and would have passed with the bar unaccounted
for on both sides.
Nine mutations, all caught. Two needed the tests rewritten first — the FILES
one asserted nothing, and the bar one used numbers that never round to zero.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The right-hand column showed one grey line — "trace-chain 3/6 · helpers" — for
a plan the panel below it was rendering in full. It now carries the plan
properly: a progress count in the header and one coloured line per task, in the
same shape the update_plan steps use so the section reads consistently whichever
plan is in it.
green ✓ done · red ✗ failed · accent • in flight · faint ○ pending
faint ⊘ skipped or cancelled — deliberately NOT red, since neither is a defect
The header count turns red on a failure and green when everything is done, so
the one thing worth noticing carries from across the screen.
THE LINES LIVE INSIDE sidebarPlanLines, and that is not a detail.
sidebarFileSelectables computes the FILES section's click offsets from
len(sidebarPlanLines); the previous one-liner sat beside the placeholder and was
safe only because it was exactly one line. Rendering a whole section there would
have misdirected every file click by the number of lines added, silently. Inside
the function the arithmetic stays correct by construction.
Live tasks first, so a long plan shows what is happening rather than what
already happened, bounded at six lines with the remainder stated — the column is
26 to 40 cells wide and shares its height with AGENTS, FILES and ACTIVITY. The
panel and the detail view are where a whole plan is read.
update_plan's own steps still win the section when it has any: the two are
different things and the TODO list is the one the model is actively editing.
The plan's NAME is not in the sidebar. At this width it would cost a task row,
and it is on the panel and in the detail view.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Pressing the PLAN header opens a two-pane drill-in: every phase down the left,
the selected phase's live agent state on the right — status, elapsed,
dependencies, tool count, spend, its prompt, what it is doing right now, and
where it ended up. Up/down moves, enter drills into the child session, esc
closes.
An OVERLAY, in the same slot the help and picker overlays use, so it composites
over the chat rather than replacing it. Nothing about the transcript, footer or
composer changes, and it renders nothing when closed or when no plan has been
admitted — which is what keeps a posture-off session unchanged. No separate
posture gate: a plan only exists under the posture, so without one there is no
PLAN line to press.
The panel's own expand/collapse stays on ctrl+g, for a glance without leaving
the conversation.
Opening lands on the task worth reading: the one running, else the first that
failed, else the first task. The selection clamps rather than wraps.
Enter is offered ONLY when the selected task has a child session to open. A
running task is still keyed by a temporary id, and a hint for a key that does
nothing trains the user to ignore hints.
The phase list marks the task in flight. Running and pending both fell into the
default glyph, so the list could not show what was actually happening.
Prompts are SUMMARISED and say when they were cut; the full text stays in the
tool output. A task that never started shows what it is waiting on rather than
a row of zeroes.
Eight mutations, all caught. Two needed the tests fixed first: the running-task
assertion compared whole rows, which differ by the selection marker anyway, so
removing the in-flight glyph passed; and nothing drove the click path at all.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The footer read "budget 0/200000 tokens" for an entire run while the cards
directly above it showed 84,953 spent. tokensUsed was assigned in exactly one
place -- from plan_completed, which arrives when the whole plan ends -- so the
one number a user watches that line for was zero for as long as it mattered.
Spend now accumulates as each task finishes. The executor's own total still
wins at the end, since it counts every task including any whose message the
panel dropped as stale, but a plan that reports no total no longer erases what
was counted.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
All five from one screenshot of a six-task chain, all in what the run REPORTED
rather than in what it did.
"error (exit code 0)" on a failed task, directly above a body reading
"Subagent failed (exit 4)" -- the card contradicting its own detail. A plan
task's failure carries no exit code, and rendering the zero value claimed one
it did not have. Only shown when non-zero now.
Dependency-skipped tasks rendered "error". specialistCancelled landed in
specialistStatusString's default arm, so a plan with ONE real failure showed
three errors: the failure plus every task skipped behind it. The default stays
"error" so an unmapped status still fails closed.
"0 tokens" in the specialists rollup while the plan reported 130,135. Nothing
has ever populated specialistInfo.tokenCount -- not for plan tasks, not for
Task sub-agents -- so the total was always zero. The segment is now omitted at
zero, matching the per-card rule (M18): a number that looks measured and is
not is worse than no number.
A plan task's spend now reaches its card, so the rollup adds up to what the
plan reports. Task sub-agents still bridge no usage; that gap is unchanged and
now shows as an absent segment rather than a false zero.
The sidebar said "no active plan" while the panel two lines below showed one
mid-flight. Its PLAN section reads the update_plan TODO list and knew nothing
about orchestrate plans. It now shows a one-line summary -- name, done/total,
failures, the task in flight -- when an orchestrate plan is running. EXACTLY
one line, because the FILES section below computes its click offsets from the
lines above it.
The sidebar test drives renderContextSidebar rather than the line helper: the
first version called the helper directly, and the mutation that removes the
section from the sidebar entirely still passed.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The popup that /effort opens showed a single "auto" row on glm-5.2. Reported
from use, with a screenshot; I had just told the user no such picker existed,
which was wrong -- newEffortPicker has been there all along.
It read availableReasoningEfforts(), the CATALOG's answer. A model with no
catalog entry has an empty ring, so the picker offered nothing settable -- on
the model this user runs. And it never offered the zeromaxing posture on ANY
model, even though picking one routes straight into handleEffortCommand, which
accepts it.
The picker now shares settableEfforts() with the /effort card, so the three
surfaces that answer "which efforts can I set" -- card, picker, command --
cannot disagree. Catalog authority is preserved: gpt-4o, which the catalog
vouches has no reasoning controls, still offers only auto and the posture.
It also preselects the active posture. Under zeromaxing m.reasoningEffort holds
"high" -- the level the posture FILLED -- so preselecting from that alone would
highlight the wrong row.
The parity test now feeds every value from BOTH surfaces back through the real
command, and asserts the two offer the same set. The picker is the surface that
actually failed in use while the card's own test passed, which is the argument
for testing them against each other rather than each against its own
expectation.
Two existing tests encoded the old behaviour. One asserted "[auto] as the only
effort option on an unsupported model" -- the reported bug, written down as an
expectation; an uncatalogued model is not an unsupported one, and its levels are
forwarded. The other pressed enter expecting to land on auto, which only held
while auto was the sole row; it now selects auto deliberately and asserts the
preselection instead.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
TWO SEPARATE THINGS FROM DRIVING A REAL PLAN.
Finished tasks now drop out of the panel after a 5s linger, so it tracks live
work instead of accumulating a transcript of it. Long enough to see a task land,
short enough that a six-task chain does not fill the footer with history.
Matches how the AGENTS sidebar already retires finished agents.
A faded task is hidden, not forgotten: the header still counts it, and /plans
still lists the whole plan. That matters, because the dependency shape fades
with the tasks -- a diamond stops looking like a diamond once its first task
goes. /plans is where the shape stays readable for the whole run, and it names
each task's dependencies explicitly rather than relying on indentation.
/effort NOW SAYS WHAT IT ACCEPTS.
On glm-5.2 the card reported "available: not listed - model is not in Zero's
catalog" and stopped. Nothing typeable, on the model this user actually runs.
And on a catalogued model it listed low/medium/high while omitting zeromaxing --
which is selected through this very command, and which the actions line already
offered.
"available" (what the CATALOG vouches for) and "you can set" (what the command
accepts) are different questions and were conflated into one line. They are now
two fields.
The distinction is enforced, not decorative:
- catalogued with a ring -> exactly that ring
- catalogued with NO ring -> nothing but the posture. gpt-4o genuinely has no
reasoning controls and the command refuses every
level there, so offering them would advertise
what the command rejects
- not catalogued -> low/medium/high, because Zero cannot vouch
either way and the headless path forwards them
- posture disabled -> the posture is dropped from BOTH the list and
the actions line, since suggesting a command the
run will refuse is worse than saying nothing
The card and the command are two doors onto "which efforts can I set", so the
parity test feeds every advertised value back through the real command rather
than comparing two lists -- a list comparison would pass while the command
rejected all of them.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two changes from driving a real six-task chain.
THE TOKEN BUDGET IS NOW OPTIONAL, AND OMITTED MEANS UNBOUNDED.
max_tokens was required and capped at 200_000, and a chain asking for exactly
that spent 469,555 -- 2.3x over -- and still lost its last task. The check runs
only BETWEEN tasks: a task is dispatched whenever any budget remains and then
spends whatever it spends, so the cap bounded nothing while stopping real work
from finishing. A number that neither bounds spend nor lets a heavy plan
complete is worse than no number, because it reads like a guarantee.
Spend is still METERED and reported everywhere it was -- PlanReport.TokensUsed,
the plan_completed event, the panel footer -- so an unbounded plan is not an
unmeasured one. A caller that wants a bound still sets max_tokens and gets the
same dispatch-time behaviour as before, asserted by test in both directions.
This removes the only ceiling on what one orchestrate call can spend. Under the
zeromaxing posture a twenty-task plan authorises 6,400 child turns. That is the
user's decision, taken after seeing the cap fail to do the job it existed for.
THE PANEL COLLAPSES.
A six-task chain took seven footer lines for the whole run, pushing the
conversation up for detail that is one keypress away. The header alone is the
state; the tasks are detail. Click the header line to open it -- the interaction
that was actually asked for -- or Ctrl+G.
NOT Ctrl+O, which already toggles the detailed transcript view: the first
attempt bound it there and the existing handler silently won. Not Ctrl+P
either, which belongs to the update_plan panel.
The dependency indent is capped at five levels. A twenty-task chain adds a rung
per link, which would indent forty columns and run off a narrow terminal; the
depths still reflect the real graph, only the drawing stops. Past the cap every
task sits at one depth, which is honest for a chain.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A read-only run carried ~5 KB of confirmation policy governing actions it could
not take. Measured on a real plan-task child: 8,365 bytes of the 17,903-byte
system prompt -- 47% of it -- were policy for capabilities the child provably
lacked (no write, no edit, no shell, no ask_user), and the system prompt is 71%
of that request. Multiplied by every task in a plan and every specialist
sub-agent.
Keyed on the RESOLVED TOOL SET, never a name or a manifest flag, and fail closed
at every step: a nil registry, a run holding nothing, and any tool whose effect
is not positively read-only all keep the policy. EffectUnknown -- MCP tools,
plugin tools, the specialist tools -- keeps it, which is why an ordinary session
is untouched.
Two things this got wrong first, both caught rather than reasoned away:
It used ToolVisible, which applies the permission mode's ADVERTISING rules on
top of the operator filters. A write tool auto mode does not advertise is
still held and still callable once approved, so a run launched with
--enabled-tools read_file,grep,glob,write_file counted as read-only and lost
its policy -- a fail-OPEN exactly inverse to the purpose. Found by driving the
binary; every unit test passed at the time. It now asks ToolAllowedByFilters,
which answers "does this run hold the tool".
Reading the registered tool set made registering a posture-gated tool change
the prompt with the posture OFF, breaking the additive-only guarantee.
TestPostureOffPrefixUnchangedByRegisteringTheTool caught it, which is what
that test exists for. A tool the run can never call now says nothing about
what the run can do -- and a tool that varies its permission by argument is
never ruled out from its static safety, however that reads.
Verified on the wire: --enabled-tools read_file,grep,glob drops it (12,310-byte
prompt); adding write_file or bash keeps it (18,789). Additivity re-proven
byte-identical across all five permission modes.
Only the confirmation policy is gated. The remaining ~3.3 KB (Editing
discipline, Testing gate, Permission and safety) lives inside system_prompt.md
and needs that file split, which touches the prompt every run shares. Separate
change.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
planReadOnlyTools bounds what a plan task may hold; readOnlySpecialistTools
decides whether a specialist counts as read-only for the Task tool's permission
gate. Different questions, same answer -- and they disagreed: lsp_navigate was
in the first and not the second, so a manifest holding exactly the plan grant
failed the read-only check.
The omission was chronology rather than a decision. readOnlySpecialistTools was
written in #243 (2026-06-18); lsp_navigate arrived in #276 two days later and
nobody went back. The tool declares EffectReadOnly with the safety reason "reads
files, modifies nothing" and resolves its path through the same scoped
confinement its read-only siblings use, so it belongs in the set.
Pinned as a SUBSET, not equality, and the direction is the point: anything a
plan task may hold must be something the wider gate also calls read-only. The
reverse is not required -- update_plan is read-only for a specialist and
deliberately not a plan tool.
NOT derived from the tools package's declared capabilities, which would be the
real class fix: update_plan is EffectInteractive, so deriving the set would
silently drop it and change the Task tool's permission gate. That is its own
change with its own reasoning, not a drive-by.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Launching a specialist and resuming one are two doors onto the same question --
which model does this specialist run on -- and they answered it differently.
BuildResumeArgsInput carried no model fields at all and BuildResumeArgs never
called appendModelArgs, so a resumed child was launched with no --model and fell
back to whatever its own config resolved. The same specialist could change model
between its first turn and its second.
Verified by building both argument sets side by side rather than by reading:
FRESH args: exec --init-session-id … --model parent-chose-this …
RESUME args: exec --resume … (no --model)
The fix calls the SHARED helper rather than repeating its rules, so the
manifest-pins-its-own-model precedence is applied once and cannot drift between
the two paths.
The relationship the regression test asserts is EQUALITY (RULES.md §3): fresh
and resumed launches of the same specialist must produce the same model and the
same reasoning effort. A second test drives Executor.Run with Resume set, since
the builder having the fields proves nothing if the call site never fills them
-- which is exactly how the two paths diverged.
PRE-EXISTING main defect, not ZeroMaxing: it affects every resumed specialist.
Separate commit so it can carry its own issue.
Note for Stage 2c: runResume also resolves the manifest by session.AgentName and
ignores params.Manifest, which is why inline-manifest children are unresumable.
Same function, still open, deliberately not fixed here.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The struct comment always claimed a plan task "inherits exactly the parent's
policy". It did not: both production call sites left PlanTaskContext.ParentModel
empty, so appendModelArgs received no parent model and passed no --model, and
the child fell back to whatever its own config resolved. After a /model switch
or a --model flag, plan tasks ran on a different model than the run that
launched them.
Decided: the documentation states the safer behaviour, so the code moves to
match the doc. Explicitly NOT a router decision -- choosing a cheaper model per
task is a real future feature and would arrive as an explicit plan field on top
of correct inheritance. Silently failing to propagate is not that feature.
The fix is not "populate the field at registration", which would have been
wrong for the TUI: its registry is built once per session while /model changes
the model between runs, so a captured value is stale by design. That is why the
fields were empty rather than merely unset -- they were in the wrong home.
ParentSessionID/ParentModel/ParentReasoningEffort now travel per call on
PlanTaskRequest, read from the same tools.RunOptions fields the Task tool reads,
and are gone from PlanTaskContext so there is no second source to drift from.
Before: parent ran --model parent-chose-this, child reported stub-model.
After: child reports parent-chose-this. Asserted per call, so a second
orchestrate call with a different model launches on that model.
The parent session id travels with it, which is what links a plan task back to
the run that spawned it rather than leaving it an orphan.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Gate 1a made a plan's tasks visible as cards. Cards cannot show the one thing
that makes a plan a plan: its shape. This adds the panel.
The shape is carried on planAdmittedMsg, not assembled as tasks finish.
ParsePlan already proved the graph acyclic and computed the topological order,
so the panel is complete before the first task starts rather than growing a
node at a time. Each task is ranked by dependency depth -- one more than its
deepest dependency -- and indented by it, so a diamond reads as one node, two
beside each other, one node. A chain steps in once per link; independent tasks
share a rung. All three are asserted.
Mounted above the pinned update_plan panel, since it describes work running
right now. It renders NOTHING when no plan has been admitted -- not an empty
box, not a blank line -- which is what keeps a posture-off session unchanged:
the panel never runs, rather than running and producing nothing.
Named orchestrate throughout. /plan (update_plan's TODO list) and /plans (this)
are genuinely different things with near-identical names, so every type, field
and function here says orchestrate and only the user-facing command says plans.
The command lists every task including any the pinned panel had to drop, and
names each task's dependencies explicitly, so the shape survives copied text and
narrow terminals where indentation does not.
Bounded at 12 rows and it states what it hid: a silently truncated list reads as
a complete one. Summaries cut on rune boundaries. The full result stays in the
tool output -- the panel summarises, and a display formatter on a data path is
how a 583-rune work product became 200 mangled runes.
The header clock stops when the plan ends, and when the run ends without a
terminal plan event. It previously kept counting while the turn that produced
the plan carried on, so a 20-second plan read as minutes.
TestSuggestionOverlayCapsRowsWithoutMoreText pinned the first and last entries
of the command palette's first window; inserting /plans pushed /ps out of it.
The window's start and size are unchanged, so the tail anchor moves to
/permissions and the comment says why it is incidental.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan ran silently: sequential, synchronous, no output until it finished. Four
things stood between the existing live-progress machinery and the plan tool,
all of them wiring rather than missing capability.
loop.go gated the progress callback on `call.Name == "Task"`, so the second
sub-agent-spawning tool got nil. Keyed now on the TOOL'S OWN DECLARATION
(tools.ChildProgressStreamer) rather than its name: `|| call.Name ==
"orchestrate"` would have been the same defect one name later, which is this
codebase's most repeated shape. Every tool that does not declare keeps the
nil callback it has today -- swarm tools included, deliberately.
NewPlanRunner built TaskRunOptions without Progress and RunWithOptions never
read options.Progress. Either half alone leaves a plan's children streaming to
nobody, so they are ONE change with one end-to-end test. This is finding 7's
class again: a second construction path that does not carry what the first one
did. PlanRunner now takes a PlanTaskRequest struct rather than a widening
parameter list, because each new positional argument is another chance for
that omission.
app.go never set Recorder, so the TUI recorded NONE of the five plan
lifecycle events (finding 9). The recorder is now a required POSITIONAL
parameter of registerSpecialistTools, like the operator filters: forgetting it
is a compile error rather than a silent nil.
TaskCancelled is a distinct outcome. Cancellation marked every remaining task
failed with "context canceled", so a deliberate Ctrl-C on a twenty-task plan
read as nineteen defects. PlanCancelled joins it as a terminal status, and
terminalStatus counts it -- without that a plan with two successes and two
cancellations reported "completed", which is RC-F exactly.
Also fixes a live render-cache collision this change would have made certain.
A rowSpecialist row carries no id and no text; every distinguishing field lives
behind row.specialistInfo, and none of it was in the cache key. Two specialist
cards in one run hashed identically, so a failed sub-agent rendered as a
successful one. Rare with Task (one child per run is typical), guaranteed with a
plan. Proven by test before fixing, then asserted with four cards including a
failure and a cancellation.
Additivity re-proven: posture off, all five permission modes byte-identical
against an origin/main binary with DeferThreshold unset, hashes unchanged.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
RULES.md §3: for every rule you touch, find its sibling and prove by test that
both agree -- one test comparing the paths on the same input, not two tests
each asserting their own expectation. The authority rule has two enforcement
points and Workstream A changed both; two sites fixed together is exactly the
shape that drifts apart later.
The relationship asserted is SUBSET, not equality, and the direction is the
point. Admission is stricter: it rejects a task naming anything outside the
grant. Dispatch narrows: it drops such a tool and grants the rest. Requiring
them to refuse identically would assert a symmetry the design does not want --
dispatch only runs on an already-admitted task, so when it disagrees its job is
to hand over less, never more.
Mutation-checked: dropping the parent check from the dispatch intersection is
caught with "the second layer widened authority", which is the defect the
"BELT AND BRACES" comment promises cannot happen.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
PlanContext.Depth was unset at both call sites, so Limits.CurrentDepth and the
executor's own nesting check both measured a constant zero. The admission
headroom check could never fire. It was only harmless because plan tasks run at
--auto low, where specialist tools do not register, so nothing could re-enter
-- an inert guard that reads like a live one, which is the shape someone later
trusts.
Wired rather than deleted: the value exists (--depth), it costs two lines, and
the check it feeds is the one thing standing between a nested run and
maxSpecialistDepth if a future phase ever lets a plan task spawn.
The TUI passes an explicit 0 with a comment: it is always a root session, so
zero is the measured value there, not an unset one.
Proven at the process boundary rather than by a unit test, since the defect was
the call site and not the helper: before, `exec --depth 7 --exec-profile
zeromaxing` ran a four-task plan; after, it refuses with "a plan's tasks would
run at depth 8, which reaches the maximum nesting depth of 8", while --depth 6
still runs.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
task_completed carried session_id and tokens; task_failed carried neither. So
the one task a user needs to open was the one task the event log could not
point them at -- and the per-task token records did not add up to the plan's
own total.
Executor.Run already goes out of its way to preserve the child session id on a
post-start failure, with a comment saying it exists so a failed child stays
drillable. The recorder discarded it one layer up.
A dependency or budget skip records an empty session id rather than omitting
the field: that task never started a child, and present-and-empty says so,
where an absent field would look like the same drop being fixed here.
The regression test compares the two events against EACH OTHER. Both already
had tests and both passed; nothing compared them, which is how the asymmetry
survived.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
reconcileProfileAfterModelSwitch called reasoningEffortAllowed directly while
the fill site called profileEffortApplies, added in 009f12b precisely to stop
treating an uncatalogued model's empty ring as a refusal. The two disagreed on
exactly that case, which is both of this user's configured models (glm-5.2 and
grok-4.5 are catalogued=false with an empty ring): selecting the posture on one
of them filled high, and switching to one with the posture already active
dropped the effort to auto and reported it as a level the model would not take.
e782470 was meant to fix this class at the cause across all consumers. It
updated one door and not the other -- the seventeenth instance of the pattern,
introduced by the fix for the sixteenth.
The rule is now ONE function, profileEffortAppliesOn, that both doors call, and
the ring's authority is passed through rather than inferred from its emptiness.
reconcileProfileAfterModelSwitch takes ringKnown, which its caller already had
and did not forward.
The comment at command_center.go described the old conservatism as deliberate
("only ever applies where support is known"). That is now the bug's
description, so it is replaced rather than left for the next reader to restore.
TestProfileEffortDoorsAgree asserts the doors against EACH OTHER across
catalogued / catalogued-with-no-ring / uncatalogued / no-model. Both doors had
tests before and both passed; nothing compared them, which is why the
disagreement shipped.
Three existing tests used kimi-k2.7-code:cloud as their "unsupported"
destination -- an UNCATALOGUED model, so they were asserting the defect. They
now use gpt-4o, which the catalog vouches has no reasoning controls, so the
genuine drop case stays covered.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The rule "a plan task may narrow the parent's grant, never widen it" was
declared, documented and validated against -- and both production call sites
left ParentTools nil, so neither the validation check nor the dispatch-time
intersection ever ran. A run restricted to grep handed a plan task read_file.
The empty case was worse. planToolGrant returned an empty slice when a task
declared no tools and the parent grant was empty; resolvedToolAllowlist read an
empty tool list as UNSPECIFIED and expanded it to the default read-only
category. The narrower the parent, the wider the child -- on the default path,
where a task declares no tools at all.
Three changes:
- The grant is now DERIVED inside registerSpecialistTools from specialist's
own exported read-only list, narrowed by the registry and the run's
operator filters. The filters are explicit parameters rather than wiring
fields, so a call site cannot omit them without failing to compile.
- Both intersections are unconditional. The "skip the check when the parent
list is empty" escape hatch is what made the rule inert; an unsupplied
grant is now a wiring bug that fails closed.
- Manifest gains ToolsResolved, so a resolved tool list can say
"deliberately nothing". A bare []string cannot: absence and emptiness are
both len()==0, and omitempty erases an empty slice entirely, so a nil check
would not survive a round trip. Same shape as audit finding M24. The flag's
meaningful value is true, so omitempty only drops the meaningless false.
planToolGrant now refuses an empty result rather than returning one, and
ExecutePlan records that as a task failure with a reason without dispatching
it, so an ungrantable plan reports partial/failed and exits 4.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Closes the two gaps left by the registration step.
TUI GATE. handleProfileCommand and revertExecProfile write the shared
PostureGate on every transition, threaded through tui.Options in the same shape
as ZeromaxingDisabled. What is proved without a TTY:
- the gate is written on posture ON and OFF through the REAL handlers, for
BOTH entry points (/effort and /profile) — not through a helper, which is
what missed the wiring four times in this feature
- a REFUSED selection (disabled workspace) leaves the gate off
- a nil gate is safe, so a caller that never wires one simply has no tool
- THE cloneToolRegistry HAZARD, proved rather than assumed: a tool reached
from a CLONE taken BEFORE a flip still observes the flip. That is the exact
ordering a captured copy or a closure over this value-typed model would get
wrong, and it is why the gate is a pointer
- concurrent Set/read, for -race: the TUI writes from its update loop while a
run's tool dispatch reads from the agent goroutine
FAILING-TASK VERIFICATION. The stub gained a prompt sentinel that makes one
specific child fail with a real provider-stream error, so a dependency failure
runs end to end rather than being asserted only at the executor. A 4-task
diamond where b fails now reports, verbatim:
Plan partial: 2 succeeded, 1 failed, 1 skipped max_speedup: 1.49x
- a [succeeded] full result verbatim
- b [failed] provider error: sentinel failure
- c [succeeded] independent sibling unaffected
- d [dependency_failed] skipped: dependency "b" did not succeed
Run incomplete (not reported as success) EXIT=4
Every level of the RC-F seam is now covered by something that can fail:
executor status, tool status, recorder, and the process exit code.
THE BUDGET DEFECT GETS ITS OWN GUARD. TestRealRunnerFeedsTheBudgetMeter drives
NewPlanRunner over an Executor whose child reports usage, and asserts a
max_tokens=1 plan launches exactly one child. A fake runner that fabricates its
own token counts cannot catch a producer that never populates them — which is
precisely how the defect survived every executor test. A second test pins that
the runner honours the per-call context rather than a captured one.
registerSpecialistTools regains its original name and takes the wiring
argument directly; the transitional wrapper is gone now that both call sites
pass wiring, so no dead indirection ships.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Wires Phase 2 into the real binary and maps a plan that did not fully complete
onto the existing incomplete exit path.
POSTURE CALLBACK — a POINTER, not a closure. The TUI model is a VALUE type
(every handler takes and returns `m model`), so a func() bool closing over it
would capture a copy frozen at registration and report the posture as it was
when the session started, forever. Re-registration fails for the reason
decision 2 rejected it earlier and worse: the TUI clones the registry per run
and the clone copies tool POINTERS, so a replacement registered into the session
registry would never reach a run already holding a clone. specialist.PostureGate
is one shared atomic flag the tool holds for the process's life; every clone
sees the same one and a posture flip needs no re-registration.
PLAN RUNNER — captures only run-INVARIANT state (executor, workspace, parent
identity and policy) and NO context. The ctx it uses is the one ExecutePlan
hands it per task, so a cancelled run cancels the task in flight; it also checks
ctx.Err() before launching, so a cancelled plan does not spend another task's
budget. Capturing a context at construction is precisely how the prototype's
background goroutine kept running after cancellation. The runner is synchronous
and holds no goroutine, so it cannot outlive its plan.
PLAN RECORDER — preserves execSessionRecorder's best-effort contract exactly.
Every bridge method returns nothing, so a recording failure has no path back
into ExecutePlan and cannot abort a plan mid-flight; nil is a no-op at every
level. The bridge is built before the session exists and its inner recorder
attached afterwards, so events before that point are simply not recorded rather
than fatal.
PARTIAL -> EXIT 4, folded into the EXISTING result.Incomplete path rather than a
second exit route, so a partial plan and a stalled loop report the same way. It
never overrides an incompleteness the loop already found.
A DEFECT FOUND BY DRIVING THE BINARY, not by any test: the budget was enforced
at dispatch against a counter that never moved. NewPlanRunner never populated
TaskResult.Tokens, so a plan with max_tokens:1 ran all three tasks. The unit
test passed because its fake runner fabricated its own token counts — the same
"test the helper, miss the wiring" pattern that produced the flag-parser hole,
the self-correct capture point, and the identity test's wrong gate.
ExecResult gains an additive TotalTokens, populated from the stream summary the
accounting path already computes.
Verified at the real surface: orchestrate absent with the posture off and
present as [shell/allow] with it on in the same auto-mode session; a 4-task
diamond running in topological order with results verbatim and max_speedup
1.33x (hand-checked: four equal tasks over three hops = 4/3); cyclic, write-tool,
max_workers and missing-budget plans each rejected with the offending ids or
values named; and budget exhaustion reporting partial with `echo $?` showing 4.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
ZeroMaxing Phase 2: an `orchestrate` tool that accepts a plan as typed tool
arguments, validates it, records it as session events, and runs it SEQUENTIALLY
through the same specialist path a Task call uses. The deliverable is DATA — is
fan-out worth building at all — not concurrency.
ADDITIVE. With the posture off, the advertised tool set, the tool-definition
bytes and the assembled prompt are byte-identical to a build without this
feature, proved by TestPostureOffPrefixUnchangedByRegisteringTheTool: two
registries differing only in whether the tool is registered, asserted equal
under BOTH auto and unsafe. The enforcing mechanism is Safety() returning
PermissionDeny, not Deferred() — deferral only hides anything when it is ACTIVE,
and an unsafe session with deferral inactive would otherwise have advertised the
tool. Deferred() stays as a second layer and is now asserted rather than assumed.
VALIDATION CANNOT BE SKIPPED. Plan's fields are unexported and ParsePlan is the
only constructor, so there is no path from tool arguments to an executable plan
that bypasses it — the prototype's validator was reachable, correct, and never
called. Rejected by default: ids by ALLOW-LIST charset, unique; every depends_on
resolves (unknown edges rejected, never skipped); the graph is acyclic by Kahn's
with the involved ids NAMED (audit U24 — nothing in this tree checked, and a
cyclic graph hangs forever); task count capped; tools read-only and within the
parent's grant; a budget with max_tokens required and max_workers exactly 1,
REJECTED rather than coerced so the field stays meaningful for Phase 3; and
depth checked at admission with the remaining headroom named, rather than an
opaque failure mid-plan.
ONE counting function — a length, not a text scan. The prototype counted source
text, so `agent ("x")` with one space counted as zero and executed anyway.
BUDGET ENFORCED AT DISPATCH, not merely validated. Under this posture every
child inherits a 320-turn ceiling, so a twenty-task plan authorises 6,400 child
turns from one tool call. The grant is intersected again at dispatch too, so a
validator bug cannot widen a task's authority.
FAILURE IS NOT SUCCESS. A failed task skips its transitive dependents, each
RECORDED with the dependency that blocked it; independent siblings still run;
the plan runs to exhaustion. Partial is its own terminal status and the tool
returns StatusError for it, so nineteen of twenty failing can never surface as a
clean result (audit RC-F).
THE METRIC is value, not safety: max_speedup = sequential_total / critical_path,
computed from recorded per-task durations over the declared DAG and surfaced in
the summary and in Meta. Kill criterion: median >= 2.0x across >= 20 real plans,
or Phase 3 is not built. Independence-violation would answer safety, but
read-only tasks are always safe to parallelise, so it would read 0 and decide
nothing.
PermissionAllow when the posture is on, deliberately, with the reasoning in the
code so Phase 3 does not inherit it blind: the approval surface renders only the
tool name and a static sentence — PermissionRequest carries Args but no renderer
reads it — so a prompt could not show the plan it was gating and would train
click-through. What bounds it instead is enforced, not advisory: read-only at
validation and dispatch, a required budget enforced at dispatch, and the posture
itself as explicit consent. When tasks can WRITE, an approval gate becomes
necessary and needs a real renderer first.
Plan state is recorded as five ordinary session events beside the specialist
ones; no new store. Recording is best-effort and never fails the run.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
PRE-EXISTING defect, surfaced while investigating the zeromaxing posture and
verified on origin/main. It is not something the posture introduced.
availableReasoningEfforts() returns an EMPTY ring for two very different
situations: a catalog model that genuinely has no reasoning controls (gpt-4.1),
and a model with no catalog entry at all (the reporter's glm-5.2 on
ollama-cloud, any custom openai-compatible endpoint). Every TUI consumer treated
the second as if it were the first.
That single fact produced two visible failures at once:
- /effort listed no levels and reported "no reasoning controls on this model"
- /effort high was REFUSED outright
- and it is why a profile's effort fill was declined with "the model does not
support that level"
Meanwhile the headless path has always disagreed. On origin/main, for the same
glm-5.2: forwardedReasoningEffort returns "high" and emits no notice, because an
unknown model "forwards the requested value as-is, since no support claim can be
made for it". So the CLI forwarded the level while the TUI refused to let the
user set it — one rule, two answers, predating this feature.
There are THREE consumers of "does this model take this level?": a manual
/effort, a profile's fill, and the headless forwarding decision. They now share
one rule — a catalog entry is authoritative and refuses; no entry means Zero
cannot vouch either way and does not block. TestEffortSettabilityAgreesAcross-
AllThreeConsumers pins that they agree, per model, including the reporter's.
/effort also distinguishes the two states in its output, because "this model has
no reasoning controls" and "Zero has no entry for this model" are different
facts and rendered identically before. Setting a level on an unlisted model now
says plainly that support is unconfirmed and an unsupported value is ignored by
the provider.
The regression tests drive the real /effort path rather than the helper. A green
helper test alongside an empty user surface is how this feature has now produced
four defects: the flag-parser hole, the self-correct capture point, the identity
test passing through the wrong gate, and this.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Three defects found by running the real binary, all in what the posture reports
or applies.
1. THE FILL SILENTLY DID NOT HAPPEN on a custom endpoint. /effort zeromaxing
left the effort at "auto" while --exec-profile zeromaxing on the SAME model
sent reasoning_effort:"high" on the wire. Same posture, same model, two
answers — the fifteenth instance of one rule applied to one of two call
paths.
The fill site used reasoningEffortAllowed, which returns false both for a
model KNOWN to lack the level and for a model the catalog has never heard of.
The headless path already distinguishes them (an unknown model forwards the
requested effort "since no support claim can be made for it"), and so does
the TUI's own model-SWITCH site, which takes a ringKnown flag for exactly
this reason. Only the profile-fill site conflated them.
profileEffortApplies now fills when the model lists the level OR when no
catalog entry exists, and declines only when an authoritative empty ring says
the model has no reasoning controls. A model name that is empty is a third
case and still declines — there is nothing to make a support claim about.
That last distinction was caught by the pre-existing fast-posture test, which
the first version of this fix broke.
THE HOLE THAT HID IT: every test asserted a surface against its OWN
expectation — the TUI tests against the TUI's rule, the CLI tests against the
CLI's. Both suites were green while the two paths disagreed, because nothing
compared them against each other for the same model. A unit test on either
helper could not have caught it.
TestProfileEffortFillAgreesWithTheHeadlessPath is that missing comparison,
run across a catalog reasoning model, an inferred one, a catalog model with
an authoritative empty ring, and an unknown endpoint.
2. THE OUTPUT CONTRADICTED ITSELF: "reasoning effort: unchanged — already at
the highest level" and "NOT raised to high: the active model does not support
that level", in the same card. Both cannot be true. The fixed clause lived in
Delta while the refusal lived in a separate line, so they were only ever
consistent by coincidence.
Delta now renders every clause from one DeltaState, with the effort clause a
single switch over EffortTransition. A contradiction is unrepresentable
rather than merely unlikely, and the separate line is gone.
3. THE BUDGET CLAUSE compared against thorough — "(thorough uses 160)" — which
is information about two profiles, not about the user. It now states the
caller's own transition, "turn budget: 80 → 320", with distinct renderings
for an already-at-320 session and an unknown current budget.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two corrections to what zeromaxing tells the user, both cases of documentation
describing behaviour that is not happening.
1. --spec-reasoning-effort zeromaxing stays rejected — it is a run posture and
has no meaning for a spec draft — but the generic "Expected low, medium, or
high" read like a bug to anyone who had just learned the name works on
--reasoning-effort. It now says why it does not apply and names both flags
that do what the user was reaching for. A genuinely unknown value keeps the
plain message: the explanation is specific to the posture name, not a new
blanket wording.
2. Delta's self-correct clause described the change from THOROUGH, not from the
caller. Telling a user sitting on the LSP-only default that self-correction
is "already armed" while silently moving them to the full project test plan
is exactly the failure this posture's honesty rules exist to prevent. Delta
is now a function of the caller's actual state and renders one of three
transitions, in /selfcorrect's own vocabulary:
self-correct: lsp -> tests (the common case)
self-correct: unchanged (tests) (genuinely unchanged)
self-correct: lsp (your /selfcorrect choice ...) (user override wins)
The third exists because the TUI lets a user turn it back off after selecting
the posture; reporting a raise there would be a third way to describe
behaviour that is not happening. The budget and effort clauses stay fixed —
effort genuinely is caller-independent, since "high" is the ceiling every
provider accepts.
The headless path captures self-correct BEFORE applyExecProfile, which arms it
as a side effect; reading it afterwards would make every run report "unchanged".
The TUI reads its LIVE state rather than selection-time state, so a later
/selfcorrect off stops the status claiming a raise the session no longer has.
A unit test on the transition helper cannot catch the capture being read at the
wrong moment, because it never runs the code that captures — so the ordering is
pinned by a test that drives the real exec path and reads stderr. That gap was
found by mutation, not by review.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
/effort zeromaxing is the primary way in; /profile zeromaxing is its alias. It
is handled beside "auto" with an EARLY RETURN, before the ReasoningEffort
conversion and before ValidReasoningEffort — it is a posture name, not a
provider level, and must never become one.
It DELEGATES to handleProfileCommand rather than re-applying the knobs, so
"both entry points resolve identically" is true by construction instead of by
two implementations a test hopes agree. The CLI mirrors it:
normalizeZeromaxingEffort folds --reasoning-effort zeromaxing into the
equivalent --exec-profile selection before mode/profile expansion, leaving the
documented precedence ordering untouched. A conflicting --exec-profile is a
usage error rather than a silent winner.
THE LOAD-BEARING GUARD: forwardedReasoningEffort refuses to forward the posture
name for any model, including catalog-unknown ones where unrecognized values
otherwise pass straight through. Normalization already prevents it reaching
there; the guard is what makes a regression upstream fail loudly instead of
sending a provider a parameter value no provider defines.
"max" stays RESERVED and unchanged through all of this: the flag parser still
rejects it, the normalizer passes it through untouched, and /effort max still
reports it unsupported exactly as before.
Driving the real binary is what found the one real gap here: the flag parser
rejected "zeromaxing" outright, so the entire --reasoning-effort entry point was
dead at the user surface while every unit test passed, because they called the
normalizer directly. TestReasoningEffortFlagAcceptsZeromaxingButNotMax is that
regression, and it fails in both directions.
Degrade honestly — this closes a real asymmetry. exec already told the user via
reasoningEffortNotice when a model could not take the requested effort; the TUI
skipped the fill in SILENCE. Same rule, one of two call paths. Both status
surfaces now render ONE shared resolved-state line
("effort: high · profile: zeromaxing · turns: 320") plus the real delta, so a
user sees what actually reached the provider rather than just a posture name.
reconcileProfileAfterModelSwitch is the sibling re-derive site and records the
same reason, so a switch that drops the effort cannot leave the status implying
a raise the run is not making.
revertExecProfile gains a FOURTH knob. It is knob-by-knob, which is exactly
where a new one gets forgotten, so the posture's restore is asserted alongside
budget, effort and self-correct — and gated on zeromaxing specifically, so
/effort auto under fast/thorough keeps its existing meaning of "clear the
effort" instead of dropping the whole profile.
The footer carries a ZEROMAXING chip beside the effort chip while the posture is
on, and drops it while Exiting — by then the posture is already off and only its
announcement is pending.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Four model-facing notices tell the model when its own posture flips: enter
(once, on the first turn after activation), a budget guideline beside it,
still-on (every continuing turn), and exit (once, after deactivation).
WHERE THEY GO IS THE WHOLE DESIGN. Every notice is appended to the CONVERSATION
tail as a user-role message, on the same channel as the existing diagnostics
nudge and the failure/plan hints. None of it reaches buildSystemPromptParts.
The system prompt and tool definitions are the provider's cached prefix — the
anthropic mapper puts its cache_control breakpoint on the last system block and
the last tool — and #760 made that prefix build once per run precisely so it
stays byte-identical across turns. Per-turn text above that breakpoint
invalidates the cache every turn, roughly doubling input cost, and nothing in
the system reports it.
So ZeromaxingStillOnNotice is a fixed literal: no turn counter, no remaining
budget, no timestamp. A test asserts it carries no digits at all, because the
hazard is not today's placement but a future edit that adds a varying part and
then moves the text into prompt assembly.
The reminders also promise NO orchestration — no workers, no fan-out, no
workflow tool. Phase 1 ships none of that, and a prompt advertising capabilities
the run does not have is a prompt-level lie the model will try to act on. A test
enforces the vocabulary.
The state machine is one pure function of (posture, turn) with no memory, so
"enter exactly once" and "still-on never on the first turn" are assertions about
a table rather than about observed side effects.
Proof, both shapes of the hazard, each caught by a DIFFERENT assertion:
- per-turn text written into messages[0] -> the prefix-extension assertion
- static text baked into the system prompt -> the explicit leak assertion
TestRunPreservesRequestPrefixAcrossTurnsUnderZeromaxing is the zeromaxing
variant of the existing prefix test; the openai one is its wire-level sibling,
proving the mapper serializes an append-only conversation into an append-only
request body. A third openai test pins that the posture name can never appear in
a serialized request at all.
Options.Zeromaxing is separate from Options.Profile because the posture arms no
escalation triggers, so Profile.Policy() returns nil for it and could not carry
this. Its zero value is Off, leaving every existing caller byte-identical.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
zeromaxing is thorough with double the turn budget again: 320 tool-turns, effort
high, self-correction armed. It fills only what the caller left unset, exactly
as the other profiles do, so an explicit flag and a --mode preset both still win
— precedence stays enforced by ordering, not by a new check.
ONE NAME. /effort zeromaxing, /profile zeromaxing, --exec-profile zeromaxing and
--reasoning-effort zeromaxing all name the same thing, and nothing else does.
No aliases: "max", "deep" and "deepmode" resolve to nothing, asserted by test,
because a second spelling is a second thing to keep in step.
"max" is RESERVED and untouched. ValidReasoningEffort still accepts
ReasoningEffortMax, so /effort max and --reasoning-effort max behave exactly as
they did — parsed as a raw provider level, rejected by the flag parser, and
unsupported by every current model. That spelling stays free for a real provider
rung; claiming it for a Zero posture would burn it.
HONEST DELTA, stated to the user rather than left in a commit message: the turn
budget is the ONLY mechanical change over thorough. Effort is already at the
ceiling and self-correction is already on. execprofile.Delta carries that
sentence to /effort, /profile and the exec selection notice.
ReasoningEffort is "high", pinned by test rather than by comment.
TestZeromaxingReasoningEffortStaysHigh explains why: every level above "high"
falls through the providers' effort mappers into their default arm — anthropic
and gemini both compute a thinking budget of 0 (extended thinking DISABLED) and
the openai mapper drops the field entirely. Raising it would silently turn
reasoning off while the UI claimed a higher posture. Raising the real ceiling
means teaching the providers those tiers first.
The raised budget propagates to spawned sub-agents. Deliberate — this is a
maximal posture — so it is asserted by test and stated in Delta rather than left
to be discovered.
SelectionRefusal is the single authority both selection paths consult. The
headless exec path and the TUI paths already apply a profile's knobs through
different code with different state; letting each decide SELECTION independently
is how a rule gets applied to one call path and omitted from its sibling. Config
gating follows mergeProjectConfig's tighten-only template: a project
.zero/config.json may set profiles.disableZeromaxing (DISABLE) but can never
clear it, so a cloned repo cannot switch a cost multiplier on for whoever opens
it — an attempted enable is dropped silently, exactly like an ignored network
"allow".
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
2026-08-16 09:10:38 +05:30
368 changed files with 76259 additions and 711 deletions
* **providers:** add MiniMax-M3.1-Flash to catalog ([#1180](https://github.com/Twigpine/zero/issues/1180)) ([d99f92b](https://github.com/Twigpine/zero/commit/d99f92b81a671f186cede81614c89b8e8a7f9451))
* **sandbox:** support self-exec pattern to eliminate separate helper binary ([#946](https://github.com/Twigpine/zero/issues/946)) ([8c4e759](https://github.com/Twigpine/zero/commit/8c4e7597989ef9b3ca67b7703dfd15d92372aed2))
* **skills:** install a skill's bundled files and point the skill tool at them ([#1167](https://github.com/Twigpine/zero/issues/1167)) ([1641506](https://github.com/Twigpine/zero/commit/164150633a8b2e6ae864ae02fd7105b201eeb68e))
### Bug Fixes
* **agent:** mark headless guardrail stops incomplete ([#1182](https://github.com/Twigpine/zero/issues/1182)) ([5aa54fe](https://github.com/Twigpine/zero/commit/5aa54fe1d2b419c9bbef73605a77919c9007cf7b))
* bump Go to 1.26.10 for GO-2026-6809 ([#1168](https://github.com/Twigpine/zero/issues/1168)) ([6393a90](https://github.com/Twigpine/zero/commit/6393a90ed618a3536e09c02852f4ec829b97545b))
* **chatgpt:** refresh subscription defaults and compatibility version ([#1179](https://github.com/Twigpine/zero/issues/1179)) ([f912f80](https://github.com/Twigpine/zero/commit/f912f80d002602e11ba0ef8548ecee58442874aa))
* **deps:** refresh the lockfile to hono 4.13.13 ([#1171](https://github.com/Twigpine/zero/issues/1171)) ([de1443b](https://github.com/Twigpine/zero/commit/de1443b2edb44fd3747cc77200d54dc8cffcd0a7))
* **oauth:** reuse the crash-safe secret store and sync encrypted publications ([#1165](https://github.com/Twigpine/zero/issues/1165)) ([84c1b15](https://github.com/Twigpine/zero/commit/84c1b1554076679a342e9cac61f8f8cdbaf3a429))
* **tools:** grep stops where a file's line breaks stop ([#1160](https://github.com/Twigpine/zero/issues/1160)) ([aa9ba84](https://github.com/Twigpine/zero/commit/aa9ba8412eb8917daa2ca5cb3fdbd0a39276fa88))
* **workspaceindex:** import gitsafe under the Twigpine module path ([#1153](https://github.com/Twigpine/zero/issues/1153)) ([ef4da13](https://github.com/Twigpine/zero/commit/ef4da1323f1097863ae278224b5f304c2fff909a))
* **workspaceindex:** run Scan's git calls through gitsafe ([#1149](https://github.com/Twigpine/zero/issues/1149)) ([2ffe183](https://github.com/Twigpine/zero/commit/2ffe1831b0950b1463ec46d487d03a5fcb679cc6))
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
For swarm members the matcher subject is the **agent type** (`subagent`, `teammate`, …), not a registered specialist profile name — swarm members run from an inline manifest keyed by agent type.
`specialistStart` fires once the child process has actually started; a launch that fails before exec (missing binary, spawn error) records no public lifecycle hooks. `specialistStop` fires once per terminal stop: completion, error, explicit stop of a recovered task, and a background task reaped by a later `TaskOutput` all share one durable claim, so a hook that notifies or cleans up does not run twice for the same sub-agent run.
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). `sessionStart` / `sessionEnd` / `specialistStart` / `specialistStop` hooks are advisory (failures are audited but do not interrupt the run). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
@@ -257,6 +261,52 @@ or use a new server name. OAuth tokens are bound to the resolved server identity
so changing a server target can require `zero mcp oauth login <server>` again.
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
#### Built-in servers
Zero seeds two MCP servers before your config is merged. Both show up in
`zero mcp list`, and any field can be overridden in your user config.
-`exa` (`https://mcp.exa.ai/mcp`) is on by default for keyless web search.
`zero mcp disable exa` turns it off.
-`aimlapi` (`https://mcp.aimlapi.com/mcp`, the AI/ML API Toolbox, model
discovery, image/video/audio generation, chat, embeddings and balance tools)
is **off by default**, because its tool runs and generations are billed to
your AI/ML API balance. Turn it on with `zero mcp enable aimlapi`, which
writes only `"disabled": false` to your user config, and off again with
`zero mcp disable aimlapi`. A project config cannot turn it on.
When `aimlapi` connects, Zero sends your AI/ML API key as
`Authorization: Bearer <key>`. The key is resolved again on every request and
is never written to `config.json`. With an aimlapi.com provider profile
(`zero providers setup aimlapi`), the key is chosen in this order:
1. An inline key on the profile (if set in config).
2. Otherwise the environment variable named by the profile's `apiKeyEnv`
(the catalog default is `AIMLAPI_API_KEY`).
3. Otherwise the key held in the credential store when the profile uses
`apiKeyStored`.
When there is no aimlapi profile, Zero uses `AIMLAPI_API_KEY` alone. If both
`AIMLAPI_API_KEY` and a stored profile key exist, step 2 wins over the store
whenever the inline key is empty — MCP requests may bill the environment key's
account, not the stored key.
No automatic key is sent when an aimlapi profile, or any profile reading
`AIMLAPI_API_KEY`, uses a base URL other than `https://api.aimlapi.com/v1`, or
when `AIMLAPI_INFERENCE_URL` is overridden, because that credential may belong
to a proxy. No key is sent when the resolved value is not a single token (for
example a comma- or newline-separated pool), when you set your own
non-empty `Authorization` header, when the server uses `"auth": "oauth"`, or
when project config touched the server. Without a key the server answers 401.
To sign in with your aimlapi.com account instead, set `"auth": "oauth"` on the
server and run `zero mcp oauth login aimlapi`.
Every request to exactly `https://mcp.aimlapi.com` (https, default port,
whatever the server is named, including one you add by hand) also carries the
attribution headers `X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`,
and gets the key under the rules above. Other MCP servers never receive these
headers or the key.
### As a server — expose Zero's tools to another agent
```bash
@@ -301,10 +351,12 @@ Install and manage:
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
zero plugins disable github-pr-review # sets "enabled": false in plugin.json
zero plugins enable github-pr-review # sets "enabled": true in plugin.json
zero plugins remove github-pr-review # alias: rm
```
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting`"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
A plugin is active when it is present under a plugins directory and its `plugin.json` has `"enabled": true` (the default when the field is omitted). Use `zero plugins disable <id>` / `zero plugins enable <id>` to toggle that field without removing the plugin. Pass `--user` to target only user plugins and ignore `./.zero/plugins`; without it, a project plugin with the same id takes precedence. The toggle edits the`enabled` value in place — the rest of the manifest keeps its formatting, field order, and numeric literals — and serializes with `plugins add` / `plugins remove` on the same plugins root. Zero reads plugins when it starts, so the change applies to the next `zero exec` run or interactive session; an interactive session that is already running keeps the plugin tools, hooks, and skills it started with until you restart it.
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
constmaxTurnsAnswer="Agent reached maximum number of turns without a final answer."
constmaxTokensAnswer="Agent reached its token budget without a final answer."
constmaxTurnsFinalAnswerPrompt="You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
// maxTokensFinalAnswerPrompt is the same ending for a different reason, and it
// says which. A run stopped for spend and a run stopped for round trips call for
// the same next action from the model and a different explanation to the reader,
// who otherwise reaches for the wrong lever — raising a turn count that was never
// what ran out.
constmaxTokensFinalAnswerPrompt="You have reached this run's token budget. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
// maxStreamStallRetries bounds how many times a turn that timed out (idle/stall)
// OR hit a mid-stream transport abort (#973) with no answer text yet is re-issued
// on a fresh connection before giving up. Incomplete tool-call previews are
// The zeromaxing execution posture's model-facing reminders.
//
// WHERE THESE GO, AND WHY IT IS NOT NEGOTIABLE
//
// Every string here is appended to the CONVERSATION as a user-role message, at
// the tail, exactly like the failure hint and the plan/progress reminders in
// the turn loop. None of it ever reaches buildSystemPromptParts.
//
// The system prompt and the tool definitions are the provider's cached prefix
// (the anthropic mapper puts its cache_control breakpoint on the last system
// block and the last tool). #760 made that prefix build ONCE per run precisely
// so it stays byte-identical across turns. A reminder that varies per turn and
// lands above the breakpoint invalidates the cache on every single turn —
// roughly doubling input cost — and nothing in the system reports it. Hence:
// tail only.
//
// The same reasoning is why ZeromaxingStillOnNotice is a fixed literal with no
// turn counter, no timestamp, and no accumulated state. It is repeated verbatim
// on every continuing turn. If it ever grew a varying part and someone later
// moved it into prompt assembly, that would be a silent cost bug rather than a
// loud test failure. TestRunPreservesRequestPrefixAcrossTurnsUnderZeromaxing is
// the tripwire.
//
// The wording is written for this project: plain, neutral, describing Zero's
// own posture in Zero's own terms. It deliberately promises no orchestration,
// no fan-out and no workflow tool — Phase 1 ships none of those, and a reminder
// that implies capabilities the run does not have is a prompt-level lie.
// Zeromaxing is where a run sits in the zeromaxing posture's lifecycle. It is
// set by whoever selected the posture (headless exec for a one-shot run, the
// TUI across a session) and read only by the turn loop.
typeZeromaxingint
const(
// ZeromaxingOff: the posture is not active. Every reminder is suppressed and
// the loop is byte-identical to a run that never heard of it. This is the
// zero value, so every existing caller keeps today's behaviour.
ZeromaxingOffZeromaxing=iota
// ZeromaxingEntering: this run is the first since the posture was turned on,
// so its first turn carries the enter notice and the budget guideline.
ZeromaxingEntering
// ZeromaxingActive: the posture was already on before this run began, so
// even the first turn gets the continuing notice rather than the enter one.
ZeromaxingActive
// ZeromaxingExiting: this run is the first since the posture was turned off.
// Its first turn carries the exit notice and nothing after that.
ZeromaxingExiting
)
// The four messages. Exported as constants so tests assert on the same bytes
// the model receives rather than on a paraphrase.
const(
// ZeromaxingEnterNotice announces the flip. It fires once, on the first turn
// of the run in which the posture was selected.
ZeromaxingEnterNotice="The zeromaxing execution posture is now active for this session. "+
"You have a substantially larger tool-turn budget than usual. Use it for depth on the task that was asked: "+
"read before changing, run the checks that would catch a mistake, and follow up on anything you noticed but could not confirm."
// ZeromaxingBudgetNotice states the budget guideline alongside the enter
// notice. Split from it so the two can be asserted independently, and so a
// future budget change touches one string.
ZeromaxingBudgetNotice="Budget guideline under zeromaxing: the larger turn budget is for verifying one task thoroughly, not for starting extra work. "+
"Finish what was asked, confirm it, and stop."
// ZeromaxingOrchestrateNotice is appended to the enter notice ONLY when the
// orchestrate tool is actually available this run. It is the behavioral
// directive that turns zeromaxing from "more turns" into "multi-agent depth".
// Naming a tool the run does not have would be a prompt-level lie the model
// would try to act on, which is why this is conditional rather than folded
// into the enter notice.
ZeromaxingOrchestrateNotice="You also have the orchestrate tool and background task support. "+
"For substantive work, split independent read/analyze pieces into a planned DAG with explicit depends_on, run them through the orchestrate tool, and verify the merged findings before acting. "+
"For long-running commands, prefer Bash with run_in_background. "+
"Work alone only on conversational turns or trivial single-file edits."
// ZeromaxingEvidenceNotice states what counts as evidence. It fires on the
// FIRST TURN OF EVERY RUN while the posture is on — once per user message,
// which is when a claim arrives and when a report is written. Repeating it on
// every tool turn would cost hundreds of copies to say something that only
// bears on the boundaries of a run.
//
// EARNED BY A MEASURED RUN, three failure classes in one submission:
//
// A property was scored top marks in five places on the strength of "the
// test passes" — while the test consumed the lazy iterator it was meant to
// prove lazy, discarded the value whose absence was the bug, and reported
// avg × 2.5 as a p99. Passing was treated as proof without once asking what
// the test would still pass with.
//
// A table of benchmark timings was reported that no command in the session
// had produced. The same test read 0.86s in one paste and 4.20s in the
// next with nothing said about the difference, and the column summed to an
// exact total no real transcript lands on. That is the most serious of the
// three, because it is not an oversight.
//
// Told its code had a defect, it agreed — and the claim was wrong. It
// revised a correct answer down and wrote a proof that contradicted its own
// preceding step. An agent that folds to whoever asserts most confidently
// follows the user rather than the evidence, and is worth less the more it
// is trusted.
ZeromaxingEvidenceNotice="Evidence discipline under zeromaxing. "+
"A passing test is not proof that a property holds: whenever you rest a claim on one, name the test AND say in one sentence what it would still pass with. "+
"Every measurement you report must come from a command you ran in this session — if a number differs from one you gave earlier, show both and account for the difference rather than quietly replacing it. "+
"And when someone tells you your code has a defect, prove or refute it from the code before you agree: agreeing without a line-level trace is a wrong answer even when the claim turns out to be right."
// ZeromaxingStillOnNotice repeats on every continuing turn.
//
// It is intentionally short and FIXED. Do not add a turn number, a
// remaining-budget count, a timestamp, or anything else that differs between
// turns — see the file comment.
ZeromaxingStillOnNotice="The zeromaxing execution posture is still active."
// ZeromaxingExitNotice announces the flip back. It fires once, on the first
// turn of the run after the posture was turned off.
ZeromaxingExitNotice="The zeromaxing execution posture is no longer active. "+
"The tool-turn budget has returned to its normal value; work at the usual depth and avoid launching heavy multi-step side tasks."
)
// zeromaxingReminders returns the reminder lines to append before the given
// turn, oldest first, or nil when there is nothing to say. turn is 1-based, so
// turn == 1 is the run's first provider request.
//
// The whole state machine is this function: it is pure, it depends only on
// (posture, turn), and it has no memory. That is what makes "enter exactly
// once" and "still-on never on the first turn" testable as data rather than as
// zeromaxing is accepted here as a POSTURE name, not a provider effort
// level: normalizeZeromaxingEffort converts it into the equivalent
// --exec-profile selection before anything reads it as an effort, and
// forwardedReasoningEffort refuses to forward it even if that regressed.
//
// "max" is deliberately NOT accepted, exactly as before — the flag has
// always rejected it and that spelling stays reserved for a real
// provider rung. Adding it here would burn the name.
switchstrings.ToLower(next){
case"low","medium","high",execprofile.Name:
default:
return"",index,execUsageError{fmt.Sprintf("invalid %s %q. Expected low, medium, high, or %s.",flag,next,execprofile.Name)}
}
case"--spec-reasoning-effort":
// The spec-draft effort is a plain provider level; the posture is a RUN
// posture and has no meaning for a draft, so it is not accepted here.
switchstrings.ToLower(next){
case"low","medium","high":
caseexecprofile.Name:
// Rejecting this with the generic "Expected low, medium, or high"
// reads like a bug to anyone who just learned the name works on
// --reasoning-effort. Say why it does not apply, and name the two
// flags that do what they were reaching for.
return"",index,execUsageError{fmt.Sprintf(
"invalid %s %q. %s is a run posture, not a reasoning level, so it does not apply to spec drafting. Use %s high for the draft, or --reasoning-effort %s to run under the posture.",
"active aimlapi profile on a proxy":{Provider:ProviderProfile{Name:"aimlapi",CatalogID:"aimlapi",BaseURL:"https://proxy.example/v1",APIKey:"proxy-key"}},
"a second aimlapi profile on a proxy":{Providers:[]ProviderProfile{
returnDefinition{},manifestError{fieldPath:field+".matcher",message:"matcher is only supported for beforeTool and afterTool hooks."}
returnDefinition{},manifestError{fieldPath:field+".matcher",message:"matcher is only supported for beforeTool, afterTool, specialistStart, and specialistStop hooks."}
returnfmt.Sprintf(" (set up the aimlapi.com provider with `zero providers setup aimlapi` or set AIMLAPI_API_KEY; automatic injection is also disabled for proxy profiles, OAuth, project config, and non-empty Authorization headers — see docs/EXTENDING.md#built-in-servers. To sign in instead, set \"auth\": \"oauth\" on this server and run `zero mcp oauth login %s`)",server.Name)
// The body is server text that may echo the key Zero injected, whole or
// cut by the read limit, so it is left out rather than matched.
returnfmt.Errorf("MCP %s server %s returned HTTP %d (response body omitted: the request carried your AI/ML API key)%s",server.Type,server.Name,response.StatusCode,hint)
Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.