Commit Graph
668 Commits
Author SHA1 Message Date
KRATOS fd4231bf47 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.
2026-07-30 10:53:25 +05:30
KRATOS 844ce7e2d0 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.
2026-07-30 10:30:14 +05:30
KRATOS 7595c82afa 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.
2026-07-30 10:07:18 +05:30
KRATOS f9d6579291 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.
2026-07-30 09:48:04 +05:30
KRATOS f747fe833d 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.
2026-07-30 09:32:15 +05:30
KRATOS 1d73e0d8ee 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.
2026-07-30 09:10:42 +05:30
KRATOS f7aeb9bf1f 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.
2026-07-30 08:43:13 +05:30
KRATOS 8ce0d894ab 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.
2026-07-30 00:46:21 +05:30
KRATOS ae7a16c01d 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.
2026-07-30 00:36:04 +05:30
KRATOS 5c3649397f 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.
2026-07-30 00:28:18 +05:30
KRATOS 92f3f4433e 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.
2026-07-30 00:13:29 +05:30
KRATOS 1fd568ea2a 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.
2026-07-30 00:03:43 +05:30
KRATOS ddeb5ba5e6 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.
2026-07-29 23:52:28 +05:30
KRATOS dcdbd02d4d 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.
2026-07-29 23:34:59 +05:30
KRATOS c67a12e426 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.
2026-07-29 23:26:10 +05:30
KRATOS ff9937e39f 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.
2026-07-29 23:05:46 +05:30
KRATOS fec3276e38 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.
2026-07-29 23:03:54 +05:30
KRATOS 7ccae3b9fa 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.
2026-07-29 21:57:14 +05:30
KRATOS b49926937b 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.
2026-07-29 21:31:58 +05:30
KRATOS 0e17caafed 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.
2026-07-29 21:23:28 +05:30
KRATOS 9909f7e666 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.
2026-07-29 20:59:40 +05:30
KRATOS 330860390a 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.
2026-07-29 20:43:26 +05:30
KRATOS 2957bd6411 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.
2026-07-29 20:29:46 +05:30
KRATOS 58b3b7c107 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.
2026-07-29 20:11:54 +05:30
KRATOS f9ecde00a5 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.
2026-07-29 17:36:03 +05:30
KRATOS 5620403e6d 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.
2026-07-29 17:20:13 +05:30
KRATOS e59394c16b 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.
2026-07-29 17:13:16 +05:30
KRATOS 3189641833 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.
2026-07-29 16:59:42 +05:30
KRATOS b904c3f751 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.
2026-07-29 16:52:41 +05:30
KRATOS 13d02de364 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.
2026-07-29 16:26:01 +05:30
KRATOS a7f54454f3 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.
2026-07-29 14:44:03 +05:30
KRATOS 96439f839b 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.
2026-07-29 14:29:18 +05:30
KRATOS 126ed00e0f 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.
2026-07-29 14:26:23 +05:30
KRATOS 86db276e55 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.
2026-07-29 14:08:48 +05:30
KRATOS 06c4a06c2f 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.
2026-07-29 13:25:15 +05:30
KRATOS 8199c430dc 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.
2026-07-29 12:26:59 +05:30
KRATOS 9c17a162fa 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.
2026-07-29 11:47:04 +05:30
KRATOS 3ee471dabd 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.
2026-07-29 11:33:33 +05:30
KRATOS e170c6fdcb 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.
2026-07-29 11:28:44 +05:30
KRATOS 7274af3f32 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.
2026-07-29 11:25:56 +05:30
KRATOS e682cb615f 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.
2026-07-29 11:18:31 +05:30
KRATOS 3b1df93754 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.
2026-07-29 00:57:28 +05:30
KRATOS 940aba20e8 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.
2026-07-29 00:43:09 +05:30
KRATOS 91218afbd2 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.
2026-07-29 00:03:10 +05:30
KRATOS b6675212a8 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.
2026-07-28 23:49:00 +05:30
KRATOS ac823643ac 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.
2026-07-28 23:31:53 +05:30
KRATOS e885d79205 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.
2026-07-28 20:34:44 +05:30
KRATOS 455e7708eb 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.
2026-07-28 20:34:44 +05:30
KRATOS 6d6b33c0fd 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.
2026-07-28 20:34:44 +05:30
KRATOS 843131f025 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".
2026-07-28 20:34:44 +05:30