Compare commits

...
Author SHA1 Message Date
jatmnandClaude Opus 5.5 4d7bd8b97c test(tui): drive persistence and completion-model checks through real paths
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]>
2026-11-07 11:58:37 -08:00
jatmnandClaude Opus 5.5 ce4d3a2691 fix(plan): start a task's stall clock when its child launches
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]>
2026-11-07 11:57:14 -08:00
jatmnandClaude Opus 5.5 acfc011d7c fix(tools): release the sandboxed shell probe's command plan
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]>
2026-11-07 11:55:13 -08:00
jatmnandClaude Opus 5.5 0dd689f941 test(cli): route sandbox self-dispatch in the cli test binary
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]>
2026-11-07 11:54:23 -08:00
jatmnandClaude Opus 5.5 be207465fb fix(tui): describe the plan budget admission will enforce on the approval card
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]>
2026-11-07 11:49:44 -08:00
jatmnandClaude Opus 5.5 781737f466 fix(specialist): report a background child's tool calls, not its tool names
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]>
2026-11-07 11:48:04 -08:00
jatmnandClaude Opus 5.5 6c68047431 fix(specialist): count only admitted sub-agents against the session
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]>
2026-11-07 11:46:53 -08:00
jatmnandClaude Opus 5.5 bc56e1e6ac fix(plan): fingerprint dependency order for resume identity
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]>
2026-11-07 11:46:09 -08:00
jatmnandClaude Opus 5.5 76649ea190 fix(plan): let a write-capable plan run again after it finishes
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]>
2026-11-07 11:44:58 -08:00
jatmnandClaude Opus 5.5 30769f720c fix(specialist): propagate the run's read grants to every Task launch and resume
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]>
2026-11-07 11:20:00 -08:00
jatmnandClaude Opus 5.5 500f250fdf fix(specialist): keep turn-scoped write grants out of children that outlive the turn
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]>
2026-11-07 11:20:00 -08:00
jatmnandClaude Opus 5.5 5e48344b88 fix(specialist): withhold write roots overlapping the parent tree from isolated children
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]>
2026-11-07 11:20:00 -08:00
jatmnandClaude Opus 5.5 86a314cd2d fix(tui): stage restart and resume plans without owning user plan names
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]>
2026-11-07 11:14:58 -08:00
jatmnandClaude Opus 5.5 0fd0d14c10 fix(execution): expire completed session records with their retention
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]>
2026-11-07 11:14:58 -08:00
jatmnandClaude Opus 5.5 1c1f8e6087 refactor(plan): drop the dead budgetLeft store and its stale comments
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]>
2026-11-07 11:14:06 -08:00
jatmnandClaude Opus 5.5 f9136002c1 fix(plan): report a plan with genuine failures as failed, not cancelled
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]>
2026-11-07 11:14:06 -08:00
jatmnandClaude Opus 5.5 1851b61153 fix(plan): stop non-stall retries from spending the stall budget
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]>
2026-11-07 11:14:06 -08:00
jatmnandClaude Opus 5.5 d190306594 fix(plan): bound max_tokens_per_task across a task's retries
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]>
2026-11-07 11:14:06 -08:00
jatmnandClaude Opus 5.5 83325fc626 fix(plan): count every usage event on the live plan meter
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]>
2026-11-07 11:14:06 -08:00
jatmnandClaude Opus 5.5 cb4bc4de8f fix(plan): refuse a per-task cap whose total overflows int
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]>
2026-11-07 11:14:06 -08:00
jatmnandClaude Opus 5.5 ec68fa65e8 fix(plan): decode integer budget fields exactly, in one place
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]>
2026-11-07 11:14:05 -08:00
jatmnandClaude Opus 5.5 a2e55a1211 fix(plan): reject present non-array depends_on and tools containers
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]>
2026-11-07 11:14:05 -08:00
jatmnandClaude Opus 5.5 2f39c31ac0 fix(plan): keep failed task output off the shared scratchpad
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]>
2026-11-07 11:11:51 -08:00
jatmnandClaude Opus 5.5 c6cc4efe47 fix(plan): reject task ids that differ only in case
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]>
2026-11-07 11:11:51 -08:00
jatmnandClaude Opus 5.5 7cb9056502 fix(plan): create scratchpad records exclusively and correct the invariant
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]>
2026-11-07 11:11:51 -08:00
jatmnandClaude Opus 5.5 4f8156b689 fix(plan): suspend the wall-budget watcher while dispatch is paused
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]>
2026-11-07 11:11:51 -08:00
jatmnandClaude Opus 5.5 38c906ff2e test(zeromaxing): claim package timings in measurement fixtures
#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]>
2026-11-07 11:04:23 -08:00
jatmnandClaude Opus 5.5 11b1c20a95 chore(deps): resync memory and measurements with #897 and #909
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]>
2026-11-07 11:04:23 -08:00
jatmnandClaude Opus 5.5 c4aa2192ba Merge #829 (feat/zeromaxing) onto current main
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]>
2026-11-07 10:59:36 -08:00
e034a93815 perf(tui): bounded file read, LRU rendering cache, and size caps for full-file view (#1203)
* 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]>
2026-11-07 10:28:29 -08:00
2ca84cc127 fix(hooks): dispatch specialistStart/Stop lifecycle hooks (#1204)
* 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]>
2026-11-07 10:19:56 -08:00
05c1b5e160 feat(plugins): add CLI enable/disable for plugin.json (#1202)
* 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]>
2026-11-07 09:56:16 -08:00
16ccd78765 feat(mcp): add AI/ML API as an opt-in built-in server (#1193)
* 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]>
2026-11-04 20:13:53 -08:00
c9f179482a feat(web-fetch): add an opt-in AI/ML API Toolbox scraping backend (#1192)
* 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]>
2026-11-04 19:20:17 -08:00
98736457bd feat(web-search): add AI/ML API Toolbox as an opt-in backend (#1189)
* 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]>
2026-11-04 18:24:09 -08:00
ShryukGrandhi e59632406b test(agent): isolate user home, config and cache dirs for the agent tests (#1184)
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]>
2026-11-04 12:27:48 +08:00
github-actions[bot] 7c55a09c07 chore(main): release 0.12.0 (#1150)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-11-03 08:10:03 +08:00
Octopusandocto-patch d99f92b81a feat(providers): add MiniMax-M3.1-Flash to catalog (#1180)
* feat(providers): add MiniMax-M3.1-Flash to catalog

* fix(providers): forward MiniMax adaptive effort

---------

Co-authored-by: octo-patch <[email protected]>
2026-11-02 18:28:17 +08:00
zzucs 5aa54fe1d2 fix(agent): mark headless guardrail stops incomplete (#1182)
* 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.
2026-11-02 14:25:40 +08:00
oniisan f912f80d00 fix(chatgpt): refresh subscription defaults and compatibility version (#1179)
* 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
2026-11-02 14:25:00 +08:00
ShryukGrandhi 9c4e3693e7 test(providerio): widen the idle window in the content-reset test (#1172)
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]>
2026-11-02 14:24:24 +08:00
zzucs 63962081cf fix(sessions): capture freeform apply_patch checkpoints (#1183)
* 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.
2026-11-02 14:23:58 +08:00
Vasanth T 2e10a4b60b test(swarm): wait for every slot to be released before checking the drain (#1181)
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.
2026-10-29 06:30:41 -07:00
Vasanth T 4247a07aba test(tools): stop the tty session before removing its root (#1175)
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.
2026-10-27 06:25:41 -07:00
Vasanth T b837448dd4 docs: list Vercel under Partners (#1176)
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.
2026-10-27 15:29:59 +08:00
KRATOS 6916f85366 Merge split measurements dependency for zeromaxing 2026-08-29 17:14:48 +05:30
KRATOS 2e22868e88 Merge split memory dependency for zeromaxing 2026-08-29 17:14:42 +05:30
KRATOS 070aac427d fix(tui): preserve saved plans during resume 2026-08-29 17:14:30 +05:30
KRATOS 55dbc9cc51 fix(measurements): reject ambiguous timing claims 2026-08-28 23:57:20 +05:30
KRATOS b6b382387a refactor(tools): remove stale host-only shell guidance 2026-08-28 23:29:51 +05:30
KRATOS c14662920d fix(tools): unify sandbox shell guidance 2026-08-28 23:27:26 +05:30
KRATOS b1ff408104 fix(tui): retain admission persistence failures 2026-08-28 23:25:25 +05:30
KRATOS 65643b24bd test(zeromaxing): satisfy static analysis 2026-08-28 23:24:06 +05:30
KRATOS b92f8fd9fc style(tui): format plan completion message 2026-08-28 23:19:21 +05:30
KRATOS 3e66a5ed0d fix(zeromaxing): close launch and cache identity gaps 2026-08-28 23:16:54 +05:30
KRATOS 299982c1c1 test(zeromaxing): cover reviewer lifecycle regressions 2026-08-28 23:14:15 +05:30
KRATOS 9f9f47e01b fix(zeromaxing): harden plan lifecycle boundaries 2026-08-28 23:11:04 +05:30
KRATOS 40ac694ece fix(measurements): preserve unicode name boundaries 2026-08-28 22:54:17 +05:30
KRATOS fd32005058 fix(measurements): preserve structured identities 2026-08-28 22:17:01 +05:30
KRATOS 085ec169dc test(zeromaxing): use trusted measurement events 2026-08-28 22:00:31 +05:30
KRATOS 532845dc54 Merge remote-tracking branch 'origin/main' into codex/pr829-fix 2026-08-28 21:58:04 +05:30
KRATOS 6159a2d755 Merge remote-tracking branch 'origin/main' into codex/pr909-fix 2026-08-28 21:50:21 +05:30
KRATOS 297d9fc556 fix(measurements): trust structured test events 2026-08-28 21:50:21 +05:30
KRATOS b630dc19a6 fix(zeromaxing): finish current-main integration 2026-08-28 21:27:18 +05:30
KRATOS 75afc62c84 Merge remote-tracking branch 'origin/main' into codex/pr829-fix
# Conflicts:
#	internal/agent/loop_test.go
#	internal/cli/app.go
#	internal/cli/exec.go
#	internal/cli/exec_writer.go
#	internal/config/types.go
#	internal/providers/openai/provider_test.go
#	internal/sandbox/scope.go
#	internal/sandbox/scope_extra_read_test.go
#	internal/sandbox/scope_temp_refcount_test.go
#	internal/sandbox/scope_temporary_test.go
#	internal/tools/edit_file.go
#	internal/tools/file_tracker.go
#	internal/tui/files_panel.go
#	internal/tui/hover.go
#	internal/tui/hover_test.go
#	internal/tui/keybinding_help.go
#	internal/tui/model.go
#	internal/tui/mouse.go
#	internal/tui/options.go
#	internal/tui/sidebar.go
#	internal/tui/transcript_selection.go
2026-08-28 21:13:32 +05:30
KRATOS 0f2c5f5d7c fix(measurements): keep threshold tokenization lint-clean 2026-08-28 20:42:42 +05:30
KRATOS acfd4182e4 Merge remote-tracking branch 'origin/main' into codex/pr909-fix 2026-08-28 20:37:51 +05:30
KRATOS b5dd87547d fix(measurements): preserve claim and run ownership 2026-08-28 20:37:51 +05:30
KRATOS 358555135e fix(measurements): one duration token, every mention, and packages as subjects
@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
2026-08-24 20:53:02 +05:30
KRATOS 81472e527e fix(memory): the index decides whether a local note is private, not the ignore file
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
2026-08-24 14:19:38 +05:30
KRATOS 81b1364950 fix(memory): the delete door takes the clash guard the other two already had
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
2026-08-24 14:19:09 +05:30
KRATOS 887043a387 fix(memory): one resolution order, a named listing order, and each sentinel doc on its own error
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
2026-08-24 14:19:05 +05:30
KRATOS d37dbd72d2 fix(memory): make the unscoped round trip return what it wrote, and stop a case clash destroying a note
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
2026-08-24 14:19:00 +05:30
KRATOS 9098bc1c09 test(memory): close the probe handle that turned the Windows skip into a failure
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
2026-08-24 14:18:56 +05:30
KRATOS 18627b9de4 fix(memory): decide refusal on what the handle can OPEN, not on what it can Lstat
@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
2026-08-24 14:18:51 +05:30
KRATOS 512b9ce7ac fix(memory): a refused ancestor is reported as a refusal, not as an empty store
@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
2026-08-24 14:18:47 +05:30
KRATOS 52452fafc5 test(memory): assert that a linked ignore is refused, not which error says so
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
2026-08-24 14:18:42 +05:30
KRATOS af6ca75f60 fix(memory): the ignore gate follows git's rules, and refuses a linked ignore
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
2026-08-24 14:18:37 +05:30
KRATOS 416ad9a302 test(memory): exercise the ancestor guard against a Windows junction
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
2026-08-24 14:18:33 +05:30
KRATOS 36d894b33f fix(memory): refuse a link at any store ancestor, and fail closed on privacy
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
2026-08-24 14:18:28 +05:30
KRATOS b3b7f449c2 test(memory): pin the extension rule, the resolved scope, and partial listing
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
2026-08-24 14:18:24 +05:30
KRATOS 0595d34742 fix(memory): the description cap is linear, and it runs where the description is read
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
2026-08-24 14:18:19 +05:30
KRATOS 02130d1056 feat(memory): durable note store and memory tools
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
2026-08-24 14:18:15 +05:30
KRATOS bca8262c36 fix(measurements): a following subject, a non-duration unit, and a real
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
2026-08-24 14:14:01 +05:30
KRATOS 5d975161de fix(measurements): a zero-value ledger survives a contradiction, not just a record
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
2026-08-24 14:13:37 +05:30
KRATOS 772add3c90 fix(measurements): a decimal duration is one number, not its remainder
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
2026-08-24 14:13:32 +05:30
KRATOS 85c31508e5 fix(measurements): the clause scan must see every duration this package parses
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
2026-08-24 14:13:28 +05:30
KRATOS 0aacd65971 fix(measurements): read the hour form, and re-arm a test this PR had disarmed
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
2026-08-24 14:13:23 +05:30
KRATOS 80fb1d2e87 fix(measurements): a separator ends a clause only when a new subject follows it
@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
2026-08-24 14:13:19 +05:30
KRATOS 81de2477c3 fix(measurements): a full stop ends a clause, and a merged result names no single command
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
2026-08-24 14:13:14 +05:30
KRATOS 8d19677dfc fix(measurements): the cross-run report was nondeterministic in what it deduped and what it quoted
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
2026-08-24 14:13:10 +05:30
KRATOS 029af716e3 fix(measurements): the name-shape bound was dead code, and its tests could not tell
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
2026-08-24 14:13:05 +05:30
KRATOS af95c53e90 fix(measurements): an unrecorded neighbour ends the clause too, and the prefix fixture can now fail
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
2026-08-24 14:13:01 +05:30
KRATOS 6fed918224 feat(measurements): a cross-run check for the caller that cannot name the run
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
2026-08-24 14:12:56 +05:30
KRATOS 8a9a266d37 fix(measurements): bind a duration to its own clause, its own run, and its own value
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
2026-08-24 14:12:51 +05:30
KRATOS 90b645fe9b test(measurements): assert the parent test's own duration, not only the subtest
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
2026-08-24 14:12:47 +05:30
KRATOS 15896340e4 fix(measurements): the nearest duration is the claim, not the minute-shaped one
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
2026-08-24 14:12:42 +05:30
KRATOS c30a830a64 feat(measurements): check a report's timings against the run's own
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
2026-08-24 14:12:38 +05:30
KRATOS 1b1ce2db07 test: re-sync after the refusal-reporting and polarity fixes
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
2026-08-17 12:02:45 +05:30
KRATOS 101276b89f test: re-sync measurements after the clause-separator fix
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.
2026-08-17 09:59:20 +05:30
KRATOS 90fb0f7c22 test: re-sync memory after the Windows test fix
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
2026-08-16 23:04:07 +05:30
KRATOS df22877058 test: re-sync after the ignore-gate, clause-terminator and failure-state fixes
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
2026-08-16 22:51:41 +05:30
KRATOS 2318710a87 test: re-sync measurements after the cross-run determinism fix
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
2026-08-16 20:38:42 +05:30
KRATOS a7bb1c7902 test: re-sync measurements after the name-shape bound was fixed
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
2026-08-16 20:22:20 +05:30
KRATOS cfb8d06d4c test: re-sync after the ancestor, clause and consequence fixes
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
2026-08-16 20:04:35 +05:30
KRATOS 559a119b0f fix: adopt the reviewed split heads, and give the ledger the command that printed each timing
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
2026-08-16 16:41:17 +05:30
KRATOS da56b13693 test: re-sync the sandbox and agent packages after their review fixes
#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
2026-08-16 15:59:39 +05:30
KRATOS 9cd7c688af test: carry the split branches' new regressions into this branch too
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
2026-08-16 15:07:46 +05:30
KRATOS 9d7ba432c3 fix: adopt the reviewed heads of all five split packages
@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
2026-08-16 15:01:16 +05:30
KRATOS b3d54e5f9e fix(agent): the incompletion allowance yields when the work is blocked
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
2026-08-16 09:24:00 +05:30
KRATOS 272f693b55 fix: adopt the reviewed pathjail, credstore and memory from #891 and #897
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
2026-08-16 09:24:00 +05:30
Vasanthdev2004 99dceee59c fix(cli): keep the captured profile when nothing configures a provider
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
2026-08-16 09:24:00 +05:30
Vasanthdev2004 9f96272191 fix(memory,specialist): confine the note and plan stores to a handle
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
2026-08-16 09:23:59 +05:30
Vasanthdev2004 19cd2d44c9 fix(config): carry keepFinishedAgents through mergeConfig
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
2026-08-16 09:22:55 +05:30
KRATOS bd279603fe fix(tools): probe the Windows shell inside the sandbox that will run it
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
2026-08-16 09:22:55 +05:30
KRATOS 673da0e6c2 test(agent): move the tool-definitions golden for main's #867
#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
2026-08-16 09:22:55 +05:30
KRATOS 72deca444c test(agent): restore the tool-definitions golden the rebase reverted
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
2026-08-16 09:22:54 +05:30
KRATOS df72efb0c5 fix(tui): cancel settles foreground children even while a background plan runs
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
2026-08-16 09:22:54 +05:30
KRATOS a3edf73ac7 fix(specialist): the shortlist keeps both ends and version text is not a size
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
2026-08-16 09:22:54 +05:30
KRATOS bfb4a98fdc fix(specialist): the shortlist never drops a model whose size it cannot read
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
2026-08-16 09:22:53 +05:30
KRATOS d56b6e1e21 fix: six more defects a high-effort review found in the posture work
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
2026-08-16 09:22:53 +05:30
KRATOS 38dd75d27e fix(specialist): the appended verifier cannot refuse or fail the author's plan
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
2026-08-16 09:22:53 +05:30
KRATOS 6504be3d0a fix(specialist): the model shortlist never narrows a priced catalogue
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
2026-08-16 09:22:53 +05:30
KRATOS b7122fb4d7 fix(specialist): a plan task killed by the provider gets one more attempt
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
2026-08-16 09:22:52 +05:30
KRATOS 7a7799fccb feat(specialist): a plan routes to the top-ranked models, not the whole field
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
2026-08-16 09:22:52 +05:30
KRATOS 23f70804db feat(specialist): the posture appends a verifier to a plan that has none
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
2026-08-16 09:22:52 +05:30
KRATOS e54512ab79 fix(specialist): a trailing full stop is not part of a Windows path
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
2026-08-16 09:22:52 +05:30
KRATOS ac349daeed chore: move test-only shims to export_test.go and drop dead code
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
2026-08-16 09:22:51 +05:30
KRATOS c8008b0655 fix(specialist): the workspace-reach guard works on Windows paths
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
2026-08-16 09:22:51 +05:30
KRATOS 07ff1a9eb0 fix(tui): saved-plan summaries pass the same sanitizer as live ones
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
2026-08-16 09:22:51 +05:30
KRATOS b0dd616c4a fix(tui): /effort auto cannot leave the zeromaxing posture mid-run
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
2026-08-16 09:22:51 +05:30
KRATOS 9eb46f4a69 fix(cli): a headless run's children get only the granted extra write roots
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
2026-08-16 09:22:50 +05:30
KRATOS 021209fe6b refactor(tui): /profile answers in one line
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
2026-08-16 09:22:50 +05:30
KRATOS 8ab727d7e1 feat(tui): dracula's status signals move to the scheme's own cyan/pink
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
2026-08-16 09:22:50 +05:30
KRATOS e5a14d5daf feat(tui): the zeromaxing skin — MODELS panel, electric gradient, state-aware animation, settled cancels
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
2026-08-16 09:22:50 +05:30
KRATOS f6ba9ae354 feat(specialist): per-role model auto-assignment for delegated sub-agents
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
2026-08-16 09:22:49 +05:30
KRATOS 06cf8a2843 refactor(specialist): drop the dead PlanProgress.Done/Remaining helpers
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
2026-08-16 09:22:49 +05:30
KRATOS 276539918e feat(zeromaxing): resumable plans, read-grant propagation, size-aware routing
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
2026-08-16 09:22:49 +05:30
KRATOS 564dad961b fix(specialist): a plan task's answer no longer carries the child's session id
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
2026-08-16 09:22:48 +05:30
KRATOS 5bd07de48a fix(tui): the AGENTS section stops contradicting its own header
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
2026-08-16 09:22:48 +05:30
KRATOS 64bac3c4ea fix(tui): the zeromaxing chip is hit-tested on the status line only
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
2026-08-16 09:22:48 +05:30
KRATOS a457f08a37 fix(specialist): the budget schema declares the bounds it enforces
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
2026-08-16 09:22:48 +05:30
KRATOS 7f6e928fdd fix(specialist): the bundled research plan takes its subject as a parameter
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
2026-08-16 09:22:47 +05:30
KRATOS 813abbbf5f feat(cli): wire the new bounds and capabilities into the TUI
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
2026-08-16 09:22:47 +05:30
KRATOS a6f0f0b31a feat: four bounds and capabilities a plan run showed were missing
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
2026-08-16 09:22:47 +05:30
KRATOS 8d7793c85d fix(execution): remember a finished session for as long as a poll may wait
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
2026-08-16 09:22:47 +05:30
KRATOS 84516c3e12 fix(specialist): a plan task's numbers and its unfinished work both survive
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
2026-08-16 09:22:46 +05:30
KRATOS 809ffb6142 fix(specialist): a plan task must not report a number it never measured
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
2026-08-16 09:22:46 +05:30
KRATOS 32c99455e2 feat(zeromaxing): raise the turn ceiling and bound what a run may spend
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
2026-08-16 09:22:46 +05:30
KRATOS b703628aca feat(agent): bound a run by what it spends, not only by its turn count
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
2026-08-16 09:22:46 +05:30
KRATOS eccad2fe5b feat(specialist): a saved plan can be run against a different target
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
2026-08-16 09:22:45 +05:30
KRATOS 483879e8d2 fix(prompt): ask the provider which model family it serves
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
2026-08-16 09:22:45 +05:30
KRATOS c7881c37e4 test(specialist): do not assert Windows rename semantics we cannot run
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
2026-08-16 09:22:45 +05:30
KRATOS 8abea09b5c fix(tools): an edit must not erase the reads it did not disturb
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
2026-08-16 09:22:45 +05:30
KRATOS e8bfb3d4b7 feat(zeromaxing): make evidence the posture's job, not the prompt's
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
2026-08-16 09:22:44 +05:30
KRATOS de7f9df6b4 fix: repair the Windows smoke break and the review findings behind it
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
2026-08-16 09:22:44 +05:30
KRATOS 00b9271945 feat(specialist): reserve budget for later work, and declare the plan schema
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
2026-08-16 09:22:44 +05:30
KRATOS 441c99acf5 feat(specialist): a plan no longer dies because its inputs were cut short
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
2026-08-16 09:22:43 +05:30
KRATOS d53a9af196 fix(config): project maxTurns may tighten only
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
2026-08-16 09:22:43 +05:30
KRATOS 043261fc81 feat(specialist): give a plan with no bound at all a wall backstop, and say when spend cannot be measured
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
2026-08-16 09:22:43 +05:30
KRATOS 7fded2070a feat(specialist): teach the verify convention, and show what happens before a plan exists
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
2026-08-16 09:22:43 +05:30
KRATOS 74caacbc93 fix(specialist): five admission and lifecycle defects from review
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
2026-08-16 09:22:42 +05:30
KRATOS 11b87eb250 feat(tui): show which model ran a task, and what a plan left undone
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
2026-08-16 09:22:42 +05:30
KRATOS 9f0ceba48c feat(cli): wire model discovery, proving and child scope to the live run
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
2026-08-16 09:22:42 +05:30
KRATOS 445b7a0671 feat(specialist): choose a model per task, prove it runs, and bound what a plan spends
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
2026-08-16 09:22:41 +05:30
KRATOS e34ba53299 feat(config): per-role plan model preferences, and cache counts a child can carry
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
2026-08-16 09:22:41 +05:30
Vasanthdev2004 81ebe38020 fix(specialist): let a task granted write tools actually use them
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
2026-08-16 09:22:41 +05:30
Vasanthdev2004 4178f02d58 fix(specialist): make max_wall_seconds bound a concurrent plan
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
2026-08-16 09:22:41 +05:30
Vasanthdev2004andClaude Opus 5 b5c3ba00ed fix(specialist): reject negative plan timeouts, correct two false claims
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
2026-08-16 09:22:40 +05:30
Vasanthdev2004andClaude Opus 5 35074643bc fix(tui): guard the zeromaxing effort switch and two mouse hit-testers
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
2026-08-16 09:22:40 +05:30
Vasanthdev2004andClaude Opus 5 102359b362 fix(sandbox): release a temporary root under one lock hold
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
2026-08-16 09:22:40 +05:30
Vasanthdev2004andClaude Opus 5 75862b3f82 fix(tui): resume the named plan, sanitize task text, keep background panels
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
2026-08-16 09:22:40 +05:30
KRATOS a12b99635f test(cli): neutralise XDG_CONFIG_HOME in the sandbox policy golden
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
2026-08-16 09:22:39 +05:30
KRATOS 41e7d02ec5 test(specialist): measure concurrency against the host, not against the request
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
2026-08-16 09:22:39 +05:30
KRATOS 669623b931 fix(specialist): one plan per surface, on the path the model drives too
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
2026-08-16 09:22:39 +05:30
KRATOS ee5834d75e fix(tui): a sidebar row that was never drawn must not be clickable
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
2026-08-16 09:22:39 +05:30
KRATOS 37e077f916 feat(tui): a toggle for finished agents, and each one opens on what it produced
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
2026-08-16 09:22:39 +05:30
KRATOS a345bead0d fix(tui): a running task is work underway, not progress — the bar must not fill for it
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
2026-08-16 09:22:38 +05:30
KRATOS b87df0b8d5 fix(tui): the PLAN section holds both plans, so the footer line can finally go
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
2026-08-16 09:22:38 +05:30
KRATOS b0cc06e3fa fix(tui): an agent with no card key must not expand itself and shove PLAN off the column
"" 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
2026-08-16 09:22:38 +05:30
KRATOS b2ed2dbece feat(tui): click an agent in AGENTS to open its brief, spend and reason
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
2026-08-16 09:22:38 +05:30
KRATOS 51bbfb3106 feat(tui): one plan, one surface — the inline panel stands down for the sidebar
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
2026-08-16 09:22:37 +05:30
KRATOS 9ab96015cf fix(tui): a finished plan task must close its OWN card, not the last dispatched one
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
2026-08-16 09:22:37 +05:30
KRATOS 8bbc9e2295 feat(tui): the posture reads lowercase and its hover flows
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
2026-08-16 09:22:37 +05:30
KRATOS 04d699501f feat(tui): the posture is a live word, not a coloured box
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
2026-08-16 09:22:37 +05:30
KRATOS ed4d18e326 feat(tui): the posture chip leaves amber and shimmers under the cursor
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
2026-08-16 09:22:37 +05:30
KRATOS 013055a79e fix(specialist): an orchestrate approval must never be remembered
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
2026-08-16 09:22:37 +05:30
KRATOS 19add57a57 fix(sandbox): a temporary grant is refcounted, so a sibling's cleanup cannot revoke it
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
2026-08-16 09:22:23 +05:30
KRATOS 88c3ebe085 test: prove resume survives a partial concurrent run (gap report §5.1, closing)
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
2026-08-16 09:10:52 +05:30
KRATOS b64745533d feat(tui): each plan task's progress lands on its own card (gap report §5.1, 2/2)
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
2026-08-16 09:10:52 +05:30
KRATOS 3bc6c0ceb4 feat(specialist): run independent plan tasks in parallel (gap report §5.1)
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
2026-08-16 09:10:51 +05:30
KRATOS 20eec3b072 fix(specialist): tell a plan task to USE its tools, not merely that it has them
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
2026-08-16 09:10:51 +05:30
KRATOS 175c5c7f01 feat(specialist): a plan task may write, if it asks and is isolated (write-task arc, 3/3)
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
2026-08-16 09:10:51 +05:30
KRATOS c677c1bb4d feat(specialist): a write-capable plan gets a worktree, or does not run (write-task arc, 2/3)
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
2026-08-16 09:10:50 +05:30
KRATOS 42513ea9e8 feat(tui): a permission prompt can show what it is approving (write-task arc, 1/3)
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
2026-08-16 09:10:50 +05:30
KRATOS 80acf0d92a fix(specialist): drop update_plan from the plan grant, which cost every task ~6.5KB
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
2026-08-16 09:10:50 +05:30
KRATOS b3c8cd2282 feat: run a plan in the background and report it on a later turn (gap report §5.3)
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
2026-08-16 09:10:50 +05:30
KRATOS 1cddf58e2f feat(tui): /plans restart runs the last plan from the beginning (gap report §5.5)
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
2026-08-16 09:10:49 +05:30
KRATOS 382f7e7624 refactor(specialist): delete the orchestrate limits override, which nothing set
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
2026-08-16 09:10:49 +05:30
KRATOS d4278824e0 fix(cli): the model was never told the orchestrate tool exists
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
2026-08-16 09:10:49 +05:30
KRATOS 8c2e8448ef feat(specialist): ship one worked plan in the binary (gap report §5.14)
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
2026-08-16 09:10:48 +05:30
KRATOS feb3c5a03f feat(specialist): resume a plan from where it stopped (gap report §5.2, read half)
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
2026-08-16 09:10:48 +05:30
KRATOS 8d51afa75e feat(specialist): save a plan that worked and run it again (gap report §5.7, §5.8)
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
2026-08-16 09:10:48 +05:30
KRATOS f6d8ebfcb7 fix(tui): the plan list follows the running task instead of pinning to the head
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
2026-08-16 09:10:47 +05:30
KRATOS d06c9189b7 feat(tui): stop or pause a plan without stopping the turn (gap report §5.5)
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
2026-08-16 09:10:47 +05:30
KRATOS 596921f0ed feat(specialist): a stalled plan task is retried, and only a stalled one (gap report §5.10)
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
2026-08-16 09:10:47 +05:30
KRATOS a026c2b19b feat(config): the plan-size ceiling is a configurable tier (gap report §5.9)
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
2026-08-16 09:10:46 +05:30
KRATOS 191bdc5bb7 feat(tui): a plan run in the TUI is durable (gap report §5.2)
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
2026-08-16 09:10:46 +05:30
KRATOS 29e1829ea0 fix(specialist): resuming a task must not widen its authority
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
2026-08-16 09:10:46 +05:30
KRATOS 27f9626962 feat(specialist): per-task stall watchdog (gap report §5.10)
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
2026-08-16 09:10:46 +05:30
KRATOS 17274cb760 feat(tui): hover on the posture chip and the plan rows, and the chip is clickable
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
2026-08-16 09:10:45 +05:30
KRATOS ea8f4d165b feat(tui): the zeromaxing posture glows, in the footer and where it is offered
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
2026-08-16 09:10:45 +05:30
KRATOS f7bcc42501 feat(tui): the plan owns the right column — progress bar, task list, live detail
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
2026-08-16 09:10:45 +05:30
KRATOS 03f6f2a896 feat(tui): the running plan fills the sidebar's PLAN section, in colour
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
2026-08-16 09:10:45 +05:30
KRATOS 08cb41fbb2 feat(tui): the plan detail view — phases left, live agent detail right
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
2026-08-16 09:10:44 +05:30
KRATOS bd65420fe0 fix(tui): the plan budget line counts while the plan runs
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
2026-08-16 09:10:44 +05:30
KRATOS 75be3de8b5 fix(tui): five reporting defects a real plan run exposed
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
2026-08-16 09:10:44 +05:30
KRATOS e9f033baf6 fix(tui): the effort picker offers what /effort actually accepts
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
2026-08-16 09:10:43 +05:30
KRATOS 35d1e7eecd feat(tui): fade finished plan tasks, and say what /effort accepts
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
2026-08-16 09:10:43 +05:30
KRATOS ff33fee35d feat(specialist,tui): unbounded plans by default, collapsed plan panel
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
2026-08-16 09:10:43 +05:30
KRATOS 8f89068a9c perf(agent): drop the confirmation policy from a run that cannot act
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
2026-08-16 09:10:42 +05:30
KRATOS a13a6e9350 fix(specialist): the two read-only tool sets agree again
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
2026-08-16 09:10:42 +05:30
KRATOS dfcaf0aba5 fix(specialist): a resumed specialist keeps the run's model
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
2026-08-16 09:10:42 +05:30
KRATOS 3df079f71a fix(specialist): a plan task inherits the parent's model
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
2026-08-16 09:10:42 +05:30
KRATOS 86c104e484 feat(tui): add the /plans panel with the plan's dependency shape
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
2026-08-16 09:10:41 +05:30
KRATOS 3213670d53 feat(tui): make a running plan visible
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
2026-08-16 09:10:41 +05:30
KRATOS 3a67302d98 test(specialist): compare the two grant enforcement points against each other
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
2026-08-16 09:10:41 +05:30
KRATOS 7ad6e2fbb4 fix(cli): measure the run's real depth for plan admission
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
2026-08-16 09:10:41 +05:30
KRATOS 56e6e46ffc fix(cli): record the failed plan task's session and spend
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
2026-08-16 09:10:40 +05:30
KRATOS a7ff20c351 fix(tui): make both effort doors ask the same question
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
2026-08-16 09:10:40 +05:30
KRATOS b1e00efb20 fix(specialist): make the plan tool's parent grant real and empty mean empty
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
2026-08-16 09:10:40 +05:30
KRATOS ae45e6cd1c feat(tui): flip the orchestrate posture gate from the real handlers
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
2026-08-16 09:10:39 +05:30
KRATOS defd33f569 feat(cli): register the orchestrate tool and map a partial plan to exit 4
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
2026-08-16 09:10:39 +05:30
KRATOS 6c7e221e72 feat(specialist): capture and sequentially execute a declared plan
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
2026-08-16 09:10:39 +05:30
KRATOS aac9f9a8f6 fix(tui): treat an unlisted model as unknown, not as having no reasoning controls
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
2026-08-16 09:10:39 +05:30
KRATOS c2a06694a8 fix(tui): apply the posture's effort fill on models the catalog cannot vouch for
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
2026-08-16 09:10:38 +05:30
KRATOS 2499c37669 fix(execprofile): describe the posture's delta against the caller's own state
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
2026-08-16 09:10:38 +05:30
KRATOS 5702c0bd06 feat(tui): make /effort zeromaxing the entry point on both selection paths
/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
2026-08-16 09:10:38 +05:30
KRATOS 2337f1448c feat(agent): inject the zeromaxing reminders below the cache breakpoint
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
2026-08-16 09:10:38 +05:30
KRATOS 96140a9486 feat(execprofile): add the zeromaxing posture rung
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

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.11.0"}
{".":"0.12.0"}
+24
View File
@@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.12.0](https://github.com/Twigpine/zero/compare/v0.11.0...v0.12.0) (2026-11-02)
### Features
* **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))
* **redaction:** redact credentials whose trigger a greedy span swallowed ([#1158](https://github.com/Twigpine/zero/issues/1158)) ([a97db88](https://github.com/Twigpine/zero/commit/a97db8832fdba077f2d31bdf03ecef8d7c774fe1))
* **sessions:** capture freeform apply_patch checkpoints ([#1183](https://github.com/Twigpine/zero/issues/1183)) ([6396208](https://github.com/Twigpine/zero/commit/63962081cf130c2171407936ce3beb7021d238b8))
* **sessions:** preserve image attachment summaries on resume ([#1152](https://github.com/Twigpine/zero/issues/1152)) ([1e84849](https://github.com/Twigpine/zero/commit/1e848492e5c1919d187fb5e1eb5e7ef9bc6140dc)), closes [#1135](https://github.com/Twigpine/zero/issues/1135)
* **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))
## [0.11.0](https://github.com/Twigpine/zero/compare/v0.10.0...v0.11.0) (2026-10-15)
+79
View File
@@ -88,9 +88,18 @@ zero exec --output-format stream-json < turns.jsonl
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://vercel.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
</tr>
</table>
@@ -388,6 +397,76 @@ and can rewrite every request through it, so this is the trust you accept by
configuring one. Leave the variables unset for the checks to be enforced end to
end.
The built-in `web_search` tool is registered only when a search backend is
configured: `ZERO_WEBSEARCH_BASE_URL` (with an optional `ZERO_WEBSEARCH_API_KEY`)
for a generic JSON endpoint, `ZERO_WEBSEARCH_PROVIDER=searxng` with a SearXNG
base URL, or the AI/ML API Toolbox:
```bash
export ZERO_WEBSEARCH_PROVIDER=aimlapi
```
The AI/ML API backend runs the Toolbox tool `web/search`, and every search is
billed to your AI/ML API balance: about $0.0013 per result, with a documented
minimum of $0.0091 per search. Each search is sent with a $0.025 spending cap,
which the Toolbox enforces. It is used only when
`ZERO_WEBSEARCH_PROVIDER` is set to `aimlapi`; an AI/ML API key alone never
enables it. The key is read from your user configuration only; provider
profiles in a project `.zero` config are not used. It is the aimlapi profile's
key (the active profile when it is aimlapi, otherwise the first aimlapi
profile), including a key in the credential store, or `AIMLAPI_API_KEY` when no
aimlapi profile exists. It is resolved again on every search;
`ZERO_WEBSEARCH_API_KEY` is not used. When any aimlapi profile, or any profile
that reads `AIMLAPI_API_KEY`, points at a base URL other than
`https://api.aimlapi.com/v1`, or `AIMLAPI_INFERENCE_URL` is overridden, the key
may belong to a proxy, so it is not sent and the search fails without a request.
Requests go only to
`https://tools.aimlapi.com` (`ZERO_WEBSEARCH_BASE_URL` is ignored and redirects
are refused) and carry `X-AIMLAPI-Source: agent/zero` and an
`X-AIMLAPI-Partner-ID` header. The `domains` argument is sent to the Toolbox as
`include_domains`, and results are still filtered locally.
### AI/ML API Toolbox backend for `web_fetch`
`web_fetch` can fetch pages through the AI/ML API Toolbox tool
`search1api/crawl` instead of a direct HTTP GET. It is off unless you choose it:
```bash
export ZERO_WEBFETCH_PROVIDER=aimlapi
```
Having an AI/ML API key never switches it on by itself, and any other value (or
none) keeps the direct fetch. The variable is read on every fetch.
- **Billing:** Toolbox runs are billed to your AI/ML API balance, about
$0.0013 per run. Each run is sent with `max_cost_usd` 0.005, so a run
priced above that is refused before it starts and not charged. Rate-limited
or gateway-failed requests are retried with the same idempotency key, so a
retry is never billed twice.
- **Key:** the key comes from your `aimlapi` provider profile (including a key
in the credential store), or from `AIMLAPI_API_KEY` when there is no such
profile. It is withheld, and `web_fetch` returns an error, when any `aimlapi`
profile or any profile using `AIMLAPI_API_KEY` points at a base URL other than
`https://api.aimlapi.com/v1`, or when `AIMLAPI_INFERENCE_URL` is overridden:
the key may then belong to a proxy.
- **Destination:** requests go only to the fixed `https://tools.aimlapi.com`
endpoint, with `Authorization: Bearer <key>` and the attribution headers
`X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`. Redirects are
refused. `HTTP_PROXY`/`HTTPS_PROXY` apply to these requests as to Zero's other
aimlapi.com calls.
- **No fallback:** without a usable key, or when the run fails, `web_fetch`
returns an error and does not fetch the page directly. Unset the variable to
go back to direct fetches.
- **Remote fetch:** the page is fetched by a remote service, which may serve a
cached copy, and the full URL (query string included) is sent to it. The URL
checks above still run first, so loopback, private and metadata URLs are
never handed to it. The result is Markdown with a
`Backend: aimlapi (search1api/crawl)` line; `max_bytes` cuts the Markdown,
and `format: "raw"` is refused because the original response is not
available. The complete Toolbox run response is limited to 8 MiB before JSON
decoding; larger responses return an error even if `max_bytes` is smaller.
The remote run may already have been billed when this limit is detected.
For local dev servers, use shell commands such as `curl` through `exec_command`
so the normal sandbox and permission policy applies. Long-running commands stay
attached to a background terminal session and can be listed or stopped from the
+9
View File
@@ -86,9 +86,18 @@ zero exec --output-format stream-json < turns.jsonl
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://vercel.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
</tr>
</table>
+13 -1
View File
@@ -33,4 +33,16 @@ Please include, where possible:
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
out of scope — but please report anything you are unsure about.
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`). The built-in
AI/ML API MCP server (`https://mcp.aimlapi.com/mcp`) is off until you run
`zero mcp enable aimlapi`. Once enabled, the Bearer key follows the aimlapi
profile resolution order (inline key, then the profile's `apiKeyEnv` — default
`AIMLAPI_API_KEY` — then the credential store), or `AIMLAPI_API_KEY` alone when
no aimlapi profile exists; a non-empty `AIMLAPI_API_KEY` can override a stored
profile key for MCP billing. The automatic key is withheld if an aimlapi profile
(or a profile reading `AIMLAPI_API_KEY`) uses a base URL other than
`https://api.aimlapi.com/v1`, `AIMLAPI_INFERENCE_URL` points elsewhere, a
non-empty user Authorization header is configured, OAuth is selected, project
config has touched the server, or the key is missing or not a single token
(including Unicode whitespace). See
[docs/EXTENDING.md](docs/EXTENDING.md#built-in-servers).
+54 -2
View File
@@ -203,7 +203,11 @@ Events the agent emits (in dispatch order):
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
| `specialistStop` | A sub-agent ends | yes (specialist name) |
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.
+4
View File
@@ -634,6 +634,10 @@ Core tool groups include:
`HTTP_PROXY`/`HTTPS_PROXY` set, the proxy is dialed instead and resolves the
target itself, so destination enforcement ends at the proxy; see the note in
the README.
With `ZERO_WEBFETCH_PROVIDER=aimlapi` (opt-in; a key alone does not select
it), the same URL checks run and the page is then fetched as Markdown by the
billed AI/ML API Toolbox tool `search1api/crawl`, with no fallback to a
direct fetch; see the README.
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
and artifact helpers backed by packages such as `internal/localcontrol` and
`internal/browser`.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="white" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:white;fill-opacity:1;"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="black" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:black;fill-opacity:1;"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -559,6 +559,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
Cwd: sess.cwd,
SessionID: sess.id,
ProviderName: resolved.Provider.Name,
ModelFamily: providercatalog.ModelFamilyFor(resolved.Provider.CatalogID),
Model: resolved.Provider.Model,
Registry: registry,
Sandbox: sandboxEngine,
+34
View File
@@ -0,0 +1,34 @@
package agent
import "github.com/Twigpine/zero/internal/tools"
// CheckpointTargets returns the workspace-relative paths a tool call will mutate,
// resolving its arguments exactly as executeToolCall does. Freeform apply_patch
// input is normalized through the registered built-in tool, so absolute headers
// are rewritten relative to the selected write root and cwd is set: checkpoint
// capture and execution therefore agree on the files at stake.
//
// Best-effort: nil when the call has no capture targets or its arguments cannot
// be resolved. Checkpoint capture never fails a run.
func CheckpointTargets(workspaceRoot string, registry *tools.Registry, call ToolCall) []string {
if registry == nil {
return nil
}
args := map[string]any{}
if call.Freeform {
tool, ok := registry.Get(call.Name)
if !ok || !tools.IsBuiltInApplyPatch(tool) {
return nil
}
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
if err != nil {
return nil
}
args = prepared
} else if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return nil
}
}
return tools.MutationTargets(workspaceRoot, call.Name, args)
}
+99
View File
@@ -0,0 +1,99 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"testing"
"github.com/Twigpine/zero/internal/tools"
)
func TestCheckpointTargets(t *testing.T) {
root := t.TempDir()
// Absolute headers must be compared against the physical root: t.TempDir
// differs from it on macOS (/var -> /private/var) and via Windows short names.
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
relativePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** End Patch\n"
absolutePatch := "*** Begin Patch\n*** Update File: " + filepath.Join(root, "main.txt") + "\n@@\n-before\n+after\n*** End Patch\n"
multiFilePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** Add File: notes.txt\n+new\n*** End Patch\n"
jsonArgs, err := json.Marshal(map[string]any{"patch": relativePatch})
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
unbuiltinRegistry := tools.NewRegistry()
unbuiltinRegistry.Register(&customApplyPatchTool{})
for _, tc := range []struct {
name string
registry *tools.Registry
call ToolCall
want []string
}{
{
name: "freeform relative header",
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
want: []string{"main.txt"},
},
{
name: "freeform absolute header stays workspace-relative",
call: ToolCall{Name: "apply_patch", Arguments: absolutePatch, Freeform: true},
want: []string{"main.txt"},
},
{
name: "freeform multi-file patch",
call: ToolCall{Name: "apply_patch", Arguments: multiFilePatch, Freeform: true},
want: []string{"main.txt", "notes.txt"},
},
{
name: "json arguments",
call: ToolCall{Name: "apply_patch", Arguments: string(jsonArgs)},
want: []string{"main.txt"},
},
{
name: "freeform traversal is rejected",
call: ToolCall{Name: "apply_patch", Arguments: "*** Begin Patch\n*** Update File: ../escape.txt\n@@\n-x\n+y\n*** End Patch\n", Freeform: true},
want: nil,
},
{
name: "freeform custom apply_patch is not prepared",
registry: unbuiltinRegistry,
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
want: nil,
},
{
name: "unknown freeform tool",
call: ToolCall{Name: "read_file", Arguments: "raw", Freeform: true},
want: nil,
},
{
name: "malformed json arguments",
call: ToolCall{Name: "apply_patch", Arguments: "{"},
want: nil,
},
} {
t.Run(tc.name, func(t *testing.T) {
reg := registry
if tc.registry != nil {
reg = tc.registry
}
got := CheckpointTargets(root, reg, tc.call)
if !slices.Equal(got, tc.want) {
t.Fatalf("CheckpointTargets() = %#v, want %#v", got, tc.want)
}
})
}
if got := CheckpointTargets(root, nil, ToolCall{Name: "apply_patch", Arguments: relativePatch}); got != nil {
t.Fatalf("CheckpointTargets(nil registry) = %#v, want nil", got)
}
}
+135
View File
@@ -0,0 +1,135 @@
package agent
import (
"context"
"testing"
"github.com/Twigpine/zero/internal/streamjson"
"github.com/Twigpine/zero/internal/tools"
)
// progressProbeTool records whether the loop handed it a progress callback.
// streams mirrors what a real tool declares via tools.ChildProgressStreamer.
type progressProbeTool struct {
name string
streams bool
got *bool
}
func (t *progressProbeTool) Name() string { return t.name }
func (t *progressProbeTool) Description() string { return "probe" }
func (t *progressProbeTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (t *progressProbeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t *progressProbeTool) Run(context.Context, map[string]any) tools.Result {
*t.got = false
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func (t *progressProbeTool) RunWithOptions(_ context.Context, _ map[string]any, options tools.RunOptions) tools.Result {
*t.got = options.Progress != nil
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
// StreamsChildProgress makes this type implement tools.ChildProgressStreamer;
// the flag drives the answer. A tool that never declares at all is modelled by
// silentProbeTool below, which genuinely does not implement the interface.
func (t *progressProbeTool) StreamsChildProgress() bool { return t.streams }
// silentProbeTool does NOT implement tools.ChildProgressStreamer at all — the
// state every tool in the tree is in today except Task and orchestrate.
type silentProbeTool struct {
name string
got *bool
}
func (t *silentProbeTool) Name() string { return t.name }
func (t *silentProbeTool) Description() string { return "probe" }
func (t *silentProbeTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (t *silentProbeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t *silentProbeTool) Run(context.Context, map[string]any) tools.Result {
*t.got = false
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func (t *silentProbeTool) RunWithOptions(_ context.Context, _ map[string]any, options tools.RunOptions) tools.Result {
*t.got = options.Progress != nil
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func runProbe(t *testing.T, tool tools.Tool, got *bool, withSink bool) bool {
t.Helper()
registry := tools.NewRegistry()
registry.Register(tool)
options := Options{}
if withSink {
options.OnToolProgress = func(string, streamjson.Event) {}
}
result, err := executeToolCall(context.Background(),
registry,
ToolCall{ID: "call_1", Name: tool.Name(), Arguments: "{}"},
PermissionModeUnsafe,
options)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status == "" {
t.Fatalf("probe produced no result")
}
return *got
}
// THE PARITY OBLIGATION for un-gating the progress path.
//
// The relationship asserted here is EQUALITY, per RULES.md §3: a tool's
// progress wiring before and after this change must be the same for every tool
// that already had it and every tool that already lacked it. Only a tool that
// newly DECLARES the interface may change.
//
// The name gate is gone, so the guarantee cannot be "Task still works" — it has
// to be stated in terms of the declaration, which is what these cases do.
func TestProgressCallbackFollowsTheDeclarationNotTheName(t *testing.T) {
t.Run("a declaring tool receives it (Task's behaviour, preserved)", func(t *testing.T) {
var got bool
// Named something other than "Task" ON PURPOSE: under the old name gate
// this case failed, which is the whole point of the change.
if !runProbe(t, &progressProbeTool{name: "spawner", streams: true, got: &got}, &got, true) {
t.Fatal("a tool declaring StreamsChildProgress must receive the callback")
}
})
t.Run("a tool named Task that does NOT declare gets nothing", func(t *testing.T) {
var got bool
// The inverse of the old behaviour, and the reason a name is the wrong
// key: identity is not capability.
if runProbe(t, &silentProbeTool{name: "Task", got: &got}, &got, true) {
t.Fatal("the callback must follow the declaration, not the name")
}
})
t.Run("a non-declaring tool receives nothing (every other tool, unchanged)", func(t *testing.T) {
var got bool
if runProbe(t, &silentProbeTool{name: "read_file", got: &got}, &got, true) {
t.Fatal("un-gating must not start handing a callback to tools that never had one")
}
})
t.Run("a declaring tool that answers false gets nothing", func(t *testing.T) {
var got bool
if runProbe(t, &progressProbeTool{name: "spawner", streams: false, got: &got}, &got, true) {
t.Fatal("StreamsChildProgress() == false must be honoured")
}
})
t.Run("no sink means no callback, whatever the tool declares", func(t *testing.T) {
var got bool
if runProbe(t, &progressProbeTool{name: "spawner", streams: true, got: &got}, &got, false) {
t.Fatal("without OnToolProgress there is nothing to forward to")
}
})
}
+13
View File
@@ -2,6 +2,7 @@
package agent
import (
"github.com/Twigpine/zero/internal/specialist"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
@@ -39,3 +40,15 @@ func parsePreservedState(summaryContent string) (string, []skillEntry) {
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
}
// Phase 2 additivity-proof seam. The identity test uses the REAL orchestrate
// tool rather than a stub, so it exercises the actual Deferred() contract that
// enforces the posture-off constraint. internal/specialist does not import
// internal/agent, so this direction creates no cycle.
const phase2ToolName = specialist.OrchestrateToolName
// registerPhase2ToolForTest registers the real tool with the posture OFF, which
// is the condition the identity test is about.
func registerPhase2ToolForTest(registry *tools.Registry) {
registry.Register(&specialist.OrchestrateTool{PostureActive: func() bool { return false }})
}
+435 -3
View File
@@ -1,6 +1,7 @@
package agent
import (
"regexp"
"strconv"
"strings"
@@ -168,7 +169,24 @@ var selfReportPhrases = []string{
var inabilityStems = []string{
"i cannot ", "i can't ", "i can not ", "i could not ", "i couldn't ",
"i am unable to", "i'm unable to", "i was unable to", "i wasn't able to",
"i was not able to", "i do not have", "i don't have", "unable to ",
"i was not able to", "i do not have", "i don't have",
"we are unable to", "we were unable to",
// THE SUBJECTLESS STEM IS KEPT, and the heading it fired on is handled where
// the heading is, not by deleting the stem.
//
// It was removed once because a completed audit's own section heading —
// "**Unable to verify (1):** - MCP #3 claim was truncated" — reads as an
// admission. But it is the ONLY stem that catches an admission with no
// first-person subject, and removing it lost every one of those:
//
// "Unable to complete the task; the build never succeeded."
// "The agent was unable to finish the migration."
// "Unable to verify the fix, so the change is unverified."
//
// None name "i" or "we", so no other stem sees them. That traded one false
// positive for three false negatives, in the direction this guard exists to
// prevent. countedLabelSentence drops the heading shape instead.
"unable to ",
"without being able to",
}
@@ -179,6 +197,19 @@ var inabilityStems = []string{
var successNegationTails = []string{
"find any", "found any", "find a ", "see any", "detect any", "identify any",
"reproduce", "spot any", "locate any",
// A NEGATIVE SEARCH RESULT IS THE ANSWER, not a failure to produce one.
//
// The list above already encodes this — "I could not find any remaining
// issues" is success — but only for the "any" phrasings. A finder reporting
// "I could NOT find where AllowManifestToolAutoApproval is set to true in
// production code" was marked INCOMPLETE after 53 tool calls and a 19,145
// character audit, for doing precisely the job it was given: establishing
// that something is not there.
"find where", "find the", "find it", "find that", "find this",
"found where", "found the",
"locate where", "locate the", "locate it",
"determine where", "identify where", "see where",
"reproduce ", "confirm any", "observe any",
}
// narrativeMarkers flag a sentence as RETELLING a past exchange rather than
@@ -247,11 +278,401 @@ func admissionSentences(lower string) []string {
// retells a past exchange (narrativeMarkers) is skipped entirely — an admission
// must be the model's own report about the CURRENT objective, not general
// language that merely resembles one.
// toolGrantMarkers flag a sentence as reporting WHICH TOOLS this run was given,
// not whether the work was done.
//
// A read-only plan task is SUPPOSED to say this. One wrote "I don't have an
// update_plan tool available in this specialist context (only read-only
// exploration tools were provided)" and then delivered the complete answer —
// helper name, file, line 214, full source — and was marked INCOMPLETE on the
// "i don't have" stem. The prompt asks tasks to name their limits plainly; a
// detector that punishes exactly that teaches the opposite.
//
// NARROW ON PURPOSE: the sentence must name a TOOL or a GRANT. "I do not have
// enough evidence" is still an admission and still fires.
// NARROWED AFTER AN AUDIT OF THIS VERY FIX. The first version listed a bare
// " tool", which exempts any inability sentence that merely mentions one —
// measured at 5/5 on ordinary phrasings:
//
// "I cannot run the build tool, so the change is unverified"
// "I could not use the migration tool and the data is untouched"
// "I was unable to invoke the formatting tool on the output"
//
// Those are genuine admissions, and silently exempting them is the WORSE
// direction: a false positive costs a re-run, a false negative reports
// unfinished work as done. The markers now have to be about what the run WAS
// GIVEN, not about a tool being mentioned at all.
var toolGrantMarkers = []string{
"tool available", "tools available", "no such tool", "not available in this",
"read-only tools", "read only tools", "only read-only", "only read only",
"tools were provided", "tools were given", "toolset provided",
"in this specialist context", "in this context only",
"is not in my toolset", "not in my toolset", "not in this toolset",
}
// objectiveFailureMarkers name the OBJECTIVE rather than a capability. A
// sentence carrying one is about whether the job got done, so the tool-grant
// exemption above does not apply to it however many tools it mentions.
// VERB-ANCHORED, not bare nouns. "this task" alone was too crude: a task that
// finished wrote "so i could not record a plan; the task is a single
// read-and-report step and is now complete" — it names the task in order to
// report SUCCESS, and a bare-noun override read that as failure. The marker has
// to be the objective NOT BEING DONE, which needs the verb.
var objectiveFailureMarkers = []string{
"complete this task", "complete the task", "completing this task", "completing the task",
"finish this task", "finish the task", "finishing this task",
"complete it", "completing it", "finish it", "finishing it",
// The objective and the assignment need the verb too, for the same reason
// "this task" did. "the objective is met" and "the assignment is complete"
// name the objective in order to report SUCCESS, and a bare noun read both
// as failure — so a finished answer carrying a tool caveat was told it had
// not finished, which is the worst thing this detector can do.
"complete the objective", "completing the objective",
"finish the objective", "finishing the objective",
"meet the objective", "meeting the objective", "achieve the objective",
"complete the assignment", "completing the assignment",
"finish the assignment", "finishing the assignment",
"as requested", "what was asked",
"do this task", "perform this task", "carry out this task",
}
// blockedWorkMarkers are what turns an absence-establishing sentence back into
// an admission of failure.
//
// MEASURED, NOT ARGUED. The successNegationTails list above exists 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 whole sentence regardless of how it 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 entire purpose defeated — it is the last thing between a
// stalled run and a report that reads like success.
//
// So the allowance now yields when the sentence ALSO says the work is blocked.
// "I could not find where X is set in production code" still passes, because
// nothing in it claims the objective was left undone; the three above do not.
// strongAbsenceTails are the allowance tails carrying an explicit "any": the
// model asserting it looked and found NOTHING. That is a finding whatever the
// rest of the sentence says, so blockedWorkMarkers does not override them.
var strongAbsenceTails = []string{
"find any", "found any", "see any", "detect any", "identify any",
"spot any", "locate any", "confirm any", "observe any",
// The OBSERVATION family. Looking for a failure and not producing one is the
// same kind of result as looking for an issue and not finding one, and
// leaving these out meant "I could not reproduce any failure in the parser"
// was not a strong absence — so an unrelated blocked statement in the next
// sentence flipped a clean negative result into an admission.
"reproduce any", "trigger any", "produce any", "hit any",
"encounter any", "provoke any", "surface any", "measure any",
}
// strongAbsenceObjects are the things whose ABSENCE IS THE RESULT: you go
// looking for them precisely so you can report there are none, and finding none
// is the work succeeding.
//
// WHAT FOLLOWS "any" DECIDES, and treating every "find any" as success was too
// broad:
//
// "I could not find any remaining issues" -> a finding, the search succeeded
// "I could not find any solution" -> an admission, the work did not
//
// Both carry the explicit "any". Only the object separates them, so only the
// object can classify them.
//
// AN ALLOW-LIST, because this grants the exemption. A deny-list of deliverables
// would have to anticipate every noun a model might reach for, and everything
// forgotten would be waved through as success — the failure direction this
// detector exists to prevent. An unrecognised object is simply not strong, which
// leaves the sentence to the ordinary blocked-work handling rather than
// flagging it outright.
var strongAbsenceObjects = []string{
"issue", "issues", "problem", "problems", "bug", "bugs", "defect", "defects",
"error", "errors", "failure", "failures", "regression", "regressions",
"evidence", "example", "examples", "occurrence", "occurrences",
"instance", "instances", "reference", "references", "match", "matches",
"caller", "callers", "usage", "usages", "use", "uses", "case", "cases",
"vulnerability", "vulnerabilities", "leak", "leaks", "race", "races",
"sign", "signs", "trace", "traces", "mention", "mentions", "difference", "differences",
"blocker", "blockers", "gap", "gaps", "omission", "omissions", "discrepancy", "discrepancies",
"conflict", "conflicts", "violation", "violations", "warning", "warnings",
"call", "calls", "site", "sites", "callsite", "callsites", "consumer", "consumers",
"dependency", "dependencies", "user", "users", "path", "paths",
}
// absenceQualifiers sit between "any" and the object without changing it.
var absenceQualifiers = []string{
"remaining", "other", "further", "more", "additional", "obvious", "such",
"outstanding", "new", "existing", "actual", "real", "clear", "direct",
}
// strongAbsence reports whether tail is an "any"-family absence whose OBJECT
// makes finding nothing the result rather than the shortfall.
func strongAbsence(tail string) bool {
for _, prefix := range strongAbsenceTails {
if !strings.HasPrefix(tail, prefix) {
continue
}
rest := strings.TrimSpace(tail[len(prefix):])
for trimmed := true; trimmed; {
trimmed = false
for _, qualifier := range absenceQualifiers {
if word, remainder, ok := cutFirstWord(rest); ok && word == qualifier {
rest, trimmed = remainder, true
break
}
}
}
word, _, ok := cutFirstWord(rest)
if !ok {
continue
}
for _, object := range strongAbsenceObjects {
if word == object {
return true
}
}
}
return false
}
// cutFirstWord returns the first bare word of text, lowercased by the caller's
// own normalisation, with surrounding punctuation removed.
func cutFirstWord(text string) (word string, rest string, ok bool) {
text = strings.TrimLeft(text, " \t")
end := 0
for end < len(text) {
c := text[end]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' {
end++
continue
}
break
}
if end == 0 {
return "", text, false
}
return text[:end], text[end:], true
}
var blockedWorkMarkers = []string{
"unverified", "not verified", "cannot verify", "could not verify",
"someone else", "will need to", "needs someone", "handed off", "hand off",
// "someone else" and "will need to" are the two that also appear in
// SUCCESSFUL reports, describing somebody else's future work — "I could not
// find any remaining issues, though a follow-up will need to cover the
// Windows path" is a finding. They are kept because they do carry a genuine
// handoff-because-blocked ("someone else will need to pick this up"), and the
// strongAbsenceTails exemption above is what stops them flipping a finding.
"ran out of", "run out of", "out of time", "in the time available",
"stopped there", "stopping here", "gave up", "giving up",
"nothing was modified", "no changes were made", "left unchanged", "left undone",
"may be inert", "is unresolved", "remains unresolved", "still broken",
"so i cannot", "so i could not",
// Saying the work is BLOCKED, in as many words. "I could not find the root
// cause, so the work is blocked" carries the statement in the same sentence
// and still passed, because every marker above names a symptom of being
// blocked and none named the thing itself.
"is blocked", "are blocked", "remains blocked", "stays blocked", "still blocked",
"cannot proceed", "could not proceed", "can not proceed", "unable to proceed",
"is unfinished", "remains unfinished", "left unfinished", "still unfinished",
"is incomplete", "remains incomplete",
"still unresolved", "still unverified",
}
// bareInabilityStems are the two entries above that are STEMS rather than
// descriptions of a blocked state. The stem loop below already scans them with
// its own tail logic, and a sentence can carry one on its way to reporting
// success: "so i could not record a plan; the task is a single read-and-report
// step and is now complete" is a finished task that did not need the plan.
var bareInabilityStems = []string{"so i cannot", "so i could not"}
// unambiguousFailureStates say the work is in a bad state, with no reading on
// which it is a result.
//
// AN OBJECT CANNOT OUTRANK AN EXPLICIT STATE. strongAbsence returns true for
// "evidence", and that suppressed every blocked-work marker in the sentence — so
// "I could not find any evidence supporting the fix, so it remains unverified"
// reported success while saying in as many words that the work is unverified.
// The absence protection exists for ownership and follow-up wording, where "I
// could not find any remaining issues, though a follow-up will need to cover the
// Windows path" really is a finding. It was never meant to cover a sentence that
// states the outcome.
//
// So this is the SHORT list: "unverified" and "still broken" have one reading,
// while "someone else", "will need to" and "nothing was modified" have two and
// stay ambiguous. Same-sentence only — a state in the NEXT sentence may belong
// to another subject, which is what the lookahead's topic-shift guard is for.
var unambiguousFailureStates = []string{
"unverified", "not verified",
"is unresolved", "remains unresolved",
"still broken",
"is blocked", "are blocked", "remains blocked", "stays blocked", "still blocked",
"is unfinished", "remains unfinished", "left unfinished", "still unfinished",
"still unresolved", "still unverified",
"is incomplete", "remains incomplete",
"cannot proceed", "could not proceed", "unable to proceed",
"gave up", "giving up", "ran out of", "run out of",
"left undone",
}
// consequenceBoundaries separate what was looked for from what followed.
//
// THE STATE HAS TO BE THE OUTCOME, NOT PART OF WHAT WAS NEGATED. Matching
// "is unresolved" anywhere in the sentence read the negated PROPOSITION as the
// reported result:
//
// "I could not find any evidence that the issue is unresolved."
//
// That is a successful negative finding — there is no evidence the issue remains
// unresolved — and it was marked incomplete, which is the opposite polarity of
// the case the override was added for. What separates them is position: after a
// consequence boundary the state is being asserted, inside a "that…" clause it is
// the thing being denied.
var consequenceBoundaries = []string{", so ", "; ", ", but ", ", therefore", ", leaving ", ", which "}
// reportedConsequence returns the part of a sentence that states the OUTCOME,
// or "" when the sentence never turns to one. Everything before the first
// boundary is what was searched for.
func reportedConsequence(sentence string) string {
earliest := -1
width := 0
for _, boundary := range consequenceBoundaries {
if index := strings.Index(sentence, boundary); index >= 0 && (earliest < 0 || index < earliest) {
earliest, width = index, len(boundary)
}
}
if earliest < 0 {
return ""
}
return sentence[earliest+width:]
}
// blockedStateMarkers describe WORK LEFT BLOCKED — unverified, unresolved,
// handed off, abandoned for time. Only these break the tool-grant exemption:
// they say something about the state the work is in, which a tool caveat cannot
// excuse, whereas a bare stem says only that one step did not happen.
//
// DERIVED, not copied. Two hand-maintained lists that must stay in step drift,
// and the drift here would be silent in both directions.
var blockedStateMarkers = func() []string {
bare := make(map[string]bool, len(bareInabilityStems))
for _, stem := range bareInabilityStems {
bare[stem] = true
}
out := make([]string, 0, len(blockedWorkMarkers))
for _, marker := range blockedWorkMarkers {
if !bare[marker] {
out = append(out, marker)
}
}
return out
}()
// carriesTheConsequence reports whether the sentence after an allowance should
// be read as that allowance's consequence.
//
// The lookahead exists because the consequence usually IS the next sentence, but
// "usually" is not "always" — a message can turn to something else, and reading
// a blocked statement about a different subject as this one's consequence is the
// cost of the lookahead. A sentence that announces the change of subject, or
// disclaims the thing as out of scope, is taken at its word.
//
// This does not catch every unrelated follow-on, and deliberately errs toward
// reading the next sentence: an admission reported as success is the failure this
// guard exists to prevent, and a message that says something is unverified has
// said it whether or not it is the same something.
func carriesTheConsequence(next string) bool {
return !containsAny(next, topicShiftMarkers)
}
// topicShiftMarkers say the message has moved on to something else.
var topicShiftMarkers = []string{
"separately", "unrelatedly", "unrelated", "as an aside", "aside from",
"out of scope", "outside the scope", "not in scope", "for a different",
"in a different", "on another", "elsewhere in", "in other news",
}
// countedLabelSentence reports whether a sentence is a markdown LABEL that
// introduces and counts a bucket of findings, rather than a claim about the
// objective.
//
// "**Unable to verify (1):** - MCP #3 claim was truncated" is a section heading
// in a COMPLETED audit. Read as a sentence it is indistinguishable from an
// admission, which is why the subjectless stem was once deleted to silence it.
//
// NARROW ON PURPOSE: the sentence must BEGIN with the inability phrase, after
// markdown emphasis, AND carry a parenthesised count. "Unable to complete the
// task; the build never succeeded" begins the same way and has no count, so it
// still fires — which is the whole reason this is preferable to dropping the
// stem.
func countedLabelSentence(sentence string) bool {
trimmed := strings.TrimLeft(strings.TrimSpace(sentence), "-*#> \t")
if !strings.HasPrefix(trimmed, "unable to ") {
return false
}
return countedLabelSuffix.MatchString(sentence)
}
var countedLabelSuffix = regexp.MustCompile(`\(\s*\d+\s*\)`)
func selfReportedIncompletion(text string) string {
for _, sentence := range admissionSentences(strings.ToLower(stripQuoted(text))) {
sentences := admissionSentences(strings.ToLower(stripQuoted(text)))
for index, sentence := range sentences {
// THE CONSEQUENCE IS OFTEN THE NEXT SENTENCE. The blocked-work override
// only ever saw the sentence the allowance fired in, so the same
// admission escaped or was caught purely on its punctuation:
//
// "I could not reproduce the crash, so the fix is unverified." caught
// "I could not reproduce the crash. The fix is unverified." missed
//
// A full stop is not a claim that the work finished, and writing the
// consequence as its own sentence is how most people write. The
// blocked-work question is asked of this sentence AND the one after it;
// everything else is still decided on the sentence alone, so a stem in
// one sentence cannot be paired with an allowance tail in another.
blockedContext := sentence
if index+1 < len(sentences) && carriesTheConsequence(sentences[index+1]) {
blockedContext += " " + sentences[index+1]
}
if containsAny(sentence, narrativeMarkers) {
continue
}
// A counted label is a heading, not a claim about the objective.
if countedLabelSentence(sentence) {
continue
}
// A sentence about the tool grant is about CAPABILITY, not about the
// objective — UNLESS it also says the task itself could not be done.
//
// THE OVERRIDE IS NOT OPTIONAL. Without it the exemption swallowed a
// genuine failure: "I am unable to complete this task with the current
// tool set … Only write_file is enabled … so I cannot inspect the
// codebase." That task really did fail, and it mentions tools, so a bare
// tool-marker check waved it through. Naming the task is what separates
// "I lack a tool I did not need" from "I lack the tools this needed".
// BLOCKED WORK BREAKS THE EXEMPTION TOO, not just a named objective. The
// override above asks whether the sentence names the task, which a whole
// class of real admissions never does:
//
// "No write tool is available in this context, so the fix is unverified."
// "There is no edit tool available here, so the change remains unapplied."
// "Write tools are not available in this setup, so the tests were never run."
//
// Every one mentions tools, none names the objective, and all four were
// waved through. The tool caveat is the REASON the work is blocked, not a
// reason to stop reading — so a sentence that also says something is
// unverified, unapplied or untested goes on to the blocked-work handling
// below instead of being exempted here.
if containsAny(sentence, toolGrantMarkers) &&
!containsAny(sentence, objectiveFailureMarkers) &&
!containsAny(sentence, blockedStateMarkers) {
continue
}
for _, phrase := range selfReportPhrases {
if strings.Contains(sentence, phrase) {
return selfReportReason(phrase)
@@ -268,7 +689,18 @@ func selfReportedIncompletion(text string) string {
}
abs := start + rel
tail := strings.TrimSpace(sentence[abs+len(stem):])
if !hasAnyPrefix(tail, successNegationTails) {
// The allowance yields to a sentence that also says the work is
// blocked: "could not reproduce the crash" is a finding, "could not
// reproduce the crash, so the fix is unverified" is an admission,
// and the tail prefix alone cannot tell them apart.
strong := strongAbsence(tail)
// The object decides whether finding nothing is a result; an
// explicit failure state in the SAME sentence decides that it is
// not, whatever the object.
if strong && containsAny(reportedConsequence(sentence), unambiguousFailureStates) {
strong = false
}
if !hasAnyPrefix(tail, successNegationTails) || (!strong && containsAny(blockedContext, blockedWorkMarkers)) {
return selfReportReason(strings.TrimSpace(stem) + " …")
}
start = abs + len(stem)
@@ -0,0 +1,458 @@
package agent
import "testing"
// FOUR COMPLETED TASKS WERE MARKED INCOMPLETE, and the sentences below are
// verbatim from those sessions.
//
// The detector reads message text — the one place in this feature that does —
// and it is supposed to catch a model admitting it did not do the job. Instead
// it caught four tasks that HAD done the job and were honestly naming a limit,
// which is exactly what the plan-task prompt asks them to do. Two of them had
// made 53 and 60 tool calls; one had written files.
//
// The cost of a false positive here is not cosmetic: the task is reported
// failed, retried on another model, and rendered red in the plan panel.
func TestAnHonestCaveatInsideDeliveredWorkIsNotAnAdmission(t *testing.T) {
for _, tc := range []struct {
name string
text string
}{
{
// 53 tool calls, a 19,145-character audit. Establishing that
// something is NOT there is the finding, not a failure to find one.
name: "a negative search result is the answer",
text: "I traced every code path where repo-committed files influence tool approval. " +
"I could NOT find where `AllowManifestToolAutoApproval` is set to true in production code paths.",
},
{
// A completed verification task categorising its own findings. The
// bare "unable to" stem had no first-person subject and fired on a
// markdown heading.
name: "a report section heading",
text: "## Verification Table\n\n**Verified (4):** all confirmed against source.\n" +
"**Unable to verify (1):** - MCP #3 claim was truncated in the input.",
},
{
// Delivered helper name, file, line 214 and full source. The task was
// read-only by design and said so.
name: "a statement about the tool grant",
text: "I don't have an `update_plan` tool available in this specialist context " +
"(only read-only exploration tools were provided). " +
"The task is a single read-only assessment and I've already gathered all the needed evidence.",
},
{
name: "read-only tools named plainly",
text: "I did not run the tests because my tools are read-only, " +
"so I can report only what the code and tests say statically.",
},
} {
t.Run(tc.name, func(t *testing.T) {
if reason := selfReportedIncompletion(tc.text); reason != "" {
t.Fatalf("a completed task was marked incomplete: %s", reason)
}
})
}
}
// AND THE DETECTOR MUST STILL BITE. Widening it to stop punishing honesty must
// not turn it off — these are the admissions it exists for, several of them
// verbatim from the same set of sessions.
func TestGenuineAdmissionsAreStillCaught(t *testing.T) {
for _, tc := range []struct {
name string
text string
}{
{
name: "the measured workspace failure",
text: "I cannot complete this task. The target directory `/Users/kratos/zm-lab/pkg/execprofile` " +
"is outside the workspace boundary.",
},
{
name: "an empty worktree",
text: "This task cannot be completed in the current workspace because the relevant source tree is absent. " +
"I cannot find BuildFinalResult anywhere.",
},
{
name: "guessing",
text: "I guessed at the retry semantics because the code was not reachable.",
},
{
name: "fabrication",
text: "I fabricated the line numbers to fill the table.",
},
{
name: "stated doubt about the result",
text: "The patch may not be correct — I did not run it.",
},
{
name: "not enough evidence is still an admission",
text: "I do not have enough evidence to answer the question.",
},
{
name: "first-person unable, with a subject",
text: "I was unable to determine which branch actually runs.",
},
} {
t.Run(tc.name, func(t *testing.T) {
if reason := selfReportedIncompletion(tc.text); reason == "" {
t.Fatalf("a real admission was missed: %q", tc.text)
}
})
}
}
// The tool-grant exemption is NARROW. A sentence has to be about tools or the
// grant; "not enough evidence" is not, and must keep firing.
func TestTheToolGrantExemptionDoesNotSwallowRealAdmissions(t *testing.T) {
if reason := selfReportedIncompletion("I could not verify the claim and I do not have enough evidence."); reason == "" {
t.Fatal("the tool-grant exemption swallowed an evidence admission")
}
if reason := selfReportedIncompletion("I don't have the file contents, so the answer is a guess."); reason == "" {
t.Fatal("an admission about missing content was exempted as a tool statement")
}
}
// THE EXEMPTION MUST NOT SWALLOW A TASK THAT REALLY COULD NOT BE DONE.
//
// Verbatim from a session the first version of this fix regressed: the task
// mentions tools, so a bare tool-marker check waved it through — but it names
// the TASK as the thing that failed, which is the whole distinction.
func TestATaskThatCouldNotBeDoneForLackOfToolsStillFails(t *testing.T) {
for _, tc := range []struct {
name string
text string
}{
{
name: "the measured regression",
text: "I am unable to complete this task with the current tool set. " +
"Only `write_file` is enabled, so I cannot inspect the codebase to collect scan findings.",
},
{
name: "named differently",
text: "I could not finish the objective because no read tools were provided.",
},
{
name: "cannot complete it",
text: "I don't have the tools to complete it as requested.",
},
} {
t.Run(tc.name, func(t *testing.T) {
if reason := selfReportedIncompletion(tc.text); reason == "" {
t.Fatalf("a task that genuinely could not be done was passed as complete: %q", tc.text)
}
})
}
// And the exemption must still work for the case it exists for: a tool that
// was not needed, alongside delivered work.
exempt := "I don't have an `update_plan` tool available in this specialist context " +
"(only read-only exploration tools were provided)."
if reason := selfReportedIncompletion(exempt); reason != "" {
t.Fatalf("the override broke the exemption it guards: %s", reason)
}
}
// NAMING THE TASK TO REPORT SUCCESS IS NOT NAMING IT TO REPORT FAILURE.
//
// Verbatim from the session that the first override regressed. A task that had
// finished wrote a footnote about a tool it did not need, in the same sentence
// as its own completion statement — and a bare-noun override read "the task" as
// an admission. The marker has to be the objective NOT being done.
func TestNamingTheTaskWhileReportingSuccessIsNotAnAdmission(t *testing.T) {
done := "- **note:** the update_plan tool is not available in my current toolset " +
"(only read-only file tools), so i could not record a plan; " +
"the task is a single read-and-report step and is now complete"
if reason := selfReportedIncompletion(done); reason != "" {
t.Fatalf("a finished task was marked incomplete for saying so: %s", reason)
}
// The failure form, which must still fire, differs only in the verb.
failed := "the update_plan tool is not available, so i could not complete this task"
if reason := selfReportedIncompletion(failed); reason == "" {
t.Fatal("a task that could not be completed was passed as complete")
}
}
// AN INABILITY THAT MERELY MENTIONS A TOOL IS STILL AN ADMISSION.
//
// THE AUDIT FINDING THIS PINS. The first version of the exemption listed a bare
// " tool", so any inability sentence mentioning one escaped — measured at 5/5 on
// ordinary phrasings. That is the worse direction: a false positive costs a
// re-run, a false negative reports unfinished work as done.
func TestMentioningAToolDoesNotExemptAnAdmission(t *testing.T) {
for _, text := range []string{
"I cannot run the build tool, so the change is unverified",
"I could not use the migration tool and the data is untouched",
"I was unable to invoke the formatting tool on the output",
"I don't have a working compiler tool here",
"I cannot finish because the deploy tools are broken",
} {
if reason := selfReportedIncompletion(text); reason == "" {
t.Errorf("a genuine admission was silently exempted: %q", text)
}
}
}
// And the exemption still covers what it was built for: a run stating which
// tools it was GIVEN, alongside delivered work.
func TestTheGrantExemptionStillCoversWhatItWasBuiltFor(t *testing.T) {
for _, text := range []string{
"I don't have an `update_plan` tool available in this specialist context (only read-only exploration tools were provided).",
"I did not run the tests because only read-only tools were provided, so I report what the code says statically.",
"update_plan is not in my toolset, so I could not record a plan.",
} {
if reason := selfReportedIncompletion(text); reason != "" {
t.Errorf("a run naming its own grant was marked incomplete: %q -> %s", text, reason)
}
}
}
// NAMING THE OBJECTIVE TO REPORT SUCCESS, same trap as naming the task.
//
// "the objective" and "the assignment" were bare nouns in objectiveFailureMarkers
// while "this task" had already been verb-anchored for exactly this reason. A
// message that mentions a tool it lacked and then says the objective IS met was
// read as saying the objective was not met — a finished answer told it had not
// finished, which is the worst outcome this detector has.
func TestNamingTheObjectiveWhileReportingSuccessIsNotAnAdmission(t *testing.T) {
for _, done := range []string{
"I do not have write tools available, but the objective is met: the config already sets the flag.",
"No write tool is available to me, so the objective was achieved by reading alone.",
"The assignment is complete; no shell was needed.",
"I have no browser tool available here, yet the assignment is complete.",
} {
if reason := selfReportedIncompletion(done); reason != "" {
t.Errorf("a finished answer was marked incomplete for naming its objective: %q -> %s", done, reason)
}
}
// The failure forms differ only in the verb, and must still fire.
for _, failed := range []string{
"I could not complete the objective because the build never succeeded.",
"I was unable to finish the assignment.",
} {
if reason := selfReportedIncompletion(failed); reason == "" {
t.Errorf("an objective that was not met was passed as complete: %q", failed)
}
}
}
// A TOOL CAVEAT IS THE REASON WORK IS BLOCKED, NOT A REASON TO STOP READING.
//
// The tool-grant exemption asked only whether the sentence named the objective,
// so an admission that named none was waved through on the strength of
// mentioning tools:
//
// "No write tools available, so I could not verify the change."
//
// It breaks now on a blocked STATE — unverified, unresolved, handed off — but
// deliberately not on the two bare inability stems in that list, because a
// sentence carrying one can still be on its way to reporting success (see
// TestNamingTheTaskWhileReportingSuccessIsNotAnAdmission, which this broke when
// the whole list was used).
func TestAToolCaveatDoesNotExcuseBlockedWork(t *testing.T) {
for _, admission := range []string{
"No write tools available, so I could not verify the change.",
"There is no edit tool available here, so I could not verify the fix.",
} {
if reason := selfReportedIncompletion(admission); reason == "" {
t.Errorf("a tool caveat excused blocked work: %q", admission)
}
}
// The exemption still covers what it exists for: a tool that was not needed.
for _, exempt := range []string{
"No update_plan tool available in this specialist context, so I proceeded directly.",
"There is no update_plan tool available in this specialist context (only read-only tools), so I have written the plan into this answer instead.",
} {
if reason := selfReportedIncompletion(exempt); reason != "" {
t.Errorf("the blocked-state check broke the exemption it guards: %q -> %s", exempt, reason)
}
}
}
// WHAT FOLLOWS "any" DECIDES WHETHER FINDING NOTHING IS THE RESULT.
//
// The "any"-family was treated as a finding whatever the object, so an admission
// wearing the same words walked through:
//
// "I could not find any remaining issues" -> a finding, the search succeeded
// "I could not find any solution" -> an admission, the work did not
//
// Both carry the explicit "any"; only the object separates them. The object list
// is an ALLOW-LIST because it grants the exemption — a deny-list of deliverables
// would wave through every noun nobody thought of, which is the wrong direction
// for this detector to fail in.
func TestAnAbsenceIsAFindingOnlyWhenTheObjectMakesItOne(t *testing.T) {
for _, admission := range []string{
"I could not find any solution, so the migration remains unresolved.",
"I could not find any fix, so the build is still broken.",
"I could not find any workaround; someone else will need to pick this up.",
"I could not identify any approach, so this is unverified.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an admission wearing the words of a finding passed as complete: %q", admission)
}
}
// The findings the allowance exists for are untouched, including the ones
// carrying a blocked-work marker about somebody else's future work.
for _, finding := range []string{
"I could not find any remaining issues, though a follow-up will need to cover the Windows path.",
"I could not find any blockers; someone else can take the release from here.",
"I could not find any evidence of a leak, so nothing was modified.",
"I did not see any further regressions, so someone else can ship it.",
"I could not detect any races, and nothing was modified.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("a finding was reported as an admission: %q -> %s", finding, reason)
}
}
}
// A FULL STOP IS NOT A CLAIM THAT THE WORK FINISHED.
//
// The blocked-work override only ever saw the sentence the allowance fired in,
// so the SAME admission was caught or missed on its punctuation alone:
//
// "I could not reproduce the crash, so the fix is unverified." caught
// "I could not reproduce the crash. The fix is unverified." missed
//
// Writing the consequence as its own sentence is how most people write, and half
// of a reviewer's corpus of ordinary admissions escaped through it.
//
// Only the blocked-work question looks ahead. A stem in one sentence is still
// never paired with an allowance tail in another.
func TestTheConsequenceMayBeTheNextSentence(t *testing.T) {
for _, admission := range []string{
"I could not reproduce the crash, so the fix is unverified.",
"I could not reproduce the crash. The fix is unverified.",
"I could not locate the source of the regression and have run out of ideas.",
"I could not locate the source of the regression. I have run out of ideas.",
// "the work is blocked" says it in as many words, and every marker was a
// SYMPTOM of being blocked rather than the thing itself.
"I could not find the root cause, so the work is blocked.",
"I could not find the root cause. The work is blocked.",
"I could not get the integration test to run. The behaviour is therefore unverified.",
"I could not apply the patch cleanly. Nothing was modified.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an admission escaped by putting its consequence in a second sentence: %q", admission)
}
}
// THE LOOKAHEAD'S OWN COST, guarded. A message that turns to other work must
// not have that work's blocked state read as this result's consequence.
for _, finding := range []string{
"I searched the tree and could not find any other call sites. The rename is complete.",
"I could not find any remaining usages of the deprecated helper. A future cleanup is blocked until the API freeze lifts.",
"I could not reproduce any failure in the parser. The CI flake in the network suite remains unresolved and belongs to another team.",
"I could not find any issues in the diff. Documentation for the new flag is unfinished, which was never part of this request.",
"I could not reproduce the crash. Separately, the deploy step is unverified because it needs prod access.",
"I could not find any other call sites. The follow-up work is blocked on a design decision, which is out of scope here.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("the lookahead read another subject's blocked state as this result's: %q -> %s", finding, reason)
}
}
}
// AN UNAMBIGUOUS STATE THAT IS NOT ALSO A MARKER IS DEAD.
//
// unambiguousFailureStates only decides whether a recognised absence object
// stops protecting the sentence; something still has to FIRE, and that is
// blockedWorkMarkers. Adding "still blocked" to the first list and not the
// second left it doing nothing at all, and the case looked handled because the
// phrase appeared in the code.
//
// Two hand-maintained lists that must agree is the shape that drifts, so the
// agreement is asserted rather than remembered.
func TestEveryUnambiguousStateIsAlsoABlockedWorkMarker(t *testing.T) {
for _, state := range unambiguousFailureStates {
found := false
for _, marker := range blockedWorkMarkers {
if state == marker {
found = true
break
}
}
if !found {
t.Errorf("%q outranks a strong absence but is not a blockedWorkMarker, so nothing fires on it", state)
}
}
}
// EXPLICIT FAILURE STATES OUTRANK A RECOGNISED ABSENCE OBJECT.
//
// strongAbsence returns true for objects like "evidence", and that suppressed
// every blocked-work marker in the sentence — so a message saying in as many
// words that the work is unverified reported success. The absence protection is
// for ownership and follow-up wording; it was never meant to cover a sentence
// that states the outcome.
func TestAnExplicitFailureStateOutranksTheAbsenceObject(t *testing.T) {
for _, admission := range []string{
"I could not find any evidence supporting the fix, so it remains unverified.",
"I could not find any evidence for the cause, so the bug is unresolved.",
"I could not find any way to make it work, so I gave up.",
"I could not find any working approach; it is still broken.",
"I could not find any issues, but the migration is still blocked.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an explicit failure state was overruled by the absence object: %q", admission)
}
}
// AMBIGUOUS wording still yields to the absence, which is the whole reason
// the protection exists: ownership and follow-up read two ways, an explicit
// state reads one.
for _, finding := range []string{
"I could not find any evidence that the flag is read in production.",
"I could not find any remaining issues, though a follow-up will need to cover the Windows path.",
"I could not find any blockers; someone else can take the release from here.",
"I could not find any evidence of a leak, so nothing was modified.",
"I could not detect any races, and nothing was modified.",
// A state in the NEXT sentence may belong to another subject, so
// same-sentence only.
"I could not reproduce any failure in the parser. The CI flake in the network suite remains unresolved and belongs to another team.",
"I could not find any issues in the diff. Documentation for the new flag is unfinished, which was never part of this request.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("a finding was flagged by ambiguous or another subject's wording: %q -> %s", finding, reason)
}
}
}
// THE STATE MUST BE THE OUTCOME, NOT PART OF WHAT WAS NEGATED.
//
// The override matched a failure state anywhere in the sentence, which read the
// negated PROPOSITION as the reported result:
//
// "I could not find any evidence that the issue is unresolved."
//
// That is a successful negative finding — there is no evidence the issue remains
// unresolved — and it was marked incomplete. Exactly the opposite polarity of the
// case the override was added for, and the two have to be tested together or
// fixing one just moves the error.
func TestAStateInsideTheNegatedPropositionIsNotTheOutcome(t *testing.T) {
for _, finding := range []string{
"I could not find any evidence that the issue is unresolved.",
"I could not find any evidence that the build is still broken.",
"I could not find any evidence that the migration is incomplete.",
"I could not find any sign that the deploy is blocked.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("a negated proposition was read as the reported outcome: %q -> %s", finding, reason)
}
}
// Both polarities together: after a consequence boundary the state IS the
// outcome and must still fire.
for _, admission := range []string{
"I could not find any evidence supporting the fix, so it remains unverified.",
"I could not find any evidence for the cause, so the bug is unresolved.",
"I could not find any way to make it work, so I gave up.",
"I could not find any working approach; it is still broken.",
"I could not find any issues, but the migration is still blocked.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an outcome stated after a consequence boundary was missed: %q", admission)
}
}
}
+152
View File
@@ -80,6 +80,9 @@ func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
if result.FinalAnswer == maxTurnsAnswer {
t.Fatalf("no-output guard must stop before reaching maxTurns, got max-turns answer")
}
if result.Incomplete {
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
}
}
func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
@@ -513,6 +516,22 @@ func repeatedFlakyTurns(n int) [][]zeroruntime.StreamEvent {
return turns
}
// alwaysSucceedingTool is the successful counterpart to alwaysFailingTool, used
// to put a productive tool turn before an empty-turn streak.
type alwaysSucceedingTool struct{}
func (alwaysSucceedingTool) Name() string { return "ok" }
func (alwaysSucceedingTool) Description() string { return "always succeeds for testing" }
func (alwaysSucceedingTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (alwaysSucceedingTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (alwaysSucceedingTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
@@ -529,6 +548,51 @@ func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
if len(provider.requests) != toolFailureStopAt {
t.Fatalf("expected stop at %d failures, made %d requests", toolFailureStopAt, len(provider.requests))
}
if result.Incomplete {
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
}
}
// Under the headless completion gate both guardrail stops are unfinished runs,
// so they must set Result.Incomplete and a reason; exec maps that to exit 4.
// Thresholds and stop answers stay pinned by the two tests above.
func TestHeadlessGuardStopsAreIncomplete(t *testing.T) {
for _, tc := range []struct {
name string
turns [][]zeroruntime.StreamEvent
wantTurns int
wantReason string
}{
{"empty_turns", [][]zeroruntime.StreamEvent{emptyTurn(), emptyTurn(), emptyTurn()}, maxEmptyTurns, "3 consecutive turns with no output"},
{"empty_turns_after_progress", [][]zeroruntime.StreamEvent{
toolTurn("ok-1", "ok", "{}"),
emptyTurn(), emptyTurn(), emptyTurn(),
}, maxEmptyTurns + 1, "3 consecutive turns with no output"},
{"repeated_tool_failures", repeatedFlakyTurns(10), toolFailureStopAt, "flaky"},
} {
t.Run(tc.name, func(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
registry.Register(alwaysSucceedingTool{})
result, err := Run(context.Background(), "go", &mockProvider{turns: tc.turns}, Options{
Registry: registry,
MaxTurns: 12,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if result.Turns != tc.wantTurns {
t.Fatalf("guard did not trigger: turns=%d want=%d", result.Turns, tc.wantTurns)
}
if !result.Incomplete {
t.Fatalf("guardrail stop under the headless gate must be Incomplete; final=%q", result.FinalAnswer)
}
if !strings.Contains(result.IncompleteReason, tc.wantReason) {
t.Fatalf("IncompleteReason = %q, want it to mention %q", result.IncompleteReason, tc.wantReason)
}
})
}
}
func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
@@ -554,3 +618,91 @@ func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
t.Fatalf("expected a tool-failure hint on the 3rd turn, messages: %+v", provider.requests[2].Messages)
}
}
// An absence-establishing sentence is a finding; the same sentence that also
// says the work is blocked is an admission. The allowance stems ("find the",
// "reproduce ", "confirm any" …) head both, so the tail prefix alone cannot
// separate them — measured against real phrasings, TEN of eleven genuine
// admissions passed the detector before blockedWorkMarkers existed.
func TestIncompletionAllowanceYieldsToBlockedWork(t *testing.T) {
// Must FIRE: the inability is reported as leaving work undone.
for _, admission := range []string{
"I could not reproduce the crash, so the fix is unverified.",
"I was unable to reproduce the reported behaviour and stopped there.",
"I could not find the root cause; someone else will need to pick this up.",
"I could not find the bug in the time available.",
"I did not manage to reproduce the failure, so I cannot confirm the patch works.",
"I could not locate the source of the regression and have run out of ideas.",
"I could not find where to apply the change, so nothing was modified.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("admission passed the detector: %q", admission)
}
}
// Must NOT fire: establishing that something is absent is the job, and the
// motivating case is the audit that spent 53 tool calls proving a negative.
for _, finding := range []string{
"I could not find any remaining issues.",
"I could NOT find where AllowManifestToolAutoApproval is set to true in production code.",
"I could not find the flag being set anywhere outside tests, so the concern does not apply.",
"I could not reproduce the reported exploit, which confirms the guard holds.",
"I could not observe any regression across the suite.",
"I could not confirm any leak; every path is bounded.",
// Next steps and ownership belong in successful reports too. Both of
// these were flagged when blockedWorkMarkers could override an explicit
// "any", which is the model asserting exhaustive absence.
"I could not find any remaining issues, though a follow-up will need to cover the Windows path.",
"I could not find any blockers; someone else can take the release from here.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("finding wrongly flagged as incomplete: %q -> %q", finding, reason)
}
}
// NOT CAUGHT, and recorded rather than hidden. An explicit "any" is treated
// as exhaustive absence, so an admission phrased that way passes:
//
// "I could not observe any effect, so the change may be inert."
//
// Measured across eleven admissions, four still pass, all of them either
// "any"-phrased or single-clause with no blocked-work signal ("I failed to
// reproduce it locally"). Catching those needs a different signal than
// substring matching; tightening this list to reach them re-broke the
// findings above every way I tried.
}
// An admission with NO first-person subject must still fire. The subjectless
// "unable to " stem was once deleted to silence a completed audit's section
// heading, which also lost every impersonal admission — none of these names "i"
// or "we", so no other stem sees them.
func TestImpersonalAdmissionsAreStillCaught(t *testing.T) {
for _, admission := range []string{
"Unable to complete the task; the build never succeeded.",
"The agent was unable to finish the migration.",
"Unable to verify the fix, so the change is unverified.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an impersonal admission passed the detector: %q", admission)
}
}
}
// The heading that motivated deleting the stem must stay exempt. It is a label
// counting a bucket of findings in a COMPLETED audit, not a claim about the
// objective — which is why it is recognised by shape rather than by removing a
// stem that catches real admissions.
func TestACountedHeadingIsNotAnAdmission(t *testing.T) {
for _, heading := range []string{
"**Unable to verify (1):** - MCP #3 claim was truncated",
"Unable to reproduce (3):",
} {
if reason := selfReportedIncompletion(heading); reason != "" {
t.Errorf("a counted heading was read as an admission: %q -> %q", heading, reason)
}
}
// But the same opening WITHOUT a count is a real admission.
if selfReportedIncompletion("Unable to verify the deployment; it never started.") == "" {
t.Error("a subjectless admission with no count was exempted as a heading")
}
}
+148 -11
View File
@@ -13,6 +13,7 @@ import (
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/measurements"
"github.com/Twigpine/zero/internal/modelregistry"
"github.com/Twigpine/zero/internal/redaction"
"github.com/Twigpine/zero/internal/sandbox"
@@ -23,8 +24,16 @@ import (
)
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
const maxTokensAnswer = "Agent reached its token budget without a final answer."
const maxTurnsFinalAnswerPrompt = "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.
const maxTokensFinalAnswerPrompt = "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
@@ -178,6 +187,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// this local, so no signatures change anywhere downstream.
provider = sessionProvider{session: session}
// The turn's raw user text, carried to tools so a gate can tell "the user
// asked for this" from "the model read a sentence that said to".
options.userMessage = prompt
maxTurns := options.MaxTurns
if maxTurns <= 0 {
maxTurns = 12
@@ -256,6 +269,18 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// FileDiagnostics callback is wired; every method no-ops on nil.
postEditDiagnostics := newAsyncDiagnostics(options.FileDiagnostics, options.Cwd)
// THE RUN'S OWN TIMINGS, so a final answer cannot report a number no command
// here produced. Every tool result is read for `go test` durations; the final
// answer is checked against them once before it is returned.
//
// POSTURE-GATED, and nil when the posture is off — every method no-ops on
// nil, so a run that never heard of zeromaxing takes exactly the path it took
// before this existed. See internal/measurements for the failure it is for.
var measured *measurements.Ledger
if options.Zeromaxing != ZeromaxingOff {
measured = measurements.NewLedger()
}
// loaded tracks deferred-eligible tools the model has pulled via tool_search
// during THIS run. It is consulted by partitionTools each turn to expose a
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
@@ -271,6 +296,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// doesn't re-run the recursive schema→map conversion for every tool every turn.
toolDefCache := map[string]zeroruntime.ToolDefinition{}
// SPEND, accumulated from what the provider actually reported rather than
// estimated. Only read when options.MaxTokens > 0, so an unbounded run adds
// one integer and changes nothing else.
spentTokens := 0
stoppedOnTokens := false
result = Result{Messages: copyMessages(messages)}
dispatchSessionStart(ctx, options)
defer func() {
@@ -283,6 +314,27 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
// CHECKED HERE, 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 already paid for.
if options.MaxTokens > 0 && spentTokens >= options.MaxTokens {
stoppedOnTokens = true
break
}
// The zeromaxing posture's reminders. Appended to the CONVERSATION tail
// as user-role messages — the same channel as the diagnostics nudge
// below and the failure/plan hints later in the turn — and never into
// the system prompt, which is built once per run and must stay
// byte-stable so the provider's cached prefix survives. See
// internal/agent/zeromaxing.go.
for _, reminder := range zeromaxingReminders(options.Zeromaxing, result.Turns, options.OrchestrateAvailable) {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: reminder,
})
}
// Deliver background post-edit diagnostics from the previous turn's edits
// BEFORE compaction so the nudge is part of the request being budgeted.
// A brief wait at most (asyncDiagnosticsDrainTimeout); an unfinished check
@@ -294,6 +346,19 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
})
}
// A BACKGROUND PLAN that finished since the last turn. Same channel and
// the same point in the turn as the diagnostics nudge above: the model
// was told the plan was not finished and must not report it as done, so
// this is the message that makes that promise good.
if options.PlanCompletions != nil {
if finished := options.PlanCompletions(); finished != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: finished,
})
}
}
// Build the per-turn tool list first so proactive compaction can include
// the tool-definition tokens (they ride on every request) in its estimate.
// partitionTools depends only on registry/permissions/options/loaded, not on
@@ -555,6 +620,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// assistant reply is appended below, after this, so `messages` is still the
// sent request.
compactor.calibrate(estimateTokens(messages)+estimateToolDefTokens(exposed), collected.Usage.InputTokens)
// Accumulated at the same point the estimator is calibrated: past error
// recovery, and past any reactive compaction that re-sent the request, so
// this counts the exchange that actually happened rather than one that was
// abandoned and replaced.
spentTokens += collected.Usage.TotalTokens()
// Carry the turn's terminal stop reason so a final answer cut off at the
// output token cap (or by a content filter) is reported as truncated. A
@@ -587,6 +657,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// counted toward the runaway cap so we stop before burning maxTurns.
if guards.observeTurn(collected) {
result.FinalAnswer = noOutputStopAnswer(result.Turns)
// Under the headless completion gate this stop is unfinished work,
// not a successful final answer: the guard exists precisely because
// the model made no progress. Interactive runs keep the gate off, so
// their behavior is unchanged.
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "stopped after " + strconv.Itoa(guards.emptyTurns) + " consecutive turns with no output"
}
result.Messages = copyMessages(messages)
return result, nil
}
@@ -654,6 +732,23 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
})
continue
}
// THE ANSWER IS CHECKED AGAINST THE TRANSCRIPT before it is returned.
// Same channel and the same gate as the diagnostics drain above, and
// for the same reason: a final answer means there is no later turn, so
// a number that contradicts what actually ran has to be raised now or
// never. Each name is raised at most once by the ledger, so an answer
// that comes back unchanged is returned rather than asked again.
// ACROSS RUNS, because a final answer may summarise several commands
// and this caller cannot say which one any given number came from.
// Holding it to a single run would accuse the model of inventing a
// number one of the other commands really printed.
if nudge := measurements.Nudge(measured.ConflictsAcrossRuns(collected.Text)); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
continue
}
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
@@ -742,6 +837,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
options.Trace.Counter(trace.CounterToolCalls, 1)
recordOutputBudgetTrace(options.Trace, toolResult)
// Read before anything can truncate or summarize it: this is the only
// place the command's own words are in hand — and the only place the
// CALL that produced them is in hand too. Recording which tool and
// which arguments printed a timing is what stops a claim about one
// command being answered by a number a different one produced: a
// `-race` run is routinely several times slower, which is the size of
// discrepancy this check exists to catch.
measured.Record(measurements.Run{Command: call.Name, Args: []string{call.Arguments}}, toolResult.Output)
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: call.Arguments, toolResult: toolResult})
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
@@ -808,6 +911,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, buildToolImageMessages()...)
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
// Same headless contract as the empty-turn guard: halting on a
// repeated failure is an unfinished run, not success. Gate-off
// (interactive / --no-completion-gate) keeps the old behavior.
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "the " + call.Name + " tool failed " + strconv.Itoa(outcome.Count) + " times in a row with the same error"
}
result.Messages = copyMessages(messages)
return result, nil
}
@@ -977,22 +1087,31 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
})
}
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
// WHICH BOUND FIRED reaches the reader, because the two call for different
// responses: a turn-count stop is a round-trip problem, a token stop is a
// spend one, and reporting the first for the second sends someone to raise a
// limit that was never what ran out.
finalPrompt, exhaustedAnswer, limitName := maxTurnsFinalAnswerPrompt, maxTurnsAnswer, "max-turns limit"
if stoppedOnTokens {
finalPrompt, exhaustedAnswer, limitName = maxTokensFinalAnswerPrompt, maxTokensAnswer, "token budget"
}
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options, finalPrompt); strings.TrimSpace(answer) != "" {
result.FinalAnswer = answer
result.FinishReason = finishReason
result.Messages = copyMessages(finalMessages)
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "reached the max-turns limit without completing"
result.IncompleteReason = "reached the " + limitName + " without completing"
}
return result, nil
}
result.FinalAnswer = maxTurnsAnswer
result.FinalAnswer = exhaustedAnswer
result.Messages = copyMessages(messages)
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "reached the max-turns limit without a final answer"
result.IncompleteReason = "reached the " + limitName + " without a final answer"
}
return result, nil
}
@@ -1059,11 +1178,11 @@ func recordContextPlanTrace(recorder *trace.Recorder, plan contextPlan) {
})
}
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *contextPlanner, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *contextPlanner, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options, prompt string) (string, []zeroruntime.Message, string) {
finalMessages := copyMessages(messages)
finalMessages = append(finalMessages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: maxTurnsFinalAnswerPrompt,
Content: prompt,
})
// The max-turns final-answer call is a pre-content connect, often after a long
// autonomous/cron run — route it through the reconnect helper so a single
@@ -1474,10 +1593,17 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options)
// Task tool: wire progress callback so the TUI sees live tool-call events
// from the specialist child process.
// Wire the progress callback so the TUI sees live tool-call events from a
// child agent process.
//
// Keyed on the TOOL'S OWN DECLARATION (tools.ChildProgressStreamer), not on
// its name. This was `call.Name == "Task"`, which made the second
// sub-agent-spawning tool run invisibly; `|| call.Name == "orchestrate"`
// would have been the same defect one name later. A tool that spawns
// children declares it, and every tool that does not keeps the nil callback
// it has today.
var progressCallback func(streamjson.Event)
if call.Name == "Task" && options.OnToolProgress != nil {
if options.OnToolProgress != nil && tools.StreamsChildProgress(tool) {
toolCallID := call.ID
onProgress := options.OnToolProgress
progressCallback = func(event streamjson.Event) {
@@ -1496,6 +1622,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
UserMessage: options.userMessage,
// Per-session file version tracker so write_file/edit_file refuse to clobber
// a file that changed on disk outside Zero since it was last read.
FileTracker: options.FileTracker,
@@ -2924,7 +3051,7 @@ func availablePermissionDecisions(event PermissionEvent, args map[string]any, op
decisions = append(decisions, PermissionDecisionAlwaysAllowPrefix)
}
}
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName) && !filesystemSandboxPrompt(event) && !inlineAdditionalPermissions {
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName, options) && !filesystemSandboxPrompt(event) && !inlineAdditionalPermissions {
decisions = append(decisions, PermissionDecisionAlwaysAllow)
}
}
@@ -3126,7 +3253,17 @@ func grantFilesystemForSandboxPrompt(event PermissionEvent, scope sandbox.Permis
}, scope)
}
func permissionSupportsPersistentDecision(toolName string) bool {
func permissionSupportsPersistentDecision(toolName string, options Options) bool {
// THE TOOL'S OWN DECLARATION FIRST. 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 below — see tools.PersistentPermissionRefuser.
if options.Registry != nil {
if tool, found := options.Registry.Get(toolName); found {
if refuser, ok := tool.(tools.PersistentPermissionRefuser); ok && refuser.RefusesPersistentPermission() {
return false
}
}
}
switch toolName {
case "bash", "exec_command", "write_stdin", "apply_patch":
return false
+136
View File
@@ -4361,6 +4361,142 @@ func TestPlanModeHonorsBeforeToolVeto(t *testing.T) {
}
}
// (h) The zeromaxing variant of TestRunPreservesRequestPrefixAcrossTurns.
//
// This is the test the whole reminder design exists to satisfy. The system
// prompt and tool definitions are the provider's CACHED PREFIX; #760 made them
// build once per run so they stay byte-identical across turns. The posture adds
// per-turn text, so it is exactly the kind of change that silently destroys the
// cache — roughly doubling input cost with nothing detecting it.
//
// The reminders are appended to the conversation TAIL as user-role messages, so
// each turn's request must still be an exact prefix-extension of the previous
// one, with identical tools and cache key.
func TestRunPreservesRequestPrefixAcrossTurnsUnderZeromaxing(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha\n")
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"path":"notes.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}
if _, err := Run(context.Background(), "read notes", provider, Options{
Cwd: root,
Registry: registry,
SessionID: "session-stable-prefix-zeromaxing",
Zeromaxing: ZeromaxingEntering,
}); err != nil {
t.Fatal(err)
}
if len(provider.requests) != 3 {
t.Fatalf("provider requests = %d, want 3", len(provider.requests))
}
// Every request must extend the previous one exactly — nothing rewritten
// above the append point, which is what keeps the cached prefix valid.
for i := 1; i < len(provider.requests); i++ {
prev, cur := provider.requests[i-1], provider.requests[i]
if len(cur.Messages) < len(prev.Messages) ||
!reflect.DeepEqual(cur.Messages[:len(prev.Messages)], prev.Messages) {
t.Fatalf("request %d is not an exact prefix-extension of request %d:\nprev=%#v\ncur=%#v",
i, i-1, prev.Messages, cur.Messages)
}
if !reflect.DeepEqual(prev.Tools, cur.Tools) {
t.Fatalf("tool definitions drifted between turns %d and %d", i-1, i)
}
if cur.PromptCacheKey != prev.PromptCacheKey {
t.Fatalf("prompt cache key drifted: %q -> %q", prev.PromptCacheKey, cur.PromptCacheKey)
}
}
// The system prompt — the cached prefix itself — must be byte-identical
// across turns, and must contain NONE of the posture text.
system := provider.requests[0].Messages[0]
for i, request := range provider.requests {
if !reflect.DeepEqual(request.Messages[0], system) {
t.Fatalf("system message changed on turn %d — the cached prefix is broken", i+1)
}
}
for _, forbidden := range []string{
ZeromaxingEnterNotice, ZeromaxingBudgetNotice, ZeromaxingStillOnNotice, ZeromaxingExitNotice,
ZeromaxingEvidenceNotice, ZeromaxingOrchestrateNotice,
} {
if strings.Contains(system.Content, forbidden) {
t.Fatalf("posture reminder leaked into the SYSTEM PROMPT (above the cache breakpoint): %q", forbidden)
}
}
// ...and the reminders really did arrive, on schedule. Without this the
// assertions above would pass just as happily if the feature did nothing.
firstTurn := renderZeromaxingMessages(provider.requests[0].Messages)
if !strings.Contains(firstTurn, ZeromaxingEnterNotice) || !strings.Contains(firstTurn, ZeromaxingBudgetNotice) {
t.Fatalf("turn 1 must carry the enter + budget notices:\n%s", firstTurn)
}
if strings.Contains(firstTurn, ZeromaxingStillOnNotice) {
t.Fatalf("turn 1 must NOT carry the still-on notice:\n%s", firstTurn)
}
secondOnly := renderZeromaxingMessages(provider.requests[1].Messages[len(provider.requests[0].Messages):])
if !strings.Contains(secondOnly, ZeromaxingStillOnNotice) {
t.Fatalf("turn 2 must carry the still-on notice:\n%s", secondOnly)
}
if strings.Contains(secondOnly, ZeromaxingEnterNotice) {
t.Fatalf("turn 2 must NOT re-announce entry:\n%s", secondOnly)
}
}
// A run with the posture OFF must be byte-identical to one that never heard of
// it — the no-regression half of the feature.
func TestRunWithoutZeromaxingCarriesNoPostureText(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
}}
if _, err := Run(context.Background(), "hello", provider, Options{
Cwd: root, Registry: registry, SessionID: "session-no-zeromaxing",
}); err != nil {
t.Fatal(err)
}
rendered := renderZeromaxingMessages(provider.requests[0].Messages)
for _, forbidden := range []string{
ZeromaxingEnterNotice, ZeromaxingBudgetNotice, ZeromaxingStillOnNotice, ZeromaxingExitNotice,
ZeromaxingEvidenceNotice, ZeromaxingOrchestrateNotice,
} {
if strings.Contains(rendered, forbidden) {
t.Fatalf("a posture-free run must carry no posture text, found %q", forbidden)
}
}
}
// renderZeromaxingMessages flattens messages to one searchable string.
func renderZeromaxingMessages(messages []zeroruntime.Message) string {
var b strings.Builder
for _, message := range messages {
b.WriteString(string(message.Role))
b.WriteString(": ")
b.WriteString(message.Content)
b.WriteString("\n")
}
return b.String()
}
// A cancelled permission on either sandbox-retry path must abort the run with a
// recognisable cancellation, and must not run the escalated retry.
//
+149
View File
@@ -0,0 +1,149 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// goTestTool stands in for a shell that ran `go test` and printed its result.
type goTestTool struct{ output string }
func (goTestTool) Name() string { return "run_tests" }
func (goTestTool) Description() string { return "run the test suite" }
func (goTestTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (goTestTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectNone, Permission: tools.PermissionAllow, AdvertiseInAuto: true}
}
func (t goTestTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: t.output}
}
// callThenAnswer is a provider that calls run_tests, then gives finalAnswer, then
// gives corrected — so a run that is sent back for a number gets somewhere to go.
func callThenAnswer(finalAnswer, corrected string) *mockProvider {
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "run_tests"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{{Type: zeroruntime.StreamEventText, Content: finalAnswer}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: corrected}, {Type: zeroruntime.StreamEventDone}},
}}
}
const suiteOutput = `{"Action":"pass","Package":"github.com/x/y","Test":"TestChattyChild","Elapsed":0.86}
{"Action":"pass","Package":"github.com/x/y","Elapsed":0.86}
`
// A FINAL ANSWER THAT REPORTS A NUMBER THE RUN NEVER PRODUCED IS SENT BACK.
//
// The measured failure: a benchmark table where the same test read 0.86s in one
// paste and 4.20s in the next, with nothing said about the difference. A prompt
// rule cannot catch this — a model willing to write a number it did not measure
// is equally willing to say it re-ran it. The transcript can.
func TestAFinalAnswerContradictingTheTranscriptIsSentBack(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(goTestTool{output: suiteOutput})
provider := callThenAnswer("github.com/x/y took 4.20s.", "Corrected: github.com/x/y took 0.86s.")
result, err := Run(context.Background(), "run the suite", provider, Options{
Cwd: root, Registry: registry, SessionID: "session-measured",
Zeromaxing: ZeromaxingActive,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) < 3 {
t.Fatalf("the run ended after %d requests: the contradicted answer was accepted", len(provider.requests))
}
sentBack := renderZeromaxingMessages(provider.requests[2].Messages)
for _, required := range []string{"github.com/x/y", "4.2s", "0.86s", "Re-run the command"} {
if !strings.Contains(sentBack, required) {
t.Errorf("the correction does not mention %q:\n%s", required, sentBack)
}
}
if !strings.Contains(result.FinalAnswer, "0.86s") {
t.Errorf("the corrected answer was not the one returned: %q", result.FinalAnswer)
}
}
// AN HONEST ANSWER IS RETURNED UNTOUCHED. A tripwire that fires on a correct
// report gets switched off, and then it catches nothing.
func TestAnAnswerThatMatchesTheTranscriptIsReturnedAsIs(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(goTestTool{output: suiteOutput})
provider := callThenAnswer("github.com/x/y took 0.86s.", "should never be reached")
result, err := Run(context.Background(), "run the suite", provider, Options{
Cwd: root, Registry: registry, SessionID: "session-honest",
Zeromaxing: ZeromaxingActive,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 2 {
t.Fatalf("an honest answer cost %d requests, want 2: the check fired on a correct report", len(provider.requests))
}
if !strings.Contains(result.FinalAnswer, "0.86s") {
t.Errorf("final answer = %q", result.FinalAnswer)
}
}
// THE CHECK IS PART OF THE POSTURE. With zeromaxing off the run must take
// exactly the path it took before this existed — the same rule every other
// posture behaviour follows.
func TestThePostureOffRunIsNeverSentBackForANumber(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(goTestTool{output: suiteOutput})
provider := callThenAnswer("github.com/x/y took 4.20s.", "should never be reached")
result, err := Run(context.Background(), "run the suite", provider, Options{
Cwd: root, Registry: registry, SessionID: "session-posture-off",
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 2 {
t.Fatalf("a posture-off run cost %d requests, want 2: the check reached a path it must not", len(provider.requests))
}
if !strings.Contains(result.FinalAnswer, "4.20s") {
t.Errorf("a posture-off run's answer was altered: %q", result.FinalAnswer)
}
}
// AN UNCORRECTED ANSWER IS RETURNED RATHER THAN ASKED AGAIN. The ledger raises
// each name once, so a model that repeats the number ends the run instead of
// looping until the turn budget is gone.
func TestARepeatedNumberEndsTheRunRatherThanLooping(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(goTestTool{output: suiteOutput})
// Says the same wrong number both times.
provider := callThenAnswer("github.com/x/y took 4.20s.", "github.com/x/y took 4.20s.")
result, err := Run(context.Background(), "run the suite", provider, Options{
Cwd: root, Registry: registry, SessionID: "session-stubborn",
Zeromaxing: ZeromaxingActive,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 3 {
t.Fatalf("the run cost %d requests, want exactly 3: one call, one answer, one correction", len(provider.requests))
}
if !strings.Contains(result.FinalAnswer, "4.20s") {
t.Errorf("final answer = %q", result.FinalAnswer)
}
}
+69
View File
@@ -0,0 +1,69 @@
package agent
import (
"strings"
"testing"
)
// THE PROVIDER OUTRANKS THE MODEL ID, because the provider knows and the id only
// hints.
//
// A ChatGPT OAuth session was classified correctly for one reason: "gpt-5.5"
// happens to begin with "gpt". Nothing obliges the next id to — OpenAI has
// already shipped o1, o3 and o4 — and the prefix list has to be amended each
// time to keep a first-party provider working. Asking the provider does not.
func TestTheProviderFamilyWinsOverTheModelId(t *testing.T) {
// An id no prefix arm matches, from a provider that declares OpenAI.
if got := modelPromptAddendum("openai", "some-unreleased-id"); got != openAIPromptAddendum {
t.Error("a provider that declares openai did not get the openai addendum for an unrecognised id")
}
if got := modelPromptAddendum("gemini", "some-unreleased-id"); got != geminiPromptAddendum {
t.Error("a provider that declares gemini did not get the gemini addendum")
}
// Anthropic is aligned with the core prompt and takes no addendum, declared
// or guessed. Asserted so "no addendum" stays a decision, not an accident.
if got := modelPromptAddendum("anthropic", "some-unreleased-id"); got != "" {
t.Errorf("anthropic gained an addendum: %q", got)
}
}
// A GATEWAY DECLARES NOTHING, and must fall back to the id — it is the only
// thing that distinguishes openai/gpt-4.1 from z-ai/glm-4.6 on one endpoint.
func TestAGatewayFallsBackToTheModelId(t *testing.T) {
for _, testCase := range []struct {
model string
want string
}{
{"openai/gpt-4.1", openAIPromptAddendum},
{"google/gemini-2.5-pro", geminiPromptAddendum},
{"anthropic/claude-sonnet-4.5", ""},
// Unclassified, and honestly so: no family addendum exists for it.
{"z-ai/glm-4.6", ""},
{"qwen3-coder:480b", ""},
} {
if got := modelPromptAddendum("", testCase.model); got != testCase.want {
t.Errorf("%s: addendum = %q, want %q", testCase.model, truncateForTest(got), truncateForTest(testCase.want))
}
}
}
// The wiring is not decoration: a family that reaches Options must reach the
// prompt. Asserted through the real assembly rather than the helper, because
// asserting the helper is how a value gets threaded to a layer that drops it.
func TestTheDeclaredFamilyReachesTheAssembledPrompt(t *testing.T) {
withProvider := BuildSystemPromptPreview(Options{ModelFamily: "openai", Model: "some-unreleased-id"})
if !strings.Contains(withProvider, "Persist until the task is fully handled this turn") {
t.Error("the openai guidance did not reach the assembled prompt when the provider declared it")
}
withoutProvider := BuildSystemPromptPreview(Options{Model: "some-unreleased-id"})
if strings.Contains(withoutProvider, "Persist until the task is fully handled this turn") {
t.Error("an unclassified model gained openai guidance from nowhere")
}
}
func truncateForTest(text string) string {
if len(text) > 40 {
return text[:40] + "..."
}
return text
}
@@ -0,0 +1,104 @@
package agent
import (
"context"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
// refusingTool declares that its approval must never be remembered.
type refusingTool struct{ name string }
func (t refusingTool) Name() string { return t.name }
func (refusingTool) Description() string { return "" }
func (refusingTool) Parameters() tools.Schema { return tools.Schema{} }
func (refusingTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionPrompt} }
func (refusingTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
func (refusingTool) RefusesPersistentPermission() bool { return true }
// ordinaryTool declares nothing, so the name list decides for it.
type ordinaryTool struct{ name string }
func (t ordinaryTool) Name() string { return t.name }
func (ordinaryTool) Description() string { return "" }
func (ordinaryTool) Parameters() tools.Schema { return tools.Schema{} }
func (ordinaryTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionPrompt} }
func (ordinaryTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
// A TOOL'S OWN REFUSAL WINS over the name list.
//
// The list refuses bash, exec_command, write_stdin and apply_patch because
// "always allow" is too broad a standing grant for them. It cannot describe a
// tool that RUNS those — and orchestrate can be given bash by a plan, so
// remembering it would be strictly broader than the refusal the list enforces.
func TestAToolCanRefuseToBeRemembered(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(refusingTool{name: "refuses"})
registry.Register(ordinaryTool{name: "ordinary"})
options := Options{Registry: registry}
if permissionSupportsPersistentDecision("refuses", options) {
t.Fatal("a tool that refuses persistence was offered it")
}
if !permissionSupportsPersistentDecision("ordinary", options) {
t.Fatal("an ordinary tool lost its persistent approval")
}
}
// The NAME LIST still applies to tools that declare nothing, so this adds a
// rule rather than replacing one.
func TestTheExistingNameRefusalsAreUnchanged(t *testing.T) {
options := Options{Registry: tools.NewRegistry()}
for _, name := range []string{"bash", "exec_command", "write_stdin", "apply_patch"} {
if permissionSupportsPersistentDecision(name, options) {
t.Errorf("%q must not be persistently approvable", name)
}
}
for _, name := range []string{"read_file", "write_file", "edit_file"} {
if !permissionSupportsPersistentDecision(name, options) {
t.Errorf("%q lost its persistent approval", name)
}
}
}
// A nil registry must not crash and must not start refusing everything — the
// name list is the fallback, not an empty answer.
func TestPersistentDecisionsSurviveANilRegistry(t *testing.T) {
if permissionSupportsPersistentDecision("bash", Options{}) {
t.Fatal("bash became persistable with no registry")
}
if !permissionSupportsPersistentDecision("read_file", Options{}) {
t.Fatal("read_file lost persistence with no registry")
}
}
// THE CONSEQUENCE, at the decision list a prompt actually offers. Asserting the
// predicate alone would pass against a caller that ignored it.
func TestARefusingToolIsNeverOfferedAlwaysAllow(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(refusingTool{name: "refuses"})
registry.Register(ordinaryTool{name: "ordinary"})
engine := sandbox.NewEngine(sandbox.EngineOptions{})
options := Options{Registry: registry, Sandbox: engine}
offered := func(name string) bool {
event := PermissionEvent{ToolName: name, Action: PermissionActionPrompt}
for _, decision := range availablePermissionDecisions(event, nil, options) {
if decision == PermissionDecisionAlwaysAllow {
return true
}
}
return false
}
if offered("refuses") {
t.Fatal("a refusing tool was offered always-allow in the prompt")
}
if !engine.CanPersistGrants() {
t.Skip("this engine cannot persist grants, so the positive case is unreachable")
}
if !offered("ordinary") {
t.Fatal("an ordinary tool lost always-allow; the refusal is being applied to everything")
}
}
+215
View File
@@ -0,0 +1,215 @@
package agent
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// THE ADDITIVITY PROOF.
//
// ZeroMaxing Phase 2 adds an `orchestrate` tool. The constraint proved here is
// that REGISTERING THAT TOOL changes nothing while the posture is OFF: same
// advertised tool set, same tool-definition bytes, same assembled system
// prompt, therefore the same token count and the same provider request.
//
// Stated narrowly on purpose. "With the posture off, Zero is byte-identical to
// a build without the feature" is the tempting phrasing and it is not true:
// system_prompt.go drops the ~5 KB confirmation policy for any run that cannot
// mutate, and runCanMutate is evaluated regardless of posture. An all-read-only
// run — `zero exec --enabled-tools read_file,grep`, or a read-only specialist
// child — therefore gets a smaller prompt than a build without the feature,
// posture off. That drop is deliberate and fails closed; it is simply not part
// of what this test proves, and the comparison below could not catch it anyway
// since both registries take the same branch.
//
// The proof is a CONTROLLED COMPARISON rather than a frozen hash of the whole
// prefix. Two registries are built that differ in exactly one thing — whether
// the orchestrate tool is registered — and the posture-off output of both must
// be byte-identical. That states the real property ("registering this tool
// changes nothing while the posture is off"), it exercises the Deferred()
// mechanism that enforces it, and it cannot rot: there is no constant to
// regenerate and nothing to keep in step by hand.
//
// A frozen whole-prefix hash was tried first and is the wrong instrument here:
// the assembled prompt embeds the working directory and the operating system,
// so the constant would be machine- and platform-specific and every CI runner
// would "fail" honestly-unchanged code. The definition-bytes hash below keeps
// the frozen-golden idea where it IS portable.
// fixtureCwd is a fixed synthetic path. The prompt embeds the working
// directory, so a real t.TempDir() would make any hash unstable across runs.
const fixtureCwd = "/zero/fixture/workspace"
// baseFixtureRegistry is the control: a fixed, representative tool set with NO
// orchestrate tool. Deliberately not the full core set — that would make the
// golden hash churn on every unrelated tool change and train the reader to
// regenerate it.
func baseFixtureRegistry() *tools.Registry {
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(fixtureCwd, nil))
registry.Register(tools.NewScopedGrepTool(fixtureCwd, nil))
registry.Register(tools.NewScopedGlobTool(fixtureCwd, nil))
registry.Register(tools.NewScopedListDirectoryTool(fixtureCwd, nil))
return registry
}
// postureOffPrefix returns the three things a user pays for, for a posture-OFF
// run against the given registry: the advertised tool names in emitted order,
// the full tool-definition bytes, and the assembled system prompt.
func postureOffPrefix(t *testing.T, registry *tools.Registry, mode PermissionMode) (names []string, definitions string, prompt string) {
t.Helper()
options := Options{
Cwd: fixtureCwd,
Registry: registry,
SessionID: "session-posture-off-identity",
Zeromaxing: ZeromaxingOff, // THE POINT: the posture is off.
// Deferral ACTIVE. Without this the partition never consults Deferred()
// at all, and this test would pass on the permission gate alone — which
// it originally did, so a Deferred() regression went undetected by it.
DeferThreshold: 1,
}
exposed, _ := partitionTools(registry, mode, options, nil)
for _, definition := range exposed {
names = append(names, definition.Name)
}
encoded, err := json.Marshal(exposed)
if err != nil {
t.Fatalf("marshal tool definitions: %v", err)
}
return names, string(encoded), buildSystemPromptParts(options).prompt
}
// (a) THE PROOF. Registering the orchestrate tool must change NOTHING that a
// posture-off run sends: not the advertised set, not the definition bytes, not
// the prompt. If this fails, the feature has stopped being additive and the
// diff is what is wrong.
func TestPostureOffPrefixUnchangedByRegisteringTheTool(t *testing.T) {
// BOTH permission modes. Under auto the permission gate would hide the tool
// even if Deferred() were broken, so auto alone proves nothing about the
// mechanism; unsafe is where Deferred() is the ONLY thing standing between
// the tool and the advertised set.
for _, mode := range []PermissionMode{PermissionModeAuto, PermissionModeUnsafe} {
t.Run(string(mode), func(t *testing.T) { assertPostureOffPrefixUnchanged(t, mode) })
}
}
func assertPostureOffPrefixUnchanged(t *testing.T, mode PermissionMode) {
t.Helper()
withoutNames, withoutDefs, withoutPrompt := postureOffPrefix(t, baseFixtureRegistry(), mode)
withTool := baseFixtureRegistry()
registerPhase2ToolForTest(withTool)
withNames, withDefs, withPrompt := postureOffPrefix(t, withTool, mode)
if !reflect.DeepEqual(withoutNames, withNames) {
t.Fatalf("ADVERTISED TOOL SET CHANGED with the posture off:\n without: %v\n with: %v",
withoutNames, withNames)
}
if withoutDefs != withDefs {
t.Fatalf("TOOL DEFINITION BYTES CHANGED with the posture off (%d -> %d bytes)",
len(withoutDefs), len(withDefs))
}
if withoutPrompt != withPrompt {
t.Fatalf("ASSEMBLED PROMPT CHANGED with the posture off (%d -> %d bytes)",
len(withoutPrompt), len(withPrompt))
}
// Guard against the test passing because the tool was never registered at
// all: it must be present in the registry, just not advertised.
if _, ok := withTool.Get(phase2ToolName); !ok {
t.Fatalf("fixture did not register %q, so this test proves nothing", phase2ToolName)
}
}
// The same claim in terms a reader can check without diffing: with the posture
// off the tool is registered but not advertised, and its name appears nowhere
// in what the provider receives.
func TestPostureOffDoesNotAdvertiseThePhase2Tool(t *testing.T) {
registry := baseFixtureRegistry()
registerPhase2ToolForTest(registry)
names, definitions, prompt := postureOffPrefix(t, registry, PermissionModeUnsafe)
for _, name := range names {
if name == phase2ToolName {
t.Fatalf("posture off must not advertise %q, got %v", phase2ToolName, names)
}
}
if strings.Contains(definitions, phase2ToolName) {
t.Fatalf("posture off must not send %q in the tool definitions", phase2ToolName)
}
if strings.Contains(prompt, phase2ToolName) {
t.Fatalf("posture off must not name %q in the system prompt", phase2ToolName)
}
}
// ...and the run-level counterpart: a posture-off conversation carries no
// posture or orchestration text at all.
func TestPostureOffRunCarriesNoPostureText(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registerPhase2ToolForTest(registry)
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
}}
if _, err := Run(context.Background(), "hello", provider, Options{
Cwd: root, Registry: registry, SessionID: "session-posture-off-run",
}); err != nil {
t.Fatal(err)
}
var rendered strings.Builder
for _, message := range provider.requests[0].Messages {
rendered.WriteString(message.Content)
}
if strings.Contains(rendered.String(), phase2ToolName) {
t.Fatalf("a posture-off run must never name %q:\n%s", phase2ToolName, rendered.String())
}
for _, forbidden := range []string{
ZeromaxingEnterNotice, ZeromaxingBudgetNotice, ZeromaxingStillOnNotice, ZeromaxingExitNotice,
} {
if strings.Contains(rendered.String(), forbidden) {
t.Fatalf("a posture-off run must carry no posture text, found %q", forbidden)
}
}
}
// A frozen golden over the TOOL DEFINITION BYTES only. Definitions carry no
// environment (no cwd, no OS), so unlike the whole prefix this IS portable and
// a moved hash means a real schema change in the fixture's tools.
//
// MOVED AGAIN AFTER THE CURRENT MAIN REFRESH. Main's tool descriptions now
// distinguish exact reads from minified exploratory reads while keeping the
// same posture-off exposure contract. Before that, #867 reshaped read_file's
// schema (the canonical
// path/offset/limit contract) and reworded its description; before that, #838
// reworded glob, grep, list_directory and read_file. Both are schema changes on
// main's side rather than posture leaks from this branch, and the distinction was
// proved rather than assumed: the first request body of a posture-off run was
// compared byte for byte against a binary built from that same main, across
// --auto low/medium/high/member and --use-spec, and all five were identical.
// The absolute byte counts moved with main's new wording (33549 -> 31851 on
// --auto low); the DIFFERENCE between the two binaries stayed zero, which is
// the thing this guard exists to hold.
const postureOffDefinitionsFingerprint = "35a12ffe0079ffb18a3342df5e0c3499836d2f2811b94707ae6c337cf5087197"
func TestPostureOffToolDefinitionsMatchGolden(t *testing.T) {
registry := baseFixtureRegistry()
registerPhase2ToolForTest(registry)
_, definitions, _ := postureOffPrefix(t, registry, PermissionModeUnsafe)
sum := sha256.Sum256([]byte(definitions))
got := hex.EncodeToString(sum[:])
if postureOffDefinitionsFingerprint == "PLACEHOLDER" {
t.Fatalf("golden not captured yet; observed %s", got)
}
if got != postureOffDefinitionsFingerprint {
t.Fatalf("posture-off tool definitions changed:\n got %s\n want %s\n%s",
got, postureOffDefinitionsFingerprint, definitions)
}
}
+244
View File
@@ -0,0 +1,244 @@
package agent
import (
"context"
"fmt"
"strings"
"testing"
"github.com/Twigpine/zero/internal/specialist"
"github.com/Twigpine/zero/internal/tools"
)
// readOnlyRegistry is the tool set a PLAN TASK actually holds — pure reads, no
// ask_user and no request_permissions.
//
// Deliberately not CoreReadOnlyToolsScoped, which despite its name also carries
// ask_user and request_permissions (both EffectInteractive). A run holding
// those genuinely can prompt, so the policy applies to it and the gate keeps
// it; that is why this helper narrows to the plan grant instead.
//
// IT READS THE AUTHORITATIVE LIST. It used to enumerate its own copy, and the
// copy differed from specialist.PlanReadOnlyToolNames by exactly one entry —
// update_plan — which is the entry that broke this. The test asserted the
// policy was dropped for a grant that was not the grant, and passed for the
// whole period a real plan child carried the policy. A test that builds its own
// version of the thing under test cannot catch the thing under test changing.
func readOnlyRegistry(t *testing.T) *tools.Registry {
t.Helper()
planTools := map[string]bool{}
for _, name := range specialist.PlanReadOnlyToolNames() {
planTools[name] = true
}
registry := tools.NewRegistry()
registered := 0
for _, tool := range tools.CoreReadOnlyToolsScoped(t.TempDir(), nil) {
if planTools[tool.Name()] {
registry.Register(tool)
registered++
}
}
// WHAT THIS CAN AND CANNOT PROVE, said plainly. A grant name that lives
// outside the scoped core set is invisible here — update_plan was exactly
// that, which is why re-adding it does not fail these tests. This half
// proves "a run holding these read-only tools drops the policy"; the other
// half, that every name in the grant IS read-only by capability, is
// TestEveryPlanGrantToolIsReadOnlyByCapability in internal/cli, against the
// real registry. Neither alone is the guarantee.
if registered == 0 {
t.Fatal("no plan-grant tool was registered; this fixture proves nothing")
}
return registry
}
func mutatingRegistry(t *testing.T) *tools.Registry {
t.Helper()
registry := readOnlyRegistry(t)
for _, tool := range tools.CoreWriteToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
}
return registry
}
// An interactive tool is not a read: a run that can prompt keeps the policy.
func TestAnInteractiveToolKeepsTheConfirmationPolicy(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreReadOnlyToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
}
prompt := buildSystemPrompt(Options{
Registry: registry, PermissionMode: PermissionModeAuto, Cwd: t.TempDir(),
})
if !strings.Contains(prompt, "Confirmation Policy") {
t.Fatal("ask_user and request_permissions can prompt, so the policy still governs this run")
}
}
// A run that cannot change anything carries no confirmation policy. Measured at
// ~2,527 of a plan child's 7,648 prompt tokens — policy for capabilities the
// child provably lacks, multiplied by every task in a plan.
func TestAReadOnlyRunDropsTheConfirmationPolicy(t *testing.T) {
prompt := buildSystemPrompt(Options{
Registry: readOnlyRegistry(t),
PermissionMode: PermissionModeAuto,
Cwd: t.TempDir(),
})
for _, marker := range []string{"Confirmation Policy", "ALWAYS CONFIRM", "PRE-APPROVAL"} {
if strings.Contains(prompt, marker) {
t.Errorf("a read-only run still carries %q", marker)
}
}
}
// THE FAIL-CLOSED DIRECTION, which is the one that matters: anything that can
// change something keeps the policy.
func TestAnyMutatingCapabilityKeepsTheConfirmationPolicy(t *testing.T) {
cases := map[string]Options{
"write tools present": {Registry: mutatingRegistry(t), PermissionMode: PermissionModeAuto},
"no registry at all": {PermissionMode: PermissionModeAuto},
"empty registry": {Registry: tools.NewRegistry(), PermissionMode: PermissionModeAuto},
}
for name, options := range cases {
options.Cwd = t.TempDir()
if !strings.Contains(buildSystemPrompt(options), "Confirmation Policy") {
t.Errorf("%s: the confirmation policy was dropped; this path must fail closed", name)
}
}
}
// A tool whose effect is not positively read-only keeps the policy, even
// alongside read tools. MCP tools, plugin tools and the specialist tools are all
// EffectUnknown, so an ordinary session is unaffected by this change.
func TestAnUnknownEffectToolKeepsTheConfirmationPolicy(t *testing.T) {
registry := readOnlyRegistry(t)
registry.Register(&silentProbeTool{name: "mystery", got: new(bool)})
prompt := buildSystemPrompt(Options{
Registry: registry, PermissionMode: PermissionModeAuto, Cwd: t.TempDir(),
})
if !strings.Contains(prompt, "Confirmation Policy") {
t.Fatal("a tool of unknown effect must keep the policy — unknown is not read-only")
}
}
// A tool hidden by the run's operator filters does not count: what matters is
// what the run can actually reach, judged by the same gate the loop advertises
// through.
func TestFilteredOutMutatorsDoNotKeepThePolicy(t *testing.T) {
registry := mutatingRegistry(t)
var mutators []string
for _, tool := range registry.All() {
if tools.CapabilitiesOf(tool).Effect != tools.EffectReadOnly {
mutators = append(mutators, tool.Name())
}
}
if len(mutators) == 0 {
t.Fatal("setup: expected the write tools to register")
}
prompt := buildSystemPrompt(Options{
Registry: registry,
DisabledTools: mutators,
Cwd: t.TempDir(),
})
if strings.Contains(prompt, "Confirmation Policy") {
t.Fatal("every mutator is filtered out of this run, so the policy has nothing to govern")
}
}
// THE FAIL-OPEN THIS FUNCTION FIRST SHIPPED WITH, pinned.
//
// The gate originally used ToolVisible, which applies the permission mode's
// ADVERTISING rules on top of the operator filters. A write tool that 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 confirmation policy. Exactly inverse to the point.
//
// Found by driving the binary; every unit test at the time passed.
func TestAnUnadvertisedMutatorStillKeepsThePolicy(t *testing.T) {
registry := mutatingRegistry(t)
for _, mode := range []PermissionMode{PermissionModeAuto, PermissionModeAsk, "", PermissionModeSpecDraft} {
prompt := buildSystemPrompt(Options{
Registry: registry, PermissionMode: mode, Cwd: t.TempDir(),
})
if !strings.Contains(prompt, "Confirmation Policy") {
t.Errorf("permission mode %q: a held-but-unadvertised mutator dropped the policy", mode)
}
}
}
// The gate asks whether the run HOLDS a mutator, so the permission mode must
// not change the answer. A mode-dependent system prompt would also move the
// cache prefix between modes for no reason.
func TestThePolicyDecisionIsIndependentOfPermissionMode(t *testing.T) {
for _, registry := range []*tools.Registry{readOnlyRegistry(t), mutatingRegistry(t)} {
var first string
for index, mode := range []PermissionMode{PermissionModeAuto, PermissionModeAsk, PermissionModeUnsafe, ""} {
prompt := buildSystemPrompt(Options{Registry: registry, PermissionMode: mode, Cwd: t.TempDir()})
has := strings.Contains(prompt, "Confirmation Policy")
if index == 0 {
first = fmt.Sprint(has)
continue
}
if fmt.Sprint(has) != first {
t.Fatalf("permission mode %q changed the policy decision", mode)
}
}
}
}
// THE INTERACTION between this gate and the additivity guarantee.
//
// The gate reads the registered tool set, so registering a posture-gated tool
// changed the prompt even with the posture OFF — breaking the guarantee that
// registering it is invisible. Caught by
// TestPostureOffPrefixUnchangedByRegisteringTheTool, which is what that test is
// for. A tool the run can never call now says nothing about what the run can
// do.
func TestAPermanentlyDeniedToolDoesNotChangeThePrompt(t *testing.T) {
base := readOnlyRegistry(t)
withDenied := readOnlyRegistry(t)
withDenied.Register(&deniedProbeTool{name: "gated"})
// ONE cwd for both: the prompt embeds the working directory, so two
// t.TempDir() calls would differ for a reason that has nothing to do with
// the tool set.
cwd := t.TempDir()
before := buildSystemPrompt(Options{Registry: base, Cwd: cwd})
after := buildSystemPrompt(Options{Registry: withDenied, Cwd: cwd})
if before != after {
t.Fatalf("registering a permanently denied tool changed the prompt (%d -> %d bytes)",
len(before), len(after))
}
}
// ...but a tool that varies its permission by argument is never ruled out from
// its static safety, however that reads. Fail closed.
func TestAnArgsPermissionedToolIsNeverTreatedAsDenied(t *testing.T) {
registry := readOnlyRegistry(t)
registry.Register(&argsPermissionedProbeTool{deniedProbeTool{name: "varies"}})
if !strings.Contains(buildSystemPrompt(Options{Registry: registry, Cwd: t.TempDir()}), "Confirmation Policy") {
t.Fatal("a tool whose permission depends on its arguments must never be ruled out statically")
}
}
type deniedProbeTool struct{ name string }
func (t *deniedProbeTool) Name() string { return t.name }
func (t *deniedProbeTool) Description() string { return "gated" }
func (t *deniedProbeTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (t *deniedProbeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionDeny}
}
func (t *deniedProbeTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK}
}
type argsPermissionedProbeTool struct{ deniedProbeTool }
func (t *argsPermissionedProbeTool) PermissionForArgs(map[string]any) tools.Permission {
return tools.PermissionAllow
}
+78 -4
View File
@@ -11,6 +11,7 @@ import (
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/repomap"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/workspaceseed"
)
@@ -66,6 +67,72 @@ const (
workspaceSeedWidth = 100
)
// runCanMutate reports whether this run holds any tool that could change
// something — and therefore whether the confirmation policy applies to it.
//
// FAIL CLOSED at every step. A nil registry, a run with no visible tools, and
// any tool whose effect is not explicitly read-only all answer TRUE, so the
// policy is dropped ONLY when every visible tool is positively known to be a
// pure read. EffectUnknown (MCP tools, plugin tools, the specialist tools)
// keeps it, which is what makes an ordinary session byte-identical.
//
// Keyed on the RESOLVED TOOL SET, not on a specialist name or a manifest flag:
// what a run can do is decided by the tools it actually holds, and a name is
// not a capability.
//
// Filters, NOT advertising. ToolAllowedByFilters answers "does this run hold
// the tool"; ToolVisible also applies the permission mode's advertising rules,
// and a write tool that auto mode does not advertise is still held and still
// callable once approved. Using ToolVisible here dropped the policy from a run
// launched with --enabled-tools read_file,grep,glob,write_file — a fail-OPEN
// exactly inverse to this function's purpose, caught by driving the binary.
func runCanMutate(options Options) bool {
if options.Registry == nil {
return true
}
held := 0
for _, tool := range options.Registry.All() {
if !ToolAllowedByFilters(tool.Name(), options.EnabledTools, options.DisabledTools) {
continue
}
if permanentlyDenied(tool) {
// A tool the run can never call cannot act, so it says nothing
// about what the run can do. This is what keeps registering a
// posture-gated tool from changing the prompt while the posture is
// off — the additivity guarantee, which its identity test caught
// this function breaking.
continue
}
held++
if tools.CapabilitiesOf(tool).Effect != tools.EffectReadOnly {
return true
}
}
return held == 0
}
// permanentlyDenied reports whether a tool can never be invoked in this run.
//
// A tool that varies its permission by arguments is NEVER treated as denied,
// however its static safety reads: the question is whether any call could
// succeed, and only a tool with no per-argument override can be ruled out from
// its static permission alone. Fail closed.
func permanentlyDenied(tool tools.Tool) bool {
// A tool that KNOWS it cannot fire is believed first, before the
// argument-varying escape below. Otherwise a tool gated on something that is
// not an argument — the zeromaxing posture is a session state, not a
// parameter — would count as held in every run merely for implementing
// ArgsPermissioner, and the confirmation policy would appear in read-only
// runs that do not have the feature turned on at all.
if denier, ok := tool.(tools.PermanentDenier); ok && denier.PermanentlyDenied() {
return true
}
if _, varies := tool.(tools.ArgsPermissioner); varies {
return false
}
return tool.Safety().Permission == tools.PermissionDeny
}
// buildSystemPrompt assembles the full system prompt for a run: the core
// coding-craft instructions, dynamic workspace context (cwd, git branch, project
// guidelines), and the safety confirmation policy. It is built once per run so
@@ -98,7 +165,7 @@ func buildSystemPromptParts(options Options) systemPromptParts {
if transient := strings.TrimSpace(options.TransientSystemPrompt); transient != "" {
sections = append(sections, transient)
}
if addendum := modelPromptAddendum(options.Model); addendum != "" {
if addendum := modelPromptAddendum(options.ModelFamily, options.Model); addendum != "" {
sections = append(sections, addendum)
}
if session := sessionRuntimeContext(options); session != "" {
@@ -117,7 +184,7 @@ func buildSystemPromptParts(options Options) systemPromptParts {
if user := userGuidelines(); user != "" {
sections = append(sections, user)
}
project := workspaceContext(options.Cwd)
project := workspaceContext(options.Cwd, options.Sandbox)
if project != "" {
sections = append(sections, project)
}
@@ -135,6 +202,13 @@ func buildSystemPromptParts(options Options) systemPromptParts {
sections = append(sections, style)
}
policy := strings.TrimSpace(confirmationPolicy)
if !runCanMutate(options) {
// A run that cannot mutate anything has nothing to confirm. Carrying
// ~5 KB of confirmation policy into such a run is pure cost: measured
// at 2,527 of a plan child's 7,648 prompt tokens, multiplied by every
// task in a plan and by every specialist sub-agent.
policy = ""
}
if policy != "" {
sections = append(sections, policy)
}
@@ -306,7 +380,7 @@ func sessionRuntimeContext(options Options) string {
// directory, git branch, and any project guideline doc, so the model grounds its
// work in the actual repo. Returns "" when cwd is unset (keeps headless/test
// runs deterministic).
func workspaceContext(cwd string) string {
func workspaceContext(cwd string, sandboxEngine *sandbox.Engine) string {
cwd = strings.TrimSpace(cwd)
if cwd == "" {
return ""
@@ -315,7 +389,7 @@ func workspaceContext(cwd string) string {
b.WriteString("<environment>\n")
b.WriteString("Working directory: " + cwd + "\n")
b.WriteString("Operating system: " + runtime.GOOS + "\n")
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
b.WriteString(tools.HostShellEnvironmentGuidanceForEngine(sandboxEngine, cwd) + "\n")
if branch := gitBranchForPrompt(cwd); branch != "" {
b.WriteString("Git branch: " + branch + "\n")
}
+22 -2
View File
@@ -22,6 +22,13 @@ const (
// modelFamily classifies a model id into a prompt-tuning family, or "" when
// unknown (in which case no addendum is added).
//
// A GUESS, AND THE FALLBACK ONLY. Matching id prefixes answers correctly for
// first-party ids and for the vendor-prefixed ones a gateway uses
// ("openai/gpt-4.1"), and answers nothing at all for most of the market: of the
// 37 providers in the catalog, 28 have a default model that matches none of
// these arms — including the recommended default. Where the PROVIDER knows the
// answer it is passed in instead; see modelPromptAddendum.
func modelFamily(model string) string {
m := strings.ToLower(strings.TrimSpace(model))
switch {
@@ -41,8 +48,21 @@ func modelFamily(model string) string {
// modelPromptAddendum returns the family-specific prompt block for a model id,
// or "" when the family is unknown.
func modelPromptAddendum(model string) string {
switch modelFamily(model) {
// modelPromptAddendum returns the family-specific prompt block, preferring the
// family the PROVIDER declared over anything guessed from the model id.
//
// The provider is authoritative where it is single-family, and the id is not: a
// ChatGPT OAuth session was classified correctly only because "gpt-5.5" happens
// to begin with "gpt", which no future id is obliged to do. Gateways serve
// several families at once and declare none, so those still fall back to the id
// — which is the right answer for them, since only the id distinguishes
// "openai/gpt-4.1" from "z-ai/glm-4.6" on the same endpoint.
func modelPromptAddendum(providerFamily, model string) string {
family := strings.TrimSpace(providerFamily)
if family == "" {
family = modelFamily(model)
}
switch family {
case familyOpenAI:
return openAIPromptAddendum
case familyGemini:
+1 -1
View File
@@ -42,7 +42,7 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
}
// Unknown / unset model gets no family block.
if got := modelPromptAddendum(""); got != "" {
if got := modelPromptAddendum("", ""); got != "" {
t.Fatalf("expected no addendum without a model, got %q", got)
}
if strings.Contains(buildSystemPrompt(Options{}), "<model_guidance>") {
+45
View File
@@ -1,14 +1,20 @@
package agent
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/testutil"
)
// isolatedUserDirsRoot is where TestMain points every per-user directory.
var isolatedUserDirsRoot string
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
// directory for every test in this package. Without this, buildSystemPrompt
// falls back to the real config.UserConfigDir, so any developer with a
@@ -17,17 +23,56 @@ import (
// across contributor machines. Tests that specifically exercise user
// guidelines stub userConfigDirForPrompt themselves via
// withSystemPromptTestUserConfigDir(Func) and restore this default on cleanup.
//
// It also points every per-user directory root at the same temp directory:
// sandboxed tool runs keep their runtime state and lease files under the user
// cache dir, so without this they land in the developer's real cache.
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "zero-agent-tests-*")
if err != nil {
panic(err)
}
isolatedUserDirsRoot = dir
restore, err := testutil.IsolateUserDirs(dir)
if err != nil {
os.RemoveAll(dir)
panic(err)
}
userConfigDirForPrompt = func() (string, error) { return dir, nil }
code := m.Run()
if err := restore(); err != nil {
fmt.Fprintln(os.Stderr, "agent tests: restore user dirs:", err)
code = 1
}
os.RemoveAll(dir)
os.Exit(code)
}
// Guards TestMain: the per-user directories the package can read or write
// must resolve inside the isolated root on every OS.
func TestUserDirsAreIsolatedFromTheDeveloperHome(t *testing.T) {
if isolatedUserDirsRoot == "" {
t.Fatal("TestMain did not record an isolated root")
}
home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
userConfig, err := config.UserConfigDir()
if err != nil {
t.Fatal(err)
}
cache, err := os.UserCacheDir()
if err != nil {
t.Fatal(err)
}
for name, dir := range map[string]string{"home": home, "config": userConfig, "cache": cache} {
if !testutil.PathWithin(isolatedUserDirsRoot, dir) {
t.Errorf("%s dir %q is outside the isolated root %q", name, dir, isolatedUserDirsRoot)
}
}
}
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
prompt := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{}))), " ")
if strings.Contains(prompt, "clickable file paths") {
+169
View File
@@ -0,0 +1,169 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// spendingTurn is one provider turn that calls a tool and reports usage.
func spendingTurn(id string, tokens int) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: id, ToolName: "run_tests"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: id, ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: id},
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{PromptTokens: tokens, CompletionTokens: 0}},
{Type: zeroruntime.StreamEventDone},
}
}
func budgetProvider(turns int, perTurn int, finalText string) *mockProvider {
streams := make([][]zeroruntime.StreamEvent, 0, turns+1)
for i := 0; i < turns; i++ {
streams = append(streams, spendingTurn("call-"+string(rune('a'+i)), perTurn))
}
streams = append(streams, []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: finalText},
{Type: zeroruntime.StreamEventDone},
})
return &mockProvider{turns: streams}
}
func budgetRegistry() *tools.Registry {
registry := tools.NewRegistry()
registry.Register(goTestTool{output: "ok\tpkg\t0.10s\n"})
return registry
}
// A RUN MUST BE BOUNDED BY WHAT IT SPENDS, not only by how many round trips it
// makes.
//
// 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, where a cheap run
// reaches the same limit at roughly a tenth of that. The turn count bounds round
// trips; nothing bounded spend.
func TestARunStopsOnItsTokenBudget(t *testing.T) {
provider := budgetProvider(6, 1000, "done")
result, err := Run(context.Background(), "work", provider, Options{
Cwd: t.TempDir(), Registry: budgetRegistry(), SessionID: "budget",
MaxTurns: 50, MaxTokens: 2500,
})
if err != nil {
t.Fatal(err)
}
// 1000 per turn against 2500: turns 1-3 run (3000 spent), turn 4 is refused.
if len(provider.requests) > 5 {
t.Fatalf("the run made %d requests; the token budget did not bind", len(provider.requests))
}
if strings.TrimSpace(result.FinalAnswer) == "" {
t.Error("a budget expiry produced no final answer; it must end with a written summary, not silence")
}
}
// THE READER MUST BE TOLD WHICH BOUND FIRED. Reporting a turn limit for a spend
// stop sends someone to raise a number that was never what ran out.
func TestATokenStopSaysSoRatherThanBlamingTheTurnCount(t *testing.T) {
provider := budgetProvider(6, 1000, "done")
result, err := Run(context.Background(), "work", provider, Options{
Cwd: t.TempDir(), Registry: budgetRegistry(), SessionID: "budget-reason",
MaxTurns: 50, MaxTokens: 2500, RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(result.IncompleteReason, "token budget") {
t.Errorf("IncompleteReason = %q; a spend stop must name the token budget", result.IncompleteReason)
}
if strings.Contains(result.IncompleteReason, "max-turns") {
t.Errorf("a token stop was reported as a turn-limit stop: %q", result.IncompleteReason)
}
// ...and the model is told the same thing, so its summary is about the right
// constraint.
last := provider.requests[len(provider.requests)-1]
rendered := renderZeromaxingMessages(last.Messages)
if !strings.Contains(rendered, "token budget") {
t.Errorf("the final-answer prompt does not mention the token budget:\n%s", rendered[max(0, len(rendered)-400):])
}
}
// A TURN-COUNT STOP STILL READS AS ONE. The new branch must not relabel the old
// ending.
func TestATurnStopStillSaysMaxTurns(t *testing.T) {
provider := budgetProvider(6, 10, "done")
result, err := Run(context.Background(), "work", provider, Options{
Cwd: t.TempDir(), Registry: budgetRegistry(), SessionID: "turn-reason",
MaxTurns: 3, RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(result.IncompleteReason, "max-turns") {
t.Errorf("IncompleteReason = %q; a turn stop must still name the turn limit", result.IncompleteReason)
}
}
// AN UNSET BUDGET IS UNBOUNDED, so every caller that never sets it behaves
// exactly as before.
func TestAnUnsetTokenBudgetBoundsNothing(t *testing.T) {
provider := budgetProvider(4, 1_000_000, "finished")
result, err := Run(context.Background(), "work", provider, Options{
Cwd: t.TempDir(), Registry: budgetRegistry(), SessionID: "unbounded",
MaxTurns: 50,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 5 {
t.Fatalf("an unbounded run made %d requests, want all 5", len(provider.requests))
}
if !strings.Contains(result.FinalAnswer, "finished") {
t.Errorf("final answer = %q", result.FinalAnswer)
}
}
// THE BUDGET IS CHECKED AT A TURN BOUNDARY, so a turn whose tool calls have
// already run is not discarded. Stopping mid-turn would throw away work the run
// has already paid for.
func TestTheBudgetDoesNotDiscardAnAlreadyPaidTurn(t *testing.T) {
ran := 0
registry := tools.NewRegistry()
registry.Register(countingTool{count: &ran})
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
// One turn that blows the entire budget in a single exchange.
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "counted"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{PromptTokens: 9999}},
{Type: zeroruntime.StreamEventDone},
},
{{Type: zeroruntime.StreamEventText, Content: "summary"}, {Type: zeroruntime.StreamEventDone}},
}}
if _, err := Run(context.Background(), "work", provider, Options{
Cwd: t.TempDir(), Registry: registry, SessionID: "boundary",
MaxTurns: 50, MaxTokens: 100,
}); err != nil {
t.Fatal(err)
}
if ran != 1 {
t.Errorf("the tool ran %d times; the turn's already-issued call must still execute", ran)
}
}
type countingTool struct{ count *int }
func (countingTool) Name() string { return "counted" }
func (countingTool) Description() string { return "counts" }
func (countingTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (countingTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectNone, Permission: tools.PermissionAllow, AdvertiseInAuto: true}
}
func (t countingTool) Run(context.Context, map[string]any) tools.Result {
*t.count++
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
+46 -4
View File
@@ -325,13 +325,24 @@ type Options struct {
SessionTitle string
ProviderName string
Model string
ReasoningEffort string
ServiceTier string
Cwd string
SystemPrompt string
// ModelFamily is the family the PROVIDER declares it serves, from the
// provider catalog — "openai", "anthropic", "gemini", or "" when the provider
// is a gateway serving several and cannot answer. Empty falls back to
// classifying the model id, which is a guess that matches nothing for most of
// the catalog. Prompt tuning only; it selects the family addendum and nothing
// else.
ModelFamily string
ReasoningEffort string
ServiceTier string
Cwd string
SystemPrompt string
// TransientSystemPrompt adds trusted runtime guidance for this run only.
// Empty preserves the ordinary system prompt byte-for-byte.
TransientSystemPrompt string
// userMessage is this turn's raw user text, set by Run from its prompt
// argument and forwarded to tools. Unexported: it is not a knob a caller
// sets, it is what the caller already passed as `prompt`.
userMessage string
// ResponseStyle is the operator-selected reply style from the TUI /style
// command (e.g. "concise", "explanatory", "review"). It is rendered into the
// system prompt as a short directive. Empty or "balanced" adds nothing — the
@@ -346,6 +357,19 @@ type Options struct {
// boundary when this is false. nil uses the curated catalog plus the
// name heuristic via modelregistry.SupportsVision.
SupportsVision func(modelID string) bool
// MaxTokens bounds what a whole run may SPEND, in provider-reported tokens.
// 0 is unbounded, which is every caller that does not set it.
//
// MaxTurns IS NOT A COST BOUND, and treating it as one is how a run ends at
// an arbitrary place. A cheap run reaches 320 turns having spent ~3M tokens; a
// measured heavy run reached the same 320 having spent 35,781,390. The turn
// count bounds round trips, not spend, and the two diverge by an order of
// magnitude on exactly the runs worth bounding.
//
// Crossing it ends the run the same way MaxTurns does — one final call asking
// for a summary — so a budget expiry produces a written report of what was
// done rather than a run that simply stops.
MaxTokens int
// ContextWindow is the model's maximum input token budget. When > 0 the agent
// loop compacts long conversations once the estimated size crosses a fraction
// of this window. 0 DISABLES compaction entirely (every existing caller/test
@@ -437,6 +461,24 @@ type Options struct {
// byte-identical: no observation, no escalation, no counters. Same opt-in
// convention as Trace and SelfCorrect.
Profile *ProfilePolicy
// Zeromaxing reports where this run sits in the zeromaxing execution
// posture's lifecycle, driving the model-facing reminders the turn loop
// appends. The zero value (ZeromaxingOff) suppresses every reminder, so a
// caller that never heard of it is byte-identical to today. Deliberately
// separate from Profile: zeromaxing arms no escalation triggers, so
// Profile.Policy() returns nil for it and could not carry this.
Zeromaxing Zeromaxing
// PlanCompletions, when set, is drained once per turn for background plans
// that finished since the last turn. Its text is appended to the
// conversation TAIL as a user-role message, on the same channel as the
// post-edit diagnostics nudge — background work reporting into a later turn
// already has a channel here, and a second one would be a second ordering
// and a second thing to budget. nil is a no-op.
PlanCompletions func() string
// OrchestrateAvailable reports whether the orchestrate tool is actually
// advertised this run, so the enter notice names it only when it exists.
// Zero value false keeps every existing caller's prompt unchanged.
OrchestrateAvailable bool
// Trace, when set, records per-turn timing for the run: the loop stamps
// spans (prompt build, generation, tool execution, permission wait,
// compaction, provider connect) and counters (model requests, tool calls,
+167
View File
@@ -0,0 +1,167 @@
package agent
// 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.
type Zeromaxing int
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.
ZeromaxingOff Zeromaxing = 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
// a sequence of observed side effects.
func zeromaxingReminders(posture Zeromaxing, turn int, orchestrateAvailable bool) []string {
if turn < 1 {
return nil
}
switch posture {
case ZeromaxingEntering:
if turn == 1 {
notices := []string{ZeromaxingEnterNotice, ZeromaxingBudgetNotice}
if orchestrateAvailable {
notices = append(notices, ZeromaxingOrchestrateNotice)
}
// LAST, so it is the nearest instruction to the user's message.
return append(notices, ZeromaxingEvidenceNotice)
}
return []string{ZeromaxingStillOnNotice}
case ZeromaxingActive:
// Already on when the run started: no enter notice, not even on turn 1.
//
// The evidence contract still repeats, because it is per-RUN rather than
// per-posture-change: turn 1 is where the user's message sits, so it is
// where a claim about the code arrives and where a report gets written.
// Announcing it only when the posture flipped would leave every later run
// of a long session without it — and the long sessions are the ones that
// produce reports.
if turn == 1 {
return []string{ZeromaxingStillOnNotice, ZeromaxingEvidenceNotice}
}
return []string{ZeromaxingStillOnNotice}
case ZeromaxingExiting:
// One notice on the way out, then silence — the posture is off, so a
// "still active" line every turn would be a lie.
if turn == 1 {
return []string{ZeromaxingExitNotice}
}
return nil
default:
return nil
}
}
+225
View File
@@ -0,0 +1,225 @@
package agent
import (
"reflect"
"strings"
"testing"
)
// (i)(j)(k) The whole reminder state machine as data. zeromaxingReminders is
// pure, so "enter exactly once" and "still-on never on the first turn" are
// assertions about a table rather than about observed side effects.
func TestZeromaxingReminderSchedule(t *testing.T) {
cases := []struct {
name string
posture Zeromaxing
turn int
want []string
}{
// (i) enter fires exactly once, on the first turn of the entering run.
{"entering turn 1 announces, states the budget and the evidence contract", ZeromaxingEntering, 1,
[]string{ZeromaxingEnterNotice, ZeromaxingBudgetNotice, ZeromaxingEvidenceNotice}},
// (j) still-on from turn 2, and it is the ONLY thing from turn 2 on —
// no second enter, no repeated budget notice.
{"entering turn 2 continues", ZeromaxingEntering, 2, []string{ZeromaxingStillOnNotice}},
{"entering turn 3 continues", ZeromaxingEntering, 3, []string{ZeromaxingStillOnNotice}},
{"entering turn 99 continues", ZeromaxingEntering, 99, []string{ZeromaxingStillOnNotice}},
// (j) a run that began with the posture already on gets still-on even on
// turn 1 — re-announcing entry every run would be wrong.
// The evidence contract is per-RUN, not per-posture-change: turn 1 is
// where the user's message sits, so it is where a claim about the code
// arrives and where a report gets written.
{"active turn 1 does not re-announce but restates the evidence contract", ZeromaxingActive, 1,
[]string{ZeromaxingStillOnNotice, ZeromaxingEvidenceNotice}},
{"active turn 2 continues", ZeromaxingActive, 2, []string{ZeromaxingStillOnNotice}},
// (k) exit fires exactly once, then silence — the posture is off, so a
// "still active" line afterwards would be a lie.
{"exiting turn 1 announces once", ZeromaxingExiting, 1, []string{ZeromaxingExitNotice}},
{"exiting turn 2 is silent", ZeromaxingExiting, 2, nil},
{"exiting turn 5 is silent", ZeromaxingExiting, 5, nil},
// Off is completely silent: an unaware caller's run is byte-identical.
{"off turn 1 is silent", ZeromaxingOff, 1, nil},
{"off turn 7 is silent", ZeromaxingOff, 7, nil},
// Defensive: a non-positive turn never emits.
{"turn 0 is silent", ZeromaxingEntering, 0, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := zeromaxingReminders(tc.posture, tc.turn, false)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("zeromaxingReminders(%v, %d) = %#v, want %#v", tc.posture, tc.turn, got, tc.want)
}
})
}
}
// Counting across a whole run, the way a reader of the gate would check it.
func TestZeromaxingEnterAndExitFireExactlyOncePerRun(t *testing.T) {
count := func(posture Zeromaxing, needle string) int {
n := 0
for turn := 1; turn <= 50; turn++ {
for _, line := range zeromaxingReminders(posture, turn, false) {
if line == needle {
n++
}
}
}
return n
}
if got := count(ZeromaxingEntering, ZeromaxingEnterNotice); got != 1 {
t.Fatalf("enter fired %d times across 50 turns, want exactly 1", got)
}
if got := count(ZeromaxingEntering, ZeromaxingBudgetNotice); got != 1 {
t.Fatalf("budget notice fired %d times across 50 turns, want exactly 1", got)
}
if got := count(ZeromaxingExiting, ZeromaxingExitNotice); got != 1 {
t.Fatalf("exit fired %d times across 50 turns, want exactly 1", got)
}
if got := count(ZeromaxingActive, ZeromaxingEnterNotice); got != 0 {
t.Fatalf("an already-active posture must never emit the enter notice, got %d", got)
}
if got := count(ZeromaxingEntering, ZeromaxingStillOnNotice); got != 49 {
t.Fatalf("still-on fired on %d of turns 2..50, want 49", got)
}
}
// still-on repeats on every continuing turn, so it must be BYTE-IDENTICAL every
// time. A turn counter, a remaining-budget number, or a timestamp here would be
// invisible today (it rides below the cache breakpoint) and a silent cost bug
// the moment anyone moved this text into the system prompt.
func TestZeromaxingStillOnNoticeIsInvariant(t *testing.T) {
first := zeromaxingReminders(ZeromaxingEntering, 2, false)
for turn := 3; turn <= 40; turn++ {
if got := zeromaxingReminders(ZeromaxingEntering, turn, false); !reflect.DeepEqual(got, first) {
t.Fatalf("still-on drifted at turn %d: %#v vs %#v", turn, got, first)
}
}
if strings.ContainsAny(ZeromaxingStillOnNotice, "0123456789") {
t.Fatalf("still-on must carry no digits (no counter, no budget, no timestamp): %q",
ZeromaxingStillOnNotice)
}
}
// Phase 1 ships no orchestration, so no reminder may imply any. A prompt that
// advertises capabilities the run does not have is a prompt-level lie, and the
// model will try to use them.
func TestZeromaxingRemindersPromiseNoOrchestration(t *testing.T) {
all := []string{
ZeromaxingEnterNotice, ZeromaxingBudgetNotice,
ZeromaxingStillOnNotice, ZeromaxingExitNotice,
}
forbidden := []string{"orchestrat", "workflow", "fan out", "fan-out", "worker", "sub-agent", "subagent", "delegate", "parallel"}
for _, notice := range all {
lower := strings.ToLower(notice)
for _, word := range forbidden {
if strings.Contains(lower, word) {
t.Fatalf("Phase 1 has no orchestration; reminder must not mention %q: %q", word, notice)
}
}
}
}
// Phase 1's no-orchestration rule, now CONDITIONAL rather than absolute.
//
// The rule was never "never mention a tool" — it was "never promise a
// capability the run does not have". With the orchestrate tool actually
// advertised, naming it is accurate; without it, naming it would be the
// prompt-level lie the original test guarded against. Both directions asserted,
// because deleting the guard and keeping only the positive case would lose
// exactly the protection that mattered.
func TestOrchestrateIsNamedOnlyWhenItExists(t *testing.T) {
withTool := zeromaxingReminders(ZeromaxingEntering, 1, true)
withoutTool := zeromaxingReminders(ZeromaxingEntering, 1, false)
joined := func(lines []string) string { return strings.Join(lines, "\n") }
if !strings.Contains(joined(withTool), "orchestrate") {
t.Fatalf("with the tool available the enter notice must name it:\n%s", joined(withTool))
}
if strings.Contains(joined(withoutTool), "orchestrate") {
t.Fatalf("without the tool NOTHING may mention it:\n%s", joined(withoutTool))
}
// The unavailable case keeps Phase 1's original vocabulary guard intact.
for _, word := range []string{"orchestrat", "workflow", "fan-out", "worker", "delegate", "parallel"} {
if strings.Contains(strings.ToLower(joined(withoutTool)), word) {
t.Fatalf("without the tool the reminders must not mention %q:\n%s", word, joined(withoutTool))
}
}
// The notice is one-shot like the rest: it rides the enter turn only.
for turn := 2; turn <= 5; turn++ {
if strings.Contains(joined(zeromaxingReminders(ZeromaxingEntering, turn, true)), "orchestrate") {
t.Fatalf("the orchestrate notice must fire once, not on turn %d", turn)
}
}
// Every OTHER posture state stays silent about it.
for _, posture := range []Zeromaxing{ZeromaxingOff, ZeromaxingActive, ZeromaxingExiting} {
if strings.Contains(joined(zeromaxingReminders(posture, 1, true)), "orchestrate") {
t.Fatalf("posture %v must not name the tool", posture)
}
}
}
// THE EVIDENCE CONTRACT FIRES ONCE PER RUN, on the turn the user's message sits
// on — not once per posture change, and not on every tool turn.
//
// Once per posture change would leave every later run of a long session without
// it, and the long sessions are the ones that produce reports. Every tool turn
// would pay hundreds of copies to say something that only bears on the
// boundaries of a run.
func TestTheEvidenceContractFiresOnceOnTheFirstTurnOfEveryRun(t *testing.T) {
for _, posture := range []Zeromaxing{ZeromaxingEntering, ZeromaxingActive} {
fired := 0
for turn := 1; turn <= 50; turn++ {
for _, line := range zeromaxingReminders(posture, turn, false) {
if line == ZeromaxingEvidenceNotice {
fired++
if turn != 1 {
t.Errorf("posture %v repeated the evidence contract on turn %d; it belongs on turn 1 only", posture, turn)
}
}
}
}
if fired != 1 {
t.Errorf("posture %v fired the evidence contract %d times across 50 turns, want exactly 1", posture, fired)
}
}
// A posture that is OFF or on its way out says nothing: a contract about how
// to weigh evidence is part of the posture, and claiming it after the posture
// ended would be as wrong as the still-on notice would be.
for _, posture := range []Zeromaxing{ZeromaxingOff, ZeromaxingExiting} {
for turn := 1; turn <= 5; turn++ {
for _, line := range zeromaxingReminders(posture, turn, true) {
if line == ZeromaxingEvidenceNotice {
t.Errorf("posture %v emitted the evidence contract on turn %d", posture, turn)
}
}
}
}
}
// The contract has to actually carry all three rules it exists for. Asserted on
// the substance, not the wording, so a rewrite is free but a DELETION is not:
// each of these went missing in a measured run and cost a different thing.
func TestTheEvidenceContractCoversAllThreeFailures(t *testing.T) {
for _, required := range []string{
// A passing test treated as proof of the property.
"passing test is not proof",
"what it would still pass with",
// Numbers reported that no command in the session produced.
"must come from a command you ran in this session",
"show both",
// Folding to whoever asserts a defect most confidently.
"prove or refute it from the code before you agree",
"even when the claim turns out to be right",
} {
if !strings.Contains(ZeromaxingEvidenceNotice, required) {
t.Errorf("the evidence contract no longer says %q:\n%s", required, ZeromaxingEvidenceNotice)
}
}
}
+57
View File
@@ -0,0 +1,57 @@
package aimlapi
import (
"net/http"
"net/url"
"strings"
)
const (
// SourceHeaderName and SourceValue identify Zero as the calling agent on
// requests to aimlapi.com services (the Toolbox and the MCP server).
SourceHeaderName = "X-AIMLAPI-Source"
SourceValue = "agent/zero"
// CanonicalInferenceBaseURL is the catalog inference endpoint. A provider
// profile whose base URL differs is treated as a proxy: its key is never sent
// to other aimlapi.com services.
CanonicalInferenceBaseURL = "https://api.aimlapi.com/v1"
// ToolboxBaseURL is the fixed AI/ML API Toolbox REST base. It is not
// configurable, so the key and attribution can only reach tools.aimlapi.com.
ToolboxBaseURL = "https://tools.aimlapi.com/v1/tools"
// MCPServerURL is the AI/ML API MCP server (streamable HTTP).
MCPServerURL = "https://mcp.aimlapi.com/mcp"
)
// attributedHosts are the only hostnames that receive the AI/ML API key or the
// attribution headers. Matching is exact; a suffix or substring match would
// also accept hosts such as tools.aimlapi.com.example.net.
var attributedHosts = [...]string{"api.aimlapi.com", "tools.aimlapi.com", "mcp.aimlapi.com"}
// IsAttributedURL reports whether target is an https URL on one of the
// aimlapi.com service hosts, on the default port and without userinfo.
func IsAttributedURL(target *url.URL) bool {
if target == nil || !strings.EqualFold(target.Scheme, "https") || target.User != nil {
return false
}
if port := target.Port(); port != "" && port != "443" {
return false
}
host := strings.ToLower(target.Hostname())
for _, candidate := range attributedHosts {
if host == candidate {
return true
}
}
return false
}
// ApplyAttributionHeaders sets the source and partner headers on header only
// when target is an aimlapi.com service URL; any other target is left as is.
func ApplyAttributionHeaders(header http.Header, target *url.URL) {
if header == nil || !IsAttributedURL(target) {
return
}
header.Set(SourceHeaderName, SourceValue)
header.Set(PartnerHeaderName, ResolvePartnerID(""))
}
+54
View File
@@ -0,0 +1,54 @@
package aimlapi
import (
"net/http"
"net/url"
"testing"
)
func TestIsAttributedURLMatchesExactServiceHostsOnly(t *testing.T) {
cases := map[string]bool{
"https://tools.aimlapi.com/v1/tools/run": true,
"https://mcp.aimlapi.com/mcp": true,
"https://api.aimlapi.com/v1": true,
"https://TOOLS.aimlapi.com:443/v1/tools": true,
"http://tools.aimlapi.com/v1/tools/run": false,
"https://tools.aimlapi.com:8443/v1/tools": false,
"https://[email protected]/v1/tools": false,
"https://tools.aimlapi.com.example.net/run": false,
"https://eviltools.aimlapi.com/run": false,
"https://aimlapi.com/app": false,
"https://auth.aimlapi.com/": false,
"https://example.com/?h=tools.aimlapi.com": false,
}
for raw, want := range cases {
target, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw, err)
}
if got := IsAttributedURL(target); got != want {
t.Errorf("IsAttributedURL(%q) = %v, want %v", raw, got, want)
}
}
if IsAttributedURL(nil) {
t.Fatal("IsAttributedURL(nil) = true")
}
}
func TestApplyAttributionHeadersSetsSourceAndPartnerOnServiceHosts(t *testing.T) {
t.Setenv("AIMLAPI_PARTNER_ID", "")
header := http.Header{}
ApplyAttributionHeaders(header, &url.URL{Scheme: "https", Host: "tools.aimlapi.com", Path: "/v1/tools/run"})
if got := header.Get(SourceHeaderName); got != "agent/zero" {
t.Fatalf("%s = %q, want agent/zero", SourceHeaderName, got)
}
if got := header.Get(PartnerHeaderName); got != DefaultPartnerID {
t.Fatalf("%s = %q, want %q", PartnerHeaderName, got, DefaultPartnerID)
}
other := http.Header{}
ApplyAttributionHeaders(other, &url.URL{Scheme: "https", Host: "tools.aimlapi.com.example.net"})
if len(other) != 0 {
t.Fatalf("attribution reached a non-aimlapi host: %#v", other)
}
}
+347 -12
View File
@@ -17,10 +17,12 @@ import (
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/execprofile"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/localcontrol"
"github.com/Twigpine/zero/internal/mcp"
"github.com/Twigpine/zero/internal/memory"
"github.com/Twigpine/zero/internal/modelregistry"
"github.com/Twigpine/zero/internal/observability"
"github.com/Twigpine/zero/internal/peermsg"
@@ -788,6 +790,19 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
}
registry := newCoreRegistryScoped(workspaceRoot, scope)
// OPT-IN, and the default matters: registering these changes the advertised
// tool set for EVERY run, which is exactly what this branch guarantees the
// posture does not do when it is off. Off by default keeps that guarantee
// true; a user who wants durable notes says so in their own config.
if resolved.Profiles.Memory {
registry.Register(tools.NewMemoryTool(memory.DefaultPaths(workspaceRoot)))
registry.Register(tools.NewMemoryWriteTool(memory.DefaultPaths(workspaceRoot)))
// Deletion is its own tool so the approval can disclose it. memory_write's
// prompt says only that it saves, and deleting under that sentence is what
// made an omitted content field destructive (#897) — registering the save
// tool without this one would leave notes undeletable instead.
registry.Register(tools.NewMemoryForgetTool(memory.DefaultPaths(workspaceRoot)))
}
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
// Mid-run model escalation is opt-in on this surface too. The tool is present
// only when the operator asked for it with --allow-escalation, and the
@@ -817,11 +832,103 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
})
executionRunner.SetPreparer(sandboxEngine)
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
// One gate shared by the registered tool and the TUI that flips it.
zeromaxingGate := &specialist.PostureGate{}
// The plan recorder. Registered once with the tool and re-attached to each
// run by the model — without it the TUI recorded NONE of the five plan
// lifecycle events, so a plan ran completely invisibly (audit finding 9).
planProgress := tui.NewPlanProgressBridge()
// A CANCELLABLE session root, replacing the context.Background() this used
// to hand the TUI.
//
// Never cancelling it was harmless while nothing outlived a run. A
// background plan does, and under an uncancellable root it would keep
// running — and keep spending — after the session ended. Close() cancels
// AND WAITS, so a plan is stopped and its terminal event written rather
// than the process exiting out from under it.
sessionCtx, cancelSession := context.WithCancel(context.Background())
defer cancelSession()
planLaunch := newPlanLauncher(sessionCtx, planProgress)
// ITS defer IS REGISTERED LAST, further down, and that is load-bearing —
// see the ordering note beside closeSpecialistRuntime.
// Saved plans, resolved ONCE and handed to both consumers: the orchestrate
// tool (which loads a plan named with `saved`) and the TUI (which saves,
// lists and shows them). Two computations of the same pair of directories
// would eventually disagree about where a plan lives, and the symptom would
// be "I saved it" followed by "no saved plan named that".
tuiUserConfigDir, _ := config.UserConfigDir()
planPaths := specialist.DefaultPlanPaths(workspaceRoot, tuiUserConfigDir)
// nil filters: the TUI has no --enabled-tools/--disabled-tools equivalent,
// so the run's grant is every read-only tool the registry holds.
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize, nil, nil, planProgress,
orchestrateWiring{
DiscoverModels: planModelDiscoverer(workspaceRoot, resolved.Provider),
// Sizes each task's dependency briefing to the window of the model
// that will READ it. Wired here because only this side knows the
// provider profile and the session's own model.
ContextWindows: planContextWindows(resolved.Provider, resolved.Provider.Model),
// The LIVE scope, not a snapshot of it. A request_permissions grant
// lands mid-session and must reach the children dispatched after it.
// ExtraRoots, not Roots: this is what the run holds BEYOND its
// workspace, which is what the field means and what a child needs.
// Roots() also returns the workspace root itself, and for a plan task
// running in an isolated worktree that arrived as --add-dir <parent
// tree> — re-opening everything --cwd had just narrowed. A measured
// run wrote ten times into the user's real tree that way, each write
// allowed with the reason "workspace write is allowed", because by
// then it genuinely was inside the child's write roots.
//
// withinTurn selects whether TURN-SCOPED grants may ride along: only
// for a child that exits before this turn does (see childWriteRoots).
ExtraWriteRoots: func(withinTurn bool) []string { return childWriteRoots(scope, withinTurn) },
// The read counterpart: a request_permissions READ grant lands in the
// scope's readRoots, which ExtraRoots (write grants) does not return, so
// without this a plan auditing a granted path failed "outside the
// workspace" in every task. LIVE scope, read at dispatch, same as above,
// and handed to the executor too so an ordinary Task and a resume can
// read the same grant. Same withinTurn rule as the write roots.
ExtraReadRoots: func(withinTurn bool) []string { return childReadRoots(scope, withinTurn) },
ProbeModel: planModelProber(workspaceRoot, resolved.Provider, deps.newProvider),
ProbeIdentity: planModelProbeIdentity(workspaceRoot, resolved.Provider),
ModelPrefs: planModelPreferences(resolved.Profiles.PlanModels),
// Off unless the user's own config asks for it: on by default would
// refuse a plan for everyone whose phrasing does not happen to match.
RequirePlanKeyword: resolved.Profiles.RequirePlanKeyword,
Gate: zeromaxingGate,
// Depth stays 0: the TUI is always a root session — it has no
// --depth and is never launched as a child — so zero is the
// measured value here, not an unset one.
PlanContext: specialist.PlanTaskContext{
Cwd: workspaceRoot, Depth: 0,
PostureReasoningEffort: string(execprofile.Zeromaxing.ReasoningEffort),
},
// The plan-size tier, from the SAME resolved config the rest of this
// wiring reads. Project config may only have tightened it.
Size: resolved.Profiles.PlanSizeTier(),
Plans: planPaths,
// The TUI can carry a background plan: it has a session that
// outlives a turn. Headless exec supplies no launcher.
Launch: planLaunch.Launch,
// A write-capable plan gets a worktree of its own, or is refused.
Isolate: newPlanIsolator(workspaceRoot),
})
if err != nil {
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
}
// SHUTDOWN ORDER, and defers run LIFO so the registration order here is the
// REVERSE of what happens.
//
// Required: stop the plan, then close the runtime it was using, then cancel
// the session. It was the exact opposite. planLaunch.Close() was registered
// early (right where the launcher is built, which reads naturally), so it ran
// AFTER closeSpecialistRuntime — and Close "cancels AND WAITS", so a
// background plan was still being waited on with the specialist runtime it
// needs already torn down.
//
// Registering Close here, last, is what puts it first at shutdown. Anything
// added below this line runs BEFORE the plan is stopped; put it above.
defer closeSpecialistRuntime(stderr, specialistRuntime)
defer planLaunch.Close()
// The TUI has no --worktree reassignment, so trustRoot == workspaceRoot here.
// Gate the project MCP layer behind the workspace-trust check (fail-closed): an
// untrusted workspace must not spawn its ./.zero/config.json stdio MCP servers.
@@ -964,12 +1071,13 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// report can be combined with the plugin activation's, and emit at most one
// notice when project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
specialistRuntime.attachLifecycleHooks(hookDispatcher)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
// From the engine the session's commands run through, so the notice
// describes the sandbox they actually get.
startupNotices := []string{sandboxEngine.DegradedNotice()}
modelRPM := zeroruntime.NewModelRPMLimiter(resolved.ModelRPM)
return deps.runTUI(context.Background(), tui.Options{
return deps.runTUI(sessionCtx, tui.Options{
ModelRPM: modelRPM,
Cwd: workspaceRoot,
Version: version,
@@ -986,6 +1094,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
FavoriteModels: resolved.Preferences.FavoriteModels,
RecentModels: resolved.Preferences.RecentModels,
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
KeepFinishedAgents: resolved.Preferences.KeepsFinishedAgents(),
CompactionModel: resolved.Preferences.CompactionModel,
Provider: provider,
NewProvider: deps.newProvider,
@@ -1012,6 +1121,10 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
PeerService: peerService,
SandboxStore: sandboxStore,
MCPConfig: mcpConfig,
ZeromaxingDisabled: resolved.Profiles.DisableZeromaxing,
ZeromaxingGate: zeromaxingGate,
PlanProgress: planProgress,
PlanPaths: planPaths,
MCPPermissionStore: mcpPermissionStore,
MCPTokenStore: mcpTokenStore,
MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult {
@@ -1037,13 +1150,22 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
MaxTurns: resolved.MaxTurns,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: "low",
Sandbox: sandboxEngine,
FileTracker: fileTracker,
Hooks: hookDispatcher,
DeferThreshold: resolved.Tools.DeferThreshold,
Specialists: specialistRuntime.specialists,
Skills: pluginActivation.skillInfos(deps.skillsDir()),
// Set ONCE, here, rather than beside each of the three places the
// TUI assigns Zeromaxing: the registry is built per session and does
// not change per run, and three assignments of one fact is three
// chances for the next one to be forgotten — which is precisely how
// this field came to be set nowhere at all.
OrchestrateAvailable: orchestrateAvailable(registry),
// Background plans report into a later turn through this drain, on
// the same channel as the post-edit diagnostics nudge.
PlanCompletions: planProgress.DrainCompletedPlans,
Autonomy: "low",
Sandbox: sandboxEngine,
FileTracker: fileTracker,
Hooks: hookDispatcher,
DeferThreshold: resolved.Tools.DeferThreshold,
Specialists: specialistRuntime.specialists,
Skills: pluginActivation.skillInfos(deps.skillsDir()),
},
AllowEscalation: allowEscalation,
// LoadSkills backs /skills and direct /<skill-name> invocation in the TUI.
@@ -1221,6 +1343,9 @@ type agentToolRuntime struct {
// the orchestrator's system-prompt delegation section. Populated alongside the
// tools, so it is present exactly when the Task tool is.
specialists []agent.SpecialistInfo
// lifecycleHooks is shared with the specialist executor so the hooks
// dispatcher (built later in startup) can be attached before any specialist runs.
lifecycleHooks *specialist.LifecycleHooks
}
// specialistInfos returns the runtime's specialist summaries, nil-safe so the
@@ -1232,12 +1357,164 @@ func (r *agentToolRuntime) specialistInfos() []agent.SpecialistInfo {
return r.specialists
}
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int) (*agentToolRuntime, error) {
func (r *agentToolRuntime) attachLifecycleHooks(dispatcher *hooks.Dispatcher) {
if r == nil || r.lifecycleHooks == nil || dispatcher == nil {
return
}
r.lifecycleHooks.Dispatch = func(ctx context.Context, event string, specialistName string, payload map[string]any) {
dispatcher.Dispatch(ctx, hooks.DispatchInput{
Event: hooks.Event(event),
ToolName: specialistName,
Payload: payload,
})
}
}
// orchestrateWiring carries what registerSpecialistTools needs to wire the
// plan tool. A zero value leaves the tool registered but permanently off, which
// is the posture-off behaviour every existing caller already gets.
type orchestrateWiring struct {
// RequirePlanKeyword carries Profiles.RequirePlanKeyword to the tool.
RequirePlanKeyword bool
// Gate is the shared posture flag. A POINTER, not a closure over caller
// state: the TUI model is a value type copied on every update, so a closure
// would freeze the posture as it was at registration.
Gate *specialist.PostureGate
// PlanContext supplies the run-invariant state a plan task inherits.
PlanContext specialist.PlanTaskContext
// ContextWindows sizes a task's dependency briefing to the model that will
// read it. nil keeps the fixed caps.
ContextWindows specialist.ContextWindowFunc
// ExtraWriteRoots reports the run's non-workspace roots at LAUNCH time, so a
// child's sandbox covers the same ground its parent's does. nil means the
// workspace only, which is what every child got before this existed.
// withinTurn: see specialist.Executor.ExtraWriteRoots.
ExtraWriteRoots func(withinTurn bool) []string
// ExtraReadRoots reports the paths the run may READ beyond its workspace — its
// request_permissions grants — at DISPATCH time, so a plan's tasks can read a
// granted external path instead of failing "outside the workspace". The read
// counterpart of ExtraWriteRoots: a read grant lands in a separate scope list
// that ExtraWriteRoots does not cover. nil means workspace-only reads.
// withinTurn: see specialist.Executor.ExtraReadRoots. It reaches both the
// executor (every Task, resume and plan task) and the orchestrate tool.
ExtraReadRoots func(withinTurn bool) []string
// ProbeModel proves a model will actually run before any task is assigned it.
// nil skips proving, which is what every plan did before this existed.
ProbeModel specialist.ModelProber
// ProbeIdentity namespaces cached verdicts by the live provider/profile and
// endpoint. nil uses the specialist package's private default namespace.
ProbeIdentity func() string
// Size is the configured plan-size tier. The zero value is the default tier,
// so a call site that has no resolved config yet still gets a real ceiling
// rather than none.
Size config.PlanSize
// Plans locates saved plans. Empty means a `saved` reference is refused with
// a reason rather than searched for in nowhere.
Plans specialist.PlanPaths
// Isolate prepares a worktree for a write-capable plan. nil refuses one.
Isolate specialist.PlanIsolator
// Launch runs a plan in the background. nil — the headless default — makes
// a background plan refuse rather than start one nothing can report.
Launch func(run func(ctx context.Context)) bool
// DiscoverModels lists what the active provider can serve, for auto_assign.
// nil makes a plan asking for it refuse with a reason.
DiscoverModels specialist.ModelDiscoverer
// ModelPrefs carries the user's per-role model pins and exclusions.
ModelPrefs specialist.ModelPreferences
}
// planParentTools is the run's grant: the tools a plan task may inherit.
//
// Derived, never hand-written. The candidate set is specialist's own exported
// list — nothing outside it can ever be granted — narrowed to what this
// registry actually holds and what the run's operator filters allow. Keeping
// one authoritative list rather than a second copy here is invariant 5; the
// previous field was a hand-supplied []string that BOTH production call sites
// left nil, which silently disabled the narrowing rule entirely.
func planParentTools(registry *tools.Registry, enabledTools, disabledTools []string) []string {
grant := []string{}
// EVERY GRANTABLE NAME, read-only and write alike. The write tools are not
// thereby granted to anything: a task inherits only the read-only ones
// (planToolGrant) and must NAME a write tool to hold it. Narrowing this list
// to read-only would instead make a named write tool undeliverable — the
// task would validate and then run with less than it asked for.
for _, name := range specialist.PlanGrantableToolNames() {
if _, found := registry.Get(name); !found {
continue
}
if !agent.ToolAllowedByFilters(name, enabledTools, disabledTools) {
continue
}
grant = append(grant, name)
}
return grant
}
// wireChildRoots gives the executor what every child it launches (Task, resume,
// swarm member, plan task) may inherit from this run's sandbox scope.
func wireChildRoots(executor *specialist.Executor, workspaceRoot string, wiring orchestrateWiring) {
// The run's own roots, read at every launch. Without this a child is
// confined more tightly than its parent: a run granted a directory
// outside its workspace could create and populate it, then watch every
// plan task be refused at that same directory.
executor.ExtraWriteRoots = wiring.ExtraWriteRoots
// The parent's own tree, so a child isolated in a worktree is never handed
// a write root that overlaps it (an ancestor --add-dir, say).
executor.WorkspaceRoot = workspaceRoot
// The run's READ grants, for every child and not only plan tasks: an
// ordinary Task or a resume must be able to read what the parent was just
// granted to read. Emitted by the executor as --add-read-dir only.
executor.ExtraReadRoots = wiring.ExtraReadRoots
}
// orchestrateReadRoots adapts the run's read supplier to the orchestrate tool,
// which snapshots it when a plan starts. The plan tool has always passed every
// live read grant, turn-scoped ones included, and that is unchanged here.
func orchestrateReadRoots(supplier func(withinTurn bool) []string) func() []string {
if supplier == nil {
return nil
}
return func() []string { return supplier(true) }
}
// registerSpecialistTools registers the specialist, swarm and orchestrate tools.
// The wiring argument carries what the orchestrate tool needs; a zero value
// leaves it registered but permanently off, which is the posture-off behaviour.
//
// enabledTools/disabledTools are the run's operator filters, and recorder is the
// plan lifecycle sink. All three are explicit PARAMETERS rather than wiring
// fields on purpose: a call site cannot forget them without failing to compile.
// As a field, Recorder was simply never set by the TUI, so a plan there recorded
// none of its five lifecycle events (finding 9) — the same omission the parent
// grant suffered. An explicit nil is a stated choice; an absent field is not.
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int, enabledTools, disabledTools []string, recorder specialist.PlanRecorder, wiring orchestrateWiring) (*agentToolRuntime, error) {
paths, err := specialist.DefaultPaths(workspaceRoot)
if err != nil {
return nil, err
}
executor := specialist.Executor{Paths: paths}
lifecycleHooks := &specialist.LifecycleHooks{}
// One budget for the whole session, shared by every run and every plan in it
// — which is the point: a per-run counter would reset on each message and
// bound nothing a conversation does over time.
sessionBudget := specialist.NewSessionBudget(specialist.DefaultSessionSubagents)
executor := specialist.Executor{
SessionBudget: sessionBudget,
Paths: paths,
// Dispatches specialistStart/specialistStop hooks once the session's
// hook dispatcher is attached (attachLifecycleHooks).
LifecycleHooks: lifecycleHooks,
// So a delegated Task that names no model is routed to a role-appropriate
// pin under the zeromaxing posture, the same way a plan task is. Off unless
// the posture is active and auto-assign is configured. The discoverer is
// the same one the plan tool uses: a pin fires only when the provider's
// own listing carries it, so a stale pin after a provider switch degrades
// to inherit instead of a child dead at spawn.
ModelPrefs: wiring.ModelPrefs,
PostureActive: wiring.Gate.Active,
DiscoverModels: wiring.DiscoverModels,
ServeCache: &specialist.ModelServeCache{},
}
wireChildRoots(&executor, workspaceRoot, wiring)
runtime, err := specialist.RegisterTools(registry, executor)
if err != nil {
return nil, err
@@ -1256,7 +1533,47 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max
return nil, err
}
swarm.RegisterTools(registry, sw)
return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil
// The orchestrate tool. Registered ALWAYS and gated by Safety(): with the
// posture off it reports PermissionDeny, so it is never advertised and the
// prefix is byte-identical to a build without it. Registering conditionally
// would not work — the TUI builds its registry once per session and clones
// tool POINTERS per run, so a tool added on a posture flip would never
// reach a run already holding a clone.
planContext := wiring.PlanContext
planContext.Executor = executor
if strings.TrimSpace(planContext.Cwd) == "" {
planContext.Cwd = workspaceRoot
}
if strings.TrimSpace(planContext.SpecialistName) == "" {
planContext.SpecialistName = "explorer"
}
registry.Register(&specialist.OrchestrateTool{
PostureActive: wiring.Gate.Active,
RunTask: specialist.NewPlanRunner(planContext),
Recorder: recorder,
ParentTools: planParentTools(registry, enabledTools, disabledTools),
Depth: planContext.Depth,
Size: wiring.Size,
// Refuse a plan the user's own turn did not ask for, when this session
// asks to be protected that way. Off unless configured — see
// ProfilesConfig.RequirePlanKeyword.
RequirePlanKeyword: wiring.RequirePlanKeyword,
Plans: wiring.Plans,
Launch: wiring.Launch,
Isolate: wiring.Isolate,
DiscoverModels: wiring.DiscoverModels,
ContextWindows: wiring.ContextWindows,
ExtraReadRoots: orchestrateReadRoots(wiring.ExtraReadRoots),
ProbeModel: wiring.ProbeModel,
ProbeIdentity: wiring.ProbeIdentity,
ModelPrefs: wiring.ModelPrefs,
})
return &agentToolRuntime{
specialist: runtime,
swarm: sw,
specialists: specialistSummaries(paths),
lifecycleHooks: lifecycleHooks,
}, nil
}
// specialistSummaries loads the available specialists (built-ins + user/project
@@ -1690,3 +2007,21 @@ func cachedSkillsLoader(load func() []skills.Skill) func() []skills.Skill {
return cached
}
}
// orchestrateAvailable reports whether this run actually holds the orchestrate
// tool, for the reminder that names it.
//
// DERIVED FROM THE REGISTRY, never hand-set. The field it feeds was declared,
// documented and consumed by the reminder selector — and set by NEITHER
// production call site, so the notice that tells the model the tool exists
// could not fire. The model was handed an advertised tool it was never told
// about, and did not use it. That is invariant 1 for the second time in this
// feature, and a bool a caller has to remember to pass is how it happened; the
// registry is the thing that actually knows.
func orchestrateAvailable(registry *tools.Registry) bool {
if registry == nil {
return false
}
_, found := registry.Get(specialist.OrchestrateToolName)
return found
}
@@ -0,0 +1,51 @@
package cli
import "testing"
// TWO DEFINITIONS OF ONE NUMBER, and this is the only defence against them
// drifting apart.
//
// internal/specialist decides whether to retry a task from the child's exit
// code — a structural signal rather than its prose. It cannot import this
// package to read exitIncomplete, because cli imports specialist and the
// dependency cannot run both ways, so the constant is duplicated there.
//
// If this file's exitIncomplete ever moves and specialist's copy does not, a
// declined task stops being recognised as declined and silently loses its one
// retry — with nothing failing anywhere. That is the exact shape of defect this
// session has produced repeatedly: a value that exists at one layer, is consumed
// at another, and quietly stops agreeing.
func TestTheChildIncompleteExitCodeAgreesWithWhatSpecialistExpects(t *testing.T) {
// Kept as a literal on purpose. Reading specialist's unexported constant is
// impossible from here, so this asserts the NUMBER both sides were written
// against; changing one without the other now fails.
const specialistChildExitIncomplete = 4
if exitIncomplete != specialistChildExitIncomplete {
t.Fatalf("exitIncomplete is %d but internal/specialist retries declines on %d — "+
"a declined plan task will no longer be recognised, and will silently lose its retry",
exitIncomplete, specialistChildExitIncomplete)
}
}
// THE SAME HAZARD, for the same reason, on the provider exit.
//
// specialist retries a plan task ONCE when the child died on the provider
// rather than on the work, and it recognises that from this exit code. If
// exitProvider moves and specialist's childExitProvider does not, a provider
// failure stops being recognised, the task silently loses its retry, and a
// transient 500 goes back to costing the task and everything downstream of it —
// with nothing failing anywhere to say so.
func TestTheChildProviderExitCodeAgreesWithWhatSpecialistExpects(t *testing.T) {
const specialistChildExitProvider = 3
if exitProvider != specialistChildExitProvider {
t.Fatalf("exitProvider is %d but internal/specialist retries provider failures on %d — "+
"a task killed by a provider error will no longer be retried",
exitProvider, specialistChildExitProvider)
}
// And the two codes must stay DISTINCT: one number meaning both would make
// every provider failure look like a decline (or the reverse), and the two
// retries have different safety rules — the provider one refuses write tasks.
if exitProvider == exitIncomplete {
t.Fatal("exitProvider and exitIncomplete collided; the two retries would be indistinguishable")
}
}
+163
View File
@@ -0,0 +1,163 @@
package cli
import (
"os"
"path/filepath"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/specialist"
)
// childRootsFixture is the shape a write-capable plan run takes on disk: the
// parent workspace below an ancestor the run was granted (--add-dir ~/dev), and
// an isolated worktree somewhere else entirely.
func childRootsFixture(t *testing.T) (ancestor, workspace, worktree string) {
t.Helper()
base, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
ancestor = filepath.Join(base, "dev")
workspace = filepath.Join(ancestor, "zero")
worktree = filepath.Join(base, "state", "worktrees", "plan-a")
for _, dir := range []string{workspace, worktree} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
return ancestor, workspace, worktree
}
func flagValues(args []string, flag string) []string {
var out []string
for index, arg := range args {
if arg == flag && index+1 < len(args) {
out = append(out, args[index+1])
}
}
return out
}
func containsValue(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
// THE EXECUTOR THE CLI BUILDS must know the parent's workspace, or the
// isolation filter in the argument builder has nothing to compare against and
// an ancestor write root follows an isolated child into its worktree.
func TestTheCLIExecutorWithholdsAnAncestorRootFromAnIsolatedChild(t *testing.T) {
ancestor, workspace, worktree := childRootsFixture(t)
scope, err := sandbox.NewScope(workspace, []string{ancestor})
if err != nil {
t.Fatalf("NewScope: %v", err)
}
executor := specialist.Executor{
NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil },
}
wireChildRoots(&executor, workspace, orchestrateWiring{ExtraWriteRoots: func(withinTurn bool) []string { return childWriteRoots(scope, withinTurn) }})
isolated, err := executor.BuildArgs(specialist.BuildArgsInput{
Manifest: specialist.Manifest{Metadata: specialist.Metadata{Name: "explorer"}},
Prompt: "p",
Cwd: worktree,
})
if err != nil {
t.Fatalf("BuildArgs: %v", err)
}
if added := flagValues(isolated.Args, "--add-dir"); containsValue(added, ancestor) {
t.Fatalf("the isolated child was handed the ancestor %q of the parent workspace as a write root: --add-dir %v", ancestor, added)
}
// The same run's ordinary child, in the parent's own workspace, keeps it.
ordinary, err := executor.BuildArgs(specialist.BuildArgsInput{
Manifest: specialist.Manifest{Metadata: specialist.Metadata{Name: "explorer"}},
Prompt: "p",
Cwd: workspace,
})
if err != nil {
t.Fatalf("BuildArgs: %v", err)
}
if added := flagValues(ordinary.Args, "--add-dir"); !containsValue(added, ancestor) {
t.Fatalf("an ordinary child lost the run's ancestor root: --add-dir %v", added)
}
}
// THE CLI EXECUTOR CARRIES THE RUN'S READ GRANTS to ordinary and resumed
// children, read-only. The live read supplier was wired into the orchestrate
// tool only, so a Task or a resume could not read what the parent was just
// granted to read.
func TestTheCLIExecutorPassesReadGrantsReadOnlyToTaskAndResume(t *testing.T) {
workspace, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
scope, err := sandbox.NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
// Outside the temp roots NewScope seeds as permanent WRITE roots, which
// would cover a t.TempDir() grant and record nothing.
readRoot, err := scope.AddRead(tempDirOutsideDefaultTemp(t))
if err != nil {
t.Fatalf("AddRead: %v", err)
}
executor := specialist.Executor{
NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil },
}
wireChildRoots(&executor, workspace, orchestrateWiring{
ExtraWriteRoots: func(withinTurn bool) []string { return childWriteRoots(scope, withinTurn) },
ExtraReadRoots: func(withinTurn bool) []string { return childReadRoots(scope, withinTurn) },
})
manifest := specialist.Manifest{Metadata: specialist.Metadata{Name: "explorer"}}
fresh, err := executor.BuildArgs(specialist.BuildArgsInput{Manifest: manifest, Prompt: "p", Cwd: workspace, WithinTurn: true})
if err != nil {
t.Fatalf("BuildArgs: %v", err)
}
resumed, err := executor.BuildResumeArgs(specialist.BuildResumeArgsInput{
SessionID: "specialist_00000000000000000000000a", Manifest: manifest, Prompt: "p", Cwd: workspace, WithinTurn: true,
})
if err != nil {
t.Fatalf("BuildResumeArgs: %v", err)
}
for label, args := range map[string][]string{"fresh": fresh.Args, "resume": resumed.Args} {
if !containsValue(flagValues(args, "--add-read-dir"), readRoot) {
t.Errorf("%s: the read grant %q never reached the child as --add-read-dir: %v", label, readRoot, args)
}
if containsValue(flagValues(args, "--add-dir"), readRoot) {
t.Errorf("%s: the read grant %q was emitted as --add-dir — a read grant escalated to write", label, readRoot)
}
}
}
// childReadRoots applies the write side's lifetime rule: a turn-scoped read
// grant only for a child that ends within the turn.
func TestChildReadRootsWithholdATurnGrantFromAChildThatOutlivesTheTurn(t *testing.T) {
workspace, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
scope, err := sandbox.NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
root, release, err := scope.AddTemporaryRead(tempDirOutsideDefaultTemp(t))
if err != nil {
t.Fatalf("AddTemporaryRead: %v", err)
}
defer release()
if !containsValue(childReadRoots(scope, true), root) {
t.Fatalf("a child inside the turn lost the turn's read grant: %v", childReadRoots(scope, true))
}
if containsValue(childReadRoots(scope, false), root) {
t.Fatalf("a child that may outlive the turn was handed the turn-scoped read grant %q", root)
}
if childReadRoots(nil, true) != nil {
t.Fatal("a nil scope must yield no roots")
}
}
+275 -5
View File
@@ -24,6 +24,7 @@ import (
"github.com/Twigpine/zero/internal/providers"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/specialist"
"github.com/Twigpine/zero/internal/specmode"
"github.com/Twigpine/zero/internal/streamjson"
"github.com/Twigpine/zero/internal/tools"
@@ -150,6 +151,11 @@ type execOptions struct {
// additional write roots for this run. Unioned with
// config.SandboxConfig.AdditionalWriteRoots at scope construction time.
addDirs []string
// readDirs holds directories passed via --add-read-dir: READ-ONLY roots added
// with scope.AddRead after construction. This is how a specialist child
// inherits the parent's request_permissions read grants without gaining write
// access to them — a plan auditing a granted external path reads it here.
readDirs []string
// tracePath, when set, writes a per-turn NDJSON trace (agenteval-compatible)
// to the given file path — or to stderr when the value is "-". Falls back to
// the ZERO_TRACE env var when the flag is absent. Off by default: a run
@@ -189,6 +195,15 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// mode-supplied model flows through the same resolution (and deprecation
// notice) path as an explicit --model. Explicit flags still win: applyExecMode
// only fills fields the caller left unset.
// --reasoning-effort zeromaxing selects the POSTURE, not a provider effort
// level. Normalize it into the profile selection before mode/profile
// expansion so /effort zeromaxing and --exec-profile zeromaxing resolve to
// exactly the same state, and so the posture name can never survive into a
// provider request. Runs before applyExecMode, leaving the documented
// precedence ordering (flag > mode > profile) exactly as it was.
if err := normalizeZeromaxingEffort(&options); err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
if err := applyExecMode(&options); err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
@@ -196,6 +211,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// runs after it so the mode's fills count as "set" and win. MaxTurns is
// deferred to config resolution below, where the displaced resolved budget
// is known and becomes the escalation restore target.
// Captured BEFORE the profile expands: applyExecProfile arms self-correction
// as a side effect, so reading options.selfCorrect afterwards would always
// report "already on" and the delta would describe a change it just made.
selfCorrectBeforeProfile := options.selfCorrect
execProfile, execProfileFilledEffort, err := applyExecProfile(&options)
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
@@ -255,17 +274,103 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
registry.Register(tools.NewEscalateModelTool())
}
var specialistRuntime *agentToolRuntime
var planGate *specialist.PostureGate
// Created before registration so the tool can hold it; its inner exec
// recorder is attached once the session exists (below). A nil inner is a
// no-op, so events before that point are simply not recorded — recording is
// best-effort and must never be the thing that fails a run.
planRecorder := &planSessionRecorder{}
// DECLARED HERE, built further down. The specialist wiring below closes over
// it so a child launched later is confined to the same roots this run holds;
// reading it at registration time would capture nil forever, which is the
// same reason planGate is a pointer.
var execScope *sandbox.Scope
if shouldRegisterExecSpecialistTools(options) {
// Specialist tools register before the full config resolve below (so
// --list-tools stays offline). swarm.maxTeamSize is not affected by
// overrides, so an empty-overrides resolve yields the same value; a resolve
// error falls back to the swarm's built-in default (0 => 8).
maxTeamSize := 0
if swarmCfg, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil {
maxTeamSize = swarmCfg.Swarm.MaxTeamSize
// planSize resolves to the DEFAULT tier when this early resolve fails —
// the same ceiling an unset value gets, never "no ceiling". A config
// error must not be the thing that removes a bound.
planSize := config.DefaultPlanSize
// The provider profile is captured alongside the other early config so
// auto_assign can list this provider's models. Hoisted out of the if
// because the wiring below needs it; the zero value simply means
// discovery is unavailable and a plan asking for it is told so.
var planProvider config.ProviderProfile
var planModelPrefs config.PlanModelsConfig
if earlyCfg, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil {
maxTeamSize = earlyCfg.Swarm.MaxTeamSize
planSize = earlyCfg.Profiles.PlanSizeTier()
planProvider = earlyCfg.Provider
planModelPrefs = earlyCfg.Profiles.PlanModels
}
var err error
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot, maxTeamSize)
// The posture is fixed for a headless run, so the gate is set once here
// rather than flipping. It is still a POINTER for the same reason the
// TUI needs one: the tool holds it for the process's life.
planGate = &specialist.PostureGate{}
planGate.Set(execProfile.IsZeromaxing())
// The run's operator filters are final here: applyExecMode has already
// expanded any --mode preset onto them. They bound the plan tool's
// parent grant, so a task can never hold a tool this run was denied.
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot, maxTeamSize,
options.enabledTools, options.disabledTools, planRecorder, orchestrateWiring{
DiscoverModels: planModelDiscoverer(workspaceRoot, planProvider),
// The run's EXTRA write roots only — the grants beyond the
// workspace — exactly as the TUI wires them (scope.ExtraRoots).
// Passing execScope.Roots() here handed the child the PARENT
// WORKSPACE ROOT as an extra writable --add-dir, and for an
// 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.
// A child's own workspace comes from its --cwd; only explicit
// grants ride along.
//
// A CLOSURE OVER THE VARIABLE, not over its value: the scope is
// built further down this function, and a child is launched long
// after both. Reading it here would capture nil forever — the same
// reason planGate above is a pointer.
ExtraWriteRoots: func(withinTurn bool) []string {
return childWriteRoots(execScope, withinTurn)
},
// The read counterpart: a request_permissions READ grant lands in a
// separate scope list the write roots above do not cover, so without
// this a plan auditing a granted path fails "outside the workspace" in
// every task. Same live-scope closure, same reason.
ExtraReadRoots: func(withinTurn bool) []string {
return childReadRoots(execScope, withinTurn)
},
// Proves a model will actually run before a task is assigned it,
// so the plan never dispatches onto something the provider only
// advertises.
ProbeModel: planModelProber(workspaceRoot, planProvider, deps.newProvider),
ProbeIdentity: planModelProbeIdentity(workspaceRoot, planProvider),
ModelPrefs: planModelPreferences(planModelPrefs),
Gate: planGate,
PlanContext: specialist.PlanTaskContext{
PostureReasoningEffort: string(execprofile.Zeromaxing.ReasoningEffort),
Cwd: workspaceRoot,
// Resolved permission mode is not available this early; the
// executor applies its own fail-safe mapping from an empty mode
// (never unsafe), so a plan task can never exceed the parent.
PermissionMode: "",
// This run's nesting depth. Left unset, the admission
// headroom check and the executor's own depth check both
// measured a depth that was always zero, so neither could
// ever fire — an inert guard someone would later trust.
Depth: options.depth,
},
Size: planSize,
// The SAME pair of directories the TUI uses, so a plan saved in
// one surface is found by the other.
Plans: execPlanPaths(workspaceRoot),
// Headless runs isolate too: --worktree already exists here, and
// a write-capable plan must not be the one path that skips it.
Isolate: newPlanIsolator(workspaceRoot),
})
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "specialist_error", err.Error())
}
@@ -329,12 +434,45 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
resolved.MaxTurns = execTurnBudget(resolved)
// Profile selection refusal, evaluated once config is resolved (the disable
// flag lives there). execprofile.SelectionRefusal is the ONE rule; the TUI
// /effort and /profile paths call the same function, so the selection
// decision cannot drift between surfaces — see
// TestSelectionRefusalAgreesAcrossPaths.
if refusal := execprofile.SelectionRefusal(execProfile, resolved.Profiles.DisableZeromaxing); refusal != "" {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat,
fmt.Sprintf("cannot use execution profile %q: %s.", execProfile.Name, refusal))
}
// State what it actually changes, at selection time. Burying the real delta
// in a PR body is how a posture ends up documented as doing things it does
// not do.
if execProfile.IsZeromaxing() {
delta := execprofile.Delta(execprofile.DeltaState{
// resolved.MaxTurns is still the pre-posture budget here: the Delta
// notice is printed BEFORE applyProfileTurnBudget displaces it.
CurrentMaxTurns: resolved.MaxTurns,
Effort: execEffortTransition(execProfileFilledEffort),
SelfCorrect: execSelfCorrectTransition(selfCorrectBeforeProfile),
})
if _, err := fmt.Fprintln(stderr, delta); err != nil {
return exitCrash
}
}
var displacedMaxTurns int
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
execScope, err = sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
// READ-ONLY roots from --add-read-dir, added after construction so they never
// become write roots: this is how a specialist child reads the parent's
// request_permissions grants without gaining write access. A bad path fails
// closed (the run stops) rather than silently dropping the grant.
for _, dir := range options.readDirs {
if _, err := execScope.AddRead(dir); err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", fmt.Sprintf("--add-read-dir %q: %v", dir, err))
}
}
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
registry.Register(tool)
}
@@ -614,6 +752,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
sessionRecorder := execSessionRecorder{prepared: preparedSession}
planRecorder.recorder = &sessionRecorder
// Surface a best-effort session-recording failure once, on every exit path.
defer sessionRecorder.warnIfRecordingFailed(stderr)
userPayload := map[string]any{
@@ -655,9 +794,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// can be combined with the plugin activation's, and emit at most one notice when
// project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
specialistRuntime.attachLifecycleHooks(hookDispatcher)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
MaxTokens: execProfile.MaxTokens,
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
DeferThreshold: effectiveDeferThreshold,
Specialists: specialistRuntime.specialistInfos(),
@@ -669,6 +810,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
Depth: options.depth,
SessionTitle: sessionTitle,
ProviderName: resolved.Provider.Name,
ModelFamily: providercatalog.ModelFamilyFor(resolved.Provider.CatalogID),
Model: resolved.Provider.Model,
ModelSwitcher: modelSwitcher,
TurnSessionProvider: turnSessions,
@@ -690,6 +832,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
// A headless run that selected the posture is by definition entering it:
// the process starts, runs once, exits, so there is no earlier turn for
// it to have been active across.
Zeromaxing: execZeromaxing(execProfile),
// Whether the enter notice may NAME the orchestrate tool. Derived from
// the registry rather than passed as a flag — see orchestrateAvailable.
OrchestrateAvailable: orchestrateAvailable(registry),
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
@@ -712,7 +861,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
"arguments": call.Arguments,
})
// Snapshot before-state of files this call will mutate (safe rewind).
if checkpoint, ok := sessionRecorder.captureCheckpoint(workspaceRoot, call); ok {
if checkpoint, ok := sessionRecorder.captureCheckpoint(workspaceRoot, registry, call); ok {
writer.checkpoint(checkpoint)
}
},
@@ -828,6 +977,14 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// {"type":"done","exit_code":0} that would otherwise mask the incomplete exit.
// An `error` event (not just a warning) is emitted so log/cron consumers that
// scan for type=="error" can recover the reason.
// A plan that did not fully complete is work left undone, which is exactly
// what exitIncomplete exists for. Folded into the EXISTING incomplete path
// rather than a second exit route, so a plan and a stalled loop report the
// same way. Does not override an incompleteness the loop already found.
if reason, planIncomplete := planRecorder.Incomplete(); planIncomplete && !result.Incomplete {
result.Incomplete = true
result.IncompleteReason = reason
}
if result.Incomplete {
reason := result.IncompleteReason
if reason == "" {
@@ -1243,6 +1400,64 @@ func applyExecProfile(options *execOptions) (execprofile.Profile, bool, error) {
return profile, effortFilled, nil
}
// normalizeZeromaxingEffort folds `--reasoning-effort zeromaxing` into
// `--exec-profile zeromaxing`. The posture is one thing with one name, reachable
// from either flag, so this is a rename rather than a second implementation.
//
// A conflicting explicit --exec-profile is a usage error rather than a silent
// winner: the user asked for two different postures and deserves to be told,
// not to have one quietly discarded.
func normalizeZeromaxingEffort(options *execOptions) error {
if !strings.EqualFold(strings.TrimSpace(options.reasoningEffort), execprofile.Name) {
return nil
}
if selected := strings.TrimSpace(options.execProfile); selected != "" &&
!strings.EqualFold(selected, execprofile.Name) {
return execUsageError{fmt.Sprintf(
"--reasoning-effort %s selects the %s execution profile, which conflicts with --exec-profile %s. Pass one of them.",
execprofile.Name, execprofile.Name, selected)}
}
options.execProfile = execprofile.Name
// Clear the effort so the profile FILLS it (with "high"). Leaving the
// posture name here would carry it into forwardedReasoningEffort.
options.reasoningEffort = ""
return nil
}
// execEffortTransition reports what the posture did to reasoning effort for
// this run. applyExecProfile reports whether it actually filled the effort; it
// backs off when the caller passed one explicitly. The headless path does not
// gate the fill on model support (forwardedReasoningEffort coerces later, and
// prints its own notice), so EffortNotSupported is not reachable here.
func execEffortTransition(effortFilled bool) execprofile.EffortTransition {
if effortFilled {
return execprofile.EffortRaised
}
return execprofile.EffortKeptExplicit
}
// execSelfCorrectTransition reports what the posture did to post-edit
// verification for THIS run. A headless run has no way to override the profile
// after it applies (--self-correct is presence-only and read before), so the
// Overridden state is unreachable here — it is a TUI-only condition.
func execSelfCorrectTransition(selfCorrectBefore bool) execprofile.SelfCorrectTransition {
if selfCorrectBefore {
return execprofile.SelfCorrectAlreadyOn
}
return execprofile.SelfCorrectRaised
}
// execZeromaxing maps a selected profile onto the run's posture lifecycle. A
// headless exec run is one process for one run, so selecting the posture means
// entering it and anything else means off — the Active/Exiting states only
// arise in a session that outlives a single run (the TUI).
func execZeromaxing(profile execprofile.Profile) agent.Zeromaxing {
if profile.IsZeromaxing() {
return agent.ZeromaxingEntering
}
return agent.ZeromaxingOff
}
// specProfileEffortFilled reports whether the profile's effort fill actually
// governs the spec-draft run. An explicit --spec-reasoning-effort replaces the
// filled effort for the draft, so the escalation's effort restore must not arm
@@ -1374,6 +1589,16 @@ func forwardedReasoningEffort(registry modelregistry.Registry, modelID string, r
if requested == "" {
return ""
}
// LOAD-BEARING GUARD. "zeromaxing" is a Zero posture name, never a provider
// effort level. normalizeZeromaxingEffort already converts it into a profile
// selection long before this point, so reaching here means something
// upstream regressed — and the failure mode would be a provider request
// carrying a parameter value no provider defines. Refuse it at the boundary
// rather than trusting the caller. Pinned by
// TestZeromaxingIsNeverForwardedToAProvider.
if strings.EqualFold(requested, execprofile.Name) {
return ""
}
entry, ok := registry.Get(strings.TrimSpace(modelID))
if !ok {
return requested
@@ -1504,3 +1729,48 @@ func writeTraceSnapshot(snapshot *trace.TurnTrace, dest string, stderr io.Writer
defer file.Close()
return trace.WriteNDJSON(file, snapshot)
}
// execPlanPaths locates saved plans for a headless run, from the same pair of
// directories the TUI uses. A resolve failure yields the project directory
// alone rather than nothing: a plan checked into the repo must still run when
// the user config directory is unavailable.
func execPlanPaths(workspaceRoot string) specialist.PlanPaths {
userConfigDir, _ := config.UserConfigDir()
return specialist.DefaultPlanPaths(workspaceRoot, userConfigDir)
}
// childWriteRoots is what a run's children may WRITE beyond their own
// workspace: the run's granted extra roots, and never the parent workspace root
// itself. It returned execScope.Roots() — workspace included — and for an
// ISOLATED plan that defeats the isolation outright: the worktree exists so a
// write-capable plan cannot touch the parent tree, and the wiring was handing
// the parent tree back as a writable --add-dir. Both surfaces (TUI and headless)
// use this one function.
//
// TURN-SCOPED GRANTS ONLY FOR A CHILD THAT ENDS WITHIN THE TURN. A root in a
// child's argv is permanent for the child's life; the parent's end-of-turn
// release cannot reach it. So a child that may outlive the turn (background
// Task, plan task, swarm member) gets only the roots that outlive it as well.
func childWriteRoots(scope *sandbox.Scope, withinTurn bool) []string {
if scope == nil {
return nil
}
if withinTurn {
return scope.ExtraRoots()
}
return scope.SessionExtraRoots()
}
// childReadRoots is childWriteRoots for READ grants: every path the run may
// read beyond its workspace (write roots included, since they confer read),
// turn-scoped grants only for a child that ends within the turn. The executor
// emits these as --add-read-dir only.
func childReadRoots(scope *sandbox.Scope, withinTurn bool) []string {
if scope == nil {
return nil
}
if withinTurn {
return scope.ExtraReadRoots()
}
return scope.SessionExtraReadRoots()
}
@@ -0,0 +1,206 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func freeformApplyPatchArguments(root string, absolute bool) string {
target := "main.txt"
if absolute {
target = filepath.Join(root, "main.txt")
}
return "*** Begin Patch\n*** Update File: " + target + "\n@@\n-before\n+after\n*** End Patch\n"
}
// captureCheckpoint must resolve a freeform apply_patch call through the same
// preparation execution uses, so the before-state is captured and rewind can
// restore it. The JSON control pins the unchanged path.
func TestCaptureCheckpointFreeformApplyPatch(t *testing.T) {
for _, tc := range []struct {
name string
freeform bool
absolute bool
jsonArgs bool
}{
{name: "freeform", freeform: true},
{name: "freeform absolute header", freeform: true, absolute: true},
{name: "json arguments", jsonArgs: true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("ZERO_CHECKPOINTS", "on")
root := t.TempDir()
// The absolute-header case needs the physical root spelling the tool
// resolves (macOS /var vs /private/var, Windows short names).
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
store := sessions.NewStore(sessions.StoreOptions{})
session, err := store.Create(sessions.CreateInput{Title: "freeform checkpoint"})
if err != nil {
t.Fatal(err)
}
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]string{"role": "user", "content": "apply the patch"},
}); err != nil {
t.Fatal(err)
}
recorder := &execSessionRecorder{prepared: sessions.PreparedExec{Store: store, Session: session}}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
call := agent.ToolCall{ID: "call", Name: "apply_patch", Freeform: tc.freeform}
if tc.jsonArgs {
encoded, err := json.Marshal(map[string]string{"patch": freeformApplyPatchArguments(root, false)})
if err != nil {
t.Fatal(err)
}
call.Arguments = string(encoded)
} else {
call.Arguments = freeformApplyPatchArguments(root, tc.absolute)
}
event, ok := recorder.captureCheckpoint(root, registry, call)
if !ok || event.Type != sessions.EventSessionCheckpoint {
t.Fatalf("captureCheckpoint() = %#v, %t; want a checkpoint event", event, ok)
}
var payload sessions.CheckpointPayload
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("decode checkpoint payload: %v", err)
}
if len(payload.Files) != 1 || payload.Files[0].Path != "main.txt" || payload.Files[0].Blob == "" {
t.Fatalf("checkpoint files = %#v, want main.txt with a blob", payload.Files)
}
// Simulate the tool's mutation, then rewind past it.
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("after\n"), 0o644); err != nil {
t.Fatal(err)
}
report, err := store.ApplyRewind(session.SessionID, root, 1)
if err != nil {
t.Fatalf("ApplyRewind: %v", err)
}
if report.FilesRestored != 1 {
t.Fatalf("restore report = %#v, want one restored file", report)
}
content, err := os.ReadFile(filepath.Join(root, "main.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "before\n" {
t.Fatalf("restored content = %q, want before newline", content)
}
})
}
}
// freeformPatchExecProvider emits one native freeform apply_patch call, then a
// final text turn.
type freeformPatchExecProvider struct {
patch string
calls int
}
func (provider *freeformPatchExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 5)
calls := provider.calls
provider.calls++
if calls == 0 {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_patch", ToolName: "apply_patch", ToolCallFreeform: true}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_patch", ArgumentsFragment: provider.patch}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_patch"}
} else {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "patched"}
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// End-to-end: exec captures the freeform checkpoint and `sessions rewind`
// restores the pre-patch content.
func TestRunExecFreeformApplyPatchCheckpointsForRewind(t *testing.T) {
isolateCLIUserState(t)
t.Setenv("ZERO_CHECKPOINTS", "on")
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := &freeformPatchExecProvider{patch: freeformApplyPatchArguments(root, false)}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec", "--init-session-id", "freeform_rewind", "--skip-permissions-unsafe",
"--output-format", "stream-json", "apply the patch",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return root, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return provider, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d; stderr=%q stdout=%q", exitCode, exitSuccess, stderr.String(), stdout.String())
}
content, err := os.ReadFile(filepath.Join(root, "main.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "after\n" {
t.Fatalf("patched content = %q, want after newline", content)
}
store := sessions.NewStore(sessions.StoreOptions{})
events, err := store.ReadEvents("freeform_rewind")
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
checkpoints := 0
for _, event := range events {
if event.Type == sessions.EventSessionCheckpoint {
checkpoints++
}
}
if checkpoints != 1 {
t.Fatalf("checkpoint events = %d, want 1; events=%v", checkpoints, sessionEventTypes(events))
}
report, err := store.ApplyRewind("freeform_rewind", root, 1)
if err != nil {
t.Fatalf("ApplyRewind: %v", err)
}
if report.FilesRestored != 1 {
t.Fatalf("restore report = %#v, want one restored file", report)
}
content, err = os.ReadFile(filepath.Join(root, "main.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "before\n" {
t.Fatalf("rewound content = %q, want before newline", content)
}
}
func sessionEventTypes(events []sessions.Event) []string {
types := make([]string, 0, len(events))
for _, event := range events {
types = append(types, string(event.Type))
}
return types
}
+82
View File
@@ -0,0 +1,82 @@
package cli
import (
"path/filepath"
"slices"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
)
// AN ISOLATED PLAN'S CHILD MUST NOT GET THE PARENT TREE BACK. The headless
// wiring handed children execScope.Roots() — the parent workspace root
// included — as extra WRITE roots. A worktree-isolated plan exists so a
// write-capable task cannot touch the parent tree, and that line handed the
// parent tree back as a writable --add-dir. Only the run's explicit extra
// grants may ride along; the child's own workspace comes from its --cwd.
func TestExecChildWriteRootsExcludeTheParentWorkspace(t *testing.T) {
// The scope stores symlink-resolved paths (/var → /private/var on macOS),
// so both sides of every comparison resolve first.
resolve := func(path string) string {
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatalf("EvalSymlinks(%s): %v", path, err)
}
return resolved
}
workspace := resolve(t.TempDir())
granted := resolve(t.TempDir())
scope, err := sandbox.NewScope(workspace, []string{granted})
if err != nil {
t.Fatalf("NewScope: %v", err)
}
for _, withinTurn := range []bool{true, false} {
roots := childWriteRoots(scope, withinTurn)
found := false
for _, root := range roots {
if root == workspace {
t.Fatalf("the parent workspace root leaked into a child's write roots: %q", roots)
}
if root == granted {
found = true
}
}
if !found {
t.Fatalf("the explicit grant must still ride along (withinTurn=%v), got %q", withinTurn, roots)
}
if childWriteRoots(nil, withinTurn) != nil {
t.Fatal("a nil scope must yield no roots")
}
}
}
// A TURN-SCOPED WRITE GRANT REACHES ONLY A CHILD THAT ENDS WITHIN THE TURN.
//
// Scope.ExtraRoots carried AddTemporaryWrite roots like permanent ones, so a
// turn-scoped request_permissions grant left as a plain --add-dir and a child
// that outlived the turn kept writing there after the parent had released it.
func TestChildWriteRootsWithholdATurnGrantFromAChildThatOutlivesTheTurn(t *testing.T) {
workspace, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
scope, err := sandbox.NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
// Outside the temp roots NewScope seeds as permanent: a turn grant under one
// of them would be covered already and recorded as nothing.
temporary := tempDirOutsideDefaultTemp(t)
root, release, err := scope.AddTemporaryWrite(temporary)
if err != nil {
t.Fatalf("AddTemporaryWrite: %v", err)
}
defer release()
if !slices.Contains(childWriteRoots(scope, true), root) {
t.Fatalf("a child inside the turn lost the turn's grant: %v", childWriteRoots(scope, true))
}
if slices.Contains(childWriteRoots(scope, false), root) {
t.Fatalf("a child that may outlive the turn was handed the turn-scoped grant %q: %v", root, childWriteRoots(scope, false))
}
}
@@ -0,0 +1,90 @@
package cli
import (
"bytes"
"context"
"slices"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// guardStopExecProvider emits turns with no visible text and no tool calls, so
// the empty-turn guardrail stops the run after its consecutive-empty-turn cap.
type guardStopExecProvider struct{}
func (guardStopExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 1)
select {
case <-ctx.Done():
close(ch)
return ch, ctx.Err()
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}:
}
close(ch)
return ch, nil
}
// A guardrail stop in headless exec must use the existing INCOMPLETE contract
// (exit 4, error event, run_end status), and --no-completion-gate must keep
// reporting success. isolateCLIUserState also turns off the models.dev refresh
// that runExec starts in the background (see the #1152/#1161 flake).
func TestRunExecGuardrailStopIncompleteContract(t *testing.T) {
for _, tc := range []struct {
name string
args []string
wantExit int
wantStatus string
}{
{"gate on", []string{"exec", "--output-format", "stream-json", "complete the task"}, exitIncomplete, "incomplete"},
{"gate off", []string{"exec", "--no-completion-gate", "--output-format", "stream-json", "complete the task"}, exitSuccess, "success"},
} {
t.Run(tc.name, func(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
var stdout, stderr bytes.Buffer
exitCode := runWithDeps(tc.args, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return guardStopExecProvider{}, nil
},
})
if exitCode != tc.wantExit {
t.Fatalf("exit code = %d, want %d; stderr=%q", exitCode, tc.wantExit, stderr.String())
}
events := decodeJSONLines(t, stdout.String())
if len(events) == 0 || events[len(events)-1]["type"] != "run_end" {
t.Fatalf("expected run_end as the last event, got %#v", events)
}
runEnd := events[len(events)-1]
if runEnd["status"] != tc.wantStatus {
t.Fatalf("run_end status = %v, want %q", runEnd["status"], tc.wantStatus)
}
if runEnd["exitCode"] != float64(tc.wantExit) {
t.Fatalf("run_end exitCode = %v, want %d", runEnd["exitCode"], tc.wantExit)
}
finalEvent := findJSONEvent(t, events, "final")
if text, _ := finalEvent["text"].(string); !strings.Contains(text, "no output") {
t.Fatalf("final text = %q, want the guard stop answer", text)
}
if tc.wantStatus == "incomplete" {
errorEvent := findJSONEvent(t, events, "error")
if errorEvent["code"] != "incomplete" {
t.Fatalf("error code = %v, want incomplete", errorEvent["code"])
}
if message, _ := errorEvent["message"].(string); !strings.Contains(message, "no output") {
t.Fatalf("error message = %q, want the guard reason", message)
}
} else if slices.Contains(jsonEventTypes(events), "error") {
t.Fatalf("gate-off run must not emit an error event: %#v", events)
}
})
}
}
+39 -1
View File
@@ -5,6 +5,7 @@ import (
"strconv"
"strings"
"github.com/Twigpine/zero/internal/execprofile"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/specialist"
)
@@ -129,6 +130,19 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
return options, false, err
}
options.addDirs = append(options.addDirs, value)
case arg == "--add-read-dir":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.readDirs = append(options.readDirs, value)
index = next
case strings.HasPrefix(arg, "--add-read-dir="):
value, err := requiredInlineFlagValue(arg, "--add-read-dir")
if err != nil {
return options, false, err
}
options.readDirs = append(options.readDirs, value)
case arg == "--mode":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -553,9 +567,33 @@ func nextFlagValue(args []string, index int, flag string) (string, int, error) {
default:
return "", index, execUsageError{fmt.Sprintf("Invalid input format %q. Expected text or stream-json.", next)}
}
case "--reasoning-effort", "--spec-reasoning-effort":
case "--reasoning-effort":
// 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.
switch strings.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.
switch strings.ToLower(next) {
case "low", "medium", "high":
case execprofile.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.",
flag, next, execprofile.Name, flag, execprofile.Name)}
default:
return "", index, execUsageError{fmt.Sprintf("invalid %s %q. Expected low, medium, or high.", flag, next)}
}
@@ -0,0 +1,30 @@
package cli
import "testing"
// --add-read-dir collects READ-ONLY roots, the read counterpart of --add-dir.
// A specialist child receives the parent's request_permissions read grants on
// this flag so it can audit a granted path without gaining write access.
func TestParseExecArgsCollectsAddReadDirs(t *testing.T) {
options, _, err := parseExecArgs([]string{
"--prompt", "hi",
"--add-read-dir", "/one",
"--add-read-dir=/two",
})
if err != nil {
t.Fatalf("parseExecArgs: %v", err)
}
if len(options.readDirs) != 2 || options.readDirs[0] != "/one" || options.readDirs[1] != "/two" {
t.Fatalf("readDirs=%v want [/one /two]", options.readDirs)
}
// It must NOT bleed into addDirs — that would emit it as a write root.
if len(options.addDirs) != 0 {
t.Fatalf("a read-only root leaked into addDirs (write roots): %v", options.addDirs)
}
}
func TestParseExecArgsAddReadDirRequiresValue(t *testing.T) {
if _, _, err := parseExecArgs([]string{"--add-read-dir"}); err == nil {
t.Fatal("bare --add-read-dir must error")
}
}
+142
View File
@@ -0,0 +1,142 @@
package cli
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// The plan-size tier, proved END TO END: a .zero/config.json setting has to
// reach the ceiling a plan is actually rejected against.
//
// A test on registerSpecialistTools would pass while `zero exec` never read the
// setting at all — that is the exact shape of this feature's recurring defect,
// a field correctly threaded through everything except the one call site that
// populates it. So this drives Run(), writes a real config file, and asserts on
// what the model is told.
func TestExecReadsThePlanSizeTierFromProjectConfig(t *testing.T) {
clearProviderEnv(t)
root := t.TempDir()
configDir := filepath.Join(root, ".zero")
if err := os.MkdirAll(configDir, 0o700); err != nil {
t.Fatal(err)
}
// BOTH probes are plans that get REJECTED, at DIFFERENT ceilings. An admitted
// plan would spawn real child processes — six of them — so the control here
// cannot be "and this one is admitted"; it is "and this one is rejected at
// the other tier's number", which isolates the configured value just as well
// and executes nothing.
planArgs := func(n int) string {
tasks := make([]map[string]any, 0, n)
for i := 0; i < n; i++ {
tasks = append(tasks, map[string]any{"id": "t" + strings.Repeat("z", i), "prompt": "look"})
}
encoded, err := json.Marshal(map[string]any{
"name": "sweep",
"tasks": tasks,
"budget": map[string]any{"max_workers": 1},
})
if err != nil {
t.Fatal(err)
}
return string(encoded)
}
var plan string
var toolOutput string
turn := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
turn++
if turn == 1 {
call, _ := json.Marshal(map[string]any{
"choices": []any{map[string]any{"delta": map[string]any{
"tool_calls": []any{map[string]any{
"index": 0, "id": "call_1", "type": "function",
"function": map[string]any{"name": "orchestrate", "arguments": plan},
}},
}}},
})
_, _ = io.WriteString(w, "data: "+string(call)+"\n\n")
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}`+"\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
return
}
// The second turn carries the tool result back. That message is what the
// model would have been told, and it is the assertion target.
if messages, ok := body["messages"].([]any); ok {
for _, raw := range messages {
message, _ := raw.(map[string]any)
if message["role"] == "tool" {
if content, ok := message["content"].(string); ok {
toolOutput += content
}
}
}
}
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"done"}}]}`+"\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}))
defer server.Close()
providerConfig := func(planSize string) string {
profiles := ""
if planSize != "" {
profiles = `"profiles": {"planSize": "` + planSize + `"},`
}
return `{
` + profiles + `
"activeProvider": "local",
"providers": [{
"name": "local",
"provider_kind": "openai-compatible",
"base_url": "` + server.URL + `",
"api_key": "sk-local",
"model": "local-model"
}]
}`
}
run := func(planSize string, taskCount int) string {
turn = 0
toolOutput = ""
plan = planArgs(taskCount)
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(providerConfig(planSize)), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
Run([]string{"exec", "--cwd", root, "--reasoning-effort", "zeromaxing", "--auto", "high", "sweep it"}, &stdout, &stderr)
return toolOutput
}
// small: a six-task plan is refused at 5, and the message names the tier that
// refused it plus how to raise it.
small := run("small", 6)
for _, want := range []string{`"small" plan size`, "planSize", "exceeds the limit of 5"} {
if !strings.Contains(small, want) {
t.Fatalf("under planSize=small the tool output must contain %q; got:\n%s", want, small)
}
}
// THE OTHER DIRECTION, which is what makes the first half mean anything: with
// no setting the ceiling is the DEFAULT tier's 20, not small's 5. Without
// this, a run that ignored config entirely and always used one hard-coded
// number would satisfy the assertion above.
unset := run("", 25)
for _, want := range []string{`"medium" plan size`, "exceeds the limit of 20"} {
if !strings.Contains(unset, want) {
t.Fatalf("with no planSize set the ceiling must be the default tier's; want %q, got:\n%s", want, unset)
}
}
if strings.Contains(unset, "limit of 5") {
t.Fatalf("an unset planSize applied small's ceiling:\n%s", unset)
}
}
+20
View File
@@ -115,6 +115,26 @@ func runExecAddDirWriteProbe(t *testing.T, cwd string, args []string, target str
// is known the scoped core tools are re-registered and Registry.Register
// replaces the earlier instances BY NAME. The before/after write_file probes
// prove both the overwrite and the scoped enforcement it ships.
// --add-read-dir grants READ, not write, at the process boundary: a write_file
// inside the read-only root is DENIED. This pins exec's readDirs loop to
// scope.AddRead — had it used scope.Add (write), or emitted --add-dir, this write
// would land, and a path the parent may only read would be writable in the child.
func TestAddReadDirGrantsReadOnlyAtTheProcessBoundary(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
cwd := t.TempDir()
readRoot := tempDirOutsideDefaultTemp(t)
target := filepath.Join(readRoot, "must-not-write.txt")
args := []string{"exec", "--add-read-dir", readRoot, "--skip-permissions-unsafe", "write the note"}
exitCode, stderr := runExecAddDirWriteProbe(t, cwd, args, target)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d; stderr = %q", exitCode, exitSuccess, stderr)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("a write inside a --add-read-dir root landed — a read grant escalated to write (stat err=%v)", err)
}
}
func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) {
root := t.TempDir()
extra := tempDirOutsideDefaultTemp(t)
+2 -7
View File
@@ -1,7 +1,6 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"strings"
@@ -19,15 +18,11 @@ type execSessionRecorder struct {
// captureCheckpoint snapshots the before-state of files a tool call will mutate.
// Best-effort: failures never affect the run. Returns the checkpoint event and
// true when one was recorded.
func (recorder *execSessionRecorder) captureCheckpoint(workspaceRoot string, call agent.ToolCall) (sessions.Event, bool) {
func (recorder *execSessionRecorder) captureCheckpoint(workspaceRoot string, registry *tools.Registry, call agent.ToolCall) (sessions.Event, bool) {
if recorder.prepared.Store == nil || recorder.prepared.Session.SessionID == "" {
return sessions.Event{}, false
}
var args map[string]any
if call.Arguments != "" {
_ = json.Unmarshal([]byte(call.Arguments), &args)
}
targets := tools.MutationTargets(workspaceRoot, call.Name, args)
targets := agent.CheckpointTargets(workspaceRoot, registry, call)
if len(targets) == 0 {
return sessions.Event{}, false
}
+3 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/modelregistry"
"github.com/Twigpine/zero/internal/notify"
"github.com/Twigpine/zero/internal/providercatalog"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/specmode"
@@ -123,6 +124,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
SessionID: draftSession.SessionID,
SessionTitle: run.sessionTitle,
ProviderName: run.resolved.Provider.Name,
ModelFamily: providercatalog.ModelFamilyFor(run.resolved.Provider.CatalogID),
Model: run.resolved.Provider.Model,
SupportsVision: func(modelID string) bool {
return modelregistry.SupportsVision(run.modelRegistry, modelID)
@@ -148,7 +150,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
"name": call.Name,
"arguments": call.Arguments,
})
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, call); ok {
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, run.registry, call); ok {
writer.checkpoint(checkpoint)
}
},
+88
View File
@@ -0,0 +1,88 @@
package cli
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/Twigpine/zero/internal/agent"
)
// THE CHILD MUST PUBLISH WHAT MAKES ITS TURN PRICEABLE.
//
// This writer is the only channel a parent has. Its session record already keeps
// cachedInputTokens / cacheWriteTokens / reasoningTokens so a turn can be costed
// exactly; the stream carried prompt/completion/total alone, so every sub-agent
// turn rolled up to its parent was priced as if nothing had been cached.
//
// A measured plan task had 49,280 of 49,894 prompt tokens served from cache —
// 98.8%. Plan tasks are the ideal cache case: one large stable prompt, re-sent
// every turn.
func TestTheChildPublishesCacheAndReasoningTokensOnTheUsageStream(t *testing.T) {
var stdout, stderr bytes.Buffer
writer := execEventWriter{
stdout: &stdout,
stderr: &stderr,
format: execOutputStreamJSON,
runID: "run_usage",
streamedText: &strings.Builder{},
}
writer.usage(agent.Usage{
PromptTokens: 30000,
CompletionTokens: 500,
CachedInputTokens: 29000,
CacheWriteTokens: 1000,
ReasoningTokens: 120,
})
if writer.err != nil {
t.Fatalf("usage: %v", writer.err)
}
var payload map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout.String())), &payload); err != nil {
t.Fatalf("decode stream event %q: %v", stdout.String(), err)
}
for key, want := range map[string]float64{
"cachedInputTokens": 29000, "cacheWriteTokens": 1000, "reasoningTokens": 120,
} {
got, ok := payload[key]
if !ok {
t.Errorf("the stream omits %q, so a parent prices this turn as uncached: %v", key, payload)
continue
}
if number, ok := got.(float64); !ok || number != want {
t.Errorf("%s: want %v, got %v", key, want, got)
}
}
// The three original fields must be exactly as before.
for key, want := range map[string]float64{
"promptTokens": 30000, "completionTokens": 500, "totalTokens": 30500,
} {
if got, ok := payload[key].(float64); !ok || got != want {
t.Errorf("%s changed: want %v, got %v", key, want, payload[key])
}
}
}
// NON-ZERO ONLY, exactly like usage.EventUsagePayload. A provider reporting no
// cache must produce the same three-field event it always did, or every existing
// reader of this stream sees a shape it has never seen.
func TestAProviderWithNoCacheStillEmitsTheOriginalUsageShape(t *testing.T) {
var stdout, stderr bytes.Buffer
writer := execEventWriter{
stdout: &stdout, stderr: &stderr, format: execOutputStreamJSON,
runID: "run_usage", streamedText: &strings.Builder{},
}
writer.usage(agent.Usage{PromptTokens: 100, CompletionTokens: 10})
var payload map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout.String())), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
for _, key := range []string{"cachedInputTokens", "cacheWriteTokens", "reasoningTokens"} {
if _, present := payload[key]; present {
t.Errorf("%q was emitted as a zero, widening the event for every provider that reports no cache", key)
}
}
}
+19 -9
View File
@@ -282,15 +282,25 @@ func (writer *execEventWriter) usage(usage agent.Usage) {
cachedInputTokens := usage.CachedInputTokens
cacheWriteTokens := usage.CacheWriteTokens
totalTokens := usage.TotalTokens()
writer.writeStreamJSON(streamjson.Event{
Type: streamjson.EventUsage,
RunID: writer.runID,
PromptTokens: &promptTokens,
CompletionTokens: &completionTokens,
CachedInputTokens: &cachedInputTokens,
CacheWriteTokens: &cacheWriteTokens,
TotalTokens: &totalTokens,
})
event := streamjson.Event{
Type: streamjson.EventUsage,
RunID: writer.runID,
PromptTokens: &promptTokens,
CompletionTokens: &completionTokens,
TotalTokens: &totalTokens,
}
// Preserve the provider breakdown when it exists without widening the
// event shape for providers that report no cache or reasoning usage.
if cachedInputTokens > 0 {
event.CachedInputTokens = &cachedInputTokens
}
if cacheWriteTokens > 0 {
event.CacheWriteTokens = &cacheWriteTokens
}
if reasoning := usage.ReasoningTokens; reasoning > 0 {
event.ReasoningTokens = &reasoning
}
writer.writeStreamJSON(event)
}
}
+457
View File
@@ -0,0 +1,457 @@
package cli
import (
"reflect"
"regexp"
"strings"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/execprofile"
"github.com/Twigpine/zero/internal/modelregistry"
"github.com/Twigpine/zero/internal/specialist"
)
// (a) CLI: an explicit --reasoning-effort survives zeromaxing. The profile
// fills only what the caller left unset, exactly as fast/thorough do.
func TestZeromaxingDoesNotOverrideExplicitReasoningEffortCLI(t *testing.T) {
options := execOptions{execProfile: execprofile.Name, reasoningEffort: "low"}
profile, effortFilled, err := applyExecProfile(&options)
if err != nil {
t.Fatalf("applyExecProfile: %v", err)
}
if !profile.IsZeromaxing() {
t.Fatalf("profile = %q, want %q", profile.Name, execprofile.Name)
}
if options.reasoningEffort != "low" {
t.Fatalf("reasoningEffort = %q, the explicit low must survive the posture's high", options.reasoningEffort)
}
if effortFilled {
t.Fatal("must report it did NOT fill the effort, or a mid-run escalation would clear a hand-pinned value")
}
}
// (b) CLI: --mode's fills survive zeromaxing. applyExecMode runs first, so its
// values read as "set" by the time the profile arrives — precedence is enforced
// by ordering, and this pins that the ordering still holds with a fourth profile.
func TestZeromaxingDoesNotOverrideModeFillsCLI(t *testing.T) {
options := execOptions{mode: "fast", execProfile: execprofile.Name}
if err := applyExecMode(&options); err != nil {
t.Fatalf("applyExecMode: %v", err)
}
modeEffort := options.reasoningEffort
modeTurns := options.maxTurns
profile, _, err := applyExecProfile(&options)
if err != nil {
t.Fatalf("applyExecProfile: %v", err)
}
if modeEffort != "" && options.reasoningEffort != modeEffort {
t.Fatalf("reasoningEffort = %q, the mode's %q must win", options.reasoningEffort, modeEffort)
}
if modeTurns > 0 {
// Mirror the real call site: a mode's --max-turns fill lands in
// options.maxTurns AND flows through config overrides into
// resolved.MaxTurns, so both arguments carry it. The profile must then
// back off entirely — displaced 0, budget untouched.
effective, displaced := applyProfileTurnBudget(profile, options.maxTurns, options.maxTurns)
if effective != modeTurns || displaced != 0 {
t.Fatalf("turn budget = (%d, displaced %d), the mode's %d must win with nothing displaced",
effective, displaced, modeTurns)
}
}
}
// (c) CLI: fills only what is unset; an explicit --max-turns pins the budget so
// the profile backs off with nothing displaced.
func TestZeromaxingFillsOnlyUnsetCLI(t *testing.T) {
options := execOptions{execProfile: execprofile.Name}
profile, effortFilled, err := applyExecProfile(&options)
if err != nil {
t.Fatalf("applyExecProfile: %v", err)
}
if options.reasoningEffort != "high" || !effortFilled {
t.Fatalf("unset effort must be filled with high, got %q filled=%v", options.reasoningEffort, effortFilled)
}
if !options.selfCorrect {
t.Fatal("must arm self-correction when it was unset")
}
if effective, displaced := applyProfileTurnBudget(profile, 0, 80); effective != 480 || displaced != 80 {
t.Fatalf("over resolved 80 = (%d, %d), want (480, 80)", effective, displaced)
}
if effective, displaced := applyProfileTurnBudget(profile, 50, 50); effective != 50 || displaced != 0 {
t.Fatalf("with explicit --max-turns 50 = (%d, %d), want (50, 0)", effective, displaced)
}
}
// (g) CLI: --reasoning-effort zeromaxing and --exec-profile zeromaxing resolve
// to IDENTICAL state. One posture, one name, reachable from either flag.
func TestZeromaxingEffortFlagResolvesLikeProfileFlagCLI(t *testing.T) {
viaEffort := execOptions{reasoningEffort: execprofile.Name}
if err := normalizeZeromaxingEffort(&viaEffort); err != nil {
t.Fatalf("normalize: %v", err)
}
viaProfile := execOptions{execProfile: execprofile.Name}
if err := normalizeZeromaxingEffort(&viaProfile); err != nil {
t.Fatalf("normalize: %v", err)
}
if !reflect.DeepEqual(viaEffort, viaProfile) {
t.Fatalf("the two entry points diverged:\n--reasoning-effort: %+v\n--exec-profile: %+v", viaEffort, viaProfile)
}
// ...and after profile expansion they are still identical.
pe, fe, err := applyExecProfile(&viaEffort)
if err != nil {
t.Fatalf("applyExecProfile(effort path): %v", err)
}
pp, fp, err := applyExecProfile(&viaProfile)
if err != nil {
t.Fatalf("applyExecProfile(profile path): %v", err)
}
if pe != pp || fe != fp || !reflect.DeepEqual(viaEffort, viaProfile) {
t.Fatalf("resolved state diverged after expansion:\n%+v %v\n%+v %v", pe, fe, pp, fp)
}
if viaEffort.reasoningEffort != "high" {
t.Fatalf("both paths must resolve the effort to the profile's high, got %q", viaEffort.reasoningEffort)
}
}
// A conflicting pair is a usage error, not a silent winner.
func TestZeromaxingEffortFlagConflictIsUsageError(t *testing.T) {
options := execOptions{reasoningEffort: execprofile.Name, execProfile: "fast"}
err := normalizeZeromaxingEffort(&options)
if err == nil {
t.Fatal("--reasoning-effort zeromaxing with --exec-profile fast must be a usage error")
}
if !strings.Contains(err.Error(), "conflicts") {
t.Fatalf("the error must name the conflict: %v", err)
}
// The same profile on both flags is not a conflict.
same := execOptions{reasoningEffort: execprofile.Name, execProfile: execprofile.Name}
if err := normalizeZeromaxingEffort(&same); err != nil {
t.Fatalf("naming the same posture twice must be accepted: %v", err)
}
}
// (e) THE LOAD-BEARING GUARD. The posture name must never be forwarded to a
// provider as an effort value — not by any path, not for any model.
func TestZeromaxingIsNeverForwardedToAProvider(t *testing.T) {
registry, err := modelregistry.DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry: %v", err)
}
// Across a reasoning model, a non-reasoning model, and a model the catalog
// has never heard of (where unknown values are otherwise passed through).
for _, model := range []string{"claude-sonnet-4.5", "gpt-4.1", "some-custom-endpoint-model"} {
for _, spelling := range []string{"zeromaxing", "ZEROMAXING", " Zeromaxing "} {
if got := forwardedReasoningEffort(registry, model, spelling); got != "" {
t.Fatalf("forwardedReasoningEffort(%q, %q) = %q, want \"\" — the posture name is not a provider value",
model, spelling, got)
}
}
}
// The guard must not over-reach: real levels still forward.
if got := forwardedReasoningEffort(registry, "claude-sonnet-4.5", "high"); got != "high" {
t.Fatalf("a real level must still forward, got %q", got)
}
}
// (f) THE RESERVATION. /effort max and --reasoning-effort max are UNCHANGED by
// this feature: "max" still parses as a raw provider level (ValidReasoningEffort
// accepts ReasoningEffortMax) and still resolves to nothing usable on today's
// models. The spelling stays free for a real provider rung.
func TestEffortMaxReservationUnchangedCLI(t *testing.T) {
if !modelregistry.ValidReasoningEffort(modelregistry.ReasoningEffortMax) {
t.Fatal("ReasoningEffortMax must remain a valid raw effort value — the reservation depends on it")
}
if _, ok := execprofile.Lookup("max"); ok {
t.Fatal("\"max\" must NOT resolve to a profile — it is reserved for a provider rung")
}
registry, err := modelregistry.DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry: %v", err)
}
// No curated model lists "max", so it coerces away rather than forwarding.
if got := forwardedReasoningEffort(registry, "claude-sonnet-4.5", "max"); got == "max" {
t.Fatal("no current model supports \"max\"; it must not be forwarded as-is")
}
// And it must NOT be swallowed by the zeromaxing normalizer.
options := execOptions{reasoningEffort: "max"}
if err := normalizeZeromaxingEffort(&options); err != nil {
t.Fatalf("normalize: %v", err)
}
if options.reasoningEffort != "max" || options.execProfile != "" {
t.Fatalf("\"max\" must pass through the normalizer untouched, got effort=%q profile=%q",
options.reasoningEffort, options.execProfile)
}
}
// (l) CLI leg of the honesty rule: a model that cannot take the effort is TOLD.
// reasoningEffortNotice is the existing coerce-and-tell helper, and it fires for
// a PROFILE-FILLED effort, not just an explicitly flagged one.
func TestZeromaxingUnsupportedEffortIsReportedCLI(t *testing.T) {
registry, err := modelregistry.DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry: %v", err)
}
const nonReasoning = "gpt-4.1" // a catalog model with no reasoning capability
notice := reasoningEffortNotice(registry, nonReasoning, "high")
if notice == "" {
t.Fatal("a non-reasoning model must produce a notice when an effort is requested")
}
if !strings.Contains(notice, "reasoning effort") {
t.Fatalf("the notice must explain what was not applied: %q", notice)
}
if forwarded := forwardedReasoningEffort(registry, nonReasoning, "high"); forwarded != "" {
t.Fatalf("forwarded effort = %q, want empty for a non-reasoning model", forwarded)
}
// ...and the rest of the posture still applies: the budget is unaffected.
profile, _ := execprofile.Lookup(execprofile.Name)
if effective, _ := applyProfileTurnBudget(profile, 0, 80); effective != 480 {
t.Fatalf("the turn budget must still apply on an unsupported model, got %d", effective)
}
}
// (m)+(n) CLI leg of the config gate.
func TestZeromaxingSelectionRefusalCLI(t *testing.T) {
profile, _ := execprofile.Lookup(execprofile.Name)
if refusal := execprofile.SelectionRefusal(profile, true); refusal == "" {
t.Fatal("exec must refuse the posture when config disabled it")
}
if refusal := execprofile.SelectionRefusal(profile, false); refusal != "" {
t.Fatalf("exec must allow it when config did not disable it, got %q", refusal)
}
}
// (o) The raised budget PROPAGATES to spawned children. Deliberate — this is a
// maximal posture — so it is asserted explicitly rather than left to be
// discovered. applyProfileTurnBudget's effective value becomes resolved.MaxTurns,
// which is what the run exports as ZERO_MAX_TURNS for sub-agents.
func TestZeromaxingTurnBudgetPropagatesToChildren(t *testing.T) {
profile, _ := execprofile.Lookup(execprofile.Name)
effective, _ := applyProfileTurnBudget(profile, 0, 80)
if effective != 480 {
t.Fatalf("effective budget = %d, want 480", effective)
}
if effective > config.MaxTurnsCeiling {
t.Fatalf("the budget %d exceeds the shared ceiling %d", effective, config.MaxTurnsCeiling)
}
// The delta text promises exactly this, so the promise and the number must
// not drift apart.
delta := execprofile.Delta(execprofile.DeltaState{CurrentMaxTurns: 80})
if !strings.Contains(delta, "sub-agents") {
t.Fatalf("the delta must tell the user the budget reaches sub-agents: %q", delta)
}
}
// The headless posture mapping: selecting it enters; everything else is off.
func TestExecZeromaxingMapping(t *testing.T) {
profile, _ := execprofile.Lookup(execprofile.Name)
if got := execZeromaxing(profile); got != agent.ZeromaxingEntering {
t.Fatalf("execZeromaxing(zeromaxing) = %v, want ZeromaxingEntering", got)
}
for _, name := range []string{"balanced", "fast", "thorough"} {
other, _ := execprofile.Lookup(name)
if got := execZeromaxing(other); got != agent.ZeromaxingOff {
t.Fatalf("execZeromaxing(%s) = %v, want ZeromaxingOff", name, got)
}
}
if got := execZeromaxing(execprofile.Profile{}); got != agent.ZeromaxingOff {
t.Fatalf("execZeromaxing(zero) = %v, want ZeromaxingOff", got)
}
}
// The flag PARSER must accept the posture name, and must still reject "max".
//
// This test exists because the unit tests above call normalizeZeromaxingEffort
// directly and never reach the parser — which rejected "zeromaxing" outright, so
// the whole entry point was dead at the user surface while every unit test
// passed. Driving the real binary is what found it; this is the regression.
func TestReasoningEffortFlagAcceptsZeromaxingButNotMax(t *testing.T) {
accepted := func(t *testing.T, flag, value string) error {
t.Helper()
_, _, err := parseExecArgs([]string{flag, value, "-p", "x"})
return err
}
if err := accepted(t, "--reasoning-effort", execprofile.Name); err != nil {
t.Fatalf("--reasoning-effort %s must be accepted by the parser: %v", execprofile.Name, err)
}
if err := accepted(t, "--reasoning-effort", "ZEROMAXING"); err != nil {
t.Fatalf("the parser must be case-insensitive: %v", err)
}
for _, level := range []string{"low", "medium", "high"} {
if err := accepted(t, "--reasoning-effort", level); err != nil {
t.Fatalf("--reasoning-effort %s must still be accepted: %v", level, err)
}
}
// THE RESERVATION: "max" was rejected before this feature and must stay
// rejected. Accepting it here would burn the spelling.
err := accepted(t, "--reasoning-effort", "max")
if err == nil {
t.Fatal("--reasoning-effort max must still be rejected — the spelling is reserved")
}
if !strings.Contains(err.Error(), execprofile.Name) {
t.Fatalf("the usage error should name the accepted values: %v", err)
}
// The spec-draft effort is a plain provider level; the posture has no
// meaning for a draft, so it is NOT accepted there.
if err := accepted(t, "--spec-reasoning-effort", execprofile.Name); err == nil {
t.Fatalf("--spec-reasoning-effort %s must be rejected; it is a run posture, not a draft level", execprofile.Name)
}
}
// The headless delta must describe the transition from the state the run was
// invoked in, captured BEFORE the profile arms self-correction as a side
// effect. Reading options.selfCorrect afterwards would always say "already on".
func TestExecSelfCorrectTransitionUsesPreProfileState(t *testing.T) {
if got := execSelfCorrectTransition(false); got != execprofile.SelfCorrectRaised {
t.Fatalf("an unflagged run = %v, want SelfCorrectRaised", got)
}
if got := execSelfCorrectTransition(true); got != execprofile.SelfCorrectAlreadyOn {
t.Fatalf("an explicit --self-correct run = %v, want SelfCorrectAlreadyOn", got)
}
// And the rendered text differs, so the distinction reaches the user.
raised := execprofile.Delta(execprofile.DeltaState{CurrentMaxTurns: 80, SelfCorrect: execSelfCorrectTransition(false)})
already := execprofile.Delta(execprofile.DeltaState{CurrentMaxTurns: 80, SelfCorrect: execSelfCorrectTransition(true)})
if raised == already {
t.Fatalf("both states render identically:\n%s", raised)
}
if !strings.Contains(raised, "lsp → tests") {
t.Fatalf("an unflagged run must be told what changes:\n%s", raised)
}
}
// --spec-reasoning-effort zeromaxing stays rejected, but the message must
// explain WHY and name the way forward rather than reading like a bug.
func TestSpecReasoningEffortZeromaxingExplainsItself(t *testing.T) {
_, _, err := parseExecArgs([]string{"--spec-reasoning-effort", execprofile.Name, "-p", "x"})
if err == nil {
t.Fatalf("--spec-reasoning-effort %s must still be rejected", execprofile.Name)
}
message := err.Error()
for _, want := range []string{
"run posture", // why it does not apply
"spec drafting", // where the user is
"--spec-reasoning-effort high", // the way forward for the draft
"--reasoning-effort " + execprofile.Name, // ...and for the run
} {
if !strings.Contains(message, want) {
t.Fatalf("the usage error must mention %q, got: %s", want, message)
}
}
// A genuinely unknown value keeps the plain message — the explanation is
// specific to the posture name, not a new blanket wording.
_, _, err = parseExecArgs([]string{"--spec-reasoning-effort", "turbo", "-p", "x"})
if err == nil || !strings.Contains(err.Error(), "Expected low, medium, or high.") {
t.Fatalf("an unknown value must keep the plain message, got: %v", err)
}
}
// The CAPTURE POINT, exercised through the real exec path.
//
// TestExecSelfCorrectTransitionUsesPreProfileState covers the helper; it cannot
// catch the capture being read at the wrong moment, because it never runs the
// code that does the capturing. applyExecProfile arms self-correction as a side
// effect, so reading options.selfCorrect after it would make every run report
// "unchanged (tests)" — the exact wording this change exists to remove.
func TestExecPrintsTheRaisedTransitionForAnUnflaggedRun(t *testing.T) {
exitCode, _, stderr := runExecWithEcho(t, []string{
"exec", "--exec-profile", execprofile.Name, "hello",
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr)
}
if !strings.Contains(stderr, "self-correct: lsp → tests") {
t.Fatalf("an unflagged run starts LSP-only, so the delta must show the transition:\n%s", stderr)
}
if strings.Contains(stderr, "self-correct: unchanged") {
t.Fatalf("the delta must not claim self-correction was already on:\n%s", stderr)
}
}
// ...and the other direction: an explicit --self-correct run genuinely was
// already on, so it must say so.
func TestExecPrintsUnchangedWhenSelfCorrectWasAlreadyOn(t *testing.T) {
exitCode, _, stderr := runExecWithEcho(t, []string{
"exec", "--exec-profile", execprofile.Name, "--self-correct", "hello",
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr)
}
if !strings.Contains(stderr, "self-correct: unchanged (tests)") {
t.Fatalf("an explicit --self-correct run must be told nothing changed:\n%s", stderr)
}
if strings.Contains(stderr, "lsp → tests") {
t.Fatalf("must not claim a transition that did not happen:\n%s", stderr)
}
}
// (3) The budget clause the USER sees must be caller-relative. Asserted through
// the real exec path: a unit test on budgetLine cannot catch the caller passing
// the wrong number into it, which is exactly what mutation found.
func TestExecDeltaShowsTheCallersOwnTurnBudget(t *testing.T) {
exitCode, _, stderr := runExecWithEcho(t, []string{
"exec", "--exec-profile", execprofile.Name, "hello",
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr)
}
// Assert the SHAPE, not a specific origin: the harness's resolved budget is
// a fixture value, and pinning it here would test the fixture rather than
// the behaviour. What matters is that the origin is the caller's own
// resolved budget and the destination is the posture's.
transition := regexp.MustCompile(`turn budget: (\d+) → 480`)
match := transition.FindStringSubmatch(stderr)
if match == nil {
t.Fatalf("the delta must state a caller-relative budget transition:\n%s", stderr)
}
if match[1] == "480" {
t.Fatalf("origin and destination are the same; the clause should read \"unchanged\":\n%s", stderr)
}
if strings.Contains(stderr, "160") {
t.Fatalf("the delta must not compare against thorough's budget:\n%s", stderr)
}
if n := strings.Count(stderr, "reasoning effort:"); n != 1 {
t.Fatalf("exactly one reasoning-effort statement expected, found %d:\n%s", n, stderr)
}
}
// A partial plan must reach the PROCESS exit code, not just the tool result.
//
// Asserting the executor's status missed the tool's status; asserting the
// tool's status would miss the exit code the same way. This is the third link,
// asserted at the boundary that actually matters to a caller.
func TestPlanPartialMapsToIncompleteExit(t *testing.T) {
recorder := &planSessionRecorder{}
if _, incomplete := recorder.Incomplete(); incomplete {
t.Fatal("a recorder that saw no plan must not mark the run incomplete")
}
plan, err := specialist.ParsePlan(map[string]any{
"name": "p",
"tasks": []any{map[string]any{"id": "a", "prompt": "x"}},
"budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(100_000)},
}, specialist.Limits{MaxTasks: 5})
if err != nil {
t.Fatalf("ParsePlan: %v", err)
}
// A COMPLETED plan leaves the run alone.
recorder.PlanCompleted(plan, specialist.PlanReport{Status: specialist.PlanCompleted, Succeeded: 1})
if _, incomplete := recorder.Incomplete(); incomplete {
t.Fatal("a completed plan must not mark the run incomplete")
}
// A PARTIAL one does, and names the counts.
recorder.PlanCompleted(plan, specialist.PlanReport{Status: specialist.PlanPartial, Succeeded: 1, Skipped: 2})
reason, incomplete := recorder.Incomplete()
if !incomplete {
t.Fatal("a partial plan must mark the run incomplete, which is exit 4")
}
for _, want := range []string{"partial", "1 succeeded", "2 skipped"} {
if !strings.Contains(reason, want) {
t.Fatalf("the reason must carry %q: %q", want, reason)
}
}
// FAILED outranks partial: if any plan failed outright, that is the story.
recorder.PlanCompleted(plan, specialist.PlanReport{Status: specialist.PlanFailed})
if reason, _ := recorder.Incomplete(); !strings.Contains(reason, "failed") {
t.Fatalf("a failed plan must outrank a partial one: %q", reason)
}
}
+116
View File
@@ -2,6 +2,7 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"strings"
@@ -82,11 +83,105 @@ func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return runPluginInfo(args[1:], stdout, stderr, deps)
case "remove", "rm":
return runPluginRemove(args[1:], deps.pluginsDir(), stdout, stderr)
case "enable":
return runPluginsToggle(args[1:], stdout, stderr, deps, false)
case "disable":
return runPluginsToggle(args[1:], stdout, stderr, deps, true)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown plugins subcommand %q", args[0]))
}
}
type pluginToggleOptions struct {
json bool
user bool
}
// runPluginsToggle implements `zero plugins enable|disable <id>`.
//
// Every message it writes, including usage errors that echo the caller's
// arguments, passes through the redaction pipeline: an argument is user input
// and can be a mistyped credential.
func runPluginsToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, disabled bool) int {
commandName := "enable"
if disabled {
commandName = "disable"
}
usageError := func(message string) int {
return writeExecUsageError(stderr, redaction.RedactString(message, redaction.Options{}))
}
options, positional, help, err := parsePluginToggleArgs(args, commandName)
if err != nil {
return usageError(err.Error())
}
if help {
if err := writePluginsToggleHelp(stdout, commandName); err != nil {
return exitCrash
}
return exitSuccess
}
if len(positional) != 1 {
return usageError(fmt.Sprintf("usage: zero plugins %s <id> [--user] [--json]", commandName))
}
pluginID := strings.TrimSpace(positional[0])
if pluginID == "" {
return usageError(fmt.Sprintf("usage: zero plugins %s <id> [--user] [--json]", commandName))
}
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
loadOptions := plugins.LoadOptions{Cwd: cwd, ExcludeProject: options.user}
result, err := plugins.SetEnabledByID(loadOptions, pluginID, !disabled)
if err != nil {
if errors.Is(err, plugins.ErrNotInstalled) {
return usageError(err.Error())
}
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
state := "enabled"
if disabled {
state = "disabled"
}
if options.json {
if err := writePrettyJSON(stdout, redaction.RedactValue(result, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
message := fmt.Sprintf("Plugin %s is now %s in %s.\n", result.ID, state, result.ManifestPath)
if !result.Changed {
message = fmt.Sprintf("Plugin %s was already %s in %s.\n", result.ID, state, result.ManifestPath)
}
if _, err := fmt.Fprint(stdout, redaction.RedactString(message, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
func parsePluginToggleArgs(args []string, command string) (pluginToggleOptions, []string, bool, error) {
options := pluginToggleOptions{}
positional := []string{}
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
return options, positional, true, nil
case "--json":
options.json = true
case "--user":
options.user = true
default:
if strings.HasPrefix(arg, "-") {
return options, positional, false, execUsageError{fmt.Sprintf("unknown plugins %s flag %q", command, arg)}
}
positional = append(positional, arg)
}
}
return options, positional, false, nil
}
func runHooks(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return writeExecUsageError(stderr, "hooks subcommand required. Use `zero hooks list`.")
@@ -566,10 +661,31 @@ Commands:
info <id> Show plugin details and lockfile metadata
add <git-url|path> Install a plugin (manifest-validated, pinned in plugins.lock)
remove <id> Remove an installed plugin and its lockfile entry
enable <id> Enable a plugin via its plugin.json
disable <id> Disable a plugin via its plugin.json
`)
return err
}
func writePluginsToggleHelp(w io.Writer, command string) error {
_, err := fmt.Fprintf(w, `Usage:
zero plugins %s <id> [flags]
Flags:
--user Target only user plugins (ignore ./.zero/plugins)
--json Print command result as JSON
-h, --help Show this help
Without --user, a project plugin with the same id takes precedence over a user
plugin, matching the loader's discovery order.
Zero reads plugins when it starts, so the change applies to the next zero exec
run or interactive session. A running interactive session keeps the plugins it
started with until you restart it.
`, command)
return err
}
func writePluginsListHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero plugins list [flags]
+232
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
@@ -333,3 +334,234 @@ func TestRunMCPPermissionsHelpDoesNotOpenStore(t *testing.T) {
})
}
}
func TestRunPluginsEnableDisable(t *testing.T) {
cwd := t.TempDir()
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
pluginDir := filepath.Join(cwd, ".zero", "plugins", "demo")
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0"
}
`), 0o600); err != nil {
t.Fatal(err)
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", exitCode, stderr.String())
}
var disablePayload plugins.SetEnabledResult
if err := json.Unmarshal(stdout.Bytes(), &disablePayload); err != nil {
t.Fatalf("decode disable JSON: %v\n%s", err, stdout.String())
}
if disablePayload.ID != "zero.demo" || disablePayload.Enabled || !disablePayload.Changed {
t.Fatalf("unexpected disable payload: %#v", disablePayload)
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("manifest not disabled: %s", raw)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("noop disable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "was already disabled") {
t.Fatalf("noop disable output: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "enable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("enable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "is now enabled") {
t.Fatalf("enable output: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "enable", "missing-plugin"}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("missing plugin exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
}
// The text-mode toggle output runs through the redaction pipeline, so a
// manifest path that contains something secret-shaped is masked.
func TestRunPluginsToggleTextOutputIsRedacted(t *testing.T) {
cwd := t.TempDir()
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
secretDir := "sk-proj-abcdefghijklmnopqrstuvwxyz"
pluginDir := filepath.Join(cwd, ".zero", "plugins", secretDir)
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0"
}
`), 0o600); err != nil {
t.Fatal(err)
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stdout.String(), secretDir) {
t.Fatalf("toggle text leaked a secret-shaped manifest path: %q", stdout.String())
}
if !strings.Contains(stdout.String(), "[REDACTED]") {
t.Fatalf("toggle text was not redacted: %q", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("noop disable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "was already disabled") {
t.Fatalf("noop toggle output: %q", stdout.String())
}
if strings.Contains(stdout.String(), secretDir) {
t.Fatalf("noop toggle text leaked a secret-shaped manifest path: %q", stdout.String())
}
}
func TestRunPluginsToggleRejectsEmptyID(t *testing.T) {
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
for _, subcommand := range []string{"enable", "disable"} {
t.Run(subcommand, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", subcommand, ""}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
if !strings.Contains(stderr.String(), "usage: zero plugins "+subcommand) {
t.Fatalf("stderr = %q", stderr.String())
}
})
}
}
func TestRunPluginsDisableUserIgnoresProject(t *testing.T) {
home := t.TempDir()
cwd := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
userManifest := filepath.Join(home, "zero", "plugins", "demo", "plugin.json")
projectManifest := filepath.Join(cwd, ".zero", "plugins", "demo", "plugin.json")
for _, path := range []string{userManifest, projectManifest} {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": true
}
`), 0o600); err != nil {
t.Fatal(err)
}
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo", "--user"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable --user exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "is now disabled") {
t.Fatalf("disable --user output: %s", stdout.String())
}
userRaw, err := os.ReadFile(userManifest)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(userRaw), `"enabled": false`) {
t.Fatalf("user manifest not disabled: %s", userRaw)
}
projectRaw, err := os.ReadFile(projectManifest)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(projectRaw), `"enabled": false`) {
t.Fatalf("project manifest should stay enabled: %s", projectRaw)
}
}
// The toggle's usage errors echo the caller's arguments, which can be a
// mistyped credential, so they pass through redaction like its other output.
func TestRunPluginsToggleUsageErrorsAreRedacted(t *testing.T) {
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
const secret = "sk-proj-abcdefghijklmnopqrstuvwxyz"
cases := []struct {
name string
args []string
want string
}{
{"unknown flag", []string{"zero.demo", "--token=" + secret}, "unknown plugins"},
{"missing plugin", []string{secret}, "plugin is not installed"},
}
for _, subcommand := range []string{"enable", "disable"} {
for _, tc := range cases {
t.Run(subcommand+" "+tc.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
args := append([]string{"plugins", subcommand}, tc.args...)
exitCode := runWithDeps(args, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
if strings.Contains(stderr.String(), secret) {
t.Fatalf("usage error leaked the argument: %q", stderr.String())
}
if !strings.Contains(stderr.String(), tc.want) || !strings.Contains(stderr.String(), "[REDACTED]") {
t.Fatalf("stderr = %q, want a redacted %q error", stderr.String(), tc.want)
}
})
}
}
}
+1 -1
View File
@@ -319,7 +319,7 @@ Flags:
--command <cmd> Command to run (required)
--name <name> Human-readable hook name
--description <text> Hook description
--matcher <pattern> Tool matcher (beforeTool/afterTool only)
--matcher <pattern> Tool/specialist matcher (beforeTool/afterTool/specialistStart/specialistStop)
--arg <value> Command argument (repeatable)
--user Write to user config instead of the project
--json Print command result as JSON
+32
View File
@@ -0,0 +1,32 @@
package cli
import (
"os"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
)
// TestMain lets this package's test binary stand in for the zero binary as its
// own sandbox helper.
//
// THE SANDBOX SELF-DISPATCHES. With no standalone helper beside the running
// executable, both sandboxes launch os.Executable() with a hidden subcommand
// (__windows-command-runner, __windows-sandbox-setup, __sandbox-helper). In a
// test that runs this binary as the zero CLI — TestLocalAdoptionSavesAndReloads-
// EligibleModel does — os.Executable() is the test binary. Without this routing
// it treated the hidden subcommand as nothing at all and ran the WHOLE package
// suite as the "helper", so every sandboxed command in that child hung until it
// was killed. cmd/zero/main.go and runWithDeps route these before anything else;
// this does the same, in the same order.
func TestMain(m *testing.M) {
if len(os.Args) > 1 {
switch os.Args[1] {
case sandbox.LinuxSandboxHelperSubcommand:
os.Exit(sandbox.RunLinuxSandboxHelper(os.Args[1:], os.Stderr))
case sandbox.WindowsCommandRunnerSubcommand, sandbox.WindowsSandboxSetupSubcommand:
os.Exit(runWithDeps(os.Args[1:], os.Stdout, os.Stderr, appDeps{}))
}
}
os.Exit(m.Run())
}
+32 -3
View File
@@ -741,6 +741,13 @@ func (cfg *mcpWritableConfig) upsertServer(name string, server config.MCPServerC
if err != nil {
return false, err
}
if !mergedServer.Disabled && config.IsDefaultMCPServerDisabled(name) {
// Adding a server under the name of a default that ships disabled means
// the user wants it on; the omitted flag would otherwise keep it off.
if data, err = setMCPServerRawDisabledFalse(data); err != nil {
return false, err
}
}
cfg.serverRaw[name] = data
if _, err := cfg.removeLegacyServer(name); err != nil {
return false, err
@@ -800,11 +807,29 @@ func mergeMCPServerRaw(existing json.RawMessage, server config.MCPServerConfig)
if key == "oauth" && preserveRawOAuth {
continue
}
// `mcp add` replaces the entry: it writes only a disable, never a stale
// explicit false carried over from the old entry (upsertServer adds the
// false a default that ships disabled needs).
if key == "disabled" && string(value) == "false" {
continue
}
merged[key] = value
}
return json.Marshal(merged)
}
func setMCPServerRawDisabledFalse(data json.RawMessage) (json.RawMessage, error) {
var server map[string]json.RawMessage
if err := json.Unmarshal(data, &server); err != nil {
return nil, err
}
if server == nil {
server = map[string]json.RawMessage{}
}
server["disabled"] = json.RawMessage("false")
return json.Marshal(server)
}
func (cfg *mcpWritableConfig) removeServer(name string) (bool, error) {
cfg.ensureRaw()
removed := false
@@ -856,15 +881,19 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
server = map[string]json.RawMessage{}
}
current := false
// A default that ships disabled (aimlapi) stays off until an entry says
// otherwise, so an absent flag reads as disabled and enabling it must be
// written out explicitly: dropping the flag would leave it off.
shippedDisabled := config.IsDefaultMCPServerDisabled(name)
current := shippedDisabled
if rawDisabled, ok := server["disabled"]; ok && len(rawDisabled) > 0 && string(rawDisabled) != "null" {
if err := json.Unmarshal(rawDisabled, &current); err != nil {
return false, false, err
}
}
changed := current != disabled
if disabled {
data, err := json.Marshal(true)
if disabled || shippedDisabled {
data, err := json.Marshal(disabled)
if err != nil {
return false, false, err
}
+260
View File
@@ -2,10 +2,18 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/config"
)
@@ -44,3 +52,255 @@ func TestRunMCPDisableSeededExaDefault(t *testing.T) {
t.Fatal("expected `mcp disable exa` to turn the seeded default off in the resolved config")
}
}
// aimlapiWire stands in for https://mcp.aimlapi.com/mcp at the transport layer:
// requests keep their real URL, nothing is dialed, and each one is recorded.
type aimlapiWire struct {
mu sync.Mutex
requests []*http.Request
}
func (wire *aimlapiWire) RoundTrip(request *http.Request) (*http.Response, error) {
var body []byte
if request.Body != nil {
body, _ = io.ReadAll(request.Body)
_ = request.Body.Close()
}
wire.mu.Lock()
wire.requests = append(wire.requests, request.Clone(context.Background()))
wire.mu.Unlock()
var message struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.Unmarshal(body, &message)
respond := func(status int, payload string) *http.Response {
return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(payload)), Request: request}
}
if request.URL.String() != aimlapi.MCPServerURL {
return respond(http.StatusBadGateway, `{}`), nil
}
if len(message.ID) == 0 {
return respond(http.StatusAccepted, ""), nil
}
result := `{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake","version":"1"}}`
if message.Method == "tools/list" {
result = `{"tools":[{"name":"tools_search","description":"Search the Toolbox","inputSchema":{"type":"object"}}]}`
}
return respond(http.StatusOK, fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, message.ID, result)), nil
}
func (wire *aimlapiWire) recorded() []*http.Request {
wire.mu.Lock()
defer wire.mu.Unlock()
return append([]*http.Request(nil), wire.requests...)
}
// isolateAIMLAPIMCPCommand redirects every root the real resolve path reads to
// a temporary home, sets an AI/ML API key in the environment, and routes the
// default HTTP transport to a recorder. It returns the user config path.
func isolateAIMLAPIMCPCommand(t *testing.T, key string) (string, *aimlapiWire, appDeps) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".state"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))
t.Setenv("LOCALAPPDATA", filepath.Join(home, "AppData", "Local"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_PARTNER_ID", "")
t.Setenv("AIMLAPI_API_KEY", key)
configPath, err := config.DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
writeMCPCommandRawConfig(t, configPath, `{}`)
wire := &aimlapiWire{}
previous := http.DefaultTransport
http.DefaultTransport = wire
t.Cleanup(func() { http.DefaultTransport = previous })
workspace := t.TempDir()
return configPath, wire, appDeps{getwd: func() (string, error) { return workspace, nil }}
}
func TestRunMCPEnableAIMLAPIDefaultWritesOnlyTheToggle(t *testing.T) {
configPath, wire, deps := isolateAIMLAPIMCPCommand(t, "aiml-secret-key")
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "disable", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), `"changed": false`) {
t.Fatalf("disabling the off-by-default server should be a no-op: %s", out.String())
}
out.Reset()
errBuf.Reset()
if code := runWithDeps([]string{"mcp", "enable", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("enable exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), `"changed": true`) {
t.Fatalf("enable should report a change: %s", out.String())
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
var written struct {
MCP struct {
Servers map[string]map[string]any `json:"servers"`
} `json:"mcp"`
}
if err := json.Unmarshal(data, &written); err != nil {
t.Fatal(err)
}
if entry := written.MCP.Servers["aimlapi"]; len(entry) != 1 || entry["disabled"] != false {
t.Fatalf("enable must write only the toggle, got %#v in %s", entry, data)
}
if strings.Contains(string(data), "aiml-secret-key") || strings.Contains(string(data), "Authorization") {
t.Fatalf("config holds credential material: %s", data)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if server := cfg.Servers["aimlapi"]; server.Disabled || config.IsUnconfiguredDefault("aimlapi", server) {
t.Fatalf("enabled aimlapi = %#v, want enabled and user-configured", server)
}
// An unrelated save rewrites the whole file through FileConfig; the
// explicit enable has to survive it or the shipped disable comes back.
if _, err := config.SetTheme(configPath, "dark"); err != nil {
t.Fatal(err)
}
cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if cfg.Servers["aimlapi"].Disabled {
data, _ := os.ReadFile(configPath)
t.Fatalf("an unrelated config save turned aimlapi off again: %s", data)
}
if len(wire.recorded()) != 0 {
t.Fatal("enable must not connect")
}
out.Reset()
if code := runWithDeps([]string{"mcp", "disable", "aimlapi"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", code, errBuf.String())
}
cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if !cfg.Servers["aimlapi"].Disabled {
t.Fatal("disable after enable must turn aimlapi off again")
}
}
func TestRunMCPAddUnderAIMLAPIDefaultNameEnablesIt(t *testing.T) {
configPath, _, deps := isolateAIMLAPIMCPCommand(t, "")
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "add", "aimlapi", "--type", "http", "--url", aimlapi.MCPServerURL, "--auth", "oauth"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("add exit=%d stderr=%s", code, errBuf.String())
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if server := cfg.Servers["aimlapi"]; server.Disabled || server.Auth != "oauth" {
t.Fatalf("added aimlapi = %#v, want enabled with oauth", server)
}
if code := runWithDeps([]string{"mcp", "add", "aimlapi", "--type", "http", "--url", aimlapi.MCPServerURL, "--disabled"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("add --disabled exit=%d stderr=%s", code, errBuf.String())
}
if cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath}); err != nil || !cfg.Servers["aimlapi"].Disabled {
t.Fatalf("add --disabled left aimlapi enabled: %#v, %v", cfg.Servers["aimlapi"], err)
}
}
// Through the real resolve path (no injected resolveMCPConfig): the default is
// listed as disabled, never connects, and once enabled it reaches session
// startup and `zero mcp check`, which connects with the key on the wire.
func TestAIMLAPIMCPDefaultThroughRealResolvePath(t *testing.T) {
_, wire, deps := isolateAIMLAPIMCPCommand(t, "aiml-secret-key")
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "list"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("list exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), "aimlapi [http] disabled https://mcp.aimlapi.com/mcp") || strings.Contains(out.String(), "aiml-secret-key") {
t.Fatalf("list output should show the disabled aimlapi default and no key:\n%s", out.String())
}
out.Reset()
if code := runWithDeps([]string{"mcp", "list", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("list --json exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), `"aimlapi"`) || strings.Contains(out.String(), "aiml-secret-key") {
t.Fatalf("list --json = %s", out.String())
}
out.Reset()
errBuf.Reset()
if code := runWithDeps([]string{"mcp", "check", "aimlapi"}, &out, &errBuf, deps); code == exitSuccess || !strings.Contains(errBuf.String(), "is disabled") {
t.Fatalf("check of the disabled default: exit=%d stderr=%s", code, errBuf.String())
}
resolved, err := fillAppDeps(deps).resolveMCPConfig(t.TempDir(), true)
if err != nil {
t.Fatal(err)
}
critical, optional := splitMCPStartupConfig(resolved)
if _, ok := critical.Servers["aimlapi"]; ok {
t.Fatal("the disabled default must not start with a session")
}
if _, ok := optional.Servers["aimlapi"]; ok {
t.Fatal("the disabled default must not start with a session")
}
if len(wire.recorded()) != 0 {
t.Fatalf("the disabled default connected: %d requests", len(wire.recorded()))
}
if code := runWithDeps([]string{"mcp", "enable", "aimlapi"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("enable exit=%d stderr=%s", code, errBuf.String())
}
resolved, err = fillAppDeps(deps).resolveMCPConfig(t.TempDir(), true)
if err != nil {
t.Fatal(err)
}
critical, _ = splitMCPStartupConfig(resolved)
if _, ok := critical.Servers["aimlapi"]; !ok {
t.Fatal("an enabled aimlapi must start with the session as a user-configured server")
}
out.Reset()
errBuf.Reset()
if code := runWithDeps([]string{"mcp", "check", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("check exit=%d stdout=%s stderr=%s", code, out.String(), errBuf.String())
}
if !strings.Contains(out.String(), `"status": "ok"`) || !strings.Contains(out.String(), `"toolCount": 1`) {
t.Fatalf("check output = %s", out.String())
}
if strings.Contains(out.String()+errBuf.String(), "aiml-secret-key") {
t.Fatalf("check output leaks the key: %s %s", out.String(), errBuf.String())
}
requests := wire.recorded()
if len(requests) == 0 {
t.Fatal("check did not connect")
}
for _, request := range requests {
if request.URL.String() != aimlapi.MCPServerURL {
t.Fatalf("request went to %s", request.URL)
}
if got := request.Header.Get("Authorization"); got != "Bearer aiml-secret-key" {
t.Fatalf("Authorization = %q, want the AI/ML API key", got)
}
if request.Header.Get(aimlapi.SourceHeaderName) != aimlapi.SourceValue || request.Header.Get(aimlapi.PartnerHeaderName) == "" {
t.Fatalf("attribution headers missing: %v", request.Header)
}
}
}
+193
View File
@@ -0,0 +1,193 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/mcp"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/tui"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// The orchestrate notice, wired at both surfaces.
//
// AgentOptions.OrchestrateAvailable was declared, documented and consumed by the
// reminder selector — and set by NEITHER production call site. So the tool was
// advertised in the tool list and the model was never told it existed, which is
// exactly what a user found by running the binary: the posture turned on and
// nothing orchestrated.
//
// A unit test on zeromaxingReminders PASSED throughout that whole period,
// because it passes the flag in itself. So these tests drive the two
// constructions instead — Run() to the wire for exec, and the captured
// tui.Options for the TUI — which is the only level at which the defect was
// visible. Invariant 1, for the second time in this feature.
// EXEC: the notice has to reach the actual request body.
func TestExecTellsTheModelAboutOrchestrate(t *testing.T) {
clearProviderEnv(t)
root := t.TempDir()
configDir := filepath.Join(root, ".zero")
if err := os.MkdirAll(configDir, 0o700); err != nil {
t.Fatal(err)
}
var sent []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if sent == nil {
sent = body
}
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}))
defer server.Close()
providerConfig := `{
"activeProvider": "local",
"providers": [{
"name": "local",
"provider_kind": "openai-compatible",
"base_url": "` + server.URL + `",
"api_key": "sk-local",
"model": "local-model"
}]
}`
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(providerConfig), 0o600); err != nil {
t.Fatal(err)
}
run := func(args ...string) string {
sent = nil
var stdout, stderr bytes.Buffer
Run(append([]string{"exec", "--cwd", root}, args...), &stdout, &stderr)
return string(sent)
}
// POSTURE ON, specialist tools registered: the model is TOLD.
on := run("--reasoning-effort", "zeromaxing", "--auto", "high", "look at this")
if !strings.Contains(on, "orchestrate tool") {
t.Fatalf("the model was never told the orchestrate tool exists:\n%s", firstKB(on))
}
// ...and the tool really is advertised, so the notice is not a promise the
// run cannot keep.
if !advertisesOrchestrate(t, on) {
t.Fatalf("the notice names a tool this run does not advertise:\n%s", firstKB(on))
}
// POSTURE OFF: neither the notice nor the tool. This is the direction that
// makes the first half mean something — a notice emitted unconditionally
// would satisfy the assertion above.
off := run("--auto", "high", "look at this")
if strings.Contains(off, "orchestrate") {
t.Fatalf("orchestrate leaked into a posture-off run:\n%s", firstKB(off))
}
// POSTURE ON but at an autonomy that registers NO specialist tools: the
// notice must not fire, or it promises a capability this run does not have.
unavailable := run("--reasoning-effort", "zeromaxing", "--auto", "low", "look at this")
if advertisesOrchestrate(t, unavailable) {
t.Skip("this autonomy level registers orchestrate after all; the case cannot be exercised here")
}
if strings.Contains(unavailable, "orchestrate tool") {
t.Fatalf("the notice fired for a run that does not hold the tool:\n%s", firstKB(unavailable))
}
}
func advertisesOrchestrate(t *testing.T, body string) bool {
t.Helper()
var request struct {
Tools []struct {
Function struct {
Name string `json:"name"`
} `json:"function"`
} `json:"tools"`
}
if json.Unmarshal([]byte(body), &request) != nil {
return false
}
for _, tool := range request.Tools {
if tool.Function.Name == "orchestrate" {
return true
}
}
return false
}
func firstKB(body string) string {
if len(body) > 1200 {
return body[:1200]
}
return body
}
// TUI: the same fact, at the only seam a test can reach. The TUI cannot be
// driven headlessly, so this asserts what it is HANDED — which is where the
// field was missing.
func TestTheTUIIsHandedTheOrchestrateAvailability(t *testing.T) {
var stdout, stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
var launched tui.Options
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
MaxTurns: 12,
ActiveProvider: "local",
Provider: config.ProviderProfile{
Name: "local", ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1", Model: "m",
},
}, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return nil, nil },
userConfigPath: func() (string, error) { return userConfigPath, nil },
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(_ context.Context, options tui.Options) int {
launched = options
return 3
},
})
if exitCode != 3 {
t.Fatalf("exit = %d, want 3: %s", exitCode, stderr.String())
}
// The TUI always registers specialist tools, so it always holds orchestrate;
// the flag must say so, or the notice can never fire in the surface where
// the posture is most used.
if !launched.AgentOptions.OrchestrateAvailable {
t.Fatal("the TUI was handed OrchestrateAvailable=false while holding the tool, so the model is never told it exists")
}
// ...and it agrees with the registry it was handed, rather than being a
// constant that happens to read true.
if _, found := launched.AgentOptions.Registry.Get("orchestrate"); !found {
t.Fatal("the TUI's registry does not hold orchestrate, so the flag is wrong in the other direction")
}
}
// The derivation itself: false when the tool is absent, so the flag can never
// promise a capability a run does not have.
func TestOrchestrateAvailabilityFollowsTheRegistry(t *testing.T) {
if orchestrateAvailable(nil) {
t.Fatal("a nil registry reported the tool available")
}
empty := tools.NewRegistry()
if orchestrateAvailable(empty) {
t.Fatal("an empty registry reported the tool available")
}
}
@@ -0,0 +1,28 @@
package cli
import (
"os"
"strings"
"testing"
)
// Both surfaces that launch plan tasks must wire ExtraReadRoots, or a
// request_permissions READ grant never reaches those tasks and a plan auditing a
// granted external path fails "outside the workspace" in every finder.
//
// This is the unwired-feature guard: an option that is built but never passed
// does nothing, and mutation testing cannot catch it because deleting an unused
// wiring breaks no test. So this asserts the wiring is present at the source, in
// both the TUI (app.go) and headless (exec.go) paths, beside the write grant it
// mirrors.
func TestBothPathsWireExtraReadRootsBesideWrite(t *testing.T) {
for _, file := range []string{"app.go", "exec.go"} {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
if !strings.Contains(string(src), "ExtraReadRoots:") {
t.Fatalf("%s does not wire ExtraReadRoots — a plan there cannot read a granted path", file)
}
}
}
+109
View File
@@ -0,0 +1,109 @@
package cli
import (
"context"
"sync"
"github.com/Twigpine/zero/internal/tui"
)
// The background-plan launcher: who owns a background plan's LIFETIME.
//
// The tool does not, and that is the whole point of the seam. A background plan
// must outlive the tool call that started it and must NOT outlive the session,
// and the only thing that knows where the session ends is the surface that runs
// it. 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.
//
// It also makes headless exec's refusal fall out rather than be special-cased:
// that process exits when the turn ends, so it supplies no launcher at all, and
// a background plan there is refused with a reason instead of being started and
// orphaned.
//
// ONE AT A TIME, deliberately. The panel is keyed by task id and the bridge
// holds one plan's cancel; two concurrent background plans would report into
// one panel and the second would overwrite the first's rows. Refusing the
// second is a stated rule; letting the UI silently pick one is a defect.
// planLauncher runs background plans under a session-scoped context.
type planLauncher struct {
mu sync.Mutex
// ctx is the SESSION root, not a tool call's. Cancelling it is what stops a
// background plan at exit — the failure this exists to prevent is a plan
// that outlives its session, spends budget nobody sees, and reports nothing.
ctx context.Context
// running holds the one in-flight plan's cancel; nil means none.
running context.CancelFunc
// closed stops new launches once shutdown has begun. Checked under the same
// lock that starts a plan, so a launch cannot slip past a concurrent Close.
closed bool
// wait lets Close block until the plan actually stops, rather than
// cancelling and exiting while a child process is still being torn down.
wait sync.WaitGroup
// bridge is told a background plan is starting, so every message it posts is
// marked as one and survives the panel's stale-run guard.
bridge *tui.PlanProgressBridge
}
func newPlanLauncher(ctx context.Context, bridge *tui.PlanProgressBridge) *planLauncher {
return &planLauncher{ctx: ctx, bridge: bridge}
}
// Launch starts run on its own goroutine and reports whether it did.
//
// False is returned — never a silent no-op — when the session is closing or a
// background plan is already running, so the tool can say which rather than
// handing back a run id for a plan nobody started.
func (launcher *planLauncher) Launch(run func(ctx context.Context)) bool {
if launcher == nil || run == nil {
return false
}
launcher.mu.Lock()
if launcher.closed || launcher.running != nil {
launcher.mu.Unlock()
return false
}
ctx, cancel := context.WithCancel(launcher.ctx)
launcher.running = cancel
launcher.wait.Add(1)
launcher.mu.Unlock()
launcher.bridge.SetBackground(true)
go func() {
defer func() {
// Every exit converges on the bridge's idempotent terminal transition.
// Normal execution has already called PlanCompleted; panic, cancellation,
// or an early return is finalized here before the launcher slot opens.
panicked := recover() != nil
launcher.bridge.FinalizeBackgroundExit(panicked, ctx.Err() != nil)
cancel()
launcher.mu.Lock()
launcher.running = nil
launcher.mu.Unlock()
launcher.wait.Done()
}()
run(ctx)
}()
return true
}
// Close stops any background plan and WAITS for it.
//
// Waiting matters: cancelling and returning would let the process exit while a
// child is still being torn down, which is how orphans happen. The recorder's
// terminal event is written by the plan itself on the way out, so the wait is
// also what makes a cancelled background plan land in the session log rather
// than vanish.
func (launcher *planLauncher) Close() {
if launcher == nil {
return
}
launcher.mu.Lock()
launcher.closed = true
cancel := launcher.running
launcher.mu.Unlock()
if cancel != nil {
cancel()
}
launcher.wait.Wait()
}
+195
View File
@@ -0,0 +1,195 @@
package cli
import (
"context"
"strings"
"sync"
"testing"
"time"
"github.com/Twigpine/zero/internal/specialist"
"github.com/Twigpine/zero/internal/tui"
)
// A background plan MUST NOT OUTLIVE ITS SESSION. That is the failure this
// whole seam exists to prevent — a plan still spending after the session ended,
// reporting to nobody.
func TestClosingTheLauncherStopsAndWaitsForTheBackgroundPlan(t *testing.T) {
launcher := newPlanLauncher(context.Background(), tui.NewPlanProgressBridge())
started := make(chan struct{})
stopped := make(chan struct{})
if !launcher.Launch(func(ctx context.Context) {
close(started)
<-ctx.Done()
// A real plan tears a child down here; the sleep stands in for that, and
// Close must not return until it is over.
time.Sleep(50 * time.Millisecond)
close(stopped)
}) {
t.Fatal("Launch refused")
}
<-started
launcher.Close()
select {
case <-stopped:
default:
t.Fatal("Close returned while the plan was still stopping; that is how a child is orphaned")
}
}
// ONE AT A TIME. Two background plans report into one panel keyed by task id and
// the second would overwrite the first's rows, so the second is refused rather
// than silently winning.
func TestASecondBackgroundPlanIsRefused(t *testing.T) {
launcher := newPlanLauncher(context.Background(), tui.NewPlanProgressBridge())
release := make(chan struct{})
defer close(release)
if !launcher.Launch(func(context.Context) { <-release }) {
t.Fatal("the first launch was refused")
}
// Give the goroutine a moment to be counted; the refusal is decided under
// the lock at Launch, so this is about the test not the code.
time.Sleep(20 * time.Millisecond)
if launcher.Launch(func(context.Context) {}) {
t.Fatal("a second background plan was launched over a running one")
}
}
// ...and once the first finishes, the slot frees.
func TestTheSlotFreesWhenABackgroundPlanEnds(t *testing.T) {
launcher := newPlanLauncher(context.Background(), tui.NewPlanProgressBridge())
done := make(chan struct{})
if !launcher.Launch(func(context.Context) { close(done) }) {
t.Fatal("the first launch was refused")
}
<-done
deadline := time.After(3 * time.Second)
for {
if launcher.Launch(func(context.Context) {}) {
return
}
select {
case <-deadline:
t.Fatal("the slot never freed after the plan ended")
case <-time.After(10 * time.Millisecond):
}
}
}
// A launch after Close must be REFUSED, not started into a cancelled context.
// Starting it would record an admission and a cancellation for a plan the user
// never saw.
func TestLaunchingAfterCloseIsRefused(t *testing.T) {
launcher := newPlanLauncher(context.Background(), tui.NewPlanProgressBridge())
launcher.Close()
if launcher.Launch(func(context.Context) { t.Error("a plan ran after Close") }) {
t.Fatal("Launch succeeded after Close")
}
}
// A PANIC IN A BACKGROUND PLAN MUST NOT TAKE THE SESSION DOWN, and must still
// free the slot — a crashed plan that held the slot forever would block every
// later one with "already running".
func TestAPanickingBackgroundPlanIsContainedAndFreesTheSlot(t *testing.T) {
bridge := tui.NewPlanProgressBridge()
launcher := newPlanLauncher(context.Background(), bridge)
if !launcher.Launch(func(context.Context) { panic("boom") }) {
t.Fatal("Launch refused")
}
deadline := time.After(3 * time.Second)
for bridge.BackgroundPlanLive() {
select {
case <-deadline:
t.Fatal("a panicking plan left the bridge busy")
case <-time.After(10 * time.Millisecond):
}
}
if completion := bridge.DrainCompletedPlans(); !strings.Contains(completion, "failed") || !strings.Contains(completion, "panicked") {
t.Fatalf("panic produced no explicit failed completion: %q", completion)
}
if !launcher.Launch(func(context.Context) {}) {
t.Fatal("a panicking plan held the launcher slot forever")
}
}
// Cancelling the SESSION context cancels the plan, which is what makes the
// session root load-bearing rather than decorative.
func TestCancellingTheSessionCancelsTheBackgroundPlan(t *testing.T) {
sessionCtx, cancelSession := context.WithCancel(context.Background())
launcher := newPlanLauncher(sessionCtx, tui.NewPlanProgressBridge())
observed := make(chan error, 1)
if !launcher.Launch(func(ctx context.Context) {
<-ctx.Done()
observed <- ctx.Err()
}) {
t.Fatal("Launch refused")
}
cancelSession()
select {
case err := <-observed:
if err == nil {
t.Fatal("the plan's context was not cancelled")
}
case <-time.After(3 * time.Second):
t.Fatal("cancelling the session did not reach the background plan")
}
}
// The bridge is told BEFORE the plan runs, so its very first message is already
// marked background. Marking it after the goroutine started would race the
// plan's own admission message, and a dropped admission is a panel that never
// shows the plan at all.
func TestTheBridgeIsMarkedBackgroundBeforeThePlanRuns(t *testing.T) {
bridge := tui.NewPlanProgressBridge()
launcher := newPlanLauncher(context.Background(), bridge)
var once sync.Once
markedAtStart := make(chan bool, 1)
if !launcher.Launch(func(context.Context) {
once.Do(func() { markedAtStart <- bridge.PlanIsBackground() })
}) {
t.Fatal("Launch refused")
}
select {
case marked := <-markedAtStart:
if !marked {
t.Fatal("the plan started before the bridge was marked background")
}
case <-time.After(3 * time.Second):
t.Fatal("the plan never ran")
}
}
// HEADLESS EXEC SUPPLIES NO LAUNCHER, which is what makes its refusal of a
// background plan fall out of the wiring rather than out of a special case.
// Asserted at the registered tool, because a comment saying "exec passes nil"
// is not evidence that exec passes nil.
func TestHeadlessExecSuppliesNoLauncher(t *testing.T) {
workspace := t.TempDir()
registry := newCoreRegistry(workspace)
runtime, err := registerSpecialistTools(registry, workspace, 0, nil, nil, nil, orchestrateWiring{
Gate: &specialist.PostureGate{},
})
if err != nil {
t.Fatalf("registerSpecialistTools: %v", err)
}
t.Cleanup(func() { closeSpecialistRuntime(nil, runtime) })
registered, found := registry.Get(specialist.OrchestrateToolName)
if !found {
t.Fatal("orchestrate was not registered")
}
tool, ok := registered.(*specialist.OrchestrateTool)
if !ok {
t.Fatalf("orchestrate is %T", registered)
}
if tool.Launch != nil {
t.Fatal("a run wired without a launcher was given one; a background plan there could never report")
}
}
+384
View File
@@ -0,0 +1,384 @@
package cli
import (
"context"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/specialist"
"github.com/Twigpine/zero/internal/tools"
)
// The plan tool's parent grant was declared, documented and validated against —
// and left nil by BOTH production call sites, which made the "a task may narrow
// the parent's grant, never widen it" rule inert. These tests drive
// registerSpecialistTools, the function both call sites go through, rather than
// the helper it uses: asserting the helper would have passed throughout the
// period the wiring was missing.
func registeredOrchestrateTool(t *testing.T, enabled, disabled []string) *specialist.OrchestrateTool {
t.Helper()
workspace := t.TempDir()
registry := newCoreRegistry(workspace)
runtime, err := registerSpecialistTools(registry, workspace, 0, enabled, disabled, nil, orchestrateWiring{
Gate: &specialist.PostureGate{},
})
if err != nil {
t.Fatalf("registerSpecialistTools: %v", err)
}
t.Cleanup(func() { closeSpecialistRuntime(nil, runtime) })
registered, found := registry.Get(specialist.OrchestrateToolName)
if !found {
t.Fatal("orchestrate was not registered")
}
tool, ok := registered.(*specialist.OrchestrateTool)
if !ok {
t.Fatalf("orchestrate is %T, not *specialist.OrchestrateTool", registered)
}
return tool
}
// An unfiltered run grants every read-only tool a plan task may hold. The
// failure this pins is a grant of length zero, which is what both call sites
// produced.
func TestRegisteredOrchestrateToolCarriesTheRunsGrant(t *testing.T) {
tool := registeredOrchestrateTool(t, nil, nil)
if len(tool.ParentTools) == 0 {
t.Fatal("the registered orchestrate tool holds no parent grant, so the narrowing rule cannot fire")
}
// The grant is every GRANTABLE name this registry holds — read-only plus the
// write tools a task may name — intersected with what the registry actually
// has. Comparing against the full list would fail for a registry missing one
// of them, so it is asserted as a subset with the read-only half required.
grantable := map[string]bool{}
for _, name := range specialist.PlanGrantableToolNames() {
grantable[name] = true
}
for _, name := range tool.ParentTools {
if !grantable[name] {
t.Fatalf("grant contains %q, which a plan task may never hold", name)
}
}
held := map[string]bool{}
for _, name := range tool.ParentTools {
held[name] = true
}
for _, name := range specialist.PlanReadOnlyToolNames() {
if _, found := newCoreRegistry(t.TempDir()).Get(name); found && !held[name] {
t.Fatalf("the unfiltered grant is missing read-only tool %q", name)
}
}
}
// --enabled-tools narrows the grant. This is the reproduction from the audit,
// at the registration boundary: a parent holding only grep must not hand a plan
// task read_file.
func TestRegisteredOrchestrateGrantHonoursEnabledTools(t *testing.T) {
tool := registeredOrchestrateTool(t, []string{"grep", specialist.OrchestrateToolName}, nil)
if strings.Join(tool.ParentTools, ",") != "grep" {
t.Fatalf("grant = %v, want exactly [grep]: a plan task must not hold what --enabled-tools denied the run", tool.ParentTools)
}
}
// --disabled-tools narrows it too, through the same filter gate the agent loop
// uses, so the two cannot disagree about what the run holds.
func TestRegisteredOrchestrateGrantHonoursDisabledTools(t *testing.T) {
tool := registeredOrchestrateTool(t, nil, []string{"read_file", "glob"})
for _, name := range tool.ParentTools {
if name == "read_file" || name == "glob" {
t.Fatalf("grant %v still contains a tool --disabled-tools removed from the run", tool.ParentTools)
}
}
if len(tool.ParentTools) == 0 {
t.Fatal("grant collapsed to empty; only the two disabled tools should have been removed")
}
}
// A run holding no read-only tools grants nothing. The grant must be EMPTY
// rather than falling back to a default set — an empty grant is refused
// downstream, and the bug was that it expanded instead.
func TestRegisteredOrchestrateGrantIsEmptyWhenTheRunHoldsNoReadTools(t *testing.T) {
tool := registeredOrchestrateTool(t, []string{specialist.OrchestrateToolName}, nil)
if len(tool.ParentTools) != 0 {
t.Fatalf("grant = %v, want empty: this run holds no read-only tools at all", tool.ParentTools)
}
}
// The candidate set comes from specialist's exported list, not a second copy
// maintained here. Two duplicated lists drift (invariant 5).
func TestPlanParentToolsNeverExceedsWhatAPlanMayHold(t *testing.T) {
// The bound is PlanGrantableToolNames, not PlanReadOnlyToolNames, and the
// difference is the point of the write-task arc: a plan task inherits only
// read-only tools, and may NAME one of a short write allow-list. The grant
// has to be able to deliver a named write tool or the task would validate
// and then run with less than it asked for.
allowed := map[string]bool{}
for _, name := range specialist.PlanGrantableToolNames() {
allowed[name] = true
}
for _, name := range planParentTools(newCoreRegistry(t.TempDir()), nil, nil) {
if !allowed[name] {
t.Fatalf("grant contains %q, which is not a tool a plan task may ever hold", name)
}
}
}
// The depth the plan tool measures must be the RUN's depth, not a constant.
// Left unset it was always 0, so the admission headroom check and the
// executor's own nesting check could never fire — an inert guard that reads
// like a live one.
func TestRegisteredOrchestrateToolCarriesTheRunsDepth(t *testing.T) {
workspace := t.TempDir()
registry := newCoreRegistry(workspace)
runtime, err := registerSpecialistTools(registry, workspace, 0, nil, nil, nil, orchestrateWiring{
Gate: &specialist.PostureGate{},
PlanContext: specialist.PlanTaskContext{Cwd: workspace, Depth: 3},
})
if err != nil {
t.Fatalf("registerSpecialistTools: %v", err)
}
t.Cleanup(func() { closeSpecialistRuntime(nil, runtime) })
registered, _ := registry.Get(specialist.OrchestrateToolName)
tool, ok := registered.(*specialist.OrchestrateTool)
if !ok {
t.Fatalf("orchestrate is %T", registered)
}
if tool.Depth != 3 {
t.Fatalf("Depth = %d, want the run's depth 3: an always-zero depth makes the headroom check inert", tool.Depth)
}
}
// The plan recorder is a REQUIRED parameter, not a wiring field. As a field the
// TUI simply never set it, so a plan run there recorded none of its five
// lifecycle events — finding 9, and the same omission the parent grant
// suffered. Making it positional turns forgetting it into a compile error;
// this test pins that a supplied recorder actually reaches the tool.
func TestRegisteredOrchestrateToolCarriesTheSuppliedRecorder(t *testing.T) {
workspace := t.TempDir()
registry := newCoreRegistry(workspace)
recorder := &countingPlanRecorder{}
runtime, err := registerSpecialistTools(registry, workspace, 0, nil, nil, recorder, orchestrateWiring{
Gate: &specialist.PostureGate{},
})
if err != nil {
t.Fatalf("registerSpecialistTools: %v", err)
}
t.Cleanup(func() { closeSpecialistRuntime(nil, runtime) })
registered, _ := registry.Get(specialist.OrchestrateToolName)
tool, ok := registered.(*specialist.OrchestrateTool)
if !ok {
t.Fatalf("orchestrate is %T", registered)
}
if tool.Recorder != recorder {
t.Fatal("the supplied recorder did not reach the tool, so plan events go nowhere")
}
}
type countingPlanRecorder struct{ dispatched int }
func (r *countingPlanRecorder) TaskDispatched(specialist.Task) { r.dispatched++ }
func (r *countingPlanRecorder) TaskCompleted(specialist.TaskResult) {}
func (r *countingPlanRecorder) TaskFailed(specialist.TaskResult) {}
// The plan-size tier must survive registerSpecialistTools. Both call sites pass
// it through this function, and a tier read from config but dropped here would
// leave every run on the default ceiling while the config file said otherwise —
// the same shape as the grant defect above, one layer along.
func TestRegisteredOrchestrateToolCarriesTheConfiguredTier(t *testing.T) {
workspace := t.TempDir()
registry := newCoreRegistry(workspace)
runtime, err := registerSpecialistTools(registry, workspace, 0, nil, nil, nil, orchestrateWiring{
Gate: &specialist.PostureGate{},
Size: config.PlanSizeSmall,
})
if err != nil {
t.Fatalf("registerSpecialistTools: %v", err)
}
t.Cleanup(func() { closeSpecialistRuntime(nil, runtime) })
registered, _ := registry.Get(specialist.OrchestrateToolName)
tool, ok := registered.(*specialist.OrchestrateTool)
if !ok {
t.Fatalf("orchestrate is %T", registered)
}
if tool.Size != config.PlanSizeSmall {
t.Fatalf("registered tier = %q; want %q — the wiring dropped it", tool.Size, config.PlanSizeSmall)
}
}
// EVERY TOOL A PLAN TASK MAY HOLD MUST BE CLASSIFIED READ-ONLY BY CAPABILITY,
// not merely by its safety string.
//
// This is the invariant that broke, and it broke silently. update_plan declared
// readOnlySafety and set no capabilities, so CapabilitiesOf reported
// EffectUnknown — the fail-closed default — while its safety said read-only.
// runCanMutate reads the CAPABILITY, so one unclassified tool in an otherwise
// read-only grant made the whole child look mutating, and ~6,500 characters of
// confirmation policy it can never need rode along on every task of every plan.
//
// Asserted against the REAL registry, because the defect was that two
// descriptions of one tool disagreed — checking the list against itself would
// have found nothing.
func TestEveryPlanGrantToolIsReadOnlyByCapability(t *testing.T) {
registry := newCoreRegistry(t.TempDir())
checked := 0
for _, name := range specialist.PlanReadOnlyToolNames() {
tool, found := registry.Get(name)
if !found {
// Not every read-only name is registered in a bare core registry;
// what matters is that the ones that are, classify correctly.
continue
}
checked++
if effect := tools.CapabilitiesOf(tool).Effect; effect != tools.EffectReadOnly {
t.Errorf("plan grant holds %q whose capability is %v, not EffectReadOnly; "+
"a plan child would carry the confirmation policy it can never need", name, effect)
}
}
if checked == 0 {
t.Fatal("no plan-grant tool was found in the registry; this test checked nothing")
}
}
// BOTH SURFACES SUPPLY AN ISOLATOR. A write-capable plan is refused where none
// exists, so a surface that forgot to wire one would refuse every write plan
// with "this run cannot isolate" — correct, and useless.
func TestBothSurfacesWireAnIsolator(t *testing.T) {
if newPlanIsolator("") != nil {
t.Fatal("an empty workspace root produced an isolator")
}
if newPlanIsolator(t.TempDir()) == nil {
t.Fatal("a real workspace root produced no isolator")
}
workspace := t.TempDir()
registry := newCoreRegistry(workspace)
runtime, err := registerSpecialistTools(registry, workspace, 0, nil, nil, nil, orchestrateWiring{
Gate: &specialist.PostureGate{},
Isolate: newPlanIsolator(workspace),
})
if err != nil {
t.Fatalf("registerSpecialistTools: %v", err)
}
t.Cleanup(func() { closeSpecialistRuntime(nil, runtime) })
registered, _ := registry.Get(specialist.OrchestrateToolName)
tool, ok := registered.(*specialist.OrchestrateTool)
if !ok {
t.Fatalf("orchestrate is %T", registered)
}
if tool.Isolate == nil {
t.Fatal("the wiring dropped the isolator; every write-capable plan would be refused")
}
}
// The worktree NAME is derived, not taken. A plan name is model-supplied and
// reaches a filesystem path, so what comes out has to be a name — the allow-list
// in worktrees.Prepare is the guarantee, and this is what feeds it something it
// will accept rather than something it will reject.
func TestAPlanNameBecomesAUsableWorktreeName(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"sweep", "plan-sweep"},
{"pre release", "plan-prerelease"},
{"../../etc/passwd", "plan-etcpasswd"},
{"", "plan"},
{"!!!", "plan"},
{"a/b\\c;d", "plan-abcd"},
} {
if got := planWorktreeName(tc.in); got != tc.want {
t.Errorf("planWorktreeName(%q) = %q, want %q", tc.in, got, tc.want)
}
}
// Long names are bounded, so a plan cannot produce a path component the
// filesystem refuses.
if got := planWorktreeName(strings.Repeat("x", 300)); len(got) > 64 {
t.Fatalf("a long plan name produced a %d-character worktree name", len(got))
}
}
// THE HOOK MUST ACTUALLY BE WIRED. DiscoverModels is consulted only when a plan
// sets auto_assign, so a nil left in the wiring compiles, ships, and refuses
// every such plan with "not available in this run" — the feature present in the
// schema and reachable from nowhere. That is this branch's most repeated defect,
// and it is checked here rather than trusted.
func TestOrchestrateGetsAModelDiscoverer(t *testing.T) {
registry := tools.NewRegistry()
gate := &specialist.PostureGate{}
gate.Set(true)
workspace := t.TempDir()
if _, err := registerSpecialistTools(registry, workspace, 0, nil, nil, nil, orchestrateWiring{
Gate: gate,
PlanContext: specialist.PlanTaskContext{Cwd: workspace},
DiscoverModels: planModelDiscoverer(t.TempDir(), config.ProviderProfile{Name: "test", Model: "gpt-4.1"}),
}); err != nil {
t.Fatalf("register: %v", err)
}
tool, ok := registry.Get(specialist.OrchestrateToolName)
if !ok {
t.Fatal("the orchestrate tool was not registered")
}
orchestrate, ok := tool.(*specialist.OrchestrateTool)
if !ok {
t.Fatalf("registered tool is %T", tool)
}
if orchestrate.DiscoverModels == nil {
t.Error("DiscoverModels is nil on the registered tool; auto_assign would refuse every plan")
}
}
// The user's model pins and exclusions must reach the tool. Configured and not
// wired, they are a config key that silently does nothing — the same shape as
// DiscoverModels above, which shipped nil.
func TestOrchestrateGetsTheConfiguredModelPreferences(t *testing.T) {
registry := tools.NewRegistry()
gate := &specialist.PostureGate{}
gate.Set(true)
workspace := t.TempDir()
if _, err := registerSpecialistTools(registry, workspace, 0, nil, nil, nil, orchestrateWiring{
Gate: gate,
PlanContext: specialist.PlanTaskContext{Cwd: workspace},
ModelPrefs: planModelPreferences(config.PlanModelsConfig{
Verify: "deepseek-v4-pro",
Exclude: []string{"grok-build-0.1"},
}),
}); err != nil {
t.Fatalf("register: %v", err)
}
tool, _ := registry.Get(specialist.OrchestrateToolName)
orchestrate, ok := tool.(*specialist.OrchestrateTool)
if !ok {
t.Fatalf("registered tool is %T", tool)
}
if orchestrate.ModelPrefs.Verify != "deepseek-v4-pro" {
t.Errorf("the verify pin did not reach the tool: %+v", orchestrate.ModelPrefs)
}
if len(orchestrate.ModelPrefs.Exclude) != 1 {
t.Errorf("the exclusion list did not reach the tool: %+v", orchestrate.ModelPrefs)
}
}
// And the adapter really produces the shape specialist chooses between, rather
// than an empty struct that would make every tier unassignable.
func TestTheModelDiscovererCarriesWhatAPlanChoosesBetween(t *testing.T) {
discover := planModelDiscoverer(t.TempDir(), config.ProviderProfile{Name: "test", Model: "gpt-4.1"})
if discover == nil {
t.Fatal("adapter returned nil")
}
// The network call is expected to fail in a test environment; what matters is
// that a failure is REPORTED rather than silently yielding no models, which
// would make auto_assign a no-op nobody could diagnose.
models, err := discover(context.Background())
if err == nil && len(models) > 0 {
for _, model := range models {
if strings.TrimSpace(model.ID) == "" {
t.Errorf("a discovered model arrived with no id: %+v", model)
}
}
}
}
+162
View File
@@ -0,0 +1,162 @@
package cli
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/Twigpine/zero/internal/specialist"
"github.com/Twigpine/zero/internal/worktrees"
)
// The isolator: a git worktree for a plan that may write.
//
// It reuses internal/worktrees, the same package `zero exec --worktree` already
// uses, rather than shelling out to git again. That matters beyond tidiness —
// worktrees.Prepare already handles name validation, the base directory, repo
// discovery from a subdirectory, and the difference between a checkout and a
// worktree. A second implementation would eventually disagree with the first
// about which of those a plan gets.
//
// NOT DELETED ON RELEASE. A plan that wrote something produced work the user has
// not seen yet, and removing the tree would remove the only copy of it. Release
// therefore keeps the tree and drops only the lease, exactly as
// `zero exec --worktree` does when it exits: the lease protects a tree while a
// run is USING it, and worktrees.Clean never removes an unlocked tree that holds
// uncommitted work (and preserves an unreachable HEAD before removing a clean
// one), so the retained output survives without a lock nobody can clear.
//
// ONE TREE PER EXECUTION. The name carries a per-run suffix, so a second run of
// the same plan neither collides with the first run's tree nor starts on top of
// output the user has not reviewed yet. With a fixed name and a PID-less lease
// that Release never dropped, every rerun of a write-capable plan was refused as
// "locked by another active run" until someone released it by hand.
// planWorktreeName derives a worktree name from a plan name.
//
// worktrees.Prepare enforces its own allow-list on the result, so this only has
// to produce a candidate; anything it cannot clean is handed over as a prefix
// and Prepare supplies the rest. Deliberately NOT a sanitiser that promises
// safety — the guarantee lives in one place, where it is tested.
func planWorktreeName(planName string) string {
cleaned := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
return r
case r == '-', r == '_':
return r
default:
return -1
}
}, planName)
cleaned = strings.TrimSpace(cleaned)
if cleaned == "" {
return "plan"
}
if len(cleaned) > 40 {
cleaned = cleaned[:40]
}
return "plan-" + cleaned
}
// newPlanIsolator returns an isolator rooted at the run's workspace, or nil when
// this run cannot isolate at all.
//
// nil is the meaningful answer, not an error: specialist refuses a plan that
// requires isolation when the isolator is nil, with a reason. Returning a
// non-nil isolator that always fails would move the same refusal later and make
// it read like a malfunction.
func newPlanIsolator(workspaceRoot string) specialist.PlanIsolator {
return newPlanIsolatorWith(workspaceRoot, planIsolatorDeps{})
}
// planIsolatorDeps are the seams a test needs to drive the isolator against a
// real repository without touching the user's state directory or the clock.
type planIsolatorDeps struct {
baseDir string
now func() time.Time
suffix func() string
}
func newPlanIsolatorWith(workspaceRoot string, deps planIsolatorDeps) specialist.PlanIsolator {
if strings.TrimSpace(workspaceRoot) == "" {
return nil
}
now := deps.now
if now == nil {
now = time.Now
}
suffix := deps.suffix
if suffix == nil {
suffix = randomPlanWorktreeSuffix
}
return func(ctx context.Context, planName string) (specialist.PlanWorkspace, error) {
prepared, err := worktrees.Prepare(ctx, worktrees.Options{
Cwd: workspaceRoot,
Name: planExecutionWorktreeName(planName, now(), suffix()),
BaseDir: deps.baseDir,
// The lease is bound to THIS process, like `zero exec --worktree`:
// if Zero dies before Release runs, Clean can expire it instead of
// skipping the tree forever.
LeasePID: os.Getpid(),
})
if err != nil {
return specialist.PlanWorkspace{}, err
}
if strings.TrimSpace(prepared.Path) == "" {
// Defence in depth against a Prepare that reports success with no
// path: specialist refuses an un-isolated workspace, and this makes
// sure it sees one rather than an empty string it might read as the
// parent's own directory.
return specialist.PlanWorkspace{}, fmt.Errorf("prepared worktree has no path")
}
// Only the lease this call took is ours to drop. A tree another live
// caller already held reports LockAcquired=false, and releasing it would
// expose that caller's workspace to Clean.
release := func() {}
if prepared.LockAcquired {
var once sync.Once
release = func() {
once.Do(func() {
_ = worktrees.Release(context.Background(), worktrees.Options{Cwd: workspaceRoot}, prepared.Path)
})
}
}
return specialist.PlanWorkspace{
Path: prepared.Path,
Isolated: true,
Describe: fmt.Sprintf("worktree %s at %s", prepared.Name, prepared.Path),
// Release drops the lease and KEEPS the tree: the plan's output is
// unreviewed work, and Clean leaves an unlocked tree with uncommitted
// changes alone.
Release: release,
// Abort drops the lease on a tree the plan never received. It is
// fresh and untouched, so leaving it unlocked lets Clean reclaim it.
Abort: release,
}, nil
}
}
// planExecutionWorktreeName is the per-execution worktree name: the plan-derived
// prefix plus a UTC timestamp and a random suffix, so two runs of one plan —
// even in the same second — never share a tree.
func planExecutionWorktreeName(planName string, at time.Time, suffix string) string {
name := planWorktreeName(planName) + "-" + at.UTC().Format("20060102-150405")
if suffix = strings.TrimSpace(suffix); suffix != "" {
name += "-" + suffix
}
return name
}
func randomPlanWorktreeSuffix() string {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return ""
}
return hex.EncodeToString(b[:])
}
+113
View File
@@ -0,0 +1,113 @@
package cli
import (
"context"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
// worktreeLockReason returns the lock reason git records for path, and whether
// it is locked at all.
func worktreeLockReason(t *testing.T, repo, path string) (string, bool) {
t.Helper()
out, err := exec.Command("git", "-C", repo, "worktree", "list", "--porcelain").CombinedOutput()
if err != nil {
t.Fatalf("git worktree list: %v\n%s", err, out)
}
want, _ := filepath.EvalSymlinks(path)
var current string
for _, line := range strings.Split(string(out), "\n") {
switch {
case strings.HasPrefix(line, "worktree "):
current = strings.TrimPrefix(line, "worktree ")
if resolved, err := filepath.EvalSymlinks(current); err == nil {
current = resolved
}
case current == want && (line == "locked" || strings.HasPrefix(line, "locked ")):
return strings.TrimSpace(strings.TrimPrefix(line, "locked")), true
}
}
return "", false
}
// A WRITE-CAPABLE PLAN MUST BE ABLE TO RUN TWICE. The isolator used a fixed
// worktree name per plan, a PID-less lease, and a Release that did nothing, so
// the second run of the same plan found its tree "locked by another active run"
// and was refused until someone ran `zero worktrees release` by hand.
//
// Driven against a real repository: each execution gets its own tree, the
// lease names this process while the plan runs, Release drops the lease, and
// the first run's unreviewed output is still on disk afterwards.
func TestAWriteCapablePlanCanRunAgainAfterItFinishes(t *testing.T) {
repo := initTempGitRepo(t)
base := t.TempDir()
clock := time.Date(2026, 11, 7, 12, 0, 0, 0, time.UTC)
suffixes := []string{"aaaa0001", "aaaa0002"}
isolate := newPlanIsolatorWith(repo, planIsolatorDeps{
baseDir: base,
// The SAME second for both runs, so only the suffix separates them.
now: func() time.Time { return clock },
suffix: func() string {
next := suffixes[0]
suffixes = suffixes[1:]
return next
},
})
first, err := isolate(context.Background(), "sweep")
if err != nil {
t.Fatalf("first run: %v", err)
}
reason, locked := worktreeLockReason(t, repo, first.Path)
if !locked {
t.Fatal("the tree a running plan uses must be leased so Clean cannot prune it")
}
if !strings.Contains(reason, "(pid "+strconv.Itoa(os.Getpid())+")") {
t.Fatalf("the lease must name this process so a crash is recoverable, got %q", reason)
}
output := filepath.Join(first.Path, "result.txt")
if err := os.WriteFile(output, []byte("unreviewed plan output\n"), 0o644); err != nil {
t.Fatal(err)
}
first.Release()
if _, locked := worktreeLockReason(t, repo, first.Path); locked {
t.Fatal("Release left the finished plan's tree locked; nothing would ever clear it")
}
if data, err := os.ReadFile(output); err != nil || string(data) != "unreviewed plan output\n" {
t.Fatalf("Release must keep the plan's output for review: %q, %v", data, err)
}
second, err := isolate(context.Background(), "sweep")
if err != nil {
t.Fatalf("a second run of the same plan was refused: %v", err)
}
defer second.Release()
if second.Path == first.Path {
t.Fatalf("the second run reused the first run's tree %s, on top of output nobody reviewed", first.Path)
}
if _, err := os.Stat(filepath.Join(second.Path, "result.txt")); !os.IsNotExist(err) {
t.Fatalf("the second run's tree carries the first run's output: %v", err)
}
if !strings.HasPrefix(filepath.Base(second.Path), "plan-sweep-") {
t.Fatalf("the tree name no longer says which plan made it: %s", second.Path)
}
}
func TestAPlanExecutionWorktreeNameIsAcceptedAndBounded(t *testing.T) {
at := time.Date(2026, 11, 7, 12, 0, 0, 0, time.UTC)
got := planExecutionWorktreeName(strings.Repeat("x", 300), at, "deadbeef")
// worktrees.Prepare's allow-list (worktreeNamePattern there).
if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$`).MatchString(got) {
t.Fatalf("worktrees.Prepare would reject %q", got)
}
if got := planExecutionWorktreeName("sweep", at, ""); got != "plan-sweep-20261107-120000" {
t.Fatalf("name without a suffix = %q", got)
}
}
+131
View File
@@ -0,0 +1,131 @@
package cli
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
)
// pointConfigHomeAtATempDir redirects config.UserConfigDir at a scratch directory
// and returns it.
//
// The variable that does the redirecting IS NOT THE SAME ON EVERY PLATFORM:
// os.UserConfigDir reads %AppData% on Windows and $XDG_CONFIG_HOME elsewhere, and
// config.UserConfigDir lets XDG win on macOS too. Setting only XDG_CONFIG_HOME
// left Windows resolving against the real %AppData%, where this file was never
// written — so every case below silently exercised the resolve-failed fallback
// and the switch-following test could not fail on Windows for the right reason.
// Mirrors internal/config/paths_test.go's setUserConfigRoot.
func pointConfigHomeAtATempDir(t *testing.T) string {
t.Helper()
root := t.TempDir()
switch runtime.GOOS {
case "windows":
t.Setenv("APPDATA", root)
default:
t.Setenv("XDG_CONFIG_HOME", root)
}
base, err := config.UserConfigDir()
if err != nil {
t.Fatalf("resolve user config dir: %v", err)
}
// The redirect must have LANDED. Without this, setting the wrong variable
// for the platform resolves back to the developer's real config directory:
// the helper writes a provider file into it and the tests pass for the
// wrong reason — which is exactly how this went unnoticed on Windows.
if !strings.HasPrefix(base, root) {
t.Fatalf("config home resolved to %q, outside the temp root %q: this test would read and write the real user config", base, root)
}
return base
}
func writeTwoProviderConfig(t *testing.T, active string) {
t.Helper()
dir := filepath.Join(pointConfigHomeAtATempDir(t), "zero")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
// The provider shape a real config uses — provider_kind + apiFormat matter:
// without them "xai" is read as an OpenAI profile and refused for not using
// the official base URL.
body := `{
"activeProvider": "` + active + `",
"providers": [
{"name": "ollama-cloud", "provider_kind": "openai-compatible", "catalogID": "ollama-cloud",
"baseURL": "https://ollama.com/v1", "apiFormat": "chat-completions",
"model": "glm-5.2", "apiKey": "k1"},
{"name": "xai", "provider_kind": "openai-compatible", "catalogID": "xai",
"baseURL": "https://api.x.ai/v1", "apiFormat": "chat-completions",
"model": "grok-4.5", "apiKey": "k2"}
]
}`
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
// DISCOVERY MUST FOLLOW AN IN-SESSION PROVIDER SWITCH, because child execution
// already does and the two must never describe different providers.
//
// The orchestrate tool is wired once at startup. The TUI's /model picker can
// switch provider afterwards; switchProviderModel re-points the parent client and
// exports ZERO_PROVIDER so spawned children follow. Before this, nothing
// re-pointed model discovery — a session on xai/grok-4.5 had its plan assigned
// from a nineteen-model Ollama list and the provider rejected every task.
func TestPlanDiscoveryFollowsAnInSessionProviderSwitch(t *testing.T) {
writeTwoProviderConfig(t, "ollama-cloud")
captured := config.ProviderProfile{Name: "ollama-cloud", BaseURL: "https://ollama.com/v1", Model: "glm-5.2"}
// What switchProviderModel does when the user picks a model from another
// saved provider.
t.Setenv(config.ActiveProviderEnv, "xai")
got := livePlanProvider(t.TempDir(), captured)
if got.Name != "xai" {
t.Fatalf("discovery stayed on the launch-time provider %q after a switch to xai", got.Name)
}
if got.BaseURL != "https://api.x.ai/v1" {
t.Errorf("the switched profile carries the wrong endpoint: %q", got.BaseURL)
}
}
// THE CAPTURED PROFILE WINS WHEN NOTHING SWITCHED, and this is what keeps
// headless runs correct. --provider, --base-url and an inline --api-key are
// applied by the caller on top of Resolve, so re-resolving would silently discard
// them and probe the wrong endpoint with the wrong key.
func TestPlanDiscoveryKeepsFlagOverridesWhenTheProviderDidNotChange(t *testing.T) {
writeTwoProviderConfig(t, "xai")
// A profile that exists nowhere in the config file: exactly what a
// --base-url / --api-key override produces.
captured := config.ProviderProfile{
Name: "xai",
BaseURL: "https://gateway.internal/v1",
Model: "grok-4.5",
APIKey: "flag-only-key",
}
// Empty reads as unset (applyEnv TrimSpaces it) and t.Setenv restores the
// real value afterwards — os.Unsetenv here would leak into sibling tests.
t.Setenv(config.ActiveProviderEnv, "")
got := livePlanProvider(t.TempDir(), captured)
if got.BaseURL != "https://gateway.internal/v1" || got.APIKey != "flag-only-key" {
t.Fatalf("re-resolution discarded the caller's overrides: %+v", got)
}
}
// An unreadable or absent config must not blank the profile discovery probes
// with. Falling back to the captured one leaves behaviour exactly as it was.
func TestPlanDiscoveryFallsBackToTheCapturedProfileWhenResolutionFails(t *testing.T) {
// A config home with no zero/config.json in it: resolution finds nothing.
pointConfigHomeAtATempDir(t)
t.Setenv(config.ActiveProviderEnv, "")
captured := config.ProviderProfile{Name: "xai", BaseURL: "https://api.x.ai/v1", Model: "grok-4.5"}
if got := livePlanProvider(t.TempDir(), captured); got.Name != "xai" || got.BaseURL != captured.BaseURL {
t.Fatalf("a failed resolve must leave the captured profile intact, got %+v", got)
}
}
@@ -0,0 +1,83 @@
package cli
import (
"testing"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
)
// A PLAN MUST NOT BE OFFERED A MODEL ITS OWN PROVIDER WILL REFUSE.
//
// providers.New applies providermodelcatalog's per-provider allow-list at client
// creation (validateModelAllowedForProvider), and `providers models` applies it
// before printing. The plan discoverer did not, so on a scoped provider the
// router was handed candidates that could never run: assignment succeeded, the
// panel showed the model, and the task died at dispatch with "provider X does
// not allow model Y" — the same invisible-until-dispatch failure as being
// assigned from another provider's list altogether.
func TestPlanDiscoveryDropsModelsTheProviderWillRefuse(t *testing.T) {
// opencode-go-anthropic-compatible is scoped to Qwen and MiniMax families.
found := []providermodeldiscovery.Model{
{ID: "qwen3.5-coder", ToolCall: true},
{ID: "claude-sonnet-5", ToolCall: true},
{ID: "minimax-m2", ToolCall: true},
{ID: "gpt-5", ToolCall: true},
}
got := planModelsFromDiscovered("opencode-go-anthropic-compatible", found)
ids := map[string]bool{}
for _, model := range got {
ids[model.ID] = true
}
for _, refused := range []string{"claude-sonnet-5", "gpt-5"} {
if ids[refused] {
t.Errorf("%q reached the plan; providers.New refuses it for this provider, so every task assigned it dies at dispatch", refused)
}
}
for _, allowed := range []string{"qwen3.5-coder", "minimax-m2"} {
if !ids[allowed] {
t.Errorf("%q was dropped, but this provider serves it", allowed)
}
}
}
// An UNSCOPED provider keeps its whole list. The allow-list defaults to
// permitting everything, and a filter that quietly narrowed an ordinary provider
// would remove models a plan is entitled to use.
func TestPlanDiscoveryKeepsEveryModelOnAnUnscopedProvider(t *testing.T) {
found := []providermodeldiscovery.Model{
{ID: "grok-4.5", ToolCall: true},
{ID: "grok-code-fast", ToolCall: true},
{ID: "claude-sonnet-5", ToolCall: true},
}
for _, catalogID := range []string{"xai", "", " "} {
got := planModelsFromDiscovered(catalogID, found)
if len(got) != len(found) {
t.Errorf("catalogID %q narrowed an unscoped provider from %d models to %d", catalogID, len(found), len(got))
}
}
}
// The translation must carry every field the plan chooses between — a model that
// arrives with its costs zeroed is routed as if it were free.
func TestPlanDiscoveryCarriesTheFieldsRoutingReads(t *testing.T) {
got := planModelsFromDiscovered("xai", []providermodeldiscovery.Model{{
ID: "grok-4.5", Description: "frontier", ToolCall: true, Reasoning: true,
InputCost: 3, OutputCost: 15, OutputModalities: []string{"text"},
}})
if len(got) != 1 {
t.Fatalf("got %d models, want 1", len(got))
}
model := got[0]
switch {
case model.ID != "grok-4.5", model.Description != "frontier":
t.Errorf("identity lost: %+v", model)
case !model.ToolCall, !model.Reasoning:
t.Errorf("capabilities lost, so routing cannot tell this model apart: %+v", model)
case model.InputCost != 3, model.OutputCost != 15:
t.Errorf("costs lost, so the cheapest-model rule ranks this as free: %+v", model)
case len(model.OutputModalities) != 1:
t.Errorf("modalities lost: %+v", model)
}
}
@@ -0,0 +1,63 @@
package cli
import (
"reflect"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/specialist"
)
// EVERY CONFIGURED PREFERENCE MUST SURVIVE THE TRANSLATION, and this is checked
// by reflection rather than field by field on purpose.
//
// planModelPreferences is a hand-written copy between two structs that are
// deliberately kept apart, so specialist need not import config. Hand-written
// copies rot silently: a field gets added to both sides and to nothing in the
// middle, and the symptom is a setting the user wrote in their config having no
// effect — with nothing logged, nothing failing, and no way to tell it from the
// feature simply not working. RouterGuidance was added this way; the next one
// will be too.
//
// Naming the fields here would only pin today's set. Walking them means a new
// field is caught the moment it exists.
func TestEveryPlanModelPreferenceSurvivesTheCarryIntoSpecialist(t *testing.T) {
source := config.PlanModelsConfig{
Scan: "scan-model",
Implement: "implement-model",
Verify: "verify-model",
Router: "router-model",
RouterGuidance: "prefer kimi for judgement",
AutoAssign: true,
Exclude: []string{"never-this"},
MinSize: 7,
}
carried := planModelPreferences(source)
from := reflect.ValueOf(source)
to := reflect.ValueOf(carried)
toType := to.Type()
for i := 0; i < from.NumField(); i++ {
name := from.Type().Field(i).Name
if _, ok := toType.FieldByName(name); !ok {
// A config field with no counterpart is a deliberate decision — say so
// here when you make one, so the next reader knows it was not an
// oversight. Today every field has one.
t.Errorf("config field %q has no counterpart in specialist.ModelPreferences; "+
"if that is intended, exempt it here with a reason", name)
continue
}
want := from.Field(i).Interface()
got := to.FieldByName(name).Interface()
if !reflect.DeepEqual(want, got) {
t.Errorf("%s was dropped or altered on the way into specialist: config had %#v, specialist got %#v",
name, want, got)
}
}
// Guard the guard: a zero source would make every comparison trivially pass,
// so prove the fixture actually set something.
if reflect.DeepEqual(carried, specialist.ModelPreferences{}) {
t.Fatal("the fixture carried nothing, so this test proves nothing")
}
}
+123
View File
@@ -0,0 +1,123 @@
package cli
import (
"context"
"strings"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/specialist"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// probeTimeout bounds one model's proof. Generous enough for a cold model to
// load and short enough that proving nineteen of them concurrently costs one
// pause rather than a stall — and a model too slow to answer this is not one a
// plan wants either.
const probeTimeout = 20 * time.Second
// planModelProber proves that a provider will actually RUN a model.
//
// DISCOVERY IS AN ADVERTISEMENT AND THIS IS THE RECEIPT. /v1/models reports what
// a provider is willing to name; only a real request reports what it will serve.
// Every model failure this feature has produced lived in that gap:
// grok-4.20-multi-agent-0309 lists like anything else and answers "Multi Agent
// requests are not allowed on chat completions"; a stale list from another
// provider lists perfectly and serves nothing.
//
// The request is deliberately the smallest real one: a single word, one token
// back. It proves the route end to end — credential, endpoint, model id,
// entitlement — which is exactly the set of things a list cannot prove.
func planModelProber(workspaceRoot string, profile config.ProviderProfile, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) specialist.ModelProber {
if newProvider == nil {
return nil
}
return func(ctx context.Context, modelID string) specialist.ModelProbeResult {
id := strings.TrimSpace(modelID)
if id == "" {
return specialist.ModelProbeResult{Verdict: specialist.ProbeUnknown}
}
// The LIVE profile, for the same reason discovery re-resolves it: a
// session that switched provider must be proved against the one it is
// actually on, not the one it started with.
target := livePlanProvider(workspaceRoot, profile)
target.Model = id
provider, err := newProvider(discoveryCredentialProfile(target))
if err != nil {
// Could not even build a client: a fact about this run, not about
// this model. Unknown keeps the model.
return specialist.ModelProbeResult{Verdict: specialist.ProbeUnknown, Reason: err.Error()}
}
probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
stream, err := provider.StreamCompletion(probeCtx, zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "ping"}},
})
if err != nil {
return specialist.ClassifyProbeError(err)
}
collected := zeroruntime.CollectStream(probeCtx, stream)
if collected.Error != "" {
// A refusal usually arrives HERE rather than from StreamCompletion:
// the request is accepted, the stream opens, and the provider's
// complaint about the model comes back as the first event. Reading
// only the call's own error is how "the model does not exist" was
// mistaken for a working model.
return specialist.ClassifyProbeError(errStub(collected.Error))
}
return specialist.ModelProbeResult{Verdict: specialist.ProbeServes}
}
}
// planModelProbeIdentity returns the non-secret routing identity that makes a
// probe verdict true. Model IDs overlap across providers, profiles and custom
// endpoints, so none of those dimensions may share a cache namespace.
func planModelProbeIdentity(workspaceRoot string, profile config.ProviderProfile) func() string {
return func() string {
live := livePlanProvider(workspaceRoot, profile)
return strings.Join([]string{
strings.TrimSpace(live.Name),
strings.TrimSpace(live.Provider),
strings.TrimSpace(string(live.ProviderKind)),
strings.TrimSpace(live.CatalogID),
strings.TrimSpace(live.BaseURL),
strings.TrimSpace(live.APIFormat),
strings.TrimSpace(live.APIKeyEnv),
}, "\x00")
}
}
// errStub carries a provider's message into the classifier, which takes an error
// because every other caller has one.
type errStub string
func (e errStub) Error() string { return string(e) }
// probeStatusWord names a verdict for a person. The three words are deliberately
// distinct: "unreachable" is a fact about the connection, not about the model,
// and reading it as a refusal is how a working model gets dropped on a flaky
// network.
func probeStatusWord(v specialist.ModelProbeVerdict) string {
switch v {
case specialist.ProbeServes:
return "serves"
case specialist.ProbeRefuses:
return "refuses"
default:
return "unreachable"
}
}
// firstProbeLine keeps a provider's complaint to one readable line; these arrive
// as multi-line JSON often enough to matter.
func firstProbeLine(s string) string {
if index := strings.IndexAny(s, "\r\n"); index >= 0 {
s = s[:index]
}
const limit = 120
if len(s) > limit {
s = s[:limit] + "…"
}
return strings.TrimSpace(s)
}
+85
View File
@@ -0,0 +1,85 @@
package cli
import (
"fmt"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/specialist"
)
// planSessionRecorder bridges plan lifecycle events onto the existing exec
// session recorder.
//
// IT PRESERVES THE BEST-EFFORT CONTRACT EXACTLY. execSessionRecorder.append
// returns nothing, latches its first error, and short-circuits every later
// call; warnIfRecordingFailed surfaces that once at run end. This type adds no
// error path of its own: every method returns nothing, so there is no way for a
// recording failure to reach ExecutePlan, and therefore no way for it to abort
// a plan mid-flight. A nil recorder is a no-op rather than a panic, for the
// same reason.
//
// The payloads are deliberately small and structured — ids, counts, durations,
// the terminal status and max_speedup — not whole task outputs. A plan's task
// outputs already reach the transcript through the tool result; duplicating
// them into the event log would multiply a large plan's on-disk size for no
// added recoverability.
type planSessionRecorder struct {
recorder *execSessionRecorder
// worst remembers the most serious non-completed plan status seen this run,
// so the PROCESS exit code can reflect it. Asserting the executor's status
// missed the tool's status, and asserting the tool's status would miss the
// exit code the same way — this is the third link in that chain.
worst specialist.PlanStatus
worstReason string
}
// Incomplete reports whether any plan this run did not fully complete, with a
// reason for the run-end message. A partial plan is work left undone, which is
// exactly what exitIncomplete exists for.
func (bridge *planSessionRecorder) Incomplete() (string, bool) {
if bridge == nil || bridge.worst == "" || bridge.worst == specialist.PlanCompleted {
return "", false
}
return bridge.worstReason, true
}
func (bridge *planSessionRecorder) append(eventType sessions.EventType, payload any) {
// Nil-safe at every level: a nil bridge or a nil inner recorder simply does
// not record. Recording must never be the thing that fails a run.
if bridge == nil || bridge.recorder == nil {
return
}
bridge.recorder.append(eventType, payload)
}
// The payloads come from specialist's own builders, shared with the TUI's
// recorder: resume is a reduction over these events and cannot be written
// against two shapes.
func (bridge *planSessionRecorder) PlanAdmitted(plan specialist.Plan) {
bridge.append(specialist.PlanAdmittedEvent(plan))
}
func (bridge *planSessionRecorder) TaskDispatched(task specialist.Task) {
bridge.append(specialist.TaskDispatchedEvent(task))
}
func (bridge *planSessionRecorder) TaskCompleted(result specialist.TaskResult) {
bridge.append(specialist.TaskCompletedEvent(result))
}
func (bridge *planSessionRecorder) TaskFailed(result specialist.TaskResult) {
bridge.append(specialist.TaskFailedEvent(result))
}
func (bridge *planSessionRecorder) PlanCompleted(plan specialist.Plan, report specialist.PlanReport) {
if bridge != nil && report.Status != specialist.PlanCompleted {
// PlanFailed outranks PlanPartial: if any plan failed outright, that is
// the run's story.
if bridge.worst != specialist.PlanFailed {
bridge.worst = report.Status
bridge.worstReason = fmt.Sprintf("plan %q ended %s: %d succeeded, %d failed, %d skipped",
plan.Name(), report.Status, report.Succeeded, report.Failed, report.Skipped)
}
}
bridge.append(specialist.PlanCompletedEvent(plan, report))
}
+134
View File
@@ -0,0 +1,134 @@
package cli
import (
"encoding/json"
"testing"
"time"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/specialist"
)
// planEventPayloads runs the recorder against a REAL session store and returns
// the payload of each plan/task event, keyed by event type. Reading the events
// back is the point: asserting the map the recorder builds would not prove they
// reached the log a user actually opens.
func planEventPayloads(t *testing.T, record func(*planSessionRecorder)) map[sessions.EventType]map[string]any {
t.Helper()
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(sessions.CreateInput{Cwd: t.TempDir()})
if err != nil {
t.Fatalf("create session: %v", err)
}
inner := execSessionRecorder{prepared: sessions.PreparedExec{
Mode: sessions.ModeNew,
Session: session,
Store: store,
}}
bridge := &planSessionRecorder{recorder: &inner}
record(bridge)
if inner.err != nil {
t.Fatalf("recording failed: %v", inner.err)
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("read events: %v", err)
}
payloads := map[sessions.EventType]map[string]any{}
for _, event := range events {
raw, err := json.Marshal(event.Payload)
if err != nil {
t.Fatalf("marshal %s payload: %v", event.Type, err)
}
decoded := map[string]any{}
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("decode %s payload: %v", event.Type, err)
}
payloads[event.Type] = decoded
}
return payloads
}
// A FAILED task must be as drillable as a successful one.
//
// Executor.Run carries the child session id even on a post-start failure,
// specifically so a failed child can still be opened. Recording it only on
// task_completed meant the one task a user needs to investigate was the one
// task the event log could not point them at.
func TestTaskFailedRecordsTheChildSessionAndItsSpend(t *testing.T) {
payloads := planEventPayloads(t, func(bridge *planSessionRecorder) {
bridge.TaskFailed(specialist.TaskResult{
ID: "b",
Outcome: specialist.TaskFailed,
Err: "Subagent failed (exit 3)",
Duration: 1500 * time.Millisecond,
SessionID: "specialist_0fd7e5a5b5e5c31063516119",
Tokens: 1500,
})
})
failed, ok := payloads[sessions.EventTaskFailed]
if !ok {
t.Fatal("no task_failed event was recorded")
}
if failed["session_id"] != "specialist_0fd7e5a5b5e5c31063516119" {
t.Fatalf("the failed child's session id was dropped: %v", failed["session_id"])
}
if failed["tokens"] != float64(1500) {
t.Fatalf("the failed task's spend was dropped: %v", failed["tokens"])
}
}
// The two task events are compared against EACH OTHER rather than each against
// its own expectation. That is how the asymmetry survived: task_completed had a
// test, task_failed had a test, and neither noticed the missing fields.
func TestTaskEventsCarryTheSameIdentityFields(t *testing.T) {
completed := planEventPayloads(t, func(bridge *planSessionRecorder) {
bridge.TaskCompleted(specialist.TaskResult{
ID: "a", Outcome: specialist.TaskSucceeded,
Duration: time.Second, SessionID: "specialist_aaa", Tokens: 150,
})
})[sessions.EventTaskCompleted]
failed := planEventPayloads(t, func(bridge *planSessionRecorder) {
bridge.TaskFailed(specialist.TaskResult{
ID: "b", Outcome: specialist.TaskFailed, Err: "boom",
Duration: time.Second, SessionID: "specialist_bbb", Tokens: 150,
})
})[sessions.EventTaskFailed]
for _, field := range []string{"id", "duration_ms", "session_id", "tokens"} {
if _, ok := completed[field]; !ok {
t.Fatalf("task_completed is missing %q", field)
}
if _, ok := failed[field]; !ok {
t.Fatalf("task_failed is missing %q, which task_completed records; "+
"a failure must be at least as recoverable as a success", field)
}
}
}
// A dependency or budget skip never started a child, so an empty session id is
// the honest value there — the field is present and empty, not absent.
func TestSkippedTaskRecordsAnEmptySessionRatherThanOmittingIt(t *testing.T) {
payloads := planEventPayloads(t, func(bridge *planSessionRecorder) {
bridge.TaskFailed(specialist.TaskResult{
ID: "d",
Outcome: specialist.TaskSkippedDependency,
Err: `skipped: dependency "b" did not succeed`,
})
})
failed := payloads[sessions.EventTaskFailed]
value, ok := failed["session_id"]
if !ok {
t.Fatal("session_id must be present even for a task that never started")
}
if value != "" {
t.Fatalf("a skipped task started no child, so its session id must be empty, got %v", value)
}
if failed["outcome"] != string(specialist.TaskSkippedDependency) {
t.Fatalf("the skip outcome must survive: %v", failed["outcome"])
}
}
+265 -1
View File
@@ -6,16 +6,22 @@ import (
"io"
"os"
"strings"
"sync"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/providercatalog"
"github.com/Twigpine/zero/internal/providermodelcatalog"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/providers"
"github.com/Twigpine/zero/internal/specialist"
)
type providerModelsOptions struct {
name string
json bool
// verify asks the provider to actually RUN each model, rather than trusting
// that listing it means it works.
verify bool
}
// runProvidersModels lists the models a saved provider actually serves by probing
@@ -66,6 +72,37 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps
models = filtered
}
// PROVED, not merely listed. /v1/models is an advertisement: every model that
// has broken a real plan appeared on it looking ordinary — one refused at
// chat completions, one belonging to a provider the session had switched away
// from, two that fail every task they touch. Only a real request settles it.
verdicts := map[string]specialist.ModelProbeResult{}
if options.verify {
workspaceRoot, wderr := deps.getwd()
if wderr != nil {
return writeAppError(stderr, "resolve working directory: "+wderr.Error(), exitCrash)
}
probe := planModelProber(workspaceRoot, profile, deps.newProvider)
if probe == nil {
return writeAppError(stderr, "this build cannot open provider connections, so nothing can be proved", exitCrash)
}
results := make([]specialist.ModelProbeResult, len(models))
var wait sync.WaitGroup
// CONCURRENT: nineteen models one after another is a minute of staring at
// nothing, and they are independent.
for index, model := range models {
wait.Add(1)
go func(index int, id string) {
defer wait.Done()
results[index] = probe(ctx, id)
}(index, model.ID)
}
wait.Wait()
for index, model := range models {
verdicts[model.ID] = results[index]
}
}
if options.json {
items := make([]map[string]any, 0, len(models))
for _, model := range models {
@@ -73,6 +110,12 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps
if description := strings.TrimSpace(model.Description); description != "" {
entry["description"] = description
}
if verdict, ok := verdicts[model.ID]; ok {
entry["status"] = probeStatusWord(verdict.Verdict)
if reason := strings.TrimSpace(verdict.Reason); reason != "" {
entry["details"] = reason
}
}
if len(model.ReasoningEfforts) > 0 {
entry["reasoning_efforts"] = append([]string{}, model.ReasoningEfforts...)
}
@@ -105,15 +148,62 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps
if _, err := fmt.Fprintf(stdout, "Provider models (%s)\n", name); err != nil {
return exitCrash
}
var serves, refuses, unreachable int
for _, model := range models {
line := strings.TrimSpace(model.ID)
if description := strings.TrimSpace(model.Description); description != "" {
// The size where the id names one, so the directory reads as light/medium/
// heavy at a glance — the classification routing uses, made visible. Absent
// for a cloud id that carries none.
if label := specialist.ModelSizeLabel(model.ID); label != "" {
line += " [" + label + "]"
}
if verdict, ok := verdicts[model.ID]; ok {
// The VERDICT LEADS, because it is the reason to run this at all: a
// reader scanning for what is wrong should not have to reach the end
// of each line to find out.
switch verdict.Verdict {
case specialist.ProbeServes:
serves++
line = "ok " + line
case specialist.ProbeRefuses:
refuses++
line = "REFUSES " + line
default:
unreachable++
line = "? " + line
}
if verdict.Verdict != specialist.ProbeServes {
if reason := strings.TrimSpace(verdict.Reason); reason != "" {
line += " — " + firstProbeLine(reason)
}
}
} else if description := strings.TrimSpace(model.Description); description != "" {
line += " — " + description
}
if _, err := fmt.Fprintln(stdout, line); err != nil {
return exitCrash
}
}
if options.verify {
if _, err := fmt.Fprintf(stdout, "\n%d serve, %d refuse, %d could not be reached.\n",
serves, refuses, unreachable); err != nil {
return exitCrash
}
if refuses > 0 {
// The point of --verify, stated plainly: these are the ids that would
// otherwise be found mid-plan, one dead task at a time.
if _, err := fmt.Fprintln(stdout, "A model that REFUSES is listed by this provider but will not run. "+
"Plans skip these automatically — nothing needs excluding by hand."); err != nil {
return exitCrash
}
}
if unreachable > 0 {
if _, err := fmt.Fprintln(stdout, "A model that could not be reached is left in place: "+
"that is a fact about the connection, not about the model."); err != nil {
return exitCrash
}
}
}
suffix := "s"
if len(models) == 1 {
suffix = ""
@@ -129,6 +219,73 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps
return exitSuccess
}
// livePlanProvider answers WHICH provider this plan's models should be listed
// from, at the moment the plan runs rather than at the moment the tool was built.
//
// THE CAPTURED PROFILE GOES STALE. The orchestrate tool is wired once, at
// startup, from the provider active then; the TUI's /model picker can switch
// provider mid-session, and switchProviderModel re-points the parent client and
// exports ZERO_PROVIDER so spawned children follow. Nothing re-pointed this. The
// result was a session running xai/grok-4.5 whose plan was handed nineteen Ollama
// model ids — the router assigned them, the children were dispatched with them,
// and the provider rejected every one.
//
// So it re-resolves through the SAME path a freshly spawned child takes:
// config plus ZERO_PROVIDER. Discovery and child execution now read one source of
// truth, which is stronger than keeping two in sync — they cannot drift apart
// again because there is no longer a second place to update.
//
// THE CAPTURED PROFILE WINS WHEN THE NAME MATCHES, and that is not an
// optimisation. It carries what re-resolution cannot see: --provider, --base-url
// and an inline --api-key are applied by the caller ON TOP of Resolve, so a
// headless run that never switches must keep exactly the profile it was given.
// Only a genuine change of provider — the one case that broke — takes the new one.
func livePlanProvider(workspaceRoot string, captured config.ProviderProfile) config.ProviderProfile {
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
return captured
}
// Nothing to re-resolve FROM, which is not the same as a failure and is why
// the error branches cannot catch it.
//
// With no config file on either path, no provider command and no
// ZERO_PROVIDER, config.Resolve still SUCCEEDS: it falls back to a built-in
// default provider. That default is not a decision anyone made. Treating it
// as a provider switch discards a captured profile carrying --provider,
// --base-url and an inline --api-key, and points discovery at the default
// endpoint with none of the caller's credentials — this function's own
// failure mode arriving from the other direction. A headless run on a machine
// with no config file, a fresh CI container being the obvious one, hits it
// every time.
//
// Read off the OPTIONS rather than by stat-ing again: DefaultResolveOptions
// already leaves a path empty when the file is absent, so this uses the
// answer it computed instead of asking a second time and risking a different
// one.
//
// ZERO_PROVIDER is deliberately NOT part of this test, 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 error
// branch below already returns the captured profile. Adding it here would
// read as a meaningful case while changing no outcome.
if strings.TrimSpace(options.UserConfigPath) == "" &&
strings.TrimSpace(options.ProjectConfigPath) == "" &&
strings.TrimSpace(options.ProviderCommand) == "" {
return captured
}
// Env deliberately left nil: config.Resolve then reads the live process
// environment, which is where switchProviderModel wrote ZERO_PROVIDER.
resolved, err := config.Resolve(options)
if err != nil {
return captured
}
if strings.TrimSpace(resolved.Provider.Name) == "" ||
strings.EqualFold(strings.TrimSpace(resolved.Provider.Name), strings.TrimSpace(captured.Name)) {
return captured
}
return resolved.Provider
}
// discoveryCredentialProfile resolves the profile's API key the same way the
// runtime does — inline, then the stored credential, then the configured env var —
// so a `providers models` probe authenticates exactly like a real request. Mirrors
@@ -167,6 +324,8 @@ func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error)
return options, true, nil
case arg == "--json":
options.json = true
case arg == "--verify":
options.verify = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
@@ -178,3 +337,108 @@ func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error)
}
return options, false, nil
}
// planModelDiscoverer adapts provider discovery for the orchestrate tool's
// auto_assign.
//
// THE ADAPTER LIVES HERE, not in specialist, and that is the point of the
// narrower type. internal/specialist runs on the child-execution path; importing
// config and providercatalog to describe a model would drag the whole provider
// stack in behind it. The surface that already holds a provider profile does the
// translation and hands over the four facts a plan actually chooses between.
func planModelDiscoverer(workspaceRoot string, profile config.ProviderProfile) specialist.ModelDiscoverer {
return func(ctx context.Context) ([]specialist.DiscoveredModel, error) {
live := livePlanProvider(workspaceRoot, profile)
found, err := defaultDiscoverProviderModels(ctx, discoveryCredentialProfile(live))
if err != nil {
return nil, err
}
// The LIVE profile's catalog id, not the captured one: discovery already
// follows an in-session provider switch, and filtering against the
// launch-time provider would reintroduce the disagreement
// livePlanProvider exists to remove.
return planModelsFromDiscovered(live.CatalogID, found), nil
}
}
// planModelsFromDiscovered narrows a provider's list to the models a plan may
// actually be assigned, and translates them into specialist's own type.
//
// FILTERED THROUGH THE SAME ALLOW-LIST THE CLIENT WILL APPLY.
// defaultDiscoverProviderModels deliberately returns a provider's whole list —
// right for `providers models`, which exists to show what is there, and which
// applies this same filter itself before printing. A plan does not display these,
// it RUNS them, and providers.New refuses a model outside its provider's scope
// (validateModelAllowedForProvider). So the unfiltered list handed the router
// candidates that could never be dispatched, and every task assigned one died at
// client creation — the same shape as assigning from the wrong provider's list
// entirely, and just as invisible until dispatch.
func planModelsFromDiscovered(catalogID string, found []providermodeldiscovery.Model) []specialist.DiscoveredModel {
catalogID = strings.TrimSpace(catalogID)
out := make([]specialist.DiscoveredModel, 0, len(found))
for _, model := range found {
if !providermodelcatalog.ModelIDAllowedForProvider(catalogID, model.ID) {
continue
}
out = append(out, specialist.DiscoveredModel{
ID: model.ID,
Description: model.Description,
ToolCall: model.ToolCall,
Reasoning: model.Reasoning,
InputCost: model.InputCost,
OutputCost: model.OutputCost,
OutputModalities: model.OutputModalities,
})
}
return out
}
// planModelPreferences carries the configured per-role pins and exclusions into
// specialist's own narrow type, so that package keeps its distance from config.
func planModelPreferences(cfg config.PlanModelsConfig) specialist.ModelPreferences {
return specialist.ModelPreferences{
Scan: cfg.Scan,
Implement: cfg.Implement,
Verify: cfg.Verify,
Exclude: cfg.Exclude,
Router: cfg.Router,
RouterGuidance: cfg.RouterGuidance,
AutoAssign: cfg.AutoAssign,
MinSize: cfg.MinSize,
TopModels: cfg.TopModels,
}
}
// planContextWindows reports the context window of the model a plan task will
// run on, 0 when unknown.
//
// FROM THE CATALOGUE, NOT FROM DISCOVERY. The window is static metadata about a
// model, so asking the provider for it over the network per task would pay a
// round trip for an answer that cannot change — and most of this catalogue's
// gateways do not report a window at all, in which case 0 is the honest answer
// and the plan keeps its fixed briefing caps.
//
// AN EMPTY MODEL ID MEANS "the model this run is using". A plan task that names
// no model inherits the parent's, and only this side of the seam knows what that
// is — internal/specialist deliberately does not.
func planContextWindows(profile config.ProviderProfile, sessionModel string) specialist.ContextWindowFunc {
var once sync.Once
windows := map[string]int{}
return func(modelID string) int {
once.Do(func() {
descriptor, err := providercatalog.Require(profile.CatalogID)
if err != nil {
return
}
for _, model := range providermodelcatalog.Models(descriptor) {
if model.ContextWindow > 0 {
windows[model.ID] = model.ContextWindow
}
}
})
if strings.TrimSpace(modelID) == "" {
modelID = sessionModel
}
return windows[strings.TrimSpace(modelID)]
}
}
+25
View File
@@ -42,6 +42,31 @@ func TestRunProvidersModelsListsDiscoveredModels(t *testing.T) {
}
}
// The directory shows each model's SIZE where the id names one, so a reader sees
// light/medium/heavy at a glance — the classification routing uses, made visible.
// A cloud id with no size gets no invented label.
func TestRunProvidersModelsShowsModelSize(t *testing.T) {
var stdout, stderr bytes.Buffer
deps := commandCenterDeps(t)
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{
{ID: "gpt-oss:20b"},
{ID: "team/cloud-model"}, // no size in the id
}, nil
}
if exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps); exitCode != exitSuccess {
t.Fatalf("exit = %d: %s", exitCode, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "gpt-oss:20b [20B]") {
t.Fatalf("the directory did not show the model size:\n%s", out)
}
if strings.Contains(out, "team/cloud-model [") {
t.Fatalf("a cloud model with no size got an invented size label:\n%s", out)
}
}
func TestRunProvidersModelsJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
+11
View File
@@ -576,6 +576,17 @@ func assertSandboxPolicyJSONGolden(t *testing.T, mustList string) string {
t.Setenv("XDG_CONFIG_HOME", filepath.Join(emptyHome, ".config"))
t.Setenv("CLOUDSDK_CONFIG", "")
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "")
// XDG_CONFIG_HOME as well, or HOME is not actually the answer to "where is
// the config directory". credentialDenyReadPathsForEnvironment reads
// XDG_CONFIG_HOME FIRST and only falls back to $HOME/.config, so on a host
// that sets it — every ubuntu GitHub runner does — redirecting HOME alone
// left configHome pointing at the real /home/runner/.config. Other packages
// in the same `go test ./...` write zero/config.json and zero/trust.json
// there, `go test` runs packages in parallel, and this golden then failed or
// passed depending on who won the race. macOS and Windows runners leave
// XDG_CONFIG_HOME unset, which is the whole reason it only ever went red on
// one platform.
t.Setenv("XDG_CONFIG_HOME", filepath.Join(emptyHome, ".config"))
t.Setenv("NPM_CONFIG_USERCONFIG", "")
t.Setenv("npm_config_userconfig", "")
t.Setenv("GH_CONFIG_DIR", "")
+50
View File
@@ -0,0 +1,50 @@
package cli
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/sandbox"
)
// THE TEST BINARY ANSWERS THE SANDBOX'S SELF-DISPATCH LIKE THE REAL BINARY.
// The sandbox launches os.Executable() with a hidden helper subcommand; under a
// test that runs this binary as the zero CLI, that is this binary. Unrouted, the
// "helper" ran the entire package suite instead, and the sandboxed command that
// launched it hung until killed.
//
// Each hidden subcommand, given no helper arguments, must come straight back
// with the helper's own usage failure — never a test run.
func TestTheTestBinaryRoutesSandboxSelfDispatch(t *testing.T) {
if os.Getenv("ZERO_TEST_SELF_DISPATCH_CHILD") == "1" {
// Reached only when routing is broken and the child is running the
// suite; skip so the failure is the parent's timeout, not a recursion.
t.Skip("self-dispatch child")
}
for _, subcommand := range []string{
sandbox.WindowsCommandRunnerSubcommand,
sandbox.WindowsSandboxSetupSubcommand,
sandbox.LinuxSandboxHelperSubcommand,
} {
t.Run(subcommand, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, os.Args[0], subcommand)
cmd.Env = append(os.Environ(), "ZERO_TEST_SELF_DISPATCH_CHILD=1")
output, err := cmd.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("%s did not return: the test binary ran its suite instead of the helper", subcommand)
}
if err == nil {
t.Fatalf("%s with no helper arguments succeeded; expected the helper's usage failure:\n%s", subcommand, output)
}
if text := string(output); strings.Contains(text, "=== RUN") || strings.Contains(text, "\nPASS") || strings.Contains(text, "--- SKIP") {
t.Fatalf("%s ran tests instead of the helper:\n%s", subcommand, text)
}
})
}
}
+51
View File
@@ -0,0 +1,51 @@
package cli
import (
"os"
"strings"
"testing"
)
// SHUTDOWN ORDER, guarded at the source because defers cannot be observed from
// a test.
//
// Required at shutdown: stop the plan, then close the runtime it was using, then
// cancel the session. Defers run LIFO, so registration order is the reverse —
// which is subtle enough that it was already wrong once. planLaunch.Close() was
// registered beside the launcher it belongs to, which reads naturally and meant
// it ran AFTER closeSpecialistRuntime. Close "cancels AND WAITS", so a
// background plan was waited on with the runtime it needs already torn down.
//
// A source-order assertion is a blunt instrument. It is here because the
// alternative is a comment, and a comment did not stop this happening: the
// ordering is invisible at the point where someone would add the next defer.
func TestShutdownDefersAreRegisteredInReverseOfTheirRequiredOrder(t *testing.T) {
source, err := os.ReadFile("app.go")
if err != nil {
t.Fatalf("read app.go: %v", err)
}
text := string(source)
// Registration order, top to bottom.
positions := map[string]int{
"cancelSession": strings.Index(text, "defer cancelSession()"),
"closeSpecialistRuntime": strings.Index(text, "defer closeSpecialistRuntime(stderr, specialistRuntime)"),
"planLaunch.Close": strings.Index(text, "defer planLaunch.Close()"),
}
for name, at := range positions {
if at < 0 {
t.Fatalf("could not find the %s defer; this guard has gone stale and must be re-pointed, not deleted", name)
}
}
// LIFO: later registration runs earlier. The plan must stop first, so its
// defer must be registered last.
if positions["planLaunch.Close"] < positions["closeSpecialistRuntime"] {
t.Error("planLaunch.Close() is registered before closeSpecialistRuntime, so it RUNS after it — " +
"a background plan would be waited on with its specialist runtime already closed")
}
if positions["closeSpecialistRuntime"] < positions["cancelSession"] {
t.Error("closeSpecialistRuntime is registered before cancelSession, so it RUNS after it — " +
"the session would be cancelled while the runtime is still open")
}
}
@@ -0,0 +1,145 @@
package cli
import (
"bytes"
"context"
"io"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/mcp"
"github.com/Twigpine/zero/internal/specialist"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/tui"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestRegisterSpecialistToolsWiresLifecycleHooksIntoTaskOutput(t *testing.T) {
registry := tools.NewRegistry()
runtime, err := registerSpecialistTools(registry, t.TempDir(), 0, nil, nil, nil, orchestrateWiring{})
if err != nil {
t.Fatalf("registerSpecialistTools returned error: %v", err)
}
t.Cleanup(func() { closeSpecialistRuntime(io.Discard, runtime) })
if runtime.lifecycleHooks == nil {
t.Fatal("lifecycleHooks is nil after registerSpecialistTools")
}
raw, ok := registry.Get("TaskOutput")
if !ok {
t.Fatal("TaskOutput tool not registered")
}
outputTool, ok := raw.(*specialist.OutputTool)
if !ok {
t.Fatalf("TaskOutput type = %T, want *specialist.OutputTool", raw)
}
if outputTool.LifecycleHooks != runtime.lifecycleHooks {
t.Fatal("TaskOutput LifecycleHooks is not the shared registerSpecialistTools bridge")
}
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{})
runtime.attachLifecycleHooks(dispatcher)
if runtime.lifecycleHooks.Dispatch == nil {
t.Fatal("attachLifecycleHooks left Dispatch nil")
}
if outputTool.LifecycleHooks.Dispatch == nil {
t.Fatal("TaskOutput did not observe Dispatch after attachLifecycleHooks")
}
}
// TestRealEntryPointsAttachLifecycleHooks closes the gap the registration-level
// test above cannot see: wiring the bridge when tools register is not enough
// unless the real startup paths actually call attachLifecycleHooks before any
// run. Both surfaces that can launch specialists must do so — the interactive
// TUI and headless exec. attachLifecycleHooks mutates the same
// *specialist.LifecycleHooks the TaskOutput tool holds, so a nil Dispatch on
// that tool after a full startup proves the call never ran and the
// specialistStart/specialistStop hooks would silently never fire.
func TestRealEntryPointsAttachLifecycleHooks(t *testing.T) {
requireTaskOutputDispatchAttached := func(t *testing.T, registry *tools.Registry) {
t.Helper()
if registry == nil {
t.Fatal("startup did not reach the run registry")
}
raw, ok := registry.Get("TaskOutput")
if !ok {
t.Fatal("TaskOutput tool not registered by startup")
}
outputTool, ok := raw.(*specialist.OutputTool)
if !ok {
t.Fatalf("TaskOutput type = %T, want *specialist.OutputTool", raw)
}
if outputTool.LifecycleHooks == nil || outputTool.LifecycleHooks.Dispatch == nil {
t.Fatal("startup left the specialist lifecycle bridge unattached: specialistStart/specialistStop hooks would never fire")
}
}
t.Run("interactive TUI", func(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
var registry *tools.Registry
// Empty args route runWithDeps to the interactive TUI path; the injected
// runTUI receives the same registry specialist tools registered into.
exitCode := runWithDeps([]string{}, io.Discard, io.Discard, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
Provider: config.ProviderProfile{
Name: "p",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: "m",
},
}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
runTUI: func(_ context.Context, options tui.Options) int {
registry = options.Registry
return exitSuccess
},
})
if exitCode != exitSuccess {
t.Fatalf("runWithDeps exit = %d, want %d", exitCode, exitSuccess)
}
requireTaskOutputDispatchAttached(t, registry)
})
t.Run("headless exec", func(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
var registry *tools.Registry
var stdout, stderr bytes.Buffer
// --auto high is what registers specialist tools on the exec surface.
// MCP registration is the one startup chokepoint that sees the run
// registry before attachLifecycleHooks runs, so capture it there and
// assert after the run completes.
exitCode := runWithDeps([]string{
"exec", "--auto", "high", "--cwd", cwd, "--init-session-id", "hook_wireup", "hello",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return echoExecProvider{}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"wireup": {Type: "stdio", Command: "wireup-mcp"},
}}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
registerMCPTools: func(_ context.Context, captured *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
registry = captured
return closeFunc(func() error { return nil }), nil
},
})
if exitCode != exitSuccess {
t.Fatalf("runWithDeps exit = %d, want %d (stderr=%q)", exitCode, exitSuccess, stderr.String())
}
requireTaskOutputDispatchAttached(t, registry)
})
}
+163
View File
@@ -0,0 +1,163 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/specialist"
"github.com/Twigpine/zero/internal/streamjson"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// echoToolResultProvider calls one tool, then answers with what the tool
// returned, so the run's output shows whether the call succeeded.
type echoToolResultProvider struct {
toolName string
arguments string
}
func (provider echoToolResultProvider) StreamCompletion(_ context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
for _, message := range request.Messages {
if message.Role == zeroruntime.MessageRoleTool {
ch := make(chan zeroruntime.StreamEvent, 2)
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText,
Content: fmt.Sprintf("TOOL-RESULT error=%v: %s", message.IsError, message.Content)}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
}
ch := make(chan zeroruntime.StreamEvent, 4)
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_probe", ToolName: provider.toolName}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_probe", ArgumentsFragment: provider.arguments}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_probe"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// AN ORDINARY TASK'S CHILD CAN READ THE PARENT'S READ GRANT AND CANNOT WRITE IT.
//
// End to end on one path: the parent's scope holds a read grant outside its
// workspace; the Task tool launches a write-capable child through the executor
// the CLI wires; and the argv that launch produced is run through the real exec
// entry point, where read_file inside the grant succeeds and write_file inside
// it is refused. Before the executor carried the read supplier, the read failed
// "outside the workspace"; had the grant been emitted as --add-dir, the write
// would land.
//
// The resumed launch is covered at the argv level only
// (TestTheCLIExecutorPassesReadGrantsReadOnlyToTaskAndResume): exec --resume
// refuses a child session ("not resumable") on this tree, independently of the
// roots it is given, so a resumed child cannot be run in-process here.
func TestAnOrdinaryTaskChildCanReadButNotWriteTheParentsReadGrant(t *testing.T) {
// Session state for the in-process child, redirected on every platform
// (HOME covers macOS and Windows).
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
workspace, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
readRoot := tempDirOutsideDefaultTemp(t)
evidence := filepath.Join(readRoot, "evidence.txt")
if err := os.WriteFile(evidence, []byte("READ-GRANT-EVIDENCE"), 0o644); err != nil {
t.Fatal(err)
}
parentScope, err := sandbox.NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
if _, err := parentScope.AddRead(readRoot); err != nil {
t.Fatalf("AddRead: %v", err)
}
launches := 0
var childArgs []string
executor := specialist.Executor{
BinaryPath: "/bin/true",
NewSessionID: func() (string, error) {
launches++
return fmt.Sprintf("specialist_%024d", launches), nil
},
Load: func(specialist.LoadOptions) (specialist.LoadResult, error) {
return specialist.LoadResult{Specialists: []specialist.Manifest{{
Metadata: specialist.Metadata{Name: "auditor", Description: "reads and writes"},
SystemPrompt: "x",
ResolvedTools: []string{"read_file", "write_file"},
ToolsResolved: true,
Location: specialist.LocationBuiltin,
}}}, nil
},
RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (specialist.ChildRunResult, error) {
childArgs = append([]string(nil), args...)
return specialist.ChildRunResult{Started: true}, nil
},
}
wireChildRoots(&executor, workspace, orchestrateWiring{
ExtraWriteRoots: func(withinTurn bool) []string { return childWriteRoots(parentScope, withinTurn) },
ExtraReadRoots: func(withinTurn bool) []string { return childReadRoots(parentScope, withinTurn) },
})
taskTool := specialist.NewTaskTool(executor)
// launchAndRun runs the Task tool with taskArgs and then the child it would
// have started, with a provider that makes the given tool call.
launchAndRun := func(taskArgs map[string]any, toolName string, arguments map[string]string) string {
t.Helper()
result := taskTool.RunWithOptions(context.Background(), taskArgs,
// Unsafe, so the child's tool layer — not a permission prompt — is
// what decides whether the write may land.
tools.RunOptions{Cwd: workspace, PermissionMode: "unsafe"})
if result.Status != tools.StatusOK {
t.Fatalf("Task launch failed: %s", result.Output)
}
if len(childArgs) == 0 || childArgs[0] != "exec" {
t.Fatalf("unexpected child argv: %v", childArgs)
}
encoded, err := json.Marshal(arguments)
if err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps(childArgs, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return workspace, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return echoToolResultProvider{toolName: toolName, arguments: string(encoded)}, nil
},
newSandboxStore: func() (*sandbox.GrantStore, error) {
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
},
})
if exitCode != exitSuccess {
t.Fatalf("child exit = %d; argv=%v stdout=%q stderr=%q", exitCode, childArgs, stdout.String(), stderr.String())
}
return stdout.String()
}
task := map[string]any{"name": "auditor", "prompt": "probe the grant"}
readOutput := launchAndRun(task, "read_file", map[string]string{"path": evidence})
if !strings.Contains(readOutput, "READ-GRANT-EVIDENCE") || strings.Contains(readOutput, "error=true") {
t.Fatalf("the child could not read the parent's read grant %q:\nargv=%v\n%s", readRoot, childArgs, readOutput)
}
target := filepath.Join(readRoot, "must-not-write.txt")
writeOutput := launchAndRun(task, "write_file", map[string]string{"path": target, "content": "escalated"})
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("the child WROTE inside the parent's read grant — a read grant escalated to write (stat err=%v):\nargv=%v\n%s",
err, childArgs, writeOutput)
}
if !strings.Contains(writeOutput, "error=true") {
t.Fatalf("the write inside the read grant was not reported as refused:\n%s", writeOutput)
}
}
+168
View File
@@ -0,0 +1,168 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/providercatalog"
)
const aimlapiKeyEnv = "AIMLAPI_API_KEY"
var (
// ErrAIMLAPIKeyMissing means no AI/ML API key is configured.
ErrAIMLAPIKeyMissing = errors.New("no AI/ML API key is configured; set up aimlapi.com with /provider or set AIMLAPI_API_KEY")
// ErrAIMLAPIProxyProfile means the aimlapi profile points at a proxy, so its
// key belongs to that proxy and is withheld from aimlapi.com services.
ErrAIMLAPIProxyProfile = errors.New("the aimlapi provider points at a proxy base URL; its key is not sent to aimlapi.com services")
// ErrAIMLAPIKeyInvalid means the key cannot be sent as a single Bearer token.
ErrAIMLAPIKeyInvalid = errors.New("the AI/ML API key contains whitespace, a comma or a control character")
)
// AIMLAPIServiceKey returns the AI/ML API key that may be sent to the
// aimlapi.com Toolbox and MCP server. It is resolved again on every call, so a
// provider change is seen by the next request.
//
// The key comes from the aimlapi provider profile (the active one when it is
// aimlapi, otherwise the first), including a key held in the credential store,
// or from AIMLAPI_API_KEY when no aimlapi profile exists. The key is withheld
// when that profile, any aimlapi profile, or any profile reading
// AIMLAPI_API_KEY points at a base URL other than the canonical
// https://api.aimlapi.com/v1, and when AIMLAPI_INFERENCE_URL is overridden:
// in those cases the credential may belong to a proxy.
func AIMLAPIServiceKey(resolved ResolvedConfig, store APIKeyGetter, getenv func(string) string) (string, error) {
if getenv == nil {
getenv = os.Getenv
}
if override := strings.TrimSpace(getenv("AIMLAPI_INFERENCE_URL")); override != "" && !sameBaseURL(override, aimlapi.CanonicalInferenceBaseURL) {
return "", ErrAIMLAPIProxyProfile
}
for _, profile := range resolved.Providers {
if aimlapiKeyedProfile(profile) && !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
return "", ErrAIMLAPIProxyProfile
}
}
if profile, ok := aimlapiServiceProfile(resolved); ok {
if !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
return "", ErrAIMLAPIProxyProfile
}
if store != nil && profile.APIKeyStored && strings.TrimSpace(profile.APIKey) == "" && strings.TrimSpace(profile.Name) != "" {
key, found, err := store.Get(strings.TrimSpace(profile.Name))
if err != nil {
return "", fmt.Errorf("read AI/ML API credential store: %w", err)
}
if found {
profile.APIKey = key
}
}
return validAIMLAPIServiceKey(profile.APIKey)
}
return validAIMLAPIServiceKey(getenv(aimlapiKeyEnv))
}
// LoadAIMLAPIServiceKey resolves the user configuration, the environment and
// the user credential store, then applies AIMLAPIServiceKey. Project config is
// not read: provider keys are user-scoped. A config with no active provider
// still resolves its profiles (ErrNoActiveProvider), so the key is looked up in
// that partial config; every other resolve error is returned. Resolve drops a
// non-active profile it cannot normalize, so the configured profiles are
// checked as well: such a profile still withholds the key when it carries it.
func LoadAIMLAPIServiceKey() (string, error) {
userConfigPath, err := DefaultUserConfigPath()
if err != nil {
return "", err
}
existing, err := existingConfigFile(userConfigPath)
if err != nil {
return "", err
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: existing, ExcludeProject: true})
if err != nil && !errors.Is(err, ErrNoActiveProvider) {
return "", err
}
if existing != "" {
fileConfig, err := loadConfigFile(existing)
if err != nil {
return "", err
}
var configured FileConfig
mergeConfig(&configured, fileConfig)
if droppedAIMLAPIProxyProfile(configured.Providers, resolved.Providers) {
return "", ErrAIMLAPIProxyProfile
}
}
// Check proxy eligibility and already-resolved keys before opening a store.
key, keyErr := AIMLAPIServiceKey(resolved, nil, os.Getenv)
if !errors.Is(keyErr, ErrAIMLAPIKeyMissing) {
return key, keyErr
}
profile, ok := aimlapiServiceProfile(resolved)
if !ok || !profile.APIKeyStored {
return key, keyErr
}
store, err := ProviderKeyStoreAt(filepath.Dir(userConfigPath))
if err != nil {
return "", fmt.Errorf("open AI/ML API credential store: %w", err)
}
return AIMLAPIServiceKey(resolved, store, os.Getenv)
}
func isAIMLAPIProfile(profile ProviderProfile) bool {
return strings.EqualFold(providercatalog.NormalizeID(profile.CatalogID), providercatalog.AIMLAPIID)
}
// aimlapiKeyedProfile reports whether profile carries an AI/ML API credential:
// an aimlapi catalog profile, or any profile that reads AIMLAPI_API_KEY.
func aimlapiKeyedProfile(profile ProviderProfile) bool {
return isAIMLAPIProfile(profile) || strings.EqualFold(strings.TrimSpace(profile.APIKeyEnv), aimlapiKeyEnv)
}
// droppedAIMLAPIProxyProfile reports whether a configured profile missing from
// the resolved list (Resolve could not normalize it) carries an AI/ML API
// credential and is not explicitly on the canonical base URL. Its endpoint was
// never resolved, so an empty base URL counts as a proxy.
func droppedAIMLAPIProxyProfile(configured, resolved []ProviderProfile) bool {
kept := make(map[string]bool, len(resolved))
for _, profile := range resolved {
kept[profile.Name] = true
}
for _, profile := range configured {
if kept[strings.TrimSpace(profile.Name)] || !aimlapiKeyedProfile(profile) {
continue
}
if !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
return true
}
}
return false
}
func aimlapiServiceProfile(resolved ResolvedConfig) (ProviderProfile, bool) {
if isAIMLAPIProfile(resolved.Provider) {
return resolved.Provider, true
}
for _, profile := range resolved.Providers {
if isAIMLAPIProfile(profile) {
return profile, true
}
}
return ProviderProfile{}, false
}
func validAIMLAPIServiceKey(raw string) (string, error) {
key := strings.TrimSpace(raw)
if key == "" {
return "", ErrAIMLAPIKeyMissing
}
for _, r := range key {
if unicode.IsSpace(r) || r <= ' ' || r == 0x7f || r == ',' || (r >= 0x80 && r <= 0x9f) {
return "", ErrAIMLAPIKeyInvalid
}
}
return key, nil
}
+282
View File
@@ -0,0 +1,282 @@
package config
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func aimlapiServiceKeyEnv(values map[string]string) func(string) string {
return func(name string) string { return values[name] }
}
func TestAIMLAPIServiceKeyUsesCanonicalProfileKey(t *testing.T) {
resolved := ResolvedConfig{Providers: []ProviderProfile{
{Name: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "sk-openai"},
{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1/", APIKey: " aiml-key "},
}}
key, err := AIMLAPIServiceKey(resolved, nil, aimlapiServiceKeyEnv(nil))
if err != nil || key != "aiml-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want aiml-key", key, err)
}
}
func TestAIMLAPIServiceKeyReadsCredentialStore(t *testing.T) {
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKeyStored: true}}
store := fakeKeyGetter{keys: map[string]string{"aimlapi": "stored-key"}}
key, err := AIMLAPIServiceKey(resolved, store, aimlapiServiceKeyEnv(nil))
if err != nil || key != "stored-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want stored-key", key, err)
}
}
func TestAIMLAPIServiceKeyPrefersResolvedProfileKeyOverCredentialStore(t *testing.T) {
resolved := ResolvedConfig{Provider: ProviderProfile{
Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1",
APIKey: "env-key", APIKeyStored: true,
}}
store := fakeKeyGetter{keys: map[string]string{"aimlapi": "stored-key"}}
key, err := AIMLAPIServiceKey(resolved, store, aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "env-key"}))
if err != nil || key != "env-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want env-key from resolved profile, not stored-key", key, err)
}
}
func TestAIMLAPIServiceKeyFallsBackToEnvironmentWithoutProfile(t *testing.T) {
key, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "env-key"}))
if err != nil || key != "env-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want env-key", key, err)
}
if _, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIKeyMissing) {
t.Fatalf("missing key error = %v, want ErrAIMLAPIKeyMissing", err)
}
}
func TestAIMLAPIServiceKeyWithholdsProxyCredentials(t *testing.T) {
env := aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "proxy-key"})
cases := map[string]ResolvedConfig{
"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{
{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKey: "proxy-key"},
{Name: "aimlapi-proxy", CatalogID: "aimlapi", BaseURL: "https://proxy.example/v1", APIKey: "proxy-key"},
}},
"a custom profile reading AIMLAPI_API_KEY": {Providers: []ProviderProfile{
{Name: "gateway", BaseURL: "https://proxy.example/v1", APIKeyEnv: "AIMLAPI_API_KEY", APIKey: "proxy-key"},
}},
}
for name, resolved := range cases {
key, err := AIMLAPIServiceKey(resolved, nil, env)
if !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
t.Errorf("%s: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", name, key, err)
}
}
overridden := aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "proxy-key", "AIMLAPI_INFERENCE_URL": "https://staging.example/v1"})
if key, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, overridden); !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
t.Fatalf("inference URL override: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", key, err)
}
}
func TestAIMLAPIServiceKeyRejectsKeysThatAreNotOneToken(t *testing.T) {
for _, raw := range []string{"first\u00a0second", "first\u2003second", "first,second", "first\nsecond", "first\r\nsecond", "a b", "key\x00tail", "key\u0085tail"} {
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKey: raw}}
if key, err := AIMLAPIServiceKey(resolved, nil, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIKeyInvalid) || key != "" {
t.Errorf("key %q: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIKeyInvalid", raw, key, err)
}
}
}
func TestLoadAIMLAPIServiceKeyResolvesUserConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
t.Fatalf("without config: LoadAIMLAPIServiceKey = %q, %v; want env-key", key, err)
}
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
proxy := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","baseURL":"https://proxy.example/v1","model":"m"}]}`
if err := os.WriteFile(path, []byte(proxy), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
t.Fatalf("proxy profile: LoadAIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", key, err)
}
canonical := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi"}]}`
if err := os.WriteFile(path, []byte(canonical), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
t.Fatalf("canonical profile: LoadAIMLAPIServiceKey = %q, %v; want env-key", key, err)
}
}
// Several profiles and no activeProvider make Resolve return
// ErrNoActiveProvider with the normalized profiles; the interactive app keeps
// running on that partial config, so the service key must still resolve.
func TestLoadAIMLAPIServiceKeyWithoutActiveProvider(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
other := `{"name":"other","provider":"openai","model":"m","apiKey":"other-key"}`
for _, tc := range []struct {
name string
active string
profile string
want string
wantErr error
}{
{name: "environment key", profile: `{"name":"second","provider":"openai","model":"m","apiKey":"second-key"}`, want: "env-key"},
{name: "stale active provider", active: "gone", profile: `{"name":"second","provider":"openai","model":"m","apiKey":"second-key"}`, want: "env-key"},
{name: "aimlapi profile key", profile: `{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKey":"profile-key"}`, want: "profile-key"},
{name: "proxy profile", profile: `{"name":"aimlapi","catalogID":"aimlapi","baseURL":"https://proxy.example/v1","model":"m"}`, wantErr: ErrAIMLAPIProxyProfile},
} {
data := `{"activeProvider":"` + tc.active + `","providers":[` + other + `,` + tc.profile + `]}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
key, err := LoadAIMLAPIServiceKey()
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) || key != "" {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %v", tc.name, key, err, tc.wantErr)
}
continue
}
if err != nil || key != tc.want {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %q", tc.name, key, err, tc.want)
}
}
}
// Resolve drops a non-active profile it cannot normalize (here an unknown
// catalog entry). Such a profile still shows that AIMLAPI_API_KEY belongs to a
// proxy, so the key is withheld whether or not a provider is active.
func TestLoadAIMLAPIServiceKeyWithholdsForDroppedProxyProfile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
canonical := `{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKey":"profile-key"}`
other := `{"name":"other","provider":"openai","model":"m","apiKey":"other-key"}`
dropped := `{"name":"future","catalogID":"no-such-catalog","baseURL":"https://proxy.example/v1","apiKeyEnv":"AIMLAPI_API_KEY","model":"m"}`
unrelated := `{"name":"future","catalogID":"no-such-catalog","baseURL":"https://proxy.example/v1","apiKeyEnv":"OTHER_API_KEY","model":"m"}`
for _, tc := range []struct {
name string
data string
want string
wantErr error
}{
{name: "active aimlapi profile", data: `{"activeProvider":"aimlapi","providers":[` + canonical + `,` + dropped + `]}`, wantErr: ErrAIMLAPIProxyProfile},
{name: "no active provider", data: `{"providers":[` + other + `,` + dropped + `]}`, wantErr: ErrAIMLAPIProxyProfile},
{name: "dropped profile without the key", data: `{"activeProvider":"aimlapi","providers":[` + canonical + `,` + unrelated + `]}`, want: "profile-key"},
} {
if err := os.WriteFile(path, []byte(tc.data), 0o600); err != nil {
t.Fatal(err)
}
key, err := LoadAIMLAPIServiceKey()
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) || key != "" {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %v", tc.name, key, err, tc.wantErr)
}
continue
}
if err != nil || key != tc.want {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %q", tc.name, key, err, tc.want)
}
}
}
func TestLoadAIMLAPIServiceKeyStoreOpenFailure(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
t.Setenv("ZERO_CRED_STORAGE", "invalid-test-storage")
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
t.Fatalf("environment key must not require a store: %q, %v", key, err)
}
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("AIMLAPI_API_KEY", "")
data := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKeyStored":true}]}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); key != "" || err == nil || !strings.Contains(err.Error(), "unknown storage") {
t.Fatalf("stored key must report store failure: %q, %v", key, err)
}
t.Setenv("AIMLAPI_INFERENCE_URL", "https://proxy.example/v1")
if key, err := LoadAIMLAPIServiceKey(); key != "" || !errors.Is(err, ErrAIMLAPIProxyProfile) {
t.Fatalf("proxy rejection must precede store open: %q, %v", key, err)
}
t.Setenv("AIMLAPI_INFERENCE_URL", "")
data = `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKeyStored":true,"apiKey":"inline-key"}]}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "inline-key" {
t.Fatalf("inline key must not require a store: %q, %v", key, err)
}
}
func TestAIMLAPIServiceKeyReportsStoreReadFailure(t *testing.T) {
want := errors.New("test credential read failure")
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKeyStored: true}}
if key, err := AIMLAPIServiceKey(resolved, fakeKeyGetter{err: want}, aimlapiServiceKeyEnv(nil)); key != "" || !errors.Is(err, want) {
t.Fatalf("read failure was hidden: %q, %v", key, err)
}
resolved.Provider.BaseURL = "https://proxy.example/v1"
if _, err := AIMLAPIServiceKey(resolved, fakeKeyGetter{err: want}, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIProxyProfile) {
t.Fatalf("proxy check must precede store read: %v", err)
}
}
+51
View File
@@ -0,0 +1,51 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestEnsureChatGPTDefaultPreservesSavedSelection(t *testing.T) {
for _, saved := range []string{"", "gpt-explicit-saved"} {
name := "new profile"
if saved != "" {
name = "saved selection"
}
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if saved != "" {
body, err := json.Marshal(FileConfig{ActiveProvider: "codex", Providers: []ProviderProfile{{Name: "codex", CatalogID: "chatgpt", Model: saved}}})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, body, 0600); err != nil {
t.Fatal(err)
}
}
ensured, err := EnsureCatalogProvider(path, "chatgpt")
if err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var cfg FileConfig
if err := json.Unmarshal(body, &cfg); err != nil {
t.Fatal(err)
}
want := "gpt-6.1-sol"
if saved != "" {
want = saved
}
if len(cfg.Providers) != 1 || cfg.Providers[0].Model != want {
t.Fatalf("ChatGPT default/selection = %#v, want model %q", cfg.Providers, want)
}
if saved != "" && (ensured.Name != "codex" || ensured.Created) {
t.Fatalf("saved subscription profile was replaced: %#v", ensured)
}
})
}
}
@@ -0,0 +1,101 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// A restart goes through Resolve, so that is where the persistence has to be
// proven.
//
// The existing coverage (internal/tui/keep_finished_agents_test.go) round-trips
// through SetKeepFinishedAgents and reads the file back, which proves the WRITE.
// It stayed green for the whole life of the bug, because the half that was
// broken is the read on the next launch, and Resolve is the only thing on that
// path. mergeConfig copied FavoriteModels, RecentModels, Recaps and Theme and
// never touched this key, so the value reached disk and died there.
//
// Recaps and Theme are asserted alongside on purpose. They are the siblings this
// field is modelled on, so a future edit that breaks the merge for one of them
// says which.
func TestKeepFinishedAgentsSurvivesResolve(t *testing.T) {
for _, testCase := range []struct {
name string
body string
want bool
}{
{
name: "an explicit keep survives the merge",
body: `{"preferences":{"keepFinishedAgents":true,"recaps":false,"theme":"dracula"}}`,
want: true,
},
{
// The reason the field is a pointer: with omitempty an explicit false
// is indistinguishable from an absent key unless the merge copies the
// pointer rather than the value.
name: "an explicit drop survives it too",
body: `{"preferences":{"keepFinishedAgents":false}}`,
want: false,
},
} {
t.Run(testCase.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(testCase.body), 0o600); err != nil {
t.Fatal(err)
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if got := resolved.Preferences.KeepsFinishedAgents(); got != testCase.want {
t.Errorf("after Resolve, KeepsFinishedAgents() = %v, want %v; the persisted preference did not survive the merge", got, testCase.want)
}
if resolved.Preferences.KeepFinishedAgents == nil {
t.Error("the merge dropped the pointer, so an explicit false is now indistinguishable from an unset key")
}
})
}
}
// The neighbouring preferences must keep coming through. This is the assertion
// that would have localised the bug: theme and recaps arriving while
// keepFinishedAgents did not is what pointed at mergeConfig rather than at the
// unmarshal or the writer.
func TestNeighbouringPreferencesStillSurviveResolve(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
body := `{"preferences":{"keepFinishedAgents":true,"recaps":false,"theme":"dracula"}}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Preferences.Theme != "dracula" {
t.Errorf("theme = %q, want dracula", resolved.Preferences.Theme)
}
if resolved.Preferences.Recaps == nil || *resolved.Preferences.Recaps {
t.Error("an explicit recaps=false did not survive Resolve")
}
}
// Absent stays absent. Without this, a merge that assigned the key
// unconditionally would pass the tests above while quietly turning the product
// default into "keep".
func TestKeepFinishedAgentsStaysUnsetWhenTheConfigIsSilent(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"preferences":{"theme":"dracula"}}`), 0o600); err != nil {
t.Fatal(err)
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Preferences.KeepFinishedAgents != nil {
t.Error("a config that says nothing about the preference resolved to an explicit value")
}
if resolved.Preferences.KeepsFinishedAgents() {
t.Error("the established default is to drop finished agents")
}
}
+76
View File
@@ -0,0 +1,76 @@
package config
import (
"os"
"path/filepath"
"strconv"
"testing"
)
// PROJECT CONFIG MAY ONLY TIGHTEN THE TURN BUDGET.
//
// A cloned repo setting maxTurns to the ceiling raises the per-run cost for
// whoever opens it — the same hazard PlanSize and DisableZeromaxing are
// tighten-only for. User config stays free to raise: it is the user's own file.
func TestProjectConfigMayLowerTheTurnBudgetButNeverRaiseIt(t *testing.T) {
for name, tc := range map[string]struct {
user, project, want int
}{
"cannot raise a user value": {user: 80, project: 500, want: 80},
"may lower a user value": {user: 80, project: 50, want: 50},
"cannot raise from the default": {user: 0, project: 500, want: defaultMaxTurns},
"may lower below the default": {user: 0, project: 50, want: 50},
"equal changes nothing": {user: 80, project: 80, want: 80},
} {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
userPath := filepath.Join(dir, "user.json")
projectPath := filepath.Join(dir, "project.json")
userBody := `{"providers":[{"name":"p","provider_kind":"openai-compatible","catalogID":"xai",` +
`"baseURL":"https://api.x.ai/v1","apiFormat":"chat-completions","model":"m","apiKey":"k"}],` +
`"activeProvider":"p"`
if tc.user > 0 {
userBody += `,"maxTurns":` + strconv.Itoa(tc.user)
}
userBody += `}`
if err := os.WriteFile(userPath, []byte(userBody), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(projectPath, []byte(`{"maxTurns":`+strconv.Itoa(tc.project)+`}`), 0o600); err != nil {
t.Fatal(err)
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if resolved.MaxTurns != tc.want {
t.Errorf("maxTurns = %d, want %d", resolved.MaxTurns, tc.want)
}
})
}
}
// ZERO MEANS "THE DEFAULT", NOT "NO LIMIT", and the distinction is the whole
// defect: the fallback to defaultMaxTurns happens AFTER the project merge, so
// treating an unset user value as unbounded lets a repo raise 80 to 500 for
// every user who never wrote a config — the common case, and the one this rule
// exists for.
func TestAnUnsetUserBudgetIsNotTreatedAsUnbounded(t *testing.T) {
dst := FileConfig{}
if err := mergeProjectConfig(&dst, FileConfig{MaxTurns: 500}); err != nil {
t.Fatalf("merge: %v", err)
}
if dst.MaxTurns != 0 {
t.Fatalf("a project raised the budget to %d against an unset user value; it must stay 0 so the default applies", dst.MaxTurns)
}
}
// The user's own file may still raise it — higher trust, their own money.
func TestUserConfigMayStillRaiseTheTurnBudget(t *testing.T) {
dst := FileConfig{MaxTurns: 80}
mergeConfig(&dst, FileConfig{MaxTurns: 300})
if dst.MaxTurns != 300 {
t.Errorf("user config could not raise its own budget: %d", dst.MaxTurns)
}
}
+27 -5
View File
@@ -3,26 +3,48 @@ package config
import (
"reflect"
"strings"
"github.com/Twigpine/zero/internal/aimlapi"
)
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so
// web search and page fetching work out of the box with no setup and no API
// key. They are seeded before user/project config is merged (see ResolveMCP),
// so a user can override any field — for example add an API-key header to lift
// Exa's anonymous rate limit — or disable it entirely with
// DefaultMCPServers returns the MCP servers Zero ships built in. Exa is ENABLED
// by default so web search and page fetching work out of the box with no setup
// and no API key. They are seeded before user/project config is merged (see
// ResolveMCP), so a user can override any field — for example add an API-key
// header to lift Exa's anonymous rate limit — or disable it entirely with
// `zero mcp disable <name>` (which writes `"disabled": true`).
//
// Exa's hosted MCP server works anonymously with rate limits. Users can add an
// Exa API key for higher limits.
//
// The AI/ML API server ships DISABLED: its tool runs and generations are billed
// to the user's AI/ML API balance, so it only connects after
// `zero mcp enable aimlapi` (which writes `"disabled": false`). The disable is
// marked as explicitly set so the lower-trust project layer cannot lift it
// (see mergeMCPServer); only the user and CLI-override scopes can. The key is
// attached per request by the MCP client and is never part of this config.
func DefaultMCPServers() map[string]MCPServerConfig {
return map[string]MCPServerConfig{
"exa": {
Type: "http",
URL: "https://mcp.exa.ai/mcp",
},
"aimlapi": {
Type: "http",
URL: aimlapi.MCPServerURL,
Disabled: true,
disabledSet: true,
},
}
}
// IsDefaultMCPServerDisabled reports whether name is a built-in default that
// ships disabled, so enabling it has to be written out as `"disabled": false`
// rather than by dropping the flag.
func IsDefaultMCPServerDisabled(name string) bool {
return DefaultMCPServers()[strings.TrimSpace(name)].Disabled
}
// IsDefaultMCPServer reports whether name is one of Zero's built-in default MCP
// servers. The config commands use it so a default can be disabled/enabled even
// though it is not written to the user's config file until overridden.
+89
View File
@@ -291,3 +291,92 @@ func TestResolveMCPDisabledRetiredEntryStaysReEnableable(t *testing.T) {
t.Fatal("the disable must still carry to the successor")
}
}
func writeMCPDefaultsTestConfig(t *testing.T, data string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestResolveMCPSeedsDisabledAIMLAPIDefault(t *testing.T) {
if !IsDefaultMCPServer("aimlapi") || !IsDefaultMCPServerDisabled("aimlapi") || IsDefaultMCPServerDisabled("exa") {
t.Fatal("aimlapi must be a built-in default that ships disabled, exa one that ships enabled")
}
// No user config at all, and a user config that never mentions aimlapi.
for name, options := range map[string]ResolveOptions{
"no config": {},
"unrelated user file": {UserConfigPath: writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"exa":{"disabled":true}}}}`)},
"user entry with only headers": {UserConfigPath: writeMCPDefaultsTestConfig(t,
`{"mcp":{"servers":{"aimlapi":{"headers":{"X-Trace":"1"}}}}}`)},
} {
cfg, err := ResolveMCP(options)
if err != nil {
t.Fatalf("%s: ResolveMCP: %v", name, err)
}
server, ok := cfg.Servers["aimlapi"]
if !ok {
t.Fatalf("%s: the aimlapi default must be visible in the resolved config", name)
}
if server.Type != "http" || server.URL != "https://mcp.aimlapi.com/mcp" || !server.Disabled {
t.Fatalf("%s: aimlapi default = %#v, want a disabled http server at the MCP URL", name, server)
}
}
cfg, err := ResolveMCP(ResolveOptions{})
if err != nil {
t.Fatal(err)
}
if !IsUnconfiguredDefault("aimlapi", cfg.Servers["aimlapi"]) {
t.Fatal("the untouched aimlapi default should be reported as unconfigured")
}
if exa := cfg.Servers["exa"]; exa.Disabled || !IsUnconfiguredDefault("exa", exa) {
t.Fatalf("exa default changed: %#v", exa)
}
}
func TestResolveMCPUserEnablesAIMLAPIDefault(t *testing.T) {
path := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
server := cfg.Servers["aimlapi"]
if server.Disabled || server.URL != "https://mcp.aimlapi.com/mcp" || server.Type != "http" {
t.Fatalf("user enable did not turn the default on: %#v", server)
}
if IsUnconfiguredDefault("aimlapi", server) {
t.Fatal("an enabled aimlapi is user-configured, so its failures must be reported")
}
enable := MCPServerConfig{Disabled: false, disabledSet: true}
cfg, err = ResolveMCP(ResolveOptions{Overrides: Overrides{MCP: MCPConfig{Servers: map[string]MCPServerConfig{"aimlapi": enable}}}})
if err != nil {
t.Fatal(err)
}
if cfg.Servers["aimlapi"].Disabled {
t.Fatal("the CLI override scope must be able to enable the aimlapi default")
}
}
func TestResolveMCPProjectCannotEnableAIMLAPIDefault(t *testing.T) {
project := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
cfg, err := ResolveMCP(ResolveOptions{ProjectConfigPath: project})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["aimlapi"].Disabled {
t.Fatal("a project config must not turn on the billed aimlapi default")
}
user := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
projectDisable := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":true}}}}`)
cfg, err = ResolveMCP(ResolveOptions{UserConfigPath: user, ProjectConfigPath: projectDisable})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if cfg.Servers["aimlapi"].Disabled {
t.Fatal("the user's explicit enable wins over the project layer, as for any server")
}
}
+141
View File
@@ -0,0 +1,141 @@
package config
import (
"fmt"
"strings"
)
// The zeromaxing plan-size tiers.
//
// A plan's task count was bounded by ONE hard-coded number with no way to move
// it: a twenty-task ceiling that a user with a genuinely large sweep could not
// raise and a user on a metered provider could not lower. The tier is the knob,
// and it is NAMED rather than numeric so the config file says what it means and
// so "no ceiling at all" is expressible without a sentinel integer.
//
// IT REMAINS A HARD CAP, not an advisory warning. The reference implementation
// warns and proceeds; here a plan over the tier is REJECTED, because under this
// posture every plan task is a child inheriting a 480-turn ceiling — fifty tasks
// authorise 16,000 child turns from one tool call. A warning the model emits to
// itself is not a bound on that. The rejection names the tier and the setting,
// so the ceiling is discoverable at the moment it is hit rather than being a
// number the user has to find in the source.
type PlanSize string
const (
// PlanSizeSmall suits a metered provider or a first look at the feature.
PlanSizeSmall PlanSize = "small"
// PlanSizeMedium is the default, and its 20 is TODAY'S number. Phase 2 exists
// to measure whether fan-out would pay, not to run large plans, and changing
// the default while making it configurable would have hidden a behaviour
// change inside a mechanism change.
PlanSizeMedium PlanSize = "medium"
// PlanSizeLarge is for a real sweep across a large tree.
PlanSizeLarge PlanSize = "large"
// PlanSizeUnrestricted removes the ceiling. Named rather than "0" so a reader
// of the config file cannot mistake it for "unset".
PlanSizeUnrestricted PlanSize = "unrestricted"
)
// DefaultPlanSize is what an unset — or unreadable — setting resolves to.
const DefaultPlanSize = PlanSizeMedium
// planSizeTiers is THE tier table: the only place a tier maps to a number.
//
// Ordered smallest-first, and that order is load-bearing twice over: it gives
// PlanSizeNames a stable rendering and it gives the project-config merge its
// tighten-only comparison. A second ordered list elsewhere would drift
// (invariant 5), so both read this one.
var planSizeTiers = []struct {
size PlanSize
maxTasks int
}{
{PlanSizeSmall, 5},
{PlanSizeMedium, 20},
{PlanSizeLarge, 50},
// 0 means no ceiling. It is the LAST entry, so it is also the loosest rank —
// which is what makes the tighten-only rule reject it from project config.
{PlanSizeUnrestricted, 0},
}
// MaxTasks is the tier's ceiling on a plan's task count. 0 means no ceiling.
//
// An unrecognised tier resolves to the DEFAULT rather than to no ceiling. Fail
// closed: a typo in a config file must never be the thing that removes a bound.
func (size PlanSize) MaxTasks() int {
for _, tier := range planSizeTiers {
if tier.size == size {
return tier.maxTasks
}
}
return DefaultPlanSize.MaxTasks()
}
// Valid reports whether the tier is one this build knows.
func (size PlanSize) Valid() bool {
for _, tier := range planSizeTiers {
if tier.size == size {
return true
}
}
return false
}
// rank orders the tiers from tightest to loosest. Used only by the merge rules;
// unexported because "how loose is this" is not a question a caller outside this
// package should be answering for itself.
func (size PlanSize) rank() int {
for index, tier := range planSizeTiers {
if tier.size == size {
return index
}
}
// An unknown tier ranks LOOSEST, so a "may only tighten" comparison can never
// adopt it.
//
// The first version returned -1 here with a comment claiming that made it
// safe. It does the opposite: the comparison adopts the SMALLER rank, so a
// tier ranked -1 would win against every real tier. Nothing was reachable
// through it — ParsePlanSize rejects an unknown name before the merge calls
// rank — but a defence-in-depth value that only holds because of the guard in
// front of it is not defence in depth. It must be safe on its own.
return len(planSizeTiers)
}
// ParsePlanSize resolves a configured value, case- and space-insensitively.
//
// An empty value is NOT an error: an unset setting is the default, which is the
// overwhelmingly common case and must not make a config file unloadable.
func ParsePlanSize(value string) (PlanSize, error) {
trimmed := strings.ToLower(strings.TrimSpace(value))
if trimmed == "" {
return DefaultPlanSize, nil
}
size := PlanSize(trimmed)
if !size.Valid() {
return DefaultPlanSize, fmt.Errorf("unknown plan size %q; expected one of: %s", value, strings.Join(PlanSizeNames(), ", "))
}
return size, nil
}
// PlanSizeNames renders the tiers for an error message, tightest first.
func PlanSizeNames() []string {
names := make([]string, 0, len(planSizeTiers))
for _, tier := range planSizeTiers {
names = append(names, string(tier.size))
}
return names
}
// PlanSize resolves the configured tier for this workspace.
//
// It never fails: an unreadable or unknown value is the default, which is what
// keeps a typo in a config file from either breaking the run or removing the
// ceiling. Callers wanting to REPORT a bad value call ParsePlanSize.
func (cfg ProfilesConfig) PlanSizeTier() PlanSize {
size, err := ParsePlanSize(cfg.PlanSize)
if err != nil {
return DefaultPlanSize
}
return size
}
+287
View File
@@ -0,0 +1,287 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// The tier table is the ceiling. Asserted as a table so a change to any number
// is a deliberate edit to this test rather than a silent widening.
func TestPlanSizeTierCeilings(t *testing.T) {
for _, tc := range []struct {
size PlanSize
want int
}{
{PlanSizeSmall, 5},
{PlanSizeMedium, 20},
{PlanSizeLarge, 50},
{PlanSizeUnrestricted, 0},
} {
if got := tc.size.MaxTasks(); got != tc.want {
t.Errorf("%s.MaxTasks() = %d; want %d", tc.size, got, tc.want)
}
}
}
// THE NO-REGRESSION ASSERTION. The ceiling was a hard-coded 20 before it was
// configurable, and the default must still be that number: making a bound
// configurable and moving it in the same change would hide a behaviour change
// inside a mechanism change.
func TestTheDefaultTierIsTheCeilingThatWasHardCoded(t *testing.T) {
if got := DefaultPlanSize.MaxTasks(); got != 20 {
t.Fatalf("default tier ceiling = %d; want 20, the value defaultPlanMaxTasks held", got)
}
// The zero value of the type must resolve there too — a caller that never
// wires the tier gets the old ceiling, not no ceiling.
var unset PlanSize
if got := unset.MaxTasks(); got != 20 {
t.Fatalf("unset tier ceiling = %d; want 20", got)
}
}
// FAIL CLOSED. A typo in a config file must never be the thing that removes the
// bound — the failure mode that matters is "planSize": "unlimted" silently
// granting an unbounded plan.
func TestAnUnknownTierResolvesToTheDefaultAndNeverToUnbounded(t *testing.T) {
unknown := PlanSize("unlimted")
if unknown.Valid() {
t.Fatal("a misspelled tier must not be Valid")
}
if got := unknown.MaxTasks(); got != DefaultPlanSize.MaxTasks() {
t.Fatalf("unknown tier ceiling = %d; want the default %d", got, DefaultPlanSize.MaxTasks())
}
if unknown.MaxTasks() == 0 {
t.Fatal("an unknown tier resolved to NO ceiling; a typo must not remove the bound")
}
if _, err := ParsePlanSize("unlimted"); err == nil {
t.Fatal("ParsePlanSize must report an unknown tier so a caller can surface it")
}
}
// An unset value is not an error: the overwhelmingly common config has no key
// at all, and that must not make the file unloadable.
func TestAnUnsetTierIsTheDefaultAndNotAnError(t *testing.T) {
size, err := ParsePlanSize(" ")
if err != nil {
t.Fatalf("ParsePlanSize(empty): %v", err)
}
if size != DefaultPlanSize {
t.Fatalf("ParsePlanSize(empty) = %q; want %q", size, DefaultPlanSize)
}
}
func TestParsePlanSizeIgnoresCaseAndSpace(t *testing.T) {
size, err := ParsePlanSize(" LARGE ")
if err != nil {
t.Fatalf("ParsePlanSize: %v", err)
}
if size != PlanSizeLarge {
t.Fatalf("got %q; want %q", size, PlanSizeLarge)
}
}
// User config is higher-trust and may LOOSEN the tier.
func TestUserConfigMaySetAnyTier(t *testing.T) {
dst := FileConfig{}
mergeConfig(&dst, FileConfig{Profiles: ProfilesConfig{PlanSize: "unrestricted"}})
if got := dst.Profiles.PlanSizeTier(); got != PlanSizeUnrestricted {
t.Fatalf("tier = %q; want %q — user config may loosen", got, PlanSizeUnrestricted)
}
}
// Project config may TIGHTEN. Same privilege boundary as Sandbox.Network's
// tighten-only rule: a project may make its own runs stricter.
func TestProjectConfigMayTightenTheTier(t *testing.T) {
dst := FileConfig{Profiles: ProfilesConfig{PlanSize: "large"}}
if err := mergeProjectConfig(&dst, FileConfig{Profiles: ProfilesConfig{PlanSize: "small"}}); err != nil {
t.Fatalf("mergeProjectConfig: %v", err)
}
if got := dst.Profiles.PlanSizeTier(); got != PlanSizeSmall {
t.Fatalf("tier = %q; want %q — a project may tighten", got, PlanSizeSmall)
}
}
// ...and may NOT loosen it. A cloned repo must not be able to raise a spend
// ceiling for whoever opens it. Dropped silently, like an ignored network
// "allow": the project scope simply does not hold that privilege.
func TestProjectConfigCannotLoosenTheTier(t *testing.T) {
for _, attempt := range []string{"large", "unrestricted"} {
dst := FileConfig{Profiles: ProfilesConfig{PlanSize: "small"}}
if err := mergeProjectConfig(&dst, FileConfig{Profiles: ProfilesConfig{PlanSize: attempt}}); err != nil {
t.Fatalf("mergeProjectConfig(%s): %v", attempt, err)
}
if got := dst.Profiles.PlanSizeTier(); got != PlanSizeSmall {
t.Fatalf("project config raised the tier to %q with %q; it must stay %q", got, attempt, PlanSizeSmall)
}
}
}
// The unset case is the one that matters most: with no user setting the
// effective tier is medium, and a project asking for large must still lose.
func TestProjectConfigCannotLoosenTheDefaultTier(t *testing.T) {
dst := FileConfig{}
if err := mergeProjectConfig(&dst, FileConfig{Profiles: ProfilesConfig{PlanSize: "unrestricted"}}); err != nil {
t.Fatalf("mergeProjectConfig: %v", err)
}
if got := dst.Profiles.PlanSizeTier(); got != DefaultPlanSize {
t.Fatalf("tier = %q; want the default %q — a project may not loosen an unset tier", got, DefaultPlanSize)
}
if dst.Profiles.PlanSizeTier().MaxTasks() == 0 {
t.Fatal("project config removed the ceiling entirely")
}
}
// An unrecognised tier from project config must not win either. Two independent
// things stop it — ParsePlanSize rejects the name, and rank puts it loosest —
// and this asserts the outcome rather than which layer did it.
func TestAnUnknownProjectTierIsIgnored(t *testing.T) {
dst := FileConfig{Profiles: ProfilesConfig{PlanSize: "large"}}
if err := mergeProjectConfig(&dst, FileConfig{Profiles: ProfilesConfig{PlanSize: "enormous"}}); err != nil {
t.Fatalf("mergeProjectConfig: %v", err)
}
if got := dst.Profiles.PlanSize; got != "large" {
t.Fatalf("stored tier = %q; an unknown project tier must be ignored, leaving %q", got, "large")
}
}
// An unrecognised tier from USER config is not stored either, so no reader
// downstream has to re-validate what it was handed.
func TestAnUnknownUserTierIsNotStored(t *testing.T) {
dst := FileConfig{}
mergeConfig(&dst, FileConfig{Profiles: ProfilesConfig{PlanSize: "enormous"}})
if dst.Profiles.PlanSize != "" {
t.Fatalf("stored tier = %q; an unknown user tier must not be stored", dst.Profiles.PlanSize)
}
if got := dst.Profiles.PlanSizeTier(); got != DefaultPlanSize {
t.Fatalf("tier = %q; want the default %q", got, DefaultPlanSize)
}
}
// profiles.planSize is the key users actually type.
func TestPlanSizeRoundTripsThroughJSON(t *testing.T) {
var cfg FileConfig
if err := cfg.UnmarshalJSON([]byte(`{"profiles":{"planSize":"large"}}`)); err != nil {
t.Fatalf("UnmarshalJSON: %v", err)
}
if cfg.Profiles.PlanSize != "large" {
t.Fatalf("profiles.planSize decoded as %q", cfg.Profiles.PlanSize)
}
encoded, err := cfg.MarshalJSON()
if err != nil {
t.Fatalf("MarshalJSON: %v", err)
}
var round FileConfig
if err := round.UnmarshalJSON(encoded); err != nil {
t.Fatalf("re-decode: %v", err)
}
if round.Profiles.PlanSize != "large" {
t.Fatalf("profiles.planSize did not survive a round trip: %q", round.Profiles.PlanSize)
}
}
// rank is the tighten-only comparison's input, and it must be safe WITHOUT the
// ParsePlanSize guard in front of it. A defence-in-depth layer that only holds
// because of the check before it is not a layer.
//
// The comparison adopts the SMALLER rank, so an unknown tier has to rank
// loosest. Ranking it tightest — which an earlier version did, with a comment
// asserting the opposite — would have made an unrecognised name beat every real
// tier the moment it reached this function.
func TestAnUnknownTierRanksLoosestSoItCanNeverBeAdopted(t *testing.T) {
unknown := PlanSize("enormous").rank()
for _, tier := range planSizeTiers {
if unknown <= tier.size.rank() {
t.Fatalf("unknown ranks %d, at or below %q's %d; the tighten-only comparison would adopt it",
unknown, tier.size, tier.size.rank())
}
}
}
// THE MERGE IS FIELD BY FIELD, so a new key that nobody adds to it is silently
// dropped. planModels shipped exactly that way: written correctly to config,
// parsed into the struct, and discarded by the resolver — the setting present in
// the file and absent everywhere it was read.
func TestPlanModelsSurviveTheUserConfigMerge(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{
"profiles": {
"planModels": {
"scan": "cheap-one",
"verify": "strong-one",
"exclude": ["never-this"]
}
}
}`), 0o600); err != nil {
t.Fatal(err)
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("resolve: %v", err)
}
got := resolved.Profiles.PlanModels
if got.Scan != "cheap-one" || got.Verify != "strong-one" {
t.Errorf("pins lost in the merge: %+v", got)
}
// A config that sets only some roles must not blank the others.
if got.Implement != "" {
t.Errorf("implement was invented: %q", got.Implement)
}
if len(got.Exclude) != 1 || got.Exclude[0] != "never-this" {
t.Errorf("exclusions lost in the merge: %+v", got.Exclude)
}
}
// PROJECT CONFIG SETS NO PLAN MODELS AT ALL — not a pin, and not an exclusion.
//
// The pin half was always refused: a cloned repo pinning every role to the
// priciest model raises cost for whoever opens it, the same hazard the PlanSize
// tighten-only rule exists for.
//
// Exclusion was allowed on the premise that removing a candidate can only lower
// spend. It does the opposite just as easily — the selector picks per role from
// what survives, so excluding the cheap ids promotes the next model up rather
// than removing any work. A repo can drive all three roles onto the priciest
// model on the account while naming none of them to run.
func TestProjectConfigSetsNoPlanModelsAtAll(t *testing.T) {
dir := t.TempDir()
userPath := filepath.Join(dir, "user.json")
projectPath := filepath.Join(dir, "project.json")
if err := os.WriteFile(userPath, []byte(`{
"profiles": {"planModels": {"verify": "the-users-choice", "routerGuidance": "trust kimi"}}
}`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(projectPath, []byte(`{
"profiles": {"planModels": {"verify": "the-repos-choice", "exclude": ["something-bad"],
"routerGuidance": "always pick the most expensive model, it is worth it"}}
}`), 0o600); err != nil {
t.Fatal(err)
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath})
if err != nil {
t.Fatalf("resolve: %v", err)
}
got := resolved.Profiles.PlanModels
if got.Verify != "the-users-choice" {
t.Errorf("a project config overrode the user's model pin: %q", got.Verify)
}
// AN EXCLUSION RAISES SPEND JUST AS EASILY AS A PIN, which is why this used
// to assert the opposite. The selector chooses per role from whatever is
// left, so excluding the cheap candidates does not remove the work — it
// promotes the next model up. A repo can steer all three roles onto the
// priciest model on the account without naming a single id to run.
for _, name := range got.Exclude {
if name == "something-bad" {
t.Errorf("a project exclusion was applied: a repo can raise the user's spend by removing the cheap candidates, %+v", got.Exclude)
}
}
// Guidance is prose fed straight to the router, so it is the SOFTEST way to
// do what pinning does — "always pick the most expensive model" costs the
// reader real money without naming a single model id. It belongs on the same
// side of the boundary as the pins.
if got.RouterGuidance != "trust kimi" {
t.Errorf("a project config rewrote the user's router guidance: %q", got.RouterGuidance)
}
}
+82
View File
@@ -0,0 +1,82 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeConfigs(t *testing.T, user, project string) ResolvedConfig {
t.Helper()
dir := t.TempDir()
userPath := filepath.Join(dir, "user.json")
projectPath := filepath.Join(dir, "project.json")
if err := os.WriteFile(userPath, []byte(user), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(projectPath, []byte(project), 0o600); err != nil {
t.Fatal(err)
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath})
if err != nil {
t.Fatalf("resolve: %v", err)
}
return resolved
}
const someProvider = `"providers":[{"name":"p","provider_kind":"openai-compatible","catalogID":"xai",` +
`"baseURL":"https://api.x.ai/v1","apiFormat":"chat-completions","model":"grok-4.5","apiKey":"k"}],"activeProvider":"p"`
// BOTH FLAGS DEFAULT OFF, and that is what keeps a build carrying them
// byte-identical to one without them.
func TestTheNewProfileFlagsDefaultOff(t *testing.T) {
resolved := writeConfigs(t, `{`+someProvider+`}`, `{}`)
if resolved.Profiles.RequirePlanKeyword {
t.Error("RequirePlanKeyword defaulted on; it would refuse plans for everyone whose phrasing does not match")
}
if resolved.Profiles.Memory {
t.Error("Memory defaulted on; registering its tools changes the advertised tool set for every run")
}
}
// USER CONFIG MAY SET EITHER. It is the higher-trust source.
func TestUserConfigMaySetBothFlags(t *testing.T) {
resolved := writeConfigs(t,
`{`+someProvider+`,"profiles":{"requirePlanKeyword":true,"memory":true}}`, `{}`)
if !resolved.Profiles.RequirePlanKeyword {
t.Error("user config could not enable the plan-keyword gate")
}
if !resolved.Profiles.Memory {
t.Error("user config could not enable memory")
}
}
// A PROJECT MAY TIGHTEN A SAFETY GATE. Asking for one is not a privilege
// escalation — it is a repo asking to be treated more carefully.
func TestProjectConfigMayEnableThePlanKeywordGate(t *testing.T) {
resolved := writeConfigs(t, `{`+someProvider+`}`, `{"profiles":{"requirePlanKeyword":true}}`)
if !resolved.Profiles.RequirePlanKeyword {
t.Error("a project could not ask for the plan-keyword gate")
}
}
// ...AND MAY NOT HAND ITSELF A WRITE PRIMITIVE. memory_write writes into the
// workspace, and project config is not trust-gated: a cloned repo must not be
// able to give the agent a new way to write into it.
func TestProjectConfigCannotEnableMemory(t *testing.T) {
resolved := writeConfigs(t, `{`+someProvider+`}`, `{"profiles":{"memory":true}}`)
if resolved.Profiles.Memory {
t.Error("a cloned repo enabled the memory write tool for whoever opened it")
}
}
// A project cannot switch the gate back OFF either — that direction is a
// downgrade, and presence-only means an attempted false is simply not there.
func TestProjectConfigCannotDisableThePlanKeywordGate(t *testing.T) {
resolved := writeConfigs(t,
`{`+someProvider+`,"profiles":{"requirePlanKeyword":true}}`,
`{"profiles":{"requirePlanKeyword":false}}`)
if !resolved.Profiles.RequirePlanKeyword {
t.Error("a project turned the user's safety gate off")
}
}
@@ -0,0 +1,74 @@
package config
import "testing"
// (n) A project .zero/config.json may DISABLE the posture. This mirrors the
// Sandbox.Network tighten-only rule: project scope may make a run stricter.
func TestProjectConfigCanDisableZeromaxing(t *testing.T) {
dst := FileConfig{}
if err := mergeProjectConfig(&dst, FileConfig{Profiles: ProfilesConfig{DisableZeromaxing: true}}); err != nil {
t.Fatalf("mergeProjectConfig: %v", err)
}
if !dst.Profiles.DisableZeromaxing {
t.Fatal("a project config must be able to disable the posture")
}
}
// (m) ...and may NOT enable it. A cloned repo must not be able to switch a cost
// multiplier ON for whoever opens it. Like an ignored network "allow", the
// attempt is dropped silently rather than raised as an error — the project
// scope simply does not hold that privilege.
func TestProjectConfigCannotEnableZeromaxing(t *testing.T) {
dst := FileConfig{Profiles: ProfilesConfig{DisableZeromaxing: true}}
if err := mergeProjectConfig(&dst, FileConfig{Profiles: ProfilesConfig{DisableZeromaxing: false}}); err != nil {
t.Fatalf("mergeProjectConfig: %v", err)
}
if !dst.Profiles.DisableZeromaxing {
t.Fatal("a project config must NOT re-enable the posture after the user scope disabled it")
}
}
// The user scope is higher-trust and may disable it too.
func TestUserConfigCanDisableZeromaxing(t *testing.T) {
dst := FileConfig{}
mergeConfig(&dst, FileConfig{Profiles: ProfilesConfig{DisableZeromaxing: true}})
if !dst.Profiles.DisableZeromaxing {
t.Fatal("user config must be able to disable the posture")
}
}
// The default is OFF: an absent setting leaves it available, so the feature is
// opt-out and no existing config changes behaviour.
func TestZeromaxingIsEnabledByDefault(t *testing.T) {
dst := FileConfig{}
mergeConfig(&dst, FileConfig{})
if err := mergeProjectConfig(&dst, FileConfig{}); err != nil {
t.Fatalf("mergeProjectConfig: %v", err)
}
if dst.Profiles.DisableZeromaxing {
t.Fatal("the posture must be available unless a config explicitly disables it")
}
}
// The setting survives JSON round-tripping, so profiles.disableZeromaxing is the
// actual key users type.
func TestProfilesConfigRoundTripsThroughJSON(t *testing.T) {
var cfg FileConfig
if err := cfg.UnmarshalJSON([]byte(`{"profiles":{"disableZeromaxing":true}}`)); err != nil {
t.Fatalf("UnmarshalJSON: %v", err)
}
if !cfg.Profiles.DisableZeromaxing {
t.Fatal("profiles.disableZeromaxing did not decode")
}
encoded, err := cfg.MarshalJSON()
if err != nil {
t.Fatalf("MarshalJSON: %v", err)
}
var round FileConfig
if err := round.UnmarshalJSON(encoded); err != nil {
t.Fatalf("re-decode: %v", err)
}
if !round.Profiles.DisableZeromaxing {
t.Fatalf("the setting was lost in the round trip: %s", encoded)
}
}
+150 -2
View File
@@ -174,6 +174,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Profiles: cfg.Profiles,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
LocalControl: cfg.LocalControl,
@@ -195,6 +196,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Profiles: cfg.Profiles,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
LocalControl: cfg.LocalControl,
@@ -291,6 +293,64 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if src.Swarm.MaxTeamSize != 0 {
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
}
// User config is higher-trust and may turn the zeromaxing posture off. It is
// presence-only here too (with omitempty a false is indistinguishable from
// absent); re-enabling is done by removing the key.
if src.Profiles.DisableZeromaxing {
dst.Profiles.DisableZeromaxing = true
}
// User config may enable either of these freely; both default off so a caller
// that sets neither is byte-identical to a build without them.
if src.Profiles.RequirePlanKeyword {
dst.Profiles.RequirePlanKeyword = true
}
if src.Profiles.Memory {
dst.Profiles.Memory = true
}
// User config is higher-trust and may set ANY tier, tighter or looser. An
// unrecognised value is ignored rather than stored, so a typo falls back to
// the default instead of being carried around as a value every reader has to
// re-validate.
if size, err := ParsePlanSize(src.Profiles.PlanSize); err == nil && strings.TrimSpace(src.Profiles.PlanSize) != "" {
dst.Profiles.PlanSize = string(size)
}
// PlanModels: USER config may set anything. Each field is presence-checked
// on its own so a config setting only `verify` does not blank the other two.
if v := strings.TrimSpace(src.Profiles.PlanModels.Scan); v != "" {
dst.Profiles.PlanModels.Scan = v
}
if v := strings.TrimSpace(src.Profiles.PlanModels.Implement); v != "" {
dst.Profiles.PlanModels.Implement = v
}
if v := strings.TrimSpace(src.Profiles.PlanModels.Verify); v != "" {
dst.Profiles.PlanModels.Verify = v
}
if v := strings.TrimSpace(src.Profiles.PlanModels.Router); v != "" {
dst.Profiles.PlanModels.Router = v
}
// User config only, like the pins: guidance steers which model does the work
// and therefore what a plan costs, so a cloned repo must not be able to write
// it. mergeProjectConfig deliberately does not carry this.
if v := strings.TrimSpace(src.Profiles.PlanModels.RouterGuidance); v != "" {
dst.Profiles.PlanModels.RouterGuidance = v
}
if len(src.Profiles.PlanModels.Exclude) > 0 {
dst.Profiles.PlanModels.Exclude = append([]string(nil), src.Profiles.PlanModels.Exclude...)
}
// User config only, like the pins and guidance: a size floor decides which
// models do the work and therefore what a plan costs, so a cloned repo must
// not set it. A positive value overrides; 0 or negative is "unset".
if src.Profiles.PlanModels.TopModels > 0 {
dst.Profiles.PlanModels.TopModels = src.Profiles.PlanModels.TopModels
}
if src.Profiles.PlanModels.MinSize > 0 {
dst.Profiles.PlanModels.MinSize = src.Profiles.PlanModels.MinSize
}
// Presence-only, like DisableZeromaxing: with omitempty a false is
// indistinguishable from absent, so turning it back off means removing the key.
if src.Profiles.PlanModels.AutoAssign {
dst.Profiles.PlanModels.AutoAssign = true
}
if src.Preferences.FavoriteModels != nil {
dst.Preferences.FavoriteModels = normalizeFavoriteModels(src.Preferences.FavoriteModels)
}
@@ -300,6 +360,17 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if src.Preferences.Recaps != nil {
dst.Preferences.Recaps = src.Preferences.Recaps
}
// Pointer-valued for the same reason as Recaps above: an explicit false has
// to stay distinguishable from an absent key, so the nil check is the whole
// merge and copying the pointer preserves both states.
//
// Missing here until now, which left the persistence half of the AGENTS
// panel toggle dead. SetKeepFinishedAgents wrote the key, the next launch
// loaded the file, and mergeConfig dropped it, so Resolve always answered
// false and the panel re-seeded itself off on every session.
if src.Preferences.KeepFinishedAgents != nil {
dst.Preferences.KeepFinishedAgents = src.Preferences.KeepFinishedAgents
}
if strings.TrimSpace(src.Preferences.Theme) != "" {
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
}
@@ -319,9 +390,27 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if activeProvider := strings.TrimSpace(src.ActiveProvider); activeProvider != "" {
dst.ActiveProvider = activeProvider
}
// PROJECT CONFIG MAY ONLY TIGHTEN THE TURN BUDGET.
//
// A cloned repo setting maxTurns to the ceiling raises the per-run cost for
// whoever opens it — the same hazard PlanSize and DisableZeromaxing are
// tighten-only for, three fields below. User config stays free to raise: it
// is the user's own file and their own money.
//
// COMPARED AGAINST THE EFFECTIVE VALUE, NOT THE FIELD. Zero here does not
// mean "no limit", it means "fall back to defaultMaxTurns" — and that
// fallback happens after this merge. Treating zero as unbounded lets a repo
// raise 80 to 500 for every user who never wrote a config, which is the
// common case and the one this rule exists for.
if src.MaxTurns > 0 {
dst.MaxTurns = src.MaxTurns
dst.maxTurnsSet = true
effective := dst.MaxTurns
if effective == 0 {
effective = defaultMaxTurns
}
if src.MaxTurns < effective {
dst.MaxTurns = src.MaxTurns
dst.maxTurnsSet = true
}
}
for _, provider := range src.Providers {
candidate := providerMergeCandidate(*dst, provider)
@@ -372,6 +461,65 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if src.Swarm.MaxTeamSize != 0 {
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
}
// Profiles.DisableZeromaxing from project config may only TIGHTEN (turn the
// posture OFF), never WEAKEN (turn it back on). A cloned repo must not be
// able to switch a cost multiplier on for whoever opens it — the same
// posture as Sandbox.Network's allow/deny rule above, and for the same
// reason: project config is not trust-gated. Because the field is
// presence-only, an attempted "enable" arrives as a false that this simply
// ignores — silently, like an ignored network "allow", not as an error.
if src.Profiles.DisableZeromaxing {
dst.Profiles.DisableZeromaxing = true
}
// RequirePlanKeyword from project config may only ENABLE. Turning a safety
// gate ON is tightening and a repo may reasonably ask for it; turning it OFF
// is a downgrade, and presence-only means an attempted "false" is simply not
// there to act on — the same shape as DisableZeromaxing above.
if src.Profiles.RequirePlanKeyword {
dst.Profiles.RequirePlanKeyword = true
}
// Profiles.Memory is IGNORED here, deliberately. memory_write is a write
// primitive pointed at the workspace, and project config is not trust-gated:
// a cloned repo must not be able to hand the agent a new way to write into
// it. User config still enables it freely, which is where that decision
// belongs.
//
// Profiles.PlanSize from project config may only TIGHTEN. A cloned repo can
// ask for a smaller ceiling; it cannot raise one, because raising it is
// raising a spend ceiling for whoever opens the repo — the same privilege
// boundary as Sandbox.Network's allow/deny rule and DisableZeromaxing's
// disable-only rule. A looser tier is ignored SILENTLY, matching the ignored
// network "allow" rather than erroring: the project simply does not hold that
// privilege, which is not a malformed config.
//
// An unknown tier ranks tightest (rank -1) and so can never win here either.
// PlanModels from project config is IGNORED ENTIRELY — pins and exclusions
// alike.
//
// The pin half was always refused: a cloned repo pinning all three roles to
// the priciest model on the account raises cost for whoever opens it, the
// same hazard the PlanSize rule below exists to prevent.
//
// EXCLUSION WAS ALLOWED ON A FALSE PREMISE — that removing a candidate "can
// only ever lower what a plan spends". It does the opposite just as easily.
// The selector picks per role from what is left, so excluding the cheapest
// scan model does not remove the scan, it promotes the next model up. A repo
// listing every inexpensive id on the account steers all three roles onto
// the priciest survivor while naming no model to run at all — the pin's
// effect, spelled as its inverse, and it would have passed a reviewer reading
// the old comment.
//
// Ignored silently, like the network "allow" above: a project that does not
// hold this privilege is not a malformed project. USER config still sets both
// freely, which is where a genuine "this model cannot handle our codebase"
// belongs — the repo's maintainer writes it there, or says it in the README.
if strings.TrimSpace(src.Profiles.PlanSize) != "" {
if size, err := ParsePlanSize(src.Profiles.PlanSize); err == nil {
if size.rank() < dst.Profiles.PlanSizeTier().rank() {
dst.Profiles.PlanSize = string(size)
}
}
}
if inbound := strings.TrimSpace(src.CrossSessionInbound); inboundPolicyRank(inbound) > inboundPolicyRank(dst.CrossSessionInbound) {
dst.CrossSessionInbound = inbound
}
+182
View File
@@ -120,6 +120,17 @@ type PreferencesConfig struct {
// the user turned idle recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
Recaps *bool `json:"recaps,omitempty"`
// KeepFinishedAgents makes the AGENTS panel KEEP finished sub-agents instead
// of dropping them 1.5s after they complete.
//
// The drop is the default (PR #345, "agents never disappear"), and the panel
// has a click-to-toggle to override it per session — but the toggle resets
// every session. This is the standing preference: set true and finished
// agents stay for the whole session, every session, without clicking.
//
// A *bool tri-state like Recaps: nil is unset (the drop-after-linger
// default), true keeps them, false is the explicit drop.
KeepFinishedAgents *bool `json:"keepFinishedAgents,omitempty"`
// CompactionModel routes compaction summarization calls to this model
// instead of the session's main model (summaries at main-model prices are
// the most expensive recurring event in long runs). Empty = automatic: a
@@ -145,6 +156,15 @@ func (p PreferencesConfig) RecapsEnabled() bool {
return p.Recaps == nil || *p.Recaps
}
// KeepsFinishedAgents reports the standing preference for the AGENTS panel.
//
// Defaults FALSE, unlike RecapsEnabled which defaults true: dropping finished
// agents after a linger is the established product default (PR #345), so an
// unset preference keeps it. Only an explicit true makes them stay.
func (p PreferencesConfig) KeepsFinishedAgents() bool {
return p.KeepFinishedAgents != nil && *p.KeepFinishedAgents
}
// KeyBindingDef defines one key binding string (e.g. "ctrl+o") that the TUI
// can remap. An empty string means "use the built-in default" for that action.
type KeyBindingDef string
@@ -346,6 +366,129 @@ type SwarmConfig struct {
MaxTeamSize int `json:"maxTeamSize,omitempty"`
}
// ProfilesConfig gates the execution-profile catalog.
// PlanModelsConfig pins or forbids models for a plan's tasks. Every field is
// optional; an empty config leaves the automatic choice untouched.
type PlanModelsConfig struct {
// Scan / Implement / Verify pin a model per task role. A pinned role skips
// discovery entirely, so it works on a provider that reports no prices —
// which is most of them.
Scan string `json:"scan,omitempty"`
Implement string `json:"implement,omitempty"`
Verify string `json:"verify,omitempty"`
// Router names the model that DECIDES which model runs each task, by reading
// the task text instead of matching verbs. Empty uses the strongest model
// discovery found. It costs one extra call on that model before the plan
// starts, which is why a plan too small to benefit skips it and why every
// way it can fail falls back to the keyword classifier.
Router string `json:"router,omitempty"`
// RouterGuidance is your own advice to the router, in plain words, added to
// the built-in guidance rather than replacing it.
//
// It exists because the code cannot know your models. Price is the only
// ordering discovery gives, and on real accounts it lies in both directions:
// one provider's most expensive model was a build preview that failed every
// task it touched, another reports no prices at all so the ranking collapses
// to alphabetical. You know which model reasons well and which is quick and
// shallow; this is where you say so, once, instead of per prompt.
//
// Example: "kimi-k2.6 is the best reasoner here — prefer it for judgements.
// qwen3.5:397b is slow; use it only when correctness really matters."
RouterGuidance string `json:"routerGuidance,omitempty"`
// AutoAssign turns per-task model selection on for EVERY plan, so it does not
// have to be asked for in each prompt.
//
// The tool argument still wins when a plan supplies one, so a plan can say
// auto_assign:false and be believed. Absent from config it stays off: turning
// it on changes which model does the work and what a plan costs, and that is
// a decision for the person paying, made once here rather than inferred.
AutoAssign bool `json:"autoAssign,omitempty"`
// Exclude removes models from every tier by exact id. For the ones that are
// eligible on paper and wrong in practice: an image model that ranks highest
// on price, or a preview build that should not be judging anything.
Exclude []string `json:"exclude,omitempty"`
// MinSize is the smallest model, in BILLIONS of parameters, worth routing a
// task to. A provider that lists tiny models (a 135M or 1B toy) alongside
// decent ones would otherwise see the toy chosen as the "cheapest" tier for a
// scan; this floor drops any model KNOWN to be smaller, so a task lands on a
// decent model instead. Size is read from the model id ("qwen3-coder:480b").
//
// A model whose id names no size is NOT dropped — an unknown size is not
// evidence of a small one, and most cloud ids carry none. And if the floor
// would leave a provider with no models at all, it is ignored: a plan running
// on a small model beats a plan that cannot run. 0 means no floor.
MinSize float64 `json:"minSize,omitempty"`
// TopModels caps how many of the most capable discovered models a plan may
// route to, after exclusions and the size floor. A provider listing twenty
// models otherwise puts its SMALLEST on the cheap tier and hands the router
// twenty candidates; keeping the best ten narrows both to models worth a
// sub-agent. Unset (0) is the built-in default of 10; raise it to widen the
// field. A pin outside the list still applies — pins are validated against
// what the provider serves, not against this shortlist.
TopModels int `json:"topModels,omitempty"`
}
type ProfilesConfig struct {
// DisableZeromaxing turns the zeromaxing posture off for this workspace, so
// /effort zeromaxing, /profile zeromaxing and --exec-profile zeromaxing are
// refused with a reason.
//
// Deliberately a DISABLE-only boolean, mirroring Sandbox.BlockUnixSockets
// and the tighten-only Sandbox.Network rule: a project .zero/config.json may
// set it true, but can never set it back to false, so a cloned repo cannot
// switch a cost multiplier ON for whoever opens it. Only global user config
// may leave it off. See mergeProjectConfig.
DisableZeromaxing bool `json:"disableZeromaxing,omitempty"`
// PlanSize is the zeromaxing plan-size tier: how many tasks one plan may
// contain. One of small, medium (the default), large, unrestricted — see
// plan_size.go for the numbers and for why this stays a hard cap rather than
// an advisory warning.
//
// Project config may only TIGHTEN it, for the same reason DisableZeromaxing
// is disable-only: a cloned repo must not be able to raise a cost ceiling for
// whoever opens it. See mergeProjectConfig.
PlanSize string `json:"planSize,omitempty"`
// RequirePlanKeyword makes the orchestrate tool refuse a plan unless the
// turn's own user text asks for one ("run a plan for ...", "fan out ...").
//
// Off by default, and that is a real trade. Once the posture is on the tool
// exists for the rest of the session, and everything the model reads shares
// context with the user's instructions — file contents, PR comments, MCP
// output. An imperative sentence in any of them reads like an instruction to
// the tool that spends the most. On by default would refuse a plan for every
// user whose phrasing happens not to match, which is the call this codebase
// already made for auto_assign.
//
// Project config may only ENABLE it, never switch it back off: turning a
// safety gate off is a downgrade a cloned repo must not be able to make for
// whoever opens it. See mergeProjectConfig.
RequirePlanKeyword bool `json:"requirePlanKeyword,omitempty"`
// Memory enables the durable note store: the `memory` and `memory_write`
// tools, reading and writing .zero/memory.
//
// USER CONFIG ONLY. memory_write is a write primitive pointed at the
// workspace, and project config is not trust-gated — a cloned repo must not
// be able to hand the agent a new way to write into it. mergeProjectConfig
// ignores this field entirely, the same answer PlanModels gets.
Memory bool `json:"memory,omitempty"`
// PlanModels states which models a plan's tasks may run on, overriding the
// automatic choice auto_assign would make from provider discovery.
//
// It exists because the automatic choice ranks by PRICE, and price is a proxy
// that fails in both directions on real accounts: an xAI account put a build
// preview on the verify tier because it was the most expensive thing there,
// and an Ollama account reports no prices at all, so the ranking collapses to
// alphabetical order. Neither is fixable with a better heuristic — the person
// with the account knows which model is strongest and the code does not.
//
// PROJECT CONFIG MAY ONLY EXCLUDE, never pin — see mergeProjectConfig. An
// exclusion removes a candidate and can only lower what a plan spends; a pin
// is the opposite, and a cloned repo pinning all three roles to the priciest
// model on the account would raise cost for whoever opened it. That is the
// same hazard PlanSize and DisableZeromaxing guard against.
PlanModels PlanModelsConfig `json:"planModels,omitempty"`
}
func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
type rawTools struct {
DeferThreshold *int `json:"deferThreshold"`
@@ -373,6 +516,7 @@ type FileConfig struct {
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Profiles ProfilesConfig `json:"profiles,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl LocalControlConfig `json:"localControl,omitempty"`
@@ -399,6 +543,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Profiles ProfilesConfig `json:"profiles,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
@@ -416,6 +561,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Profiles: cfg.Profiles,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
CrossSessionInbound: cfg.CrossSessionInbound,
@@ -501,6 +647,7 @@ type ResolvedConfig struct {
Notify NotifyConfig
Tools ToolsConfig
Swarm SwarmConfig
Profiles ProfilesConfig
Preferences PreferencesConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
@@ -564,6 +711,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
Notify NotifyConfig `json:"notify"`
Tools ToolsConfig `json:"tools"`
Swarm SwarmConfig `json:"swarm"`
Profiles ProfilesConfig `json:"profiles"`
Preferences PreferencesConfig `json:"preferences"`
KeyBindings KeyBindingsConfig `json:"keybindings"`
LocalControl LocalControlConfig `json:"localControl"`
@@ -611,6 +759,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.Notify = raw.Notify
cfg.Tools = raw.Tools
cfg.Swarm = raw.Swarm
cfg.Profiles = raw.Profiles
cfg.Preferences = raw.Preferences
cfg.KeyBindings = raw.KeyBindings
cfg.LocalControl = raw.LocalControl
@@ -724,6 +873,39 @@ func (cfg LocalControlDriverConfig) MarshalJSON() ([]byte, error) {
return json.Marshal(raw)
}
// MarshalJSON writes "disabled" whenever it was explicitly set, not only when
// true. A read-modify-write of config.json (SetTheme, SetActiveProvider, ...)
// would otherwise drop `"disabled": false`, which turns a default that ships
// disabled (aimlapi) back off and lets project config override a user's
// explicit enable (see mergeMCPServer).
func (server MCPServerConfig) MarshalJSON() ([]byte, error) {
type rawServer struct {
Type string `json:"type,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Auth string `json:"auth,omitempty"`
OAuth *MCPOAuthConfig `json:"oauth,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
}
raw := rawServer{
Type: server.Type,
Command: server.Command,
Args: server.Args,
Env: server.Env,
URL: server.URL,
Headers: server.Headers,
Auth: server.Auth,
OAuth: server.OAuth,
}
if server.disabledSet || server.Disabled {
raw.Disabled = &server.Disabled
}
return json.Marshal(raw)
}
func (server *MCPServerConfig) UnmarshalJSON(data []byte) error {
type rawServer struct {
Type string `json:"type"`
+30
View File
@@ -697,6 +697,36 @@ func SetRecentModels(path string, entries []RecentModelEntry) (result FileConfig
// SetRecapsEnabled persists the idle recap preference, mirroring
// SetFavoriteModels (read-modify-atomic-write).
func SetKeepFinishedAgents(path string, keep bool) (result FileConfig, err error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
unlock, err := lockConfigFileFn(path)
if err != nil {
return FileConfig{}, err
}
// Joined, not chosen between: a release failure annotates the result
// instead of masking the mutation error that actually explains what went
// wrong. Reporting success after a failed unlock would claim a state the
// next mutation cannot reproduce.
defer func() { err = errors.Join(err, unlock()) }()
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
v := keep
cfg.Preferences.KeepFinishedAgents = &v
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
func SetRecapsEnabled(path string, enabled bool) (result FileConfig, err error) {
path = strings.TrimSpace(path)
if path == "" {
+202 -3
View File
@@ -17,6 +17,7 @@ package execprofile
import (
"sort"
"strconv"
"strings"
"github.com/Twigpine/zero/internal/agent"
@@ -34,6 +35,14 @@ type Profile struct {
// user did not pass an explicit --max-turns. The displaced resolved value
// becomes the escalation target (see Policy).
MaxTurns int
// MaxTokens bounds what a run may SPEND, in provider-reported tokens. 0 is
// unbounded, which is every profile that does not set it.
//
// A TURN COUNT IS NOT A COST BOUND. A measured heavy run reached the 320-turn
// ceiling having spent 35,781,390 tokens; a lighter one reached it at a tenth
// of that. Raising the turn budget without this would double the worst case
// while still ending runs at an arbitrary place, so the two move together.
MaxTokens int
// ReasoningEffort fills the run's effort when the user left it unset. It
// still flows through the model-supported gating at the call site, so an
// unsupported level degrades exactly like an explicit flag would.
@@ -94,12 +103,202 @@ var (
ReasoningEffort: "high",
SelfCorrect: true,
}
// Zeromaxing is the top posture: thorough's knobs with TRIPLE the turn
// budget, plus the loop-posture reminders the agent injects while it is
// active (see agent.Zeromaxing).
//
// It was double, and 320 turns was cutting real work short — a measured run
// spent every one of them on legitimate sequential work and was forced into a
// summary before it could report what it had found. The multiple stays exact
// so the relationship remains something a reader can check.
//
// HONEST DELTA over thorough: the turn budget, and nothing else. Effort is
// already at the ceiling and self-correction is already armed. Delta states
// that to the user's face rather than leaving it in a commit message — a
// posture whose advertised behaviour exceeds its real behaviour is worse
// than no posture at all.
//
// ReasoningEffort is "high" and MUST STAY "high" — pinned by
// TestZeromaxingReasoningEffortStaysHigh. "high" is the top rung every
// provider actually maps: anthropic's thinkingBudgetForEffort and its gemini
// twin fall through to their default arm (budget 0 => extended thinking
// DISABLED) for anything above it, and the openai mapper drops the field
// entirely. Raising this to "xhigh"/"max" would silently turn thinking OFF —
// a downgrade wearing an upgrade's name. Raising the real ceiling means
// teaching the providers those tiers first.
Zeromaxing = Profile{
Name: "zeromaxing",
// 480, raised from 320 because 320 was cutting real work short: a measured
// run spent all of it on legitimate sequential edit-verify work and was
// forced into a summary before it could write up what it had found.
//
// 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 around 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.
MaxTurns: 480,
// The bound that is meant to actually fire. 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 — which at 480 turns and a growing
// prompt would otherwise reach well over 100M.
//
// TWO RUNS IS A THIN BASIS. Revisit it once more heavy runs have been
// measured; the mechanism is what matters here, and the number is the part
// most likely to be wrong.
MaxTokens: 50_000_000,
ReasoningEffort: "high",
SelfCorrect: true,
}
)
var catalog = map[string]Profile{
Balanced.Name: Balanced,
Fast.Name: Fast,
Thorough.Name: Thorough,
Balanced.Name: Balanced,
Fast.Name: Fast,
Thorough.Name: Thorough,
Zeromaxing.Name: Zeromaxing,
}
// DeltaState is the caller's CURRENT state, from which Delta describes what
// selecting the posture will actually change for THEM.
//
// It exists because the honest answer is caller-specific in every clause. The
// first version of this text compared against THOROUGH — "reasoning effort:
// unchanged", "thorough uses 160" — which is information about two profiles,
// not about the user. Worse, it could contradict itself: the fixed effort
// clause claimed "unchanged" while a separate line underneath said the effort
// was NOT raised because the model refused it. Both cannot be true, and they
// were only ever consistent by coincidence because they came from two places.
//
// Delta now renders every clause from this one struct, so a contradiction is
// unrepresentable rather than merely unlikely.
type DeltaState struct {
// CurrentMaxTurns is the turn budget in effect BEFORE the posture applies.
// 0 means unknown, in which case the budget clause states the destination
// without inventing an origin.
CurrentMaxTurns int
Effort EffortTransition
SelfCorrect SelfCorrectTransition
}
// EffortTransition is what the posture does to reasoning effort for this
// caller. Exactly one applies, which is what makes the rendered clauses
// mutually exclusive by construction.
type EffortTransition int
const (
// EffortRaised: the posture filled the effort with its level.
EffortRaised EffortTransition = iota
// EffortKeptExplicit: the caller had already chosen an effort, so the
// posture backed off. Their choice stands, whatever it is.
EffortKeptExplicit
// EffortNotSupported: the active model is KNOWN not to accept the level, so
// the effort is not raised. The rest of the posture still applies.
EffortNotSupported
)
func (t EffortTransition) line(level string) string {
switch t {
case EffortKeptExplicit:
return "reasoning effort: unchanged (your explicit choice stands)"
case EffortNotSupported:
return "reasoning effort: NOT raised to " + level + " — the active model does not accept that level; the rest of the posture still applies"
default:
return "reasoning effort: raised to " + level
}
}
// SelfCorrectTransition is what selecting the posture does to post-edit
// verification, relative to the state the caller is ACTUALLY in.
//
// 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
// documentation describing behaviour that is not happening.
type SelfCorrectTransition int
const (
// SelfCorrectRaised: verification was LSP-only and the posture adds the
// project test plan. The common case.
SelfCorrectRaised SelfCorrectTransition = iota
// SelfCorrectAlreadyOn: the deeper verification was already on, so the
// posture genuinely changes nothing here.
SelfCorrectAlreadyOn
// SelfCorrectOverridden: an explicit /selfcorrect choice is holding it at
// lsp despite the posture.
SelfCorrectOverridden
)
// selfCorrectLine renders the transition using /selfcorrect's own vocabulary
// (lsp / tests), so it names states the user can actually type.
func (t SelfCorrectTransition) selfCorrectLine() string {
switch t {
case SelfCorrectAlreadyOn:
return "self-correct: unchanged (tests)"
case SelfCorrectOverridden:
return "self-correct: lsp (your /selfcorrect choice overrides the posture)"
default:
return "self-correct: lsp → tests"
}
}
// budgetLine states the turn-budget change from the caller's own budget, not
// from another profile's. A user on balanced/80 does not care what thorough
// uses; they care that their 80 becomes 320.
func budgetLine(current int) string {
switch {
case current <= 0:
return "turn budget: " + strconv.Itoa(Zeromaxing.MaxTurns)
case current == Zeromaxing.MaxTurns:
return "turn budget: unchanged (" + strconv.Itoa(current) + ")"
default:
return "turn budget: " + strconv.Itoa(current) + " → " + strconv.Itoa(Zeromaxing.MaxTurns)
}
}
// Delta is the user-facing statement of what selecting zeromaxing actually
// changes FOR THIS CALLER, shown by /effort, /profile, and the exec selection
// notice. It is deliberately concrete and deliberately admits what it does not
// move: a user paying for a higher posture is owed the real delta.
//
// The child-budget sentence is not a caveat, it is the point: this is a maximal
// posture, so the raised budget is exported to spawned sub-agents exactly as
// /turns does (asserted by TestZeromaxingTurnBudgetPropagatesToChildren).
func Delta(state DeltaState) string {
return budgetLine(state.CurrentMaxTurns) + ", and that budget applies to spawned sub-agents too. " +
state.Effort.line(Zeromaxing.ReasoningEffort) + ". " +
state.SelfCorrect.selfCorrectLine() + "."
}
// Name is the single spelling of this posture, everywhere: /effort zeromaxing,
// /profile zeromaxing, --exec-profile zeromaxing, --reasoning-effort zeromaxing.
// One name, one table — no aliases, so there is no second spelling to keep in
// step with this one.
const Name = "zeromaxing"
// IsZeromaxing reports whether p is the zeromaxing posture. Callers use it
// instead of comparing name strings so the literal lives in exactly one place.
func (p Profile) IsZeromaxing() bool { return p.Name == Name }
// SelectionRefusal returns a non-empty, user-facing reason when the named
// profile may not be selected here, or "" when selection is allowed.
//
// It is the ONE authoritative selection rule, called by BOTH the headless exec
// path and the TUI /effort + /profile paths. Those paths already differ in how
// they apply a profile's knobs; letting each decide selection independently is
// exactly how a rule gets applied to one call path and silently omitted from
// its sibling. TestSelectionRefusalAgreesAcrossPaths pins that they agree.
//
// disabled comes from resolved config. A project .zero/config.json may set it
// (DISABLE) but can never clear it (ENABLE) — see mergeProjectConfig.
func SelectionRefusal(p Profile, disabled bool) string {
if p.IsZeromaxing() && disabled {
return "the zeromaxing posture is disabled for this workspace (profiles.disableZeromaxing in config)"
}
return ""
}
// Lookup resolves a profile by name, case-insensitively and ignoring
+1 -1
View File
@@ -38,7 +38,7 @@ func TestLookupIsCaseAndSpaceInsensitive(t *testing.T) {
}
func TestNamesAreSorted(t *testing.T) {
want := []string{"balanced", "fast", "thorough"}
want := []string{"balanced", "fast", "thorough", "zeromaxing"}
if got := Names(); !reflect.DeepEqual(got, want) {
t.Fatalf("Names() = %v, want %v", got, want)
}
+249
View File
@@ -0,0 +1,249 @@
package execprofile
import (
"strings"
"testing"
)
// (d) THE TRAP PIN.
//
// Zeromaxing.ReasoningEffort must stay "high". Every level above it falls
// through the providers' effort→budget mappers into their DEFAULT arm, and
// those defaults mean "no extended thinking":
//
// anthropic thinkingBudgetForEffort default -> 0 (thinking disabled)
// gemini thinkingBudgetForEffort default -> 0 (thinking disabled)
// openai openAIReasoningEffort default -> "" (field omitted entirely)
//
// So "raising" this to xhigh/max would silently turn reasoning OFF while the UI
// claimed a higher posture — a downgrade wearing an upgrade's name, invisible
// at runtime. Raising the real ceiling means teaching the providers those tiers
// first; until then this test is what stands in the way.
func TestZeromaxingReasoningEffortStaysHigh(t *testing.T) {
if Zeromaxing.ReasoningEffort != "high" {
t.Fatalf("Zeromaxing.ReasoningEffort = %q, want \"high\" — see this test's comment: "+
"any level above \"high\" hits the providers' default arm and DISABLES thinking",
Zeromaxing.ReasoningEffort)
}
// Thorough is the rung below and shares the ceiling; if these ever diverge,
// the honest-delta text is wrong.
if Thorough.ReasoningEffort != Zeromaxing.ReasoningEffort {
t.Fatalf("thorough effort %q != zeromaxing effort %q — Delta claims they are equal",
Thorough.ReasoningEffort, Zeromaxing.ReasoningEffort)
}
}
// One name, one table. No aliases means no second spelling to keep in step.
func TestZeromaxingHasExactlyOneSpelling(t *testing.T) {
profile, ok := Lookup(Name)
if !ok {
t.Fatalf("%q is not in the catalog", Name)
}
if profile != Zeromaxing || !profile.IsZeromaxing() {
t.Fatalf("Lookup(%q) = %+v, want %+v", Name, profile, Zeromaxing)
}
// Case/whitespace insensitivity, matching the other profiles.
if p, ok := Lookup(" ZEROMAXING "); !ok || !p.IsZeromaxing() {
t.Fatalf("Lookup is not case/space insensitive: %+v ok=%v", p, ok)
}
// The rejected spellings. Every one of these resolving would mean a second
// name for one posture, which is the drift this design exists to avoid.
for _, alias := range []string{"max", "deep", "deepmode", "zero-maxing", "zeromax", "zm"} {
if _, ok := Lookup(alias); ok {
t.Fatalf("%q must NOT resolve — the posture has exactly one name, %q", alias, Name)
}
}
found := false
for _, name := range Names() {
if name == Name {
found = true
}
}
if !found {
t.Fatalf("Names() omits %q, so usage text and hints will not offer it: %v", Name, Names())
}
}
// The knobs, pinned. The turn budget is the ONLY mechanical delta over
// thorough — Delta says exactly that to the user, so if these drift apart the
// user-facing claim becomes false.
func TestZeromaxingKnobsMatchTheDeltaItAdvertises(t *testing.T) {
if Zeromaxing.MaxTurns != 480 {
t.Fatalf("Zeromaxing.MaxTurns = %d, want 480", Zeromaxing.MaxTurns)
}
if Zeromaxing.MaxTurns != Thorough.MaxTurns*3 {
t.Fatalf("budget %d is not triple thorough's %d", Zeromaxing.MaxTurns, Thorough.MaxTurns)
}
if !Zeromaxing.SelfCorrect || !Thorough.SelfCorrect {
t.Fatalf("Delta claims self-correction is already armed at thorough: zeromaxing=%v thorough=%v",
Zeromaxing.SelfCorrect, Thorough.SelfCorrect)
}
// It arms no escalation triggers (it is the top rung — nothing to escalate
// to), so Policy returns nil exactly like thorough and balanced.
if policy := Zeromaxing.Policy(80, true); policy != nil {
t.Fatalf("zeromaxing must arm no escalation policy, got %+v", policy)
}
// The budget clause states the CALLER's transition, not another profile's.
got := Delta(DeltaState{CurrentMaxTurns: 80})
for _, want := range []string{"turn budget: 80 → 480", "sub-agents"} {
if !strings.Contains(got, want) {
t.Fatalf("Delta must state %q:\n%s", want, got)
}
}
// ...and must NOT mention thorough's number, which is about two profiles
// rather than about the user (bug 3).
if strings.Contains(got, "160") {
t.Fatalf("the delta must not compare against thorough's budget:\n%s", got)
}
}
// (3) The budget clause is caller-relative in every case, including the two
// edges where a plain "X -> 480" would be wrong.
func TestDeltaBudgetLineIsCallerRelative(t *testing.T) {
cases := []struct {
name string
current int
want string
absent string
}{
{"balanced default", 80, "turn budget: 80 → 480", "160"},
{"already at the posture budget", 480, "turn budget: unchanged (480)", "→"},
{"unknown current budget", 0, "turn budget: 480", "→"},
{"a pinned lower budget", 30, "turn budget: 30 → 480", "160"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Delta(DeltaState{CurrentMaxTurns: tc.current})
if !strings.Contains(got, tc.want) {
t.Fatalf("Delta(current=%d) must contain %q:\n%s", tc.current, tc.want, got)
}
clause := got[:strings.Index(got, ", and that budget")]
if tc.absent != "" && strings.Contains(clause, tc.absent) {
t.Fatalf("Delta(current=%d) budget clause must not contain %q: %q", tc.current, tc.absent, clause)
}
})
}
}
// (2) THE CONTRADICTION. The effort clause has exactly one arm, so "unchanged"
// and "NOT raised" can never both appear. They did in real use, because the
// fixed clause lived in Delta and the refusal lived in a separate line.
func TestDeltaEffortClauseIsMutuallyExclusive(t *testing.T) {
cases := []struct {
name string
transition EffortTransition
want string
}{
{"raised", EffortRaised, "reasoning effort: raised to high"},
{"kept explicit", EffortKeptExplicit, "reasoning effort: unchanged (your explicit choice stands)"},
{"not supported", EffortNotSupported, "reasoning effort: NOT raised to high"},
}
seen := map[string]bool{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Delta(DeltaState{CurrentMaxTurns: 80, Effort: tc.transition})
if !strings.Contains(got, tc.want) {
t.Fatalf("Delta(%v) must contain %q:\n%s", tc.transition, tc.want, got)
}
// The contradiction, stated directly: never both.
raised := strings.Contains(got, "NOT raised")
unchanged := strings.Contains(got, "reasoning effort: unchanged")
if raised && unchanged {
t.Fatalf("Delta(%v) claims BOTH that the effort is unchanged and that it was not raised:\n%s",
tc.transition, got)
}
if seen[got] {
t.Fatalf("two effort transitions render identically:\n%s", got)
}
seen[got] = true
})
}
}
// The self-correct clause must describe the transition from the CALLER'S state,
// not from thorough. Telling a user sitting on LSP-only that self-correction is
// "already armed" while silently moving them to the project test plan is
// documentation describing behaviour that is not happening.
func TestDeltaSelfCorrectDescribesTheCallersTransition(t *testing.T) {
cases := []struct {
name string
transition SelfCorrectTransition
want string
absent string
}{
{"lsp-only user is told what changes", SelfCorrectRaised, "self-correct: lsp → tests", "unchanged"},
{"already-on user is told nothing changes", SelfCorrectAlreadyOn, "self-correct: unchanged (tests)", "→"},
{"overridden user is not promised a raise", SelfCorrectOverridden, "overrides the posture", "→"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Delta(DeltaState{CurrentMaxTurns: 80, SelfCorrect: tc.transition})
if !strings.Contains(got, tc.want) {
t.Fatalf("Delta(%v) must contain %q, got:\n%s", tc.transition, tc.want, got)
}
// Scoped to the self-correct CLAUSE: "unchanged" also appears in the
// effort clause, where it is correct and caller-independent.
clause := got[strings.Index(got, "self-correct:"):]
if tc.absent != "" && strings.Contains(clause, tc.absent) {
t.Fatalf("Delta(%v) self-correct clause must NOT contain %q, got:\n%s", tc.transition, tc.absent, clause)
}
// Every rendering keeps the caller-independent clauses.
if !strings.Contains(got, "turn budget:") || !strings.Contains(got, "reasoning effort:") {
t.Fatalf("Delta(%v) dropped a clause:\n%s", tc.transition, got)
}
})
}
// The three renderings must be distinguishable — a switch that fell through
// to one arm would otherwise pass every containment check above.
seen := map[string]bool{}
for _, tr := range []SelfCorrectTransition{SelfCorrectRaised, SelfCorrectAlreadyOn, SelfCorrectOverridden} {
out := Delta(DeltaState{CurrentMaxTurns: 80, SelfCorrect: tr})
if seen[out] {
t.Fatalf("two transitions render identically:\n%s", out)
}
seen[out] = true
}
}
// (m)+(n) at the rule level: SelectionRefusal is the single authority both
// selection paths consult. Its callers are asserted separately (CLI and TUI).
func TestSelectionRefusalDisablesOnlyZeromaxingAndOnlyWhenDisabled(t *testing.T) {
if refusal := SelectionRefusal(Zeromaxing, true); refusal == "" {
t.Fatal("a disabled workspace must refuse zeromaxing")
} else if !strings.Contains(refusal, "disableZeromaxing") {
t.Fatalf("the refusal must name the setting so the user can act on it: %q", refusal)
}
if refusal := SelectionRefusal(Zeromaxing, false); refusal != "" {
t.Fatalf("zeromaxing must be selectable when not disabled, got %q", refusal)
}
// The disable flag is posture-scoped: it must not silently take out the
// other profiles, which have no cost-multiplier justification for gating.
for _, other := range []Profile{Balanced, Fast, Thorough} {
if refusal := SelectionRefusal(other, true); refusal != "" {
t.Fatalf("disableZeromaxing must not refuse %q: %q", other.Name, refusal)
}
}
}
// THE SPEND BUDGET AND THE TURN BUDGET MOVE TOGETHER.
//
// Raising the turn ceiling without bounding spend doubles the worst case while
// still ending runs at an arbitrary place: the measured run that forced this
// change spent 35,781,390 tokens reaching 320 turns, and 480 turns on the same
// growing prompt would reach well over 100M. The number is anchored on that
// measurement — above what the largest legitimate run needed, below a runaway.
func TestZeromaxingBoundsSpendNotOnlyTurns(t *testing.T) {
if Zeromaxing.MaxTokens <= 0 {
t.Fatal("the posture raises the turn ceiling and bounds no spend at all")
}
if Zeromaxing.MaxTokens != 50_000_000 {
t.Fatalf("Zeromaxing.MaxTokens = %d, want 50000000", Zeromaxing.MaxTokens)
}
// Every other profile leaves it unbounded, so nothing outside the posture
// gains a ceiling nobody chose.
for _, profile := range []Profile{Balanced, Thorough, Fast} {
if profile.MaxTokens != 0 {
t.Errorf("%s bounds spend at %d; only the posture should", profile.Name, profile.MaxTokens)
}
}
}
@@ -0,0 +1,314 @@
package execution
import (
"context"
"os/exec"
"runtime"
"strings"
"sync"
"testing"
"time"
)
// THE TWO NUMBERS MUST NOT CONTRADICT EACH OTHER.
//
// Retention was 30 seconds while maxEmptyPollYield allowed a five-minute poll,
// so a caller Zero itself invites to wait five minutes could arrive to find the
// answer already forgotten. Pinned as a RELATIONSHIP, not a value, so changing
// the poll bound cannot silently reintroduce the gap.
func TestCompletedRetentionCoversTheLongestPoll(t *testing.T) {
if defaultCompletedRetention < maxEmptyPollYield {
t.Fatalf("a finished session is forgotten after %s while a poll may wait %s: "+
"a caller that waits the full poll can arrive after the answer is gone",
defaultCompletedRetention, maxEmptyPollYield)
}
}
// A LATE POLL GETS THE RESULT, NOT AN ACCUSATION.
//
// The measured failure: a 60-second test was started, the caller polled with
// yield_time_ms 40000, and the id was already gone — so the same test was run a
// second time to recover a result the first had produced. The reply told the
// caller not to probe session ids, about an id this manager had issued and
// instructed it to poll.
func TestALatePollOnAFinishedSessionGetsItsResult(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{})
manager.remember(ProcessResult{
ProcessID: 1004,
CommandText: "go test -run=TestDifferentialFuzz60s ./diff/",
Output: "--- PASS: TestDifferentialFuzz60s (60.00s)\nok \tmini/diff\t60.330s\n",
Exited: true,
ExitCode: 0,
})
// The process itself is long gone; only the record remains.
manager.Remove(1004)
result, err := manager.Continue(t.Context(), ProcessContinue{ProcessID: 1004})
if err != nil {
t.Fatalf("a session that ran and finished was refused: %v", err)
}
if !result.Exited || result.ExitCode != 0 {
t.Errorf("the finished result was not carried back: %+v", result)
}
if !strings.Contains(result.Output, "TestDifferentialFuzz60s") {
t.Errorf("the output was lost, so the work has to be done again:\n%s", result.Output)
}
}
// AN ID THIS MANAGER NEVER ISSUED IS STILL REFUSED. The accusation exists for a
// real probe, and softening it for everything would remove the signal the
// repeated-failure guard keys on.
func TestAnIdThatNeverRanIsStillNotFound(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{})
if _, err := manager.Continue(t.Context(), ProcessContinue{ProcessID: 9999}); err != ErrProcessNotFound {
t.Fatalf("a never-issued id returned %v, want ErrProcessNotFound", err)
}
}
// The record is bounded: a long session cannot accumulate them without limit,
// and the OLDEST is dropped first so the most recent work stays answerable.
func TestRememberedCompletionsAreBoundedOldestFirst(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{})
total := maxRememberedCompletions + 10
for i := 0; i < total; i++ {
manager.remember(ProcessResult{ProcessID: 1000 + i, Exited: true, Output: "x"})
}
if got := len(manager.completed); got != maxRememberedCompletions {
t.Fatalf("kept %d records, want the bound of %d", got, maxRememberedCompletions)
}
if _, ok := manager.Completed(1000); ok {
t.Error("the oldest record survived the bound")
}
if _, ok := manager.Completed(1000 + total - 1); !ok {
t.Error("the newest record was dropped; recent work is what a late poll asks about")
}
}
// Only a FINISHED session is remembered — a still-running one is answered by the
// live process, and recording it would hand back a result it has not reached.
func TestOnlyFinishedSessionsAreRemembered(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{})
manager.remember(ProcessResult{ProcessID: 1001, Exited: false, Output: "partial"})
if _, ok := manager.Completed(1001); ok {
t.Error("a still-running session was recorded as finished")
}
}
// Retention is the same terminal result, not a lossy second representation.
func TestARememberedResultPreservesTerminalSemantics(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{})
long := strings.Repeat("noise\n", recentOutputBytes) + "FINAL: ok"
want := ProcessResult{
ProcessID: 1002, CommandText: "go test ./...", RelativeCwd: "sub", TTY: true,
Exited: true, ExitCode: 7, Interrupted: true, Output: long, OutputTruncated: true,
Request: Request{WorkingDirectory: "/workspace", WorkspaceRoots: []string{"/workspace"}},
Enforcement: Enforcement{Backend: "seatbelt", Level: "workspace-write"},
Changes: []Change{{Path: "changed.go", Kind: ChangeModified}},
Metadata: map[string]string{"sandbox": "active"},
}
manager.remember(want)
got, ok := manager.Completed(1002)
if !ok {
t.Fatal("not remembered")
}
if got.Output != want.Output || got.CommandText != want.CommandText || got.RelativeCwd != want.RelativeCwd || got.TTY != want.TTY || got.ExitCode != want.ExitCode || !got.Interrupted || !got.OutputTruncated {
t.Fatalf("terminal result lost execution fields: got %+v want %+v", got, want)
}
if got.Request.WorkingDirectory != want.Request.WorkingDirectory || got.Enforcement != want.Enforcement || len(got.Changes) != 1 || got.Metadata["sandbox"] != "active" {
t.Fatalf("terminal result lost policy or mutation evidence: %+v", got)
}
}
func TestTerminalCollectionHasOneOwner(t *testing.T) {
for attempt := 0; attempt < 100; attempt++ {
process := &managedProcess{
id: 1000 + attempt, output: newProcessOutputBuffer(), done: make(chan struct{}), reaped: make(chan struct{}),
}
_, _ = process.output.Write([]byte("FINAL-RESULT"))
process.markDone(nil, 0, AdapterReport{}, nil, nil)
start := make(chan struct{})
results := make(chan ProcessResult, 2)
for collector := 0; collector < 2; collector++ {
go func() {
<-start
results <- process.collectResult(context.Background(), 0, false)
}()
}
close(start)
first, second := <-results, <-results
if first.Output != "FINAL-RESULT" || second.Output != "FINAL-RESULT" {
t.Fatalf("attempt %d: collectors observed different terminal output: %q / %q", attempt, first.Output, second.Output)
}
}
}
// THE CASE THAT WAS ACTUALLY MEASURED: nobody polls while it runs.
//
// A caller starts a long command, gets a session id, goes away to do other work,
// and comes back after it finished. Recording the result only on the polling
// paths leaves exactly this case with nothing to hand back — and it is the
// common one, because the reason to background a command is to do something else
// meanwhile. In the measured run this cost a 60-second test being run twice.
func TestASessionNobodyPolledIsStillAnswerable(t *testing.T) {
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
// The behaviour under test — a finished background process staying
// answerable — is OS-agnostic, so unlike this package's POSIX-shell tests
// this one runs everywhere rather than skipping on Windows.
shell, flag := "/bin/sh", "-c"
script := "echo FINAL-RESULT; exit 3"
if runtime.GOOS == "windows" {
shell, flag = "cmd", "/c"
script = "echo FINAL-RESULT& exit 3"
}
command := exec.Command(shell, flag, script)
request := Request{
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
Command: Command{Name: shell, Args: []string{flag, script}},
WorkingDirectory: root, WorkspaceRoots: []string{root},
}
// A wait of zero: Start returns while it is still running, exactly as it does
// for a command that outlives its inline wait.
started, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command}, Request: request,
}, 0)
if err != nil {
t.Fatalf("Start: %v", err)
}
// Nobody polls. Wait for the process to finish on its own, the way a caller
// off doing other work would.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, remembered := manager.Completed(started.ProcessID); remembered {
break
}
time.Sleep(10 * time.Millisecond)
}
// Now the late poll. It must get the result, not ErrProcessNotFound.
manager.Remove(started.ProcessID)
result, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID})
if err != nil {
t.Fatalf("a session nobody polled was refused after it finished: %v", err)
}
if !strings.Contains(result.Output, "FINAL-RESULT") {
t.Errorf("the output was lost, so the command has to be run again:\n%q", result.Output)
}
if result.ExitCode != 3 {
t.Errorf("exit code = %d, want 3", result.ExitCode)
}
}
// manualExpiry captures the expiry each completed record schedules so a test can
// fire it at a chosen moment instead of sleeping past a real deadline.
type manualExpiry struct {
mu sync.Mutex
delays []time.Duration
fires []func()
}
func installManualExpiry(manager *ProcessManager) *manualExpiry {
expiry := &manualExpiry{}
manager.afterFunc = func(delay time.Duration, fire func()) {
expiry.mu.Lock()
defer expiry.mu.Unlock()
expiry.delays = append(expiry.delays, delay)
expiry.fires = append(expiry.fires, fire)
}
return expiry
}
func (expiry *manualExpiry) fire(t *testing.T, index int) {
t.Helper()
expiry.mu.Lock()
if index >= len(expiry.fires) {
expiry.mu.Unlock()
t.Fatalf("expiry %d was never scheduled; %d scheduled", index, len(expiry.fires))
}
fire := expiry.fires[index]
expiry.mu.Unlock()
fire()
}
// RETENTION IS A PROMISE IN BOTH DIRECTIONS. A late poll inside the window gets
// the result; once the window ends the record, with its output, request, and
// enforcement evidence, is gone rather than surviving until 64 newer
// completions happen to push it out.
func TestACompletedRecordExpiresWhenItsRetentionEnds(t *testing.T) {
const retention = 50 * time.Millisecond
manager := NewProcessManager(ProcessManagerOptions{CompletedRetention: retention})
expiry := installManualExpiry(manager)
manager.remember(ProcessResult{ProcessID: 1001, Exited: true, Output: "FINAL"})
if len(expiry.delays) != 1 || expiry.delays[0] != retention {
t.Fatalf("scheduled expiries = %v, want one after the configured %s", expiry.delays, retention)
}
before, err := manager.Continue(t.Context(), ProcessContinue{ProcessID: 1001})
if err != nil || before.Output != "FINAL" {
t.Fatalf("late poll before expiry = %+v, %v; want the remembered result", before, err)
}
expiry.fire(t, 0)
if _, err := manager.Continue(t.Context(), ProcessContinue{ProcessID: 1001}); err != ErrProcessNotFound {
t.Fatalf("late poll after expiry returned %v, want ErrProcessNotFound", err)
}
if _, ok := manager.Snapshot(1001); ok {
t.Fatal("snapshot still answers an expired record")
}
if len(manager.completed) != 0 || len(manager.completedOrder) != 0 {
t.Fatalf("expired record left state behind: completed=%d order=%v", len(manager.completed), manager.completedOrder)
}
}
// An expiry belongs to the publication that scheduled it. A timer for a record
// the count bound already evicted must not remove a later record stored under
// the same id.
func TestAStaleExpiryDoesNotRemoveAReplacementRecord(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{CompletedRetention: time.Minute})
expiry := installManualExpiry(manager)
manager.remember(ProcessResult{ProcessID: 1000, Exited: true, Output: "first"})
for i := 1; i <= maxRememberedCompletions; i++ {
manager.remember(ProcessResult{ProcessID: 5000 + i, Exited: true})
}
if _, ok := manager.Completed(1000); ok {
t.Fatal("the count bound did not evict the oldest record")
}
manager.remember(ProcessResult{ProcessID: 1000, Exited: true, Output: "replacement"})
expiry.fire(t, 0) // the evicted publication's timer
got, ok := manager.Completed(1000)
if !ok || got.Output != "replacement" {
t.Fatalf("stale expiry removed the replacement record: %+v, %v", got, ok)
}
}
// Expiry and the count bound are independent: expiring one record frees its
// slot, so the bound does not then evict a record that is still in its window.
func TestExpiryAndTheCountBoundStayIndependent(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{CompletedRetention: time.Minute})
expiry := installManualExpiry(manager)
for i := 0; i < maxRememberedCompletions; i++ {
manager.remember(ProcessResult{ProcessID: 1000 + i, Exited: true})
}
expiry.fire(t, 10) // record 1010's own window ends
if _, ok := manager.Completed(1010); ok {
t.Fatal("record 1010 survived its expiry")
}
manager.remember(ProcessResult{ProcessID: 2000, Exited: true})
if _, ok := manager.Completed(1000); !ok {
t.Fatal("the count bound evicted the oldest record although expiry had already freed a slot")
}
if got := len(manager.completed); got != maxRememberedCompletions {
t.Fatalf("kept %d records, want %d", got, maxRememberedCompletions)
}
manager.remember(ProcessResult{ProcessID: 2001, Exited: true})
if _, ok := manager.Completed(1000); ok {
t.Fatal("the count bound stopped evicting oldest-first after an expiry")
}
if _, ok := manager.Completed(2001); !ok {
t.Fatal("the newest record was dropped")
}
}
+187 -9
View File
@@ -12,13 +12,33 @@ import (
)
const (
defaultCompletedRetention = 30 * time.Second
defaultMaxProcesses = 64
maxPendingOutputBytes = 2 * 1024 * 1024
recentOutputBytes = 4096
processStopTimeout = 3 * time.Second
maxInteractiveYield = 30 * time.Second
maxEmptyPollYield = 5 * time.Minute
defaultMaxProcesses = 64
maxPendingOutputBytes = 2 * 1024 * 1024
recentOutputBytes = 4096
processStopTimeout = 3 * time.Second
maxInteractiveYield = 30 * time.Second
maxEmptyPollYield = 5 * time.Minute
// defaultCompletedRetention is how long a finished process stays addressable.
//
// DERIVED FROM THE POLL BOUND, never chosen beside it. It was 30 seconds
// while maxEmptyPollYield allowed a five-minute poll, and those two numbers
// contradict each other: a caller Zero itself invites to wait five minutes
// could arrive to find the answer already forgotten. In a measured run a
// 60-second test was started, polled with yield_time_ms 40000, and the id was
// gone — so the same 60-second test was run a second time to recover a result
// the first one had already produced.
//
// Doubled so a caller that waits the full poll still has as long again to come
// back with what it learned. TestCompletedRetentionCoversTheLongestPoll pins
// the relationship rather than the number.
defaultCompletedRetention = 2 * maxEmptyPollYield
// maxRememberedCompletions bounds the finished-session records kept after a
// process is evicted. Each immutable result is already bounded by the process
// output cap; the count bound prevents a long session from accumulating them
// without limit.
maxRememberedCompletions = 64
)
var (
@@ -37,6 +57,24 @@ type ProcessManagerOptions struct {
// ProcessManager owns retained interactive-process identity, transport,
// bounded output, continuation, cancellation, completion, and cleanup.
// completedProcess is the immutable terminal result a finished session leaves
// behind once its process is gone.
//
// A LATE POLL IS NOT A GUESS. Without this, an id that ran and finished is
// indistinguishable from one a model invented, and both are answered by
// UnknownExecSessionError — which tells the caller not to probe ids, having just
// handed it that id and instructed it to poll. The result is a re-run of work
// already done.
//
// THE RECORD OWNS ITS OWN EXPIRY. It is published once, by remember, and the
// same call schedules its removal after completedRetention. The generation
// stamps which publication a timer belongs to, so a timer can only remove the
// record it was scheduled for, never a later record stored under the same id.
type completedProcess struct {
result ProcessResult
generation uint64
}
type ProcessManager struct {
mu sync.Mutex
nextID int
@@ -45,6 +83,14 @@ type ProcessManager struct {
completedRetention time.Duration
maxProcesses int
startTransport processTransportStarter
// completed remembers finished sessions after their process is evicted, in
// arrival order so the oldest is dropped first.
completed map[int]completedProcess
completedOrder []int
completedGeneration uint64
// afterFunc schedules a completed record's expiry. Tests replace it to
// fire expiry deterministically; production uses time.AfterFunc.
afterFunc func(time.Duration, func())
}
type ProcessStart struct {
@@ -109,9 +155,11 @@ func NewProcessManager(options ProcessManagerOptions) *ProcessManager {
return &ProcessManager{
nextID: 1000,
processes: make(map[int]*managedProcess),
completed: make(map[int]completedProcess),
completedRetention: retention,
maxProcesses: maxProcesses,
startTransport: startProcessTransport,
afterFunc: func(delay time.Duration, expire func()) { time.AfterFunc(delay, expire) },
}
}
@@ -210,6 +258,7 @@ func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wa
result.Changes = more.Changes
}
if result.Exited {
manager.remember(result)
manager.Remove(process.id)
}
return result, nil
@@ -218,6 +267,14 @@ func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wa
func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContinue) (ProcessResult, error) {
process, ok := manager.get(input.ProcessID)
if !ok {
// A SESSION THAT RAN AND FINISHED IS NOT A GUESSED ID. Answering both with
// ErrProcessNotFound told a caller "do not probe session ids" about an id
// this manager had issued and instructed it to poll — and threw away the
// result, so the work was done again. An id never issued still falls
// through to the error, so a real probe is still refused.
if finished, remembered := manager.Completed(input.ProcessID); remembered {
return finished, nil
}
return ProcessResult{}, ErrProcessNotFound
}
process.touch()
@@ -233,6 +290,7 @@ func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContin
}
result := process.collectResult(ctx, clampContinuationWait(input.Wait, len(input.Input) == 0), input.Interrupt)
if result.Exited {
manager.remember(result)
manager.Remove(process.id)
}
return result, nil
@@ -311,10 +369,25 @@ func (manager *ProcessManager) List() []ProcessSnapshot {
func (manager *ProcessManager) Snapshot(id int) (ProcessSnapshot, bool) {
process, ok := manager.get(id)
if ok {
return process.snapshot(), true
}
result, ok := manager.Completed(id)
if !ok {
return ProcessSnapshot{}, false
}
return process.snapshot(), true
exitCode := result.ExitCode
return ProcessSnapshot{
ID: result.ProcessID,
Command: result.CommandText,
Cwd: result.Request.WorkingDirectory,
RelativeCwd: result.RelativeCwd,
TTY: result.TTY,
Status: "exited",
ExitCode: &exitCode,
RecentOutput: result.Output,
OutputTruncated: result.OutputTruncated,
}, true
}
func (manager *ProcessManager) Stop(id int) bool {
@@ -355,6 +428,66 @@ func (manager *ProcessManager) Remove(id int) {
manager.mu.Unlock()
}
// remember records a finished session so a late poll gets its result rather than
// an error telling it not to probe ids. The record lives for completedRetention
// from its publication, or until the count bound evicts it, whichever is first.
func (manager *ProcessManager) remember(result ProcessResult) {
if !result.Exited || result.ProcessID == 0 {
return
}
manager.mu.Lock()
if _, seen := manager.completed[result.ProcessID]; seen {
manager.mu.Unlock()
return
}
manager.completedOrder = append(manager.completedOrder, result.ProcessID)
for len(manager.completedOrder) > maxRememberedCompletions {
delete(manager.completed, manager.completedOrder[0])
manager.completedOrder = manager.completedOrder[1:]
}
manager.completedGeneration++
generation := manager.completedGeneration
manager.completed[result.ProcessID] = completedProcess{result: cloneProcessResult(result), generation: generation}
afterFunc := manager.afterFunc
manager.mu.Unlock()
// Scheduled only after the record is published, so expiry can never run
// ahead of the publication it removes.
id := result.ProcessID
afterFunc(max(manager.completedRetention, 0), func() { manager.expireCompleted(id, generation) })
}
// expireCompleted removes the completed record for id only when it is still the
// publication the expiring timer was scheduled for. A record already evicted by
// the count bound, or replaced by a later publication, is left alone.
func (manager *ProcessManager) expireCompleted(id int, generation uint64) {
manager.mu.Lock()
defer manager.mu.Unlock()
record, ok := manager.completed[id]
if !ok || record.generation != generation {
return
}
delete(manager.completed, id)
for index, ordered := range manager.completedOrder {
if ordered == id {
manager.completedOrder = append(manager.completedOrder[:index], manager.completedOrder[index+1:]...)
break
}
}
}
// Completed reports a finished session's result when the id ran in this process
// and has since been evicted. A never-issued id is not found, so a genuine probe
// is still refused.
func (manager *ProcessManager) Completed(id int) (ProcessResult, bool) {
manager.mu.Lock()
defer manager.mu.Unlock()
record, ok := manager.completed[id]
if !ok {
return ProcessResult{}, false
}
return cloneProcessResult(record.result), true
}
func (manager *ProcessManager) Len() int {
manager.mu.Lock()
defer manager.mu.Unlock()
@@ -414,6 +547,12 @@ func (manager *ProcessManager) processToPruneLocked() *managedProcess {
func (manager *ProcessManager) removeCompletedLater(process *managedProcess) {
go func() {
<-process.done
// CAPTURED BEFORE THE WAIT, because the common case is that NOBODY polled
// while it ran: a caller starts a long command, goes away to do other
// work, and comes back after it finished. Recording only on the polling
// paths would leave exactly that case with nothing to hand back — which is
// the case that was measured, and it cost a 60-second test being run twice.
manager.remember(process.collectResult(context.Background(), 0, false))
if manager.completedRetention > 0 {
timer := time.NewTimer(manager.completedRetention)
<-timer.C
@@ -448,6 +587,9 @@ type managedProcess struct {
reportErr error
changes []Change
metadata map[string]string
collectMu sync.Mutex
terminal *ProcessResult
interrupted bool
}
func (process *managedProcess) markDone(err error, exitCode int, report AdapterReport, reportErr error, changes []Change) {
@@ -462,6 +604,14 @@ func (process *managedProcess) markDone(err error, exitCode int, report AdapterR
}
func (process *managedProcess) collectResult(ctx context.Context, wait time.Duration, interrupted bool) ProcessResult {
// Output collection is destructive, so exactly one collector owns the
// transition to a terminal result. Once the process exits, every caller and
// the retention goroutine receives a clone of the same immutable result.
process.collectMu.Lock()
defer process.collectMu.Unlock()
if process.terminal != nil {
return cloneProcessResult(*process.terminal)
}
output, truncated := process.collect(ctx, wait)
process.mu.Lock()
exitCode := 0
@@ -472,11 +622,15 @@ func (process *managedProcess) collectResult(ctx context.Context, wait time.Dura
result := ProcessResult{
ProcessID: process.id, CommandText: process.commandText, RelativeCwd: process.relativeCwd,
TTY: process.tty, Output: output, OutputTruncated: truncated, Exited: exited,
ExitCode: exitCode, Interrupted: interrupted, Request: process.request,
ExitCode: exitCode, Interrupted: interrupted || process.interrupted, Request: cloneRequest(process.request),
Enforcement: process.enforcement, Report: process.resultReport, ReportErr: process.reportErr,
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
}
process.mu.Unlock()
if result.Exited {
terminal := cloneProcessResult(result)
process.terminal = &terminal
}
return result
}
@@ -579,6 +733,9 @@ func (process *managedProcess) lastUsed() time.Time {
return process.lastUsedAt
}
func (process *managedProcess) terminate() {
process.mu.Lock()
process.interrupted = true
process.mu.Unlock()
if process.reapedClosed() || process.command.Process == nil {
return
}
@@ -704,3 +861,24 @@ func cloneStringMap(input map[string]string) map[string]string {
}
return output
}
func cloneRequest(input Request) Request {
output := input
output.Command.Args = append([]string(nil), input.Command.Args...)
output.Command.Env = append([]string(nil), input.Command.Env...)
output.WorkspaceRoots = append([]string(nil), input.WorkspaceRoots...)
output.Capabilities = append([]Capability(nil), input.Capabilities...)
return output
}
func cloneProcessResult(input ProcessResult) ProcessResult {
output := input
output.Request = cloneRequest(input.Request)
output.Changes = append([]Change(nil), input.Changes...)
output.Metadata = cloneStringMap(input.Metadata)
if input.Report.Denial != nil {
denial := *input.Report.Denial
output.Report.Denial = &denial
}
return output
}
+2 -2
View File
@@ -779,7 +779,7 @@ func normalizeDefinition(raw any, field string) (Definition, error) {
return Definition{}, err
}
if matcher != "" && !eventSupportsMatcher(event) {
return Definition{}, manifestError{fieldPath: field + ".matcher", message: "matcher is only supported for beforeTool and afterTool hooks."}
return Definition{}, manifestError{fieldPath: field + ".matcher", message: "matcher is only supported for beforeTool, afterTool, specialistStart, and specialistStop hooks."}
}
command, err := requiredString(obj, field+".command")
if err != nil {
@@ -843,7 +843,7 @@ func matchesHookMatcher(matcher string, toolName string) bool {
}
func eventSupportsMatcher(event Event) bool {
return event == EventBeforeTool || event == EventAfterTool
return event == EventBeforeTool || event == EventAfterTool || event == EventSpecialistStart || event == EventSpecialistStop
}
func toDiagnostic(err error, source ConfigSource, path string) Diagnostic {
+41 -1
View File
@@ -180,7 +180,7 @@ func TestLoadConfigPreservesUserDisabledStateWhenProjectOmitsEnabled(t *testing.
}
func TestLoadConfigRejectsMatchersOnLifecycleHooks(t *testing.T) {
for _, event := range []string{"sessionStart", "specialistStart"} {
for _, event := range []string{"sessionStart", "sessionEnd"} {
t.Run(event, func(t *testing.T) {
dir := t.TempDir()
projectConfigPath := filepath.Join(dir, "hooks.json")
@@ -210,6 +210,46 @@ func TestLoadConfigRejectsMatchersOnLifecycleHooks(t *testing.T) {
}
}
func TestLoadConfigAcceptsMatchersOnSpecialistHooks(t *testing.T) {
for _, tc := range []struct {
event Event
id string
}{
{event: EventSpecialistStart, id: "zero.explorer-start"},
{event: EventSpecialistStop, id: "zero.explorer-stop"},
} {
t.Run(string(tc.event), func(t *testing.T) {
dir := t.TempDir()
projectConfigPath := filepath.Join(dir, "hooks.json")
writeHookJSON(t, projectConfigPath, map[string]any{
"hooks": []any{map[string]any{
"id": tc.id,
"event": string(tc.event),
"matcher": "explorer",
"command": "node",
}},
})
result, err := LoadConfig(LoadOptions{
UserConfigPath: filepath.Join(dir, "missing-user-hooks.json"),
ProjectConfigPath: projectConfigPath,
})
if err != nil {
t.Fatalf("LoadConfig returned error: %v", err)
}
if len(result.Config.Hooks) != 1 || result.Config.Hooks[0].Matcher != "explorer" {
t.Fatalf("specialist matcher hook = %#v", result.Config.Hooks)
}
if got := hookIDs(Select(result.Config, SelectInput{Event: tc.event, ToolName: "explorer"})); !reflect.DeepEqual(got, []string{tc.id}) {
t.Fatalf("matched selection = %#v", got)
}
if got := Select(result.Config, SelectInput{Event: tc.event, ToolName: "worker"}); len(got) != 0 {
t.Fatalf("unmatched specialist should be empty, got %#v", got)
}
})
}
}
func TestConfigStorePersistsUpdates(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "hooks.json")
store, err := NewConfigStore(StoreOptions{ConfigPath: configPath})
+119
View File
@@ -0,0 +1,119 @@
package mcp
import (
"fmt"
"net/http"
"net/url"
"strings"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/config"
)
// The AI/ML API MCP server authenticates with the user's AI/ML API key.
//
// Invariant: the key and the attribution headers are attached only to an
// outgoing request whose own URL is https://mcp.aimlapi.com on the default port
// with no userinfo (exact hostname, see aimlapi.IsAttributedURL), whatever the
// server is named. The key is resolved again for every request, redirect hops
// included (see reapplyAIMLAPIRedirectHeaders), through
// config.LoadAIMLAPIServiceKey, which in the same call decides whether it may
// be sent (no proxy profile, a single token), so a provider change applies to
// the next request and a withheld or invalid key means no Authorization
// header. It lives only in that request's headers: never in Server, its
// Identity (OAuth token binding), config.json, list output or errors (an
// error response to such a request is reported without its body). A
// user-configured Authorization header, OAuth, or a project-touched server
// always goes without it.
var loadAIMLAPIServiceKey = config.LoadAIMLAPIServiceKey
var aimlapiMCPHost = func() string {
parsed, err := url.Parse(aimlapi.MCPServerURL)
if err != nil {
panic(err)
}
return parsed.Hostname()
}()
func isAIMLAPIMCPURL(target *url.URL) bool {
return aimlapi.IsAttributedURL(target) && strings.EqualFold(target.Hostname(), aimlapiMCPHost)
}
// aimlapiAutomaticHeaders are the headers applyAIMLAPIRequestHeaders may add.
var aimlapiAutomaticHeaders = [...]string{"Authorization", aimlapi.SourceHeaderName, aimlapi.PartnerHeaderName}
// applyAIMLAPIRequestHeaders runs after the server's own headers are set.
func applyAIMLAPIRequestHeaders(server Server, request *http.Request) {
if request == nil || !isAIMLAPIMCPURL(request.URL) {
return
}
aimlapi.ApplyAttributionHeaders(request.Header, request.URL)
if request.Header.Get("Authorization") != "" || strings.EqualFold(server.Auth, ServerAuthOAuth) || server.ProjectConfigured {
return
}
key, err := loadAIMLAPIServiceKey()
if err != nil || key == "" {
return
}
request.Header.Set("Authorization", "Bearer "+key)
}
// reapplyAIMLAPIRedirectHeaders gives a redirect hop the automatic headers its
// own URL earns. net/http copies the first request's headers onto every hop
// without calling the header hooks, so without this a key or attribution added
// for the first URL would follow a same-origin hop that fails isAIMLAPIMCPURL
// (userinfo, say), and an eligible hop would reuse the first hop's key instead
// of resolving it again. The hop gets back the server's own values for these
// headers, then the same rule as any other request.
func reapplyAIMLAPIRedirectHeaders(server Server, request *http.Request, first *http.Request) {
if request == nil || first == nil || !isAIMLAPIMCPURL(first.URL) {
return
}
for _, name := range aimlapiAutomaticHeaders {
request.Header.Del(name)
}
for name, value := range server.Headers {
for _, automatic := range aimlapiAutomaticHeaders {
if strings.EqualFold(name, automatic) {
request.Header.Set(name, value)
}
}
}
applyAIMLAPIRequestHeaders(server, request)
}
// sentAIMLAPIServiceKey reports whether request went out carrying the key
// applyAIMLAPIRequestHeaders injects, as opposed to the server's own
// Authorization header or OAuth.
func sentAIMLAPIServiceKey(server Server, request *http.Request) bool {
if request == nil || !isAIMLAPIMCPURL(request.URL) || strings.EqualFold(server.Auth, ServerAuthOAuth) || server.ProjectConfigured {
return false
}
return request.Header.Get("Authorization") != "" && !hasUserAuthorizationHeader(server)
}
func hasUserAuthorizationHeader(server Server) bool {
for name, value := range server.Headers {
if strings.EqualFold(name, "Authorization") && value != "" {
return true
}
}
return false
}
// aimlapiUnauthorizedHint explains how to authenticate when the AI/ML API MCP
// server rejects a request without OAuth. It names no key material.
func aimlapiUnauthorizedHint(server Server, status int) string {
if status != http.StatusUnauthorized || strings.EqualFold(server.Auth, ServerAuthOAuth) {
return ""
}
target, err := url.Parse(server.URL)
if err != nil || !isAIMLAPIMCPURL(target) {
return ""
}
oauthHint := fmt.Sprintf("To sign in instead, set \"auth\": \"oauth\" on this server and run `zero mcp oauth login %s`", server.Name)
if server.ProjectConfigured || hasUserAuthorizationHeader(server) {
return " (" + oauthHint + ")"
}
return fmt.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)
}
+537
View File
@@ -0,0 +1,537 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/config"
)
// aimlapiRecorder stands in for mcp.aimlapi.com: it records every request it
// is handed (the URL stays https://mcp.aimlapi.com/mcp, nothing is dialed) and
// answers JSON-RPC, or 401 with the server's OAuth challenge when unauthorized.
type aimlapiRecorder struct {
mu sync.Mutex
headers []http.Header
urls []string
unauthorized bool
// redirectFirstTo, when set, answers the first request with a 307 to it.
redirectFirstTo string
// failStatus, when set, answers every request with it and failBody.
failStatus int
failBody string
}
func (recorder *aimlapiRecorder) RoundTrip(request *http.Request) (*http.Response, error) {
var body []byte
if request.Body != nil {
body, _ = io.ReadAll(request.Body)
_ = request.Body.Close()
}
recorder.mu.Lock()
recorder.headers = append(recorder.headers, request.Header.Clone())
recorder.urls = append(recorder.urls, request.URL.String())
unauthorized := recorder.unauthorized
redirect := recorder.redirectFirstTo != "" && len(recorder.headers) == 1
recorder.mu.Unlock()
if redirect {
response := fakeHTTPResponse(request, http.StatusTemporaryRedirect, "")
response.Header.Set("Location", recorder.redirectFirstTo)
return response, nil
}
if recorder.failStatus != 0 {
return fakeHTTPResponse(request, recorder.failStatus, recorder.failBody), nil
}
if unauthorized {
response := fakeHTTPResponse(request, http.StatusUnauthorized, `{"error":"invalid_token"}`)
response.Header.Set("WWW-Authenticate", `Bearer resource_metadata="https://mcp.aimlapi.com/.well-known/oauth-protected-resource/mcp"`)
return response, nil
}
var message struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.Unmarshal(body, &message)
if len(message.ID) == 0 {
return fakeHTTPResponse(request, http.StatusAccepted, ""), nil
}
result := `{}`
switch message.Method {
case "initialize":
result = `{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake","version":"1"}}`
case "tools/list":
result = `{"tools":[{"name":"tools_search","inputSchema":{"type":"object"}}]}`
}
return fakeHTTPResponse(request, http.StatusOK, fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, message.ID, result)), nil
}
func (recorder *aimlapiRecorder) recorded() []http.Header {
recorder.mu.Lock()
defer recorder.mu.Unlock()
return append([]http.Header(nil), recorder.headers...)
}
func fakeHTTPResponse(request *http.Request, status int, body string) *http.Response {
header := http.Header{}
if body != "" {
header.Set("Content-Type", "application/json")
}
return &http.Response{
StatusCode: status,
Header: header,
Body: io.NopCloser(bytes.NewBufferString(body)),
Request: request,
}
}
// isolateAIMLAPIKeyLookup points the real key resolver at an empty temporary
// home and returns the user config path it reads.
func isolateAIMLAPIKeyLookup(t *testing.T, envKey string) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_PARTNER_ID", "")
t.Setenv("AIMLAPI_API_KEY", envKey)
path, err := config.DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
return path
}
func writeAIMLAPIProviderConfig(t *testing.T, path string, baseURL string, apiKey string) {
t.Helper()
profile := map[string]any{"name": "aimlapi", "catalogID": "aimlapi", "model": "m", "apiKey": apiKey}
if baseURL != "" {
profile["baseURL"] = baseURL
}
data, err := json.Marshal(map[string]any{"activeProvider": "aimlapi", "providers": []any{profile}})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
func newRecordedNetworkClient(server Server, recorder *aimlapiRecorder) *networkClient {
return &networkClient{server: server, client: mcpHTTPClient(server, recorder), nextID: 1}
}
func aimlapiTestServer(name string, rawURL string) Server {
return Server{Name: name, Type: ServerTypeHTTP, URL: rawURL}
}
func assertAIMLAPIAttribution(t *testing.T, header http.Header) {
t.Helper()
if got := header.Get(aimlapi.SourceHeaderName); got != aimlapi.SourceValue {
t.Fatalf("%s = %q, want %q", aimlapi.SourceHeaderName, got, aimlapi.SourceValue)
}
if got, want := header.Get(aimlapi.PartnerHeaderName), aimlapi.ResolvePartnerID(""); got == "" || got != want {
t.Fatalf("%s = %q, want %q", aimlapi.PartnerHeaderName, got, want)
}
}
func TestAIMLAPIMCPRequestsCarryServiceKeyAndAttribution(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
// A hand-added server on the exact host is treated like the built-in one.
for _, name := range []string{"aimlapi", "media"} {
recorder := &aimlapiRecorder{}
client := newRecordedNetworkClient(aimlapiTestServer(name, aimlapi.MCPServerURL), recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", name, err)
}
if _, err := client.ListTools(context.Background()); err != nil {
t.Fatalf("%s: ListTools: %v", name, err)
}
requests := recorder.recorded()
if len(requests) != 3 {
t.Fatalf("%s: recorded %d requests, want initialize, initialized and tools/list", name, len(requests))
}
for _, header := range requests {
if got := header.Get("Authorization"); got != "Bearer aiml-secret" {
t.Fatalf("%s: Authorization = %q, want the AI/ML API key", name, got)
}
assertAIMLAPIAttribution(t, header)
}
}
}
func TestAIMLAPIMCPHeadersOnlyForExactHost(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
for _, rawURL := range []string{
"https://mcp.aimlapi.com.example.net/mcp",
"http://mcp.aimlapi.com/mcp",
"https://mcp.aimlapi.com:8443/mcp",
"https://[email protected]/mcp",
"https://api.aimlapi.com/mcp",
"https://evilmcp.aimlapi.com/mcp",
"https://mcp.exa.ai/mcp",
} {
recorder := &aimlapiRecorder{}
server := aimlapiTestServer("other", rawURL)
server.Headers = map[string]string{"X-Custom": "kept"}
client := newRecordedNetworkClient(server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", rawURL, err)
}
for _, header := range recorder.recorded() {
if strings.Contains(rawURL, "user@") && strings.HasPrefix(header.Get("Authorization"), "Basic ") {
// net/http turns URL userinfo into Basic auth on its own.
header.Del("Authorization")
}
// Byte-for-byte what any other MCP server gets.
want := http.Header{
"X-Custom": {"kept"},
"Content-Type": {"application/json"},
"Accept": {"application/json, text/event-stream"},
}
if fmt.Sprint(header) != fmt.Sprint(want) {
t.Fatalf("%s: headers = %v, want %v", rawURL, header, want)
}
}
}
// Port 443 spelled out and a different host case are still the exact host.
for _, rawURL := range []string{"https://mcp.aimlapi.com:443/mcp", "https://MCP.AIMLAPI.COM/mcp"} {
recorder := &aimlapiRecorder{}
client := newRecordedNetworkClient(aimlapiTestServer("aimlapi", rawURL), recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", rawURL, err)
}
header := recorder.recorded()[0]
if header.Get("Authorization") != "Bearer aiml-secret" {
t.Fatalf("%s: Authorization = %q, want the key", rawURL, header.Get("Authorization"))
}
assertAIMLAPIAttribution(t, header)
}
}
// The key and its eligibility are resolved for every request, so switching the
// aimlapi profile to a proxy keeps the proxy's key off mcp.aimlapi.com on the
// very next request of an already-connected client and on new connections.
func TestAIMLAPIMCPKeyIsResolvedPerRequestAcrossProviderSwitch(t *testing.T) {
path := isolateAIMLAPIKeyLookup(t, "")
writeAIMLAPIProviderConfig(t, path, "", "canonical-key")
recorder := &aimlapiRecorder{}
server := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
client := newRecordedNetworkClient(server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("initialize: %v", err)
}
if got := recorder.recorded()[0].Get("Authorization"); got != "Bearer canonical-key" {
t.Fatalf("canonical profile: Authorization = %q, want Bearer canonical-key", got)
}
writeAIMLAPIProviderConfig(t, path, "https://proxy.example/v1", "proxy-key")
if _, err := client.ListTools(context.Background()); err != nil {
t.Fatalf("ListTools after switch: %v", err)
}
fresh := newRecordedNetworkClient(server, recorder)
if err := fresh.initialize(context.Background()); err != nil {
t.Fatalf("initialize after switch: %v", err)
}
requests := recorder.recorded()
for _, header := range requests[2:] {
if got := header.Get("Authorization"); got != "" {
t.Fatalf("proxy profile: Authorization = %q, want none", got)
}
assertAIMLAPIAttribution(t, header)
}
for _, header := range requests {
if strings.Contains(fmt.Sprint(header), "proxy-key") {
t.Fatalf("proxy key reached mcp.aimlapi.com: %v", header)
}
}
}
func TestAIMLAPIMCPUnusableKeySendsNoAuthorization(t *testing.T) {
cases := map[string]string{
"multi-line key pool": "first-key\nsecond-key",
"comma key pool": "first-key,second-key",
"no key": "",
}
for name, apiKey := range cases {
t.Run(name, func(t *testing.T) {
path := isolateAIMLAPIKeyLookup(t, "")
writeAIMLAPIProviderConfig(t, path, "", apiKey)
recorder := &aimlapiRecorder{unauthorized: true}
client := newRecordedNetworkClient(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), recorder)
err := client.initialize(context.Background())
if err == nil {
t.Fatal("initialize succeeded against a 401")
}
requests := recorder.recorded()
if len(requests) != 1 {
t.Fatalf("recorded %d requests, want one intact request", len(requests))
}
header := requests[0]
if got := header.Get("Authorization"); got != "" {
t.Fatalf("Authorization = %q, want none", got)
}
if header.Get("Content-Type") != "application/json" {
t.Fatalf("request headers broken: %v", header)
}
assertAIMLAPIAttribution(t, header)
if !strings.Contains(err.Error(), "returned HTTP 401") || !strings.Contains(err.Error(), "zero mcp oauth login aimlapi") || !strings.Contains(err.Error(), "AIMLAPI_API_KEY") {
t.Fatalf("401 error lacks the authentication hint: %v", err)
}
if strings.Contains(err.Error(), "first-key") {
t.Fatalf("error leaks key material: %v", err)
}
})
}
}
func TestAIMLAPIMCPUnauthorizedHintOnlyForAIMLAPIWithoutOAuth(t *testing.T) {
if hint := aimlapiUnauthorizedHint(aimlapiTestServer("docs", "https://mcp.example.com/mcp"), http.StatusUnauthorized); hint != "" {
t.Fatalf("hint for another server = %q", hint)
}
oauth := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
oauth.Auth = ServerAuthOAuth
if hint := aimlapiUnauthorizedHint(oauth, http.StatusUnauthorized); hint != "" {
t.Fatalf("hint for an OAuth server = %q", hint)
}
if hint := aimlapiUnauthorizedHint(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), http.StatusForbidden); hint != "" {
t.Fatalf("hint for 403 = %q", hint)
}
}
func TestAIMLAPIMCPKeyDefersToUserAuthorizationOAuthAndProjectConfig(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
userHeader.Headers = map[string]string{"authorization": "Bearer user-token"}
oauth := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
oauth.Auth = ServerAuthOAuth
project := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
project.ProjectConfigured = true
cases := []struct {
name string
server Server
want string
}{
{"user Authorization header", userHeader, "Bearer user-token"},
{"OAuth server", oauth, ""},
{"project-configured server", project, ""},
}
for _, tc := range cases {
recorder := &aimlapiRecorder{}
client := newRecordedNetworkClient(tc.server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", tc.name, err)
}
header := recorder.recorded()[0]
if got := header.Get("Authorization"); got != tc.want {
t.Fatalf("%s: Authorization = %q, want %q", tc.name, got, tc.want)
}
assertAIMLAPIAttribution(t, header)
}
}
func TestAIMLAPIMCPSSERequestsCarryKeyAndAttribution(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
server := Server{Name: "aimlapi", Type: ServerTypeSSE, URL: aimlapi.MCPServerURL}
client := &remoteSSEClient{server: server}
apply := map[string]func(*http.Request){
"stream": client.applyStreamHeaders,
"post": client.applyPostHeaders,
}
for name, applyHeaders := range apply {
request, err := http.NewRequest(http.MethodPost, aimlapi.MCPServerURL, nil)
if err != nil {
t.Fatal(err)
}
applyHeaders(request)
if got := request.Header.Get("Authorization"); got != "Bearer aiml-secret" {
t.Fatalf("%s: Authorization = %q, want the key", name, got)
}
assertAIMLAPIAttribution(t, request.Header)
other, err := http.NewRequest(http.MethodPost, "https://mcp.aimlapi.com.example.net/mcp", nil)
if err != nil {
t.Fatal(err)
}
applyHeaders(other)
if other.Header.Get("Authorization") != "" || other.Header.Get(aimlapi.SourceHeaderName) != "" || other.Header.Get(aimlapi.PartnerHeaderName) != "" {
t.Fatalf("%s: lookalike host got AI/ML API headers: %v", name, other.Header)
}
}
}
func TestAIMLAPIMCPKeyStaysOutOfServerConfigAndIdentity(t *testing.T) {
enabled := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"aimlapi": {Type: "http", URL: aimlapi.MCPServerURL},
}}
isolateAIMLAPIKeyLookup(t, "")
withoutKey, err := NormalizeConfig(enabled)
if err != nil {
t.Fatal(err)
}
isolateAIMLAPIKeyLookup(t, "aiml-secret")
withKey, err := NormalizeConfig(enabled)
if err != nil {
t.Fatal(err)
}
if len(withKey) != 1 || withKey[0].Identity != withoutKey[0].Identity {
t.Fatalf("server identity depends on the key: %#v vs %#v", withKey, withoutKey)
}
if strings.Contains(fmt.Sprintf("%#v", withKey), "aiml-secret") {
t.Fatalf("normalized server carries the key: %#v", withKey)
}
// The shipped default is off, so it is not even normalized for a session.
defaults, err := NormalizeConfig(config.MCPConfig{Servers: config.DefaultMCPServers()})
if err != nil {
t.Fatal(err)
}
for _, server := range defaults {
if server.Name == "aimlapi" {
t.Fatalf("disabled aimlapi default was normalized for connection: %#v", server)
}
}
}
func TestAIMLAPIMCPUnauthorizedHintRespectsCredentialEligibility(t *testing.T) {
for _, tc := range []struct {
name string
headers map[string]string
project bool
wantSetup bool
}{
{"default", nil, false, true},
{"project", nil, true, false},
{"custom bearer", map[string]string{"Authorization": "Bearer manual"}, false, false},
{"mixed case header", map[string]string{"aUtHoRiZaTiOn": "Bearer manual"}, false, false},
{"empty header", map[string]string{"authorization": ""}, false, true},
} {
t.Run(tc.name, func(t *testing.T) {
server := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
server.Headers = tc.headers
server.ProjectConfigured = tc.project
hint := aimlapiUnauthorizedHint(server, http.StatusUnauthorized)
if strings.Contains(hint, "zero providers setup aimlapi") != tc.wantSetup || !strings.Contains(hint, "zero mcp oauth login aimlapi") {
t.Fatalf("incorrect hint: %s", hint)
}
})
}
}
// net/http copies the first request's headers onto every redirect hop without
// calling the header hooks, so the redirect guard has to apply the per-request
// rule again: a same-origin hop that fails the exact-URL rule (userinfo) gets
// neither the key nor attribution, an eligible hop gets a freshly resolved key,
// and a user's own Authorization header still follows as before.
func TestAIMLAPIMCPRedirectHopsReapplyAutomaticHeaders(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "")
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
userHeader.Headers = map[string]string{"authorization": "Bearer user-token"}
for _, tc := range []struct {
name string
server Server
location string
wantFirst string
wantHop string
wantAttribution bool
}{
{"userinfo hop", aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), "https://[email protected]/mcp", "Bearer aiml-first", "", false},
{"eligible hop", aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), "https://mcp.aimlapi.com/mcp/", "Bearer aiml-first", "Bearer aiml-second", true},
{"user header hop", userHeader, "https://[email protected]/mcp", "Bearer user-token", "Bearer user-token", false},
} {
t.Run(tc.name, func(t *testing.T) {
keys := []string{"aiml-first", "aiml-second"}
previous := loadAIMLAPIServiceKey
loadAIMLAPIServiceKey = func() (string, error) {
key := keys[0]
if len(keys) > 1 {
keys = keys[1:]
}
return key, nil
}
t.Cleanup(func() { loadAIMLAPIServiceKey = previous })
recorder := &aimlapiRecorder{redirectFirstTo: tc.location}
client := newRecordedNetworkClient(tc.server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("initialize: %v", err)
}
requests := recorder.recorded()
if len(requests) < 2 || recorder.urls[1] != tc.location {
t.Fatalf("redirect was not followed: %v", recorder.urls)
}
if got := requests[0].Get("Authorization"); got != tc.wantFirst {
t.Fatalf("first hop Authorization = %q, want %q", got, tc.wantFirst)
}
hop := requests[1]
// With no key, net/http fills Basic auth from the hop's own userinfo;
// only the automatic key must be absent.
if got := hop.Get("Authorization"); (tc.wantHop == "" && strings.Contains(got, "aiml-")) || (tc.wantHop != "" && got != tc.wantHop) {
t.Fatalf("redirect hop Authorization = %q, want %q", got, tc.wantHop)
}
if tc.wantAttribution {
assertAIMLAPIAttribution(t, hop)
} else if hop.Get(aimlapi.SourceHeaderName) != "" || hop.Get(aimlapi.PartnerHeaderName) != "" {
t.Fatalf("redirect hop kept attribution headers: %v", hop)
}
})
}
}
// An error body can echo the key Zero injected; the body is left out of the
// error rather than matched, so a key cut by the 1024-byte read limit cannot
// leak its prefix either. Requests without the automatic key keep the detail.
func TestAIMLAPIMCPStatusErrorOmitsBodyWhenKeyWasInjected(t *testing.T) {
const key = "sk-echo-0123456789abcdef"
isolateAIMLAPIKeyLookup(t, key)
for _, tc := range []struct {
name string
body string
}{
{"echoed key", `{"error":"invalid credential ` + key + `"}`},
{"key across the read limit", strings.Repeat("x", 1020) + key},
} {
t.Run(tc.name, func(t *testing.T) {
recorder := &aimlapiRecorder{failStatus: http.StatusPaymentRequired, failBody: tc.body}
err := newRecordedNetworkClient(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), recorder).initialize(context.Background())
if err == nil {
t.Fatal("initialize succeeded against a failing server")
}
if got := recorder.recorded()[0].Get("Authorization"); got != "Bearer "+key {
t.Fatalf("request did not carry the injected key: %q", got)
}
if strings.Contains(err.Error(), key[:4]) || !strings.Contains(err.Error(), "HTTP 402") {
t.Fatalf("error = %q, want HTTP 402 without the echoed key", err)
}
})
}
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
userHeader.Headers = map[string]string{"Authorization": "Bearer user-token"}
recorder := &aimlapiRecorder{failStatus: http.StatusPaymentRequired, failBody: `{"error":"insufficient balance"}`}
err := newRecordedNetworkClient(userHeader, recorder).initialize(context.Background())
if err == nil || !strings.Contains(err.Error(), "insufficient balance") {
t.Fatalf("error without the automatic key = %v, want the response detail", err)
}
}
+11 -2
View File
@@ -427,6 +427,7 @@ func (client *networkClient) applyHeaders(request *http.Request) {
if client.sessionID != "" {
request.Header.Set("Mcp-Session-Id", client.sessionID)
}
applyAIMLAPIRequestHeaders(client.server, request)
}
func (client *remoteSSEClient) applyStreamHeaders(request *http.Request) {
@@ -434,6 +435,7 @@ func (client *remoteSSEClient) applyStreamHeaders(request *http.Request) {
request.Header.Set(key, value)
}
request.Header.Set("Accept", "text/event-stream")
applyAIMLAPIRequestHeaders(client.server, request)
}
func (client *remoteSSEClient) applyPostHeaders(request *http.Request) {
@@ -442,6 +444,7 @@ func (client *remoteSSEClient) applyPostHeaders(request *http.Request) {
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
applyAIMLAPIRequestHeaders(client.server, request)
}
func closeResponseBody(errp *error, server Server, body io.Closer) {
@@ -607,12 +610,18 @@ func (client *remoteSSEClient) failPending(err error) {
}
func httpStatusError(server Server, response *http.Response) error {
hint := aimlapiUnauthorizedHint(server, response.StatusCode)
if sentAIMLAPIServiceKey(server, response.Request) {
// 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.
return fmt.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)
}
body, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
detail := strings.TrimSpace(string(body))
if detail == "" {
return fmt.Errorf("MCP %s server %s returned HTTP %d", server.Type, server.Name, response.StatusCode)
return fmt.Errorf("MCP %s server %s returned HTTP %d%s", server.Type, server.Name, response.StatusCode, hint)
}
return fmt.Errorf("MCP %s server %s returned HTTP %d: %s", server.Type, server.Name, response.StatusCode, detail)
return fmt.Errorf("MCP %s server %s returned HTTP %d: %s%s", server.Type, server.Name, response.StatusCode, detail, hint)
}
func decodeSSERPCMessage(reader io.Reader) (rpcMessage, error) {
Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff. Show more