@sema-agent/core 7.38.2 → 7.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +175 -6
- package/dist/core/ask-origin.d.ts +16 -7
- package/dist/core/ask-origin.js +4 -2
- package/dist/core/auto-mode-directive.d.ts +114 -19
- package/dist/core/auto-mode-directive.js +67 -19
- package/dist/core/checkpoint-store.d.ts +72 -41
- package/dist/core/checkpoint-store.js +28 -48
- package/dist/core/classifier-unavailable-arm.js +2 -2
- package/dist/core/decision-receipt.d.ts +2 -0
- package/dist/core/decision-receipt.js +1 -1
- package/dist/core/engine-notice.d.ts +41 -17
- package/dist/core/engine-notice.js +26 -14
- package/dist/core/gate-outcome.d.ts +80 -42
- package/dist/core/gate-outcome.js +21 -6
- package/dist/core/governance-codes.d.ts +1 -3
- package/dist/core/governance-codes.js +0 -3
- package/dist/core/hooks.d.ts +1 -1
- package/dist/core/hooks.js +2 -2
- package/dist/core/human-input-projection.d.ts +12 -0
- package/dist/core/human-input-projection.js +12 -0
- package/dist/core/memory-engine/engine.js +11 -11
- package/dist/core/memory-engine/layout.d.ts +21 -2
- package/dist/core/memory-engine/layout.js +17 -5
- package/dist/core/mode-policy.d.ts +26 -0
- package/dist/core/mode-policy.js +37 -1
- package/dist/core/output-style.d.ts +21 -0
- package/dist/core/output-style.js +29 -0
- package/dist/core/real-approval.d.ts +25 -0
- package/dist/core/real-approval.js +27 -0
- package/dist/core/reminder-mint.d.ts +0 -9
- package/dist/core/reminder-mint.js +0 -11
- package/dist/core/retired-keys.d.ts +2 -1
- package/dist/core/retired-keys.js +3 -1
- package/dist/core/runner/assemble-result.d.ts +2 -0
- package/dist/core/runner/assemble-result.js +1 -1
- package/dist/core/runner/compaction-restate.d.ts +45 -4
- package/dist/core/runner/compaction-restate.js +89 -11
- package/dist/core/runner/contracts.d.ts +6 -6
- package/dist/core/runner/denial-limit-arms.js +4 -4
- package/dist/core/runner/inherited-ask-grants.d.ts +4 -2
- package/dist/core/runner/inherited-ask-grants.js +0 -3
- package/dist/core/runner/permission-rule-lanes.d.ts +18 -0
- package/dist/core/runner/permission-rule-lanes.js +35 -25
- package/dist/core/runner/prepare-context-lane.d.ts +1 -0
- package/dist/core/runner/prepare-context-lane.js +6 -0
- package/dist/core/runner/prepare-inherited-gate.js +2 -9
- package/dist/core/runner/prepare-task.js +10 -24
- package/dist/core/runner/prepare-wiring-manifest.js +2 -2
- package/dist/core/runner/prompt-block.d.ts +9 -0
- package/dist/core/runner/prompt-block.js +8 -0
- package/dist/core/runner/resume-admission.js +4 -1
- package/dist/core/runner/resume-apply.js +28 -18
- package/dist/core/runner/resume-policy-outcome.js +10 -4
- package/dist/core/runner/run-attachment-seats.js +1 -1
- package/dist/core/runner/run-identity-wiring.js +9 -2
- package/dist/core/runner/run-leg.js +42 -31
- package/dist/core/runner/run-settle-and-teardown.js +4 -3
- package/dist/core/runner/run-turn-boundary.js +4 -3
- package/dist/core/runner/runtask.d.ts +4 -2
- package/dist/core/runner/steer-admission.d.ts +9 -0
- package/dist/core/runner/steer-admission.js +8 -0
- package/dist/core/runner/stream-steer-verb.js +16 -11
- package/dist/core/runner/synthetic-tools.d.ts +4 -0
- package/dist/core/runner/tool-turn-repair-seat.js +1 -1
- package/dist/core/runner/turn-attachments.d.ts +16 -22
- package/dist/core/runner/turn-attachments.js +3 -20
- package/dist/core/runner-deps.d.ts +1 -1
- package/dist/core/store-contracts/checkpoint-store-contract.d.ts +8 -0
- package/dist/core/store-contracts/checkpoint-store-contract.js +23 -0
- package/dist/core/task-event.d.ts +7 -2
- package/dist/core/task-spec.d.ts +29 -7
- package/dist/core/terminal-cause.d.ts +44 -3
- package/dist/core/terminal-cause.js +5 -0
- package/dist/core/tool-policy.d.ts +55 -29
- package/dist/core/tool-policy.js +42 -31
- package/dist/core/types.d.ts +1 -1
- package/dist/core/types.js +1 -1
- package/dist/core/wiring-manifest.d.ts +17 -3
- package/dist/core/wiring-manifest.js +8 -3
- package/dist/engine/harness/agent-harness.d.ts +10 -0
- package/dist/engine/harness/agent-harness.js +12 -1
- package/dist/engine/llm/tool-turns.d.ts +15 -2
- package/dist/engine/llm/tool-turns.js +2 -2
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/prompt-assembly/event-registry.js +2 -1
- package/dist/stores/file/fs-atomic.d.ts +29 -4
- package/dist/stores/file/fs-atomic.js +55 -6
- package/dist/tools/fs/index.d.ts +2 -1
- package/dist/tools/fs/read-deny-home.d.ts +3 -2
- package/dist/tools/fs/read-deny-home.js +6 -4
- package/dist/tools/fs/shell-read-check.d.ts +10 -3
- package/dist/tools/fs/shell-read-check.js +2 -2
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +21 -1
- package/wire-changes/core@7.38.2.json +20 -2
- package/wire-changes/core@7.38.3.json +36 -0
- package/wire-changes/core@7.39.0.json +845 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,178 @@
|
|
|
1
|
+
## 7.39.0 — 2026-10-02
|
|
2
|
+
|
|
3
|
+
**Hard-breaking changes:** six — #1254: `TaskSpec.attachments.planModeReminder` is retired (a spec still carrying it fails at prepare, `config.spec_retired_key`) and the `steering_injected` source `plan_mode` is no longer minted (the union loses the member) — its three sentences are in the #1254 section below. #1266: a decision carrying `foldedAskClasses` on an object the engine's own policy fold did not attest (a policy's own spelling, a copy of a `combinePolicies` result) is refused at the receipt. Who is affected, how to change, what happens if not: the #1266 section below. #1262: `permissionMode: "dontAsk"` now refuses the four operator-authority asks it used to put to a person (an org rule, an operator-forced ask, the shell floor, governance unavailable) — zero cards — its three sentences are in the #1262 section below. #1213: a checkpoint row still carrying a pre-7.34.0 mode-seed key (`autoModeRequested` / `approverPosture` / a doctrine `shellGate` word) is refused at resume, before the claim — the migration is deleted — its three sentences are in the #1213 section below. #1213 (closed-set row deleted): the notice code `resume.legacy_mode_seed_migrated` leaves `ENGINE_NOTICE_CODES` (its audience and minter rows with it) — its three sentences are in the notice-code section below. #1265 (`policy_refused`'s `who.party` moves `none` → `policy`): a consumer that reads a `policy_refused` settlement and expects `who.party === "none"` (a hand-copied party set, a `none`-only branch) now meets `policy` with the channel's `approver` transcribed — who is affected: a party switch or a hand-copied party set downstream (server `trace/project.ts` withholds unknown parties); how to change: read `SETTLEMENT_PARTIES` / `isSettlementParty` and treat `policy` as a decider; what happens if not: every `policy_refused` record is withheld or mis-bucketed — its section is #1265 below. (#1260 adds a closed-set row and narrows what a by-class approver answers — its three sentences are in its section. #1265 adds a closed-set row, a new closed set and moves `policy_refused`'s party word — its three sentences are in its section.)
|
|
4
|
+
|
|
5
|
+
**Closed sets.** `ASK_CLASSES` / `AskClass` +1: `shell_floor` (#1260), placed after `operator_forced`, with `operator_forced`'s row in `MODE_ARBITRATION` (`bypassImmune`, not rule-clearable, classifier- and sandbox-eligible); not in `RELEASABLE_ASK_CLASSES`. A downstream `Record<AskClass, …>` table, a copied `AskClass` union or a table pinned to the class count gains one row. `steering_injected.source` −`plan_mode` +`output_style` (#1254 / #1255; still 19 members): a downstream `Record<source, …>` table drops one row and adds one. `EVENT_PROMPT_REGISTRY` kinds −`plan_mode` +`output_style` +`permission_posture`. New thrown code `config.output_style_invalid`. `ENGINE_NOTICE_CODES` / `NOTICE_AUDIENCE` / `NOTICE_MINTER` −1: `resume.legacy_mode_seed_migrated` (#1213); the thrown code `resume.legacy_mode_seed` is retired with it (no thrower any more). `CheckpointErrorReason` is unchanged: `seat_retired` gains a second construction site (#1213). `CheckpointError.code` +1: `checkpoint.config_refused` (#1285), with `detail.code` (new closed set `CheckpointConfigRefusalCode`, four words, internal) and `detail.field` +2 (`taskConfig`, `inheritedGate`): a downstream table over the code union or over `detail.field` gains the new words. `SETTLEMENT_KINDS` / `SettlementKind` +1: `policy_allowed` (#1265), placed before `policy_refused`, with its rows in `SETTLEMENT_IS_REFUSAL` (false) and `SETTLEMENT_NOBODY_JUDGED` (false); new closed set `SETTLEMENT_PARTIES` / `SettlementParty` (five words; `policy_refused`'s `who.party` moves `none` → `policy`). New closed set `PROMPT_BLOCK_CAUSES` / `PromptBlockCause` (#1277; four words: `block`, `crashed`, `timeout`, `cancelled`) and `TerminalBlockedDetail["kind"]` (one word, `prompt_blocked_by_hook`). **Package exports:** +10 / −0 — `OUTPUT_STYLE_NAME_MAX` (#1255); `receiveHookResult`, `isEngineRefusal` (#1274); `SETTLEMENT_PARTIES`, `SettlementParty`, `isSettlementParty` (#1265); `PROMPT_BLOCK_CAUSES`, `PromptBlockCause`, `isPromptBlockCause`, `TerminalBlockedDetail` (#1277). New optional type keys `TaskSpec.outputStyle`, `TerminalCause` (`blocked`) `.detail` (#1277), `ShellReadJudgeInput.readDenyHomes` (#1276); new optional type key `CheckpointError.detail.code` (#1285); removed type key `TaskSpec.attachments.planModeReminder`.
|
|
6
|
+
|
|
7
|
+
### The shell floor is its own ask class; an ancestor's card no longer covers it (#1260; closed set +1; behavior (tighten); +0 exports) (@server @cli @client-core @sdk @test)
|
|
8
|
+
|
|
9
|
+
- **Before (v7.38.0).** Under the operator's `shellGate: "always"`, a delegated `Bash` / `Monitor` call the ancestor's policy asked about met two authorities: the ancestor's card (its yes recorded as a grant for the classes the card carried) and the floor's own card at the child's gate. The floor's class was `operator_forced` — shared with a deployment-authored ask, a bare `mandated` ask and a deployment-marked tool — and the consume half held it apart by one more guard on the ask's `mandate: "operator_always"` word. That word is a single winner: an `authoredBy: "deployment"` member or a bare `mandated` bit on the child's surviving ask removes it. Measured on v7.38.0 (Bash and Monitor alike): the host handing the SAME deployment-authored policy object to `toolPolicy` and `parentConstraints[].policy`, a deployment-authored or `mandated` child policy under a deployment-authored or `mandated` ancestor ask — one card; the floor's card never came (KL-1216-a). A child `mandated` / deployment-authored ask under a plain ancestor ask: two cards, the second with no floor word.
|
|
10
|
+
- **Now.** The gate classes the floor — a shell-gated seat under the `always` tier, the facts the `operator_always` word is read from — as `shell_floor` (`src/core/ask-origin.ts` `classifyAsk`). No inherited station mints it (its card carries no floor sentence: `inheritedStationFacts` passes `shellGated: false`), so no grant carries it (a policy can no longer write the engine's `foldedAskClasses` member itself — #1266 below), and coverage (`askGrantCovers`: every class but `ordinary` was on the card) keeps the floor's card on its own; the word guard is retired (`src/core/runner/inherited-ask-grants.ts`). Every shape above: two cards on one `toolCallId`, the second carrying `shell_floor`; the ancestor allowing: one card, `["shell_floor"]`, `mandate: "operator_always"`. `dontAsk` keeps `shell_floor` for a person as it kept `operator_forced` (that keep-set is retired with #1262). The `mandate` word is unchanged: where another door displaces it, the card's `classes` is what names the floor.
|
|
11
|
+
- **Who is hurt.** A function `onAsk` that answers by class and lets a machine answer `operator_forced` (§4.6e): it answered the shell floor too, and no longer does — the floor's card is refused (or reaches whoever that host hands refusals to) and the command does not run. A host with an exhaustive `Record<AskClass, …>` table or its own copy of the class union fails to type-check until it adds the row. The recommended approver (`ANSWERED = {"ordinary"}`) is unaffected: it refused the floor before and refuses it now.
|
|
12
|
+
- **Compensation.** Name `shell_floor` beside `operator_forced` in the answered set if a machine may confirm the operator's shell floor (`new Set(["ordinary", "operator_forced", "shell_floor"])`); add the `shell_floor` row to an exhaustive table (copy `operator_forced`'s semantics).
|
|
13
|
+
- **If unchanged.** The host's leg stops running shell commands under `shellGate: "always"` that it ran on v7.38.0 (every such command's floor card is refused), and a TypeScript host's build is red at its class table.
|
|
14
|
+
- Consumers: server — `ASK_CLASSES` / `arbitrateByMode` are read from the package (no table to copy); the unattended answered set is `{"ordinary"}` (unchanged reading). cli — the bypass-posture table reads the single `mandate` word; reading `classes.includes("shell_floor")` closes the "the floor hides behind another word" known limit it documents. client-core — class members are open strings (pass-through). sdk — the `AskClass` union gains `"shell_floor"`. test — the shapes above, Bash and Monitor.
|
|
15
|
+
- Pins: `test/backlog1260-shell-floor-class.test.ts` — ① F-2e / F-2f / the shared policy object, Bash and Monitor (two cards, the second `shell_floor`; baseline: the shared object 1 card); ② the rest of the family (baseline: four shapes 1 card, two shapes without the floor's word); ③ positive controls (the ancestor allows ⇒ one card, the floor's); ④ §4.6e (a host answering `operator_forced` no longer answers the floor; naming `shell_floor` restores it; the row; `dontAsk` on the lane); ⑤ the consume half (coverage is the one rule; the word is not read). `test/inherited-gate-rb40-review-gaps.test.ts` F-2e / F-2f name the two cards' classes.
|
|
16
|
+
|
|
17
|
+
### `foldedAskClasses` is engine-written: only the engine's policy fold may carry it (#1266; behavior (tighten: the receipt); hard-breaking; +0 exports) (@server @test)
|
|
18
|
+
|
|
19
|
+
- **Before (v7.38.0).** The member records the classes of the asks a `combinePolicies` fold collapsed. The receipt held its words to `ASK_CLASSES` but took it from any policy, and the class derivation read it: a policy writing it on its own ask put a class on the card no station had judged, and under delegation the grant a person's yes recorded on the ancestor's card carried that class, so the child's gate found it covered and asked no question of its own. Measured on v7.38.0: the ancestor's policy writing `foldedAskClasses: ["write_protection"]` on its ask about a delegated `Write` onto `<cwd>/.claude/projects/slug/memory/a.md` — one card, and the protected file was written (without the forged member: two cards, the second the protection question). On the #1260 tree, `["shell_floor"]` removed the shell floor's card the same way.
|
|
20
|
+
- **Now.** `combinePolicies` attests the ask it returns with the exact list it wrote (an identity-keyed engine attestation, carried wherever the engine carries its records); the receipt refuses — named, fail-closed, `{ kind: "denied", deniedBy: "policy" }` — a decision carrying `foldedAskClasses` on any other object or with any other list. Every station after the receipt (the class derivation, the inherited stations, the grant) reads only a fold the engine ran. The engine's own reconstructions of a received ask (the inherited station's mark frame, its denial-limit fallback, the classifier-unavailable re-ask) carry the attestation; one that did not would be refused by name, never read silently. The fold's own object handed back unchanged — as the task's policy, an ancestor's, or both — flows exactly as before.
|
|
21
|
+
- **Who is affected.** A host that hands the engine a COPY of a `combinePolicies` result carrying `foldedAskClasses` — a spread (`{ ...ask, authoredBy: "deployment" }`), a `structuredClone`, a rebuilt object — or writes the member itself. Named: server `src/runtime-governance.ts:421-433` (`createGovernanceAskMarkingPolicy` spreads the fold's ask through `withDeploymentAuthorship`, `src/operator-ask.ts:79-81`), whenever the fold collapsed an ask that carries a class member — both governance layers asking on one call, or ONE layer folding the per-command decisions of a compound command (server `commandPolicy` with deny + ask rules on `ls && pwd`: server's reading on this build before its fix, zero cards and the command not run; after its fix one card, then run).
|
|
22
|
+
- **How to change.** Put what you add on each layer before the fold, and hand the fold's output back as it is: server — wrap each governance policy with `withDeploymentAuthorship`, then `combinePolicies` them as the outermost layer. (Alternative: a host that must rebuild the decision leaves `foldedAskClasses` off the copy — every class the fold names has a documented member the host can write itself.)
|
|
23
|
+
- **If unchanged.** Such a call is refused at the receipt — the tool does not run, no card, the text names `"foldedAskClasses"` — whenever the fold collapsed an ask carrying a class.
|
|
24
|
+
- Pins: `test/backlog1266-folded-ask-classes-signed.test.ts` — ① forged `shell_floor` / `write_protection` / `operator_forced` / `org_rule` on a `Write` onto the protected memory file, delegated and direct: refused, 0 cards, 0 writes; ② forged `shell_floor` on `Bash` under the floor, both lanes: refused; ③ the fold's own object passes, a copy / an edited list / the same list on a fresh object are refused by name; ④ positive controls (the fold's class reaches the card and the grant — direct, delegated, shared); ⑤ the station's frame reconstruction keeps the attestation (the float ends as the unresolvable refusal). Re-anchored: `test/backlog1231-receipt-closed-sets.test.ts` ④ (the in-set control takes the fold's own object), `test/backlog1111-permission-mode-arbitration.test.ts` (the fence pin's folded explainer is built by `combinePolicies`).
|
|
25
|
+
|
|
26
|
+
### The person's missing-result line is for the run's own calls (#1257; behavior (fewer user lines); +0 exports) (@server @cli @client-core @test)
|
|
27
|
+
|
|
28
|
+
- **Before (v7.38.0).** A call left with no result before its id is used again cannot be closed by an append (#1235), so the transcript never mends and the request view closes the call on the wire on every request of every later run. The repair seat is per run, so `task.tool_turn_incomplete` (`cause: "missing_result_synthesized"`) told the person about the same historical call once in each run of the session, for ever. Measured on v7.38.0: two runs over `[user, asst(call_0), user, asst(call_0), res(call_0)]`, one user line in each run, on all three lanes.
|
|
29
|
+
- **Now.** When its run begins the harness takes a snapshot of the assistant messages standing in the transcript (`src/engine/harness/agent-harness.ts`, `runHistory`, one `WeakSet` per run, taken before the run's own prompt enters); the request view marks a `missing_result` repair on one of them `beforeRun` (`ToolTurnRepair.beforeRun`, internal, not a package export; membership by message, never by position, so a request projection that inserts, drops or replaces other messages — the media cap replaces a prompt whose image it strips — cannot move it); the repair seat leaves marked repairs out of the user line (`src/core/runner/tool-turn-repair-seat.ts`). A call the run itself leaves unanswered is still announced, once per run. The operator line `context.tool_turn_repaired` is unchanged: still once per run per (form, calls), the same `detail` with no new key. The synthesized close and the request bytes are unchanged. A copy of a transcript message standing in the view (a projection that replaced an assistant message) is not in the snapshot and reads as the run's own: the line is announced as before, never silently dropped (KL-1257-a).
|
|
30
|
+
- **Who is hurt.** A host that read the user line to learn that a resumed session carries a call with no recorded result: it no longer arrives on that line. The fact stays on the operator line; the person-facing line speaks only of this run's own calls.
|
|
31
|
+
- **Compensation.** Read `context.tool_turn_repaired` (`detail.repairs[]`, form `missing_result`) for the transcript's history.
|
|
32
|
+
- Pins: `test/backlog1257-historical-missing-result-silent.test.ts` — ① three runs on each lane, no user line (baseline 1 in each); ② the run's own call still announced (view + seat; the real harness's own snapshot; a snapshot per run on a reused harness; a copied message reads as the run's own = announced); ③ the operator line unchanged on each lane and in the seat; ④ the three lanes read the same; ⑤ an id the run reuses does not revive the historical call; ⑥ no snapshot = the line as before; ⑦ a hands-enabled leg in a real git repo; ⑧ a prompt whose image the media cap strips, two runs. Baseline 19 of 25 red. `test/backlog1224-tool-turn-invariants.test.ts` ② re-pinned on its user-line cell only (the call it seeds went unanswered before the run's prompt: the operator line and the wire cells are as before).
|
|
33
|
+
|
|
34
|
+
### The posture frame that ended is retired by its exit frame (#1253; behavior (new model-facing frames); +0 exports) (@server @cli @client-core @test)
|
|
35
|
+
|
|
36
|
+
- **Before (v7.38.0).** The engine stated a leg's posture — "## Auto Mode Active", "While bypass permissions mode is active" — once in the active context and never retracted it: a leg whose effective mode left `auto` / `bypassPermissions` (a next leg on the session, a `setPermissionMode` steer, a parent's live ceiling) kept the marked frame standing with nothing saying it had ended. Measured on stage-7390: auto→default→auto on one session read `[auto] / [auto] / [auto]`, bypass→default `[bypass] / [bypass]`, a mid-leg steer auto→default `[auto]` on every later request.
|
|
37
|
+
- **Now.** One table (`POSTURE_TABLE`, `src/core/auto-mode-directive.ts`: plan / bypass / auto, each with its frame and its exit frame) and one rule (`composePostureFrames`): fold the active context's marked posture frames in order (a frame makes its family stand, the family's exit retires it), mint the exit of every standing family the owed row does not belong to, then the owed frame when it is not standing. The exits are the reference implementation's own exit-renderer arms: auto → "## Exited Auto Mode … ask clarifying questions …"; bypass → "## Exited Auto Mode … Resume using the dedicated tools for file reads, searches, and edits." (the steer-only arm with the shell-first sentence). auto ↔ bypass is a variant switch: restated, never exited. Four sites ask the same question: a leg's first request, a resumed leg's continuation head, every turn boundary a request can follow (a text-final turn included — a queued steer continues it; not when a stop is owed or the run aborted) and a landed compaction. Frames are appended, never rewritten; the newest carrier takes the request-clip protection slot, so a retired frame loses it. At a boundary and after a landed compaction the frames are appended to the transcript as one engine-minted message — awaited, through one helper (`appendOwedPosture`) — before the next request's context is rebuilt, so they reach that very request ahead of every queued steer (a user steer, the diagnostics note, the attachment bundle), and the transcript is the one place that says which family stands (a compaction at the same boundary reads them back and says nothing twice); a composition that could not read the context is asked again at the next boundary. A failed append follows the rule of every engine frame written at a boundary (the git frame's re-assert, the compaction itself): the run continues and the failure is reported (`onError`, phase `degraded`); the composition stays dirty, so the next boundary writes the frames again; until then the frames ride each request's view without being persisted, so no request goes out with a posture the engine knows is stale. A boundary that holds engine injections (a person's bare rejection halted the batch) does not write the transcript either: the frames go the same way, request view only, nothing reported since nothing failed. A queued steer that continues the run carries them on its request, the next unheld boundary writes them, and a run that ends leaves them to the next leg's first turn. Entering plan retires the auto family (one family-frame each, never side by side — the reference keeps its auto frame through plan because its plan mode can run the auto classifier; this engine's plan arms none; core-decided under C-R210 ④). The bypass exit fires although the reference never fires one for a bypass frame (its exit flag is set only on leaving auto) — the model keeps no shell-first posture past the mode that called for it (C-R210 ③).
|
|
38
|
+
- **Who is hurt.** A probe pinning the absence of any posture text on a leg that follows an `auto` / `bypass` leg on the same session (it now carries the exit frame), or counting the user-lane bytes of such a leg. The posture frames carry no `steering_injected` echo (unchanged).
|
|
39
|
+
- **Compensation.** Count frames by the engine's mark, not by text; expect one exit frame per frame that ended.
|
|
40
|
+
- Pins: `test/backlog1253-posture-exit.test.ts` — ① the composer over every (standing → owed) pair of the table; ② three legs auto→default→auto (`[auto] / [auto, auto_exit] / [auto, auto_exit, auto]`, baseline `[auto]` ×3), bypass→default, auto→bypass→auto; ③ the four sites, one positive control each; ④ a spliced exit block — unmarked, or spelling the session's mark — retires nothing (engine regions are read by metadata); ⑤ the protection slot follows the newest carrier; ⑥ a failed context read / a failed append leaves the composition dirty, the boundary and the compaction arm write through one helper (e2e: a steer auto → plan on a run compacting at every boundary states exactly one planning frame per request — two before codex r1's fix); ⑦ EnterPlanMode at a boundary LSP diagnostics take: the request first carrying the diagnostics states `[bypass, bypass-exit, plan]`. `test/backlog1253-posture-lanes.test.ts`, on the anthropic / openai / responses brains reading the request bodies: a queued user steer + auto → plan (the posture precedes the steer in the next request, the steer delivered — before: the steer drained first and the request still said auto), the diagnostics boundary, a same-boundary compaction (one planning frame), a failed append (0 then exactly 1), a new Runner on the same session (no re-mint), and the tool-turn law on every body; auto → plan → auto and `EnterPlanMode` inside an auto leg (no auto residue in plan).
|
|
41
|
+
|
|
42
|
+
### The planning posture is stated from the mode; `attachments.planModeReminder` is retired (#1254; behavior (new model-facing frames); hard-breaking; steering source −`plan_mode`; +0 exports) (@server @cli @client-core @sdk @test)
|
|
43
|
+
|
|
44
|
+
- **Before (v7.38.0).** A `permissionMode: "plan"` leg's first request carried no plan instruction at all; the planning reminder was an opt-in boundary attachment (`attachments.planModeReminder`, default off — no shell turned it on) that started only after the first model call, and leaving plan said nothing. Its echo was `steering_injected` source `plan_mode`.
|
|
45
|
+
- **Now.** The planning posture is the first row of the posture table: the full frame at the head of the first request of a plan leg (and at the boundary after the model's `EnterPlanMode`), restated every 5 assistant turns (full on the 1st / 6th / 11th statement since the last exit, sparse between — counted from the context, so the cadence carries across legs and an exit restarts it), and "## Exited Plan Mode\n\nYou have exited plan mode. You can now make edits, run tools, and take actions." when the leg leaves plan (a next leg, a steer, a `plan_review` approval — the exit rides the resumed continuation). The plan row outranks the bypass row (a plan leg whose asks a blanket seat answers is told plan). The bodies are the opt-in lane's own bytes, moved (no plan-file clause: the engine keeps no plan file — a shell that has one names its path in `appendSystemPrompt`). The frames are model-lane only: no echo.
|
|
46
|
+
- **Worded by what the leg has (#1282).** The planning frame names the plan-review tool (`ExitPlanMode`) only to a leg whose minted roster carries it — a checkpoint store or `interactiveTools: true` mounts it; `interactiveTools: false` or no store does not. The plan family is three rows tried in this order: a delegated child (the reference's sub-agent variant: the same opening, no exit tool named, "answer the request comprehensively — your final answer, carrying the plan, goes back to the agent that delegated to you"), a leg with the tool (the body above, unchanged byte for byte), and a leg without it ("… give it as your final answer and stop"). Each has its own sparse restatement (the child's restatement is its one body, as in the reference). The exit frame names no tool and retires whichever stood. A leg resumed under a different roster is restated the owed wording and never exited (the same rule as the auto family's two arms). Before, every plan leg was told to call the tool, whether or not it was on its roster, and a plan parent's delegated child got the main agent's wording. Who is hurt: a host that matches the planning frame's text (the no-tool and child bodies are new strings; the tool-naming body keeps its digest). Not a closed-set or export change.
|
|
47
|
+
- **Who is affected.** A host that writes `attachments.planModeReminder` (server `src/spec-fields.ts:60,71` passes it through, `src/http/admission.ts:876,880` admits it, `src/http/wire-types.ts:213` declares it; client-core `src/attachmentsWireCaps.ts:116,138,164` lists it in the literal-true family; sdk `packages/sdk/src/types.ts:815` and both `openapi.yaml` declare it), and a consumer that matches `steering_injected.source === "plan_mode"` (client-core `src/steering.ts` `SOURCE_LABELS.plan_mode` — an open-set label table, it simply never fires; cli `src/sema/steeringAttachments.test.ts` feeds the word to that projection).
|
|
48
|
+
- **How to change.** Drop the key everywhere it is written or forwarded (nothing replaces it — the planning posture needs no opt-in), remove it from wire schemas, and drop `plan_mode` from any exhaustive table over the steering sources.
|
|
49
|
+
- **If unchanged.** A spec still carrying `planModeReminder` (`true` or `false`) fails at prepare with `config.spec_retired_key` ("TaskSpec.attachments.planModeReminder is retired …") and no model call is made; a TypeScript host writing it does not compile; a `plan_mode` echo never arrives.
|
|
50
|
+
- Pins: `test/backlog1254-plan-posture.test.ts` — ① first request (baseline: 0 on every call); ② the 26-call cadence with no attachments config, and the composer's 6th-statement / exit-restart rows; ③ the exit on the next leg, `EnterPlanMode` at the boundary, plan outranks bypass; ④ the retired key refused (`true` / `false`), absent from the type; ⑤ the bodies byte-locked to their pre-move digests. #1282: `test/backlog1282-plan-frame-roster.test.ts` — ① the wording follows the roster (no store / store / `interactiveTools` both ways; baseline named the tool on every row), ② a delegated child states the sub-agent body and the row order holds on the real lane (the child inherits the store, so its roster carries the tool), ③ each wording restates with its own body, ④ the exit pairs with whichever stood, ⑤ a wording switch restates and never exits, ⑥ the new bodies byte-locked.
|
|
51
|
+
|
|
52
|
+
### `TaskSpec.outputStyle`: the engine states the shell's output style (#1255; closed set +1 `output_style`; behavior (new seat); +1 export) (@server @cli @client-core @sdk @test)
|
|
53
|
+
|
|
54
|
+
- **Before (v7.38.0).** No seat: an `outputStyle` key was ignored, and a shell that spliced the "… output style is active." reminder into the objective sent unmarked reminder-shaped text — data, by the mark rule — so across two processes the shell's `/output-style` did not reach the model as an instruction.
|
|
55
|
+
- **Now.** `TaskSpec.outputStyle?: { name: string; turnReminder?: string }`: the engine mints the reference implementation's per-turn sentence `"<name> output style is active. <turnReminder ?? "Remember to follow the specific guidelines for this style.">"` in its own marked reminder at every attachment cycle (each leg's first request, a resumed leg's continuation, every boundary a request follows that tool results or an already-queued input continue — a continuation decided later (a Stop hook sending the turn on; a steer that arrives only during a compaction's summary call) is not restated, and the reference's stop-hook continuation attaches nothing there either; KL-1284-b), echoed as `steering_injected` source `output_style` where the steer lane delivers it; clippable under the boundary byte cap because it is said again at the next one. A boundary that tool results continue carries it on the steer lane (the attachment bundle); a text-final boundary that QUEUED INPUT continues (a caller steer or follow-up queued while the model was answering) carries it as an engine-minted message written to the transcript ahead of that input — the posture's own lane (#1284) — because a steer makes a request and would extend a run that ended. That write has no `steering_injected` echo, and a text-final turn nothing continues gets none (one request, no trailing frame). The style's guidelines stay in `systemPrompt` / `appendSystemPrompt`. Bad values are refused at prepare by name, `config.output_style_invalid`: not an object of exactly `name` / `turnReminder`; a `name` that is not a string, empty or blank, or longer than `OUTPUT_STYLE_NAME_MAX` (256 — the reference's own bound, past which it silently drops the reminder; exported); a `turnReminder` present but not a non-empty string.
|
|
56
|
+
- **Not retracted (as in the reference).** When a later leg declares no style, the earlier legs' style frames stay in the history; the engine mints no "style ended" text, and neither does the reference. Its producer goes silent for the default style (`Y$r`), and the change reaches the model only through the shell: a new system prompt, plus the `/output-style` command's own stdout line. A shell that switches styles declares the new style, and swaps the guidelines in `systemPrompt` / `appendSystemPrompt` (KL-1255-c).
|
|
57
|
+
- **Who is hurt.** A host that already put an `outputStyle` key on the spec (it was ignored; a value outside the shape now fails prepare). A consumer with an exhaustive table over the steering sources gains the `output_style` row.
|
|
58
|
+
- **Compensation.** Declare the seat only for a non-default style, with the style's display name; pre-validate against `OUTPUT_STYLE_NAME_MAX`.
|
|
59
|
+
- Pins: `test/backlog1255-output-style.test.ts` — ① the marked frame (default / declared reminder; baseline 0), the spliced copy stays data; ② one frame per call over a four-call run, a text-final turn not extended; ③ clipped at one boundary, whole at the next; ④ the resumed continuation carries it, echoed; ⑤ ten bad shapes refused with zero model calls, the bound at exactly 256; ⑥ the source union locked (+`output_style`, −`plan_mode`), a plan leg echoes nothing. #1284: `test/backlog1284-output-style-boundary.test.ts` — ① the text-final turn a queued steer or follow-up continues restates it ahead of the input (baseline `[1,1]`), ② a text-final turn nobody continues is not extended and leaves no trailing frame, ③ the posture and the style ride the same continuing request, ④ the tool-calling boundaries unchanged.
|
|
60
|
+
|
|
61
|
+
### A live caller steer is accounted at consumption: `human_input` `applied` under the `queued` frame's `inputId` (#1273; behavior (a second frame per live steer); +0 exports) (@server @cli @client-core @test)
|
|
62
|
+
|
|
63
|
+
- **Before (v7.38.0).** A caller steer accepted by `TaskStream.steer` emitted one `human_input` frame, `delivery: "queued"`, at acceptance and nothing when the model consumed it; `delivery: "applied"` existed only on the resume replay (a parked steer drained into a continuation prompt). A serving layer's accepted-input ledger could not tell an input the model had read before its process died from one it had not.
|
|
64
|
+
- **Now.** The harness reports each caller frame once, when it is in the transcript — the frame's `message_end` append (`AgentHarness.settleInjectedFrame` → `onUserInputConsumed`; the request that follows carries it) — and the runner mints `human_input{ delivery: "applied", carrier: "steer", source: "steer", inputId }` with the `queued` frame's id and the same `actor` / `issuer` / `principal`, through `buildAppliedHumanInputEvent`, the one constructor the resume replay now shares. The frame precedes that request and the message's `message_committed`, and `queued` always precedes `applied`: the harness can append a frame while the enqueue the caller awaits is still broadcasting, so the acceptance frame is published once through an acceptance seat (`steer-admission.ts`) by whichever of the verb and the consumption sink gets there first (`sessionSeq` follows publication order). The `applied` frame repeats the identity keys the `queued` frame carried (`parentToolCallId`, and `sourceTaskId` only when the leg's spec names a `taskId`): the acceptance seat hands them back and the sink does not recompute them. Both lanes: `priority` `next` / `now` (steer queue) and `later` (follow-up queue). **Consumption is the transcript append, not the queue drain:** a frame drained into a turn that a halt or an abort then drops before the turn starts was never consumed — it has no `applied` frame, stays in the park / undrained account (`task.user_steer_undrained` / `task.user_followup_undrained`, `cause: "leg_end"`), and a durable park's resume replays it (`applied`, once). No new notice code, no new `delivery` word.
|
|
65
|
+
- **Identity on the replay arm (present form).** A parked input's `applied` is minted by the resumed leg, which stamps its own identity (`parentToolCallId` when it runs as a sub-agent, `sourceTaskId` = the spec's `taskId` or else the session id); the parked entry (text, trust, `inputId`, tier, actor) carries no identity from the parking leg. The two frames agree on a top-level leg and on a sub-agent leg that names a `taskId`; they differ in one shape — a sub-agent leg that names no `taskId`: the parking leg's `queued` has no `sourceTaskId` and the replayed `applied` carries the session id. A consumer that books by `sourceTaskId` joins a parked input's two frames on `inputId`.
|
|
66
|
+
- **`sessionSeq`.** Each frame takes the next ordinal of the leg: the `applied` frame is not given its `queued` frame's number (the objective 1, `queued` 2, `applied` 3 when nothing else was accepted between). Join the two on `inputId`, never on `sessionSeq`.
|
|
67
|
+
- **Who is hurt.** A consumer that turns every `human_input` frame into a row — a count of inputs, a rendered line, a notification — sees two frames per live steer where it saw one (a steer parked and then resumed already produced `queued` on the parking leg and `applied` on the resume leg under one `inputId`; the live lane now says the same two words). A read of the downstream sources (`src/` of server, cli and client-core, 2026-10-02; grep plus the cited readers read around the cited lines — not an exhaustive census): none renders the frame as a message and none de-duplicates on `inputId`. server: `src/trace/ledger-sink.ts:313-316` and `src/http/resume-legs.ts:1888` append every frame to the events ledger, `src/http/routes/tasks.ts:1096-1100` forwards every frame on the SSE stream (`delivery` verbatim, a fresh `eventId` per frame), `src/trace/project.ts:2350` projects it to no content frame. client-core: `src/adapt/arms.ts:350-379` makes one chrome ledger record per frame (no transcript row), `src/adapter/downstream/eventToSdkMessage.ts:2039-2069` one neutral internal arm carrying `delivery` verbatim. cli: `src/sema/upstreamBridge.ts:2294-2307` and `src/sema/seamQueryEngine.ts:1675-1691` log one debug audit line per frame (carrier, eventId, actor — not `delivery`: two lines now for one steer, told apart by `eventId`), `src/seam/adapter/runStream.ts:434-455` the same with `delivery`. A test that counts the `human_input` frames of a live steer fails (two in this repository were re-pinned to the acceptance frame).
|
|
68
|
+
- **Compensation.** Key on `inputId` + `delivery`: `queued` = the engine accepted it, `applied` = it is in the transcript the next request carries. A serving layer that books accepted inputs can narrow an "unknown whether consumed" verdict for a crashed run to "never consumed" when it holds no `applied` frame for the input.
|
|
69
|
+
- **If unchanged.** A consumer that counts frames reports two inputs for one steer.
|
|
70
|
+
- Pins: `test/backlog1273-live-steer-consumed.test.ts` — ① steer lane / follow-up lane / two inputs / attribution (baseline: `[queued]` each; `applied` after `queued` and before the answer that read the steer); ①′ the steer call answering only after the carrying request (baseline of the fix: `[applied, queued]`); ④ the live frames' identity keys equal (a sub-agent leg without a `taskId`, with one, a top-level leg); ⑤ the replay arm's present form; ② accepted then cut by `interrupt()`, and drained into the pending batch then dropped by a halt latched in the stop gate's window — no `applied`, the undrained notice (green controls on the baseline); ③ park → resume: one `applied` on the resume leg; H1 / H2 the harness windows (the sink fires once at the append, before the next request, with the park record; not at a drain that a halt then drops). Re-pinned to the acceptance frame: `test/human-input-event.test.ts`, `test/steer-input-id.test.ts`; the ordinal pin `test/design393-s1-machinery-sink.test.ts` reads 1, 2, 3.
|
|
71
|
+
|
|
72
|
+
### `receiveHookResult` and `isEngineRefusal` are exported: the PreToolUse seat's receipt, for a host that composes hooks (#1274; behavior (the engine's refusal is frozen; one handed back to the seat is taken as itself); +2 exports) (@server @cli @client-core @test)
|
|
73
|
+
|
|
74
|
+
- **Before (v7.38.0).** The seat's receipt was internal. A host folding several `PreToolUse` hooks into the one the engine takes read each result by plain property access and rebuilt the composed result by spread, so for a carrier the engine refuses (a Proxy, a member on the prototype, a member held by an accessor) it answered differently from the engine run on the same result: a prototype `deny` was dropped by the spread (the call was allowed), a Proxy `allow` was folded into an ordinary allow. The engine's own refusal object was an ordinary mutable object, and handed back to the seat it was re-read as the hook's own `deny` (the deny observer stayed silent).
|
|
75
|
+
- **Now.** Two names at the package root. `receiveHookResult(result)` is `refuseOutOfContractDecision(…, { reasonIsNonInput: true })` — the function the engine's seat calls (`screenPreToolUseResult` in `src/core/hooks.ts` now calls it): for an object it answers the engine's own refusal (`isEngineRefusal(answer)` true — relay it as the composed hook's result and the engine takes it as its own fail-closed deny, deny observer included) or the engine's frozen, detached copy (fold the copy, never the original); `undefined` / `null` / `false` are no opinion and come back as themselves. The caller's object is not changed and none of its code runs (no Proxy trap, no accessor). The seat's already-received arm takes the engine's own refusal by identity, so a screened answer handed back returns as itself on both the hook seat and the policy seat; the refusal is frozen at its mint (a host cannot rewrite one and hand it back), and a spread copy `{ ...refusal, action: "allow" }` carries no identity and is judged like any other result (a legal allow passes — the host's own decision).
|
|
76
|
+
- **Who is hurt.** No in-contract result changes. Code that mutated the engine's refusal object throws in strict mode; a policy or hook that returned the engine's refusal object (obtainable only from the engine's own `deny` of a refused carrier) gets that object back as itself where it got an equal-content copy. A host's composed result for a refused carrier changes from the host's reading to the engine's — the intended difference.
|
|
77
|
+
- **Compensation.** Screen each slot with `receiveHookResult`; fold only the copy; return a refusal as it is.
|
|
78
|
+
- **If unchanged.** The host keeps answering differently from the engine for those carriers.
|
|
79
|
+
- Pins: `test/backlog1274-hook-seat-receipt-export.test.ts` — ① the three carriers refused, recognised and named, plus the rest of the refused table; ② an in-contract result gets a frozen detached copy, a screened answer handed back returns as itself, no opinion comes back as itself; ③ the screen leaves the input untouched (no trap, no accessor, not frozen); ④ the package-root import; ⑤ the equivalence table — 17 inputs, the export's answer against a real Runner run with the input as the hook's return (refused ⇒ blocked, the observer reads the refusal's sentence, and the relayed body behaves the same; accepted ⇒ the receipt behaves as the original); ⑥ the refusal is frozen (assign / delete / redefine throw), handed back it is the same refusal on both seats and the policy seat still blocks, a spread copy is an ordinary result.
|
|
80
|
+
|
|
81
|
+
### `dontAsk` refuses every ask but a hook's own: the operator-authority keep-set is retired (#1262; C-R205; behavior (tighten); hard-breaking; +0 exports) (@server @cli @client-core @sdk @test)
|
|
82
|
+
|
|
83
|
+
- **Before (v7.38.0).** The dontAsk arm of `arbitrateByMode` refused every ask except four classes it kept for a person — `org_rule`, `operator_forced`, `shell_floor` (split from `operator_forced` by #1260, same row) and `governance_unavailable` — and a hook's ask no member of the set floors. Measured on stage-7390 `0cf772db6`, real Runner lane (one `touch` call, an approver answering yes): `permissionMode: "dontAsk"` × each of the four classes ⇒ one card each; an approver answering only `ordinary` was still called once per class.
|
|
84
|
+
- **Now (the reference 2.1.280: the main permission flow turns every ask into a refusal under dontAsk, whatever its origin, before the `hookAskFloor` branch — `cli280.js` 15717026 `if(xe==="dontAsk"&&!He)return{behavior:"deny",decisionReason:{type:"mode",mode:"dontAsk"}}`, `lot()` 6497990; the one exception `He` is a Chrome-domain / host-assumed consent source the engine has no counterpart for).** The dontAsk column of the mode arbitration has one value for every class: refused, `tool_end.gate.disposition = { kind: "denied", deniedBy: "mode", mode: "dontAsk", askClass, classes }`, zero cards, the tool does not run. The one ask dontAsk still puts to a person is a PreToolUse hook's ask that no rule or safety check also raised — unchanged since 7.35.0 (KL-7340rg-a): the reference hands a hook's ask to its permission callback as a forced decision, past that conversion (`ZJ` 14669235 → `bf` 15715966 `if(h)return h`). Rules −1 (the retained set). The other modes' columns, `RELEASABLE_ASK_CLASSES` and every `MODE_ARBITRATION` row are unchanged.
|
|
85
|
+
- **Who is affected.** A deployment that runs the engine under `permissionMode: "dontAsk"` (core's sixth word; server's request face excludes it, `sema-server/src/task-settings.ts:91`, and client-core folds it to `default` on the headless wire, `headlessPermissionModeWire.ts:119`) and relied on one of the four classes still carding — an unattended approver (`onAsk`) answering operator-forced confirmations, the operator's `shellGate: "always"` floor, an org rule's ask or the governance-unavailable fail-closed ask under that mode. A read-only grep of the server, cli and client-core sources found no such reliance (inferred for any other host).
|
|
86
|
+
- **How to change.** Run those legs under `default` with an `onAsk` that answers by class (§4.6e — name `operator_forced` / `shell_floor` / `org_rule` / `governance_unavailable` in the answered set), or state the permission as an explicit allow rule; keep `dontAsk` for runs that must never ask.
|
|
87
|
+
- **If unchanged.** Those calls are refused by the mode — no card, the tool does not run, `deniedBy: "mode"` naming the class — where they were put to the approver.
|
|
88
|
+
- **Residual (KL-7340rg-a).** A hook's ask on the same call as one of the four classes still produces one card under dontAsk (class set `["hook", <class>]` holds no `hookAskFloor` member); the class alone is refused.
|
|
89
|
+
- **Follow-up (#1283; mode before seat).** Under `dontAsk` nothing answers a policy ask, so the manifest's report and the bypass posture frame now read the mode before the seat. `WiringManifest.ask.effective` is `auto_deny` on a `dontAsk` leg whatever the seat and the park lane (v7.38.0: `human_reachable` with an approver, `auto_allow` with `onAsk: "allow"`, `park_only` on an armed park lane with no seat); `deriveAskEffective(form, park, mode?)` gains the optional third parameter and the posture door keeps calling it without one (it judges the seat alone). `configFingerprint` still hashes the seat's own word, so no fingerprint moves. The bypass posture frame ("While bypass permissions mode is active…") is owed only to a leg whose LIVE mode is `bypassPermissions`, or — outside `dontAsk` — one whose blanket `"allow"` seat answers nobody: a `dontAsk` leg with `onAsk: "allow"` no longer opens with it. **The reported word is the leg's START under its start mode** (a steer re-reports through the mode verb's receipt, never a new manifest), so the engine's own per-call readers — the classifier's approval seat, the posture facts, the peer lane's mode fold — keep reading the seat's own word (`askSeatEffective`, internal, no new export) and the live mode beside it: a leg that starts in `dontAsk` and is steered to `default` / `auto` keeps its seat (the bypass frame is stated over a blanket allow seat once the mode is `default`; a classifier that cannot rule hands the ask to a live approver). **Who is affected:** a host that reads `ask.effective` on a `dontAsk` leg to tell its user whether a card can come (it now reads `auto_deny`, which is what happens) and treats the word as steady across a steer (it is the start-of-leg word; read `permissionMode` receipts for the live mode); the peer lane's mode class is unchanged. **How to change:** none needed; a deployment that wanted the bypass framing runs `bypassPermissions`. **If unchanged:** the corrected words are what the manifest and the first request carry. Pins: `test/backlog1283-dontask-manifest-posture.test.ts` — ① the derivation (baseline: 10 of 12 cells red), ② the real manifest (baseline: three red), ③ the first request's frames (baseline: `dontAsk` × allow stated the bypass frame), ④ the posture row and the live default → `dontAsk` flip (baseline: the frame never retired), ⑤ the fingerprint, ⑥ a `dontAsk` start steered out (posture; a hung classifier with a live approver and with none — guards against the start-of-leg word reaching the readers; green on stage-7390 by construction, red under D1395), `test/prepare-wiring-manifest.test.ts` (the peer refs carry the seat's word). Mutations D1390, D1390b, D1391, D1394, D1395a–c.
|
|
90
|
+
- Pins: `test/backlog1262-dontask-refuses-every-ask.test.ts` — ① the four classes on the real lane (baseline: one card each); ② default / bypassPermissions controls (still carded, bypass releases nothing); ③ a by-class approver is never called; ④ a hook's own ask stays a card; ⑤ the table, every class. Re-pinned: `backlog1111` J12 ×3, `backlog1218` ②.3, `backlog1260` ④.2 / ④.3, `backlog1194e` ⑤, `backlog7340rg` ②. Mutations D1262a–h.
|
|
91
|
+
|
|
92
|
+
### A checkpoint row carrying a retired mode-seed key is refused before the claim; `migrateLegacyModeSeed` is deleted (#1213; behavior (migration → refusal); hard-breaking; +0 exports) (@server @test)
|
|
93
|
+
|
|
94
|
+
- **Before (v7.38.0).** A resumed row's `inheritedGate.autoModeRequested` / `approverPosture` / a doctrine `shellGate` word (written by 7.33.x and before) was read once by `migrateLegacyModeSeed` into `permissionMode` and the shell floor and announced (`resume.legacy_mode_seed_migrated`); a shape it could not read was refused with `resume.legacy_mode_seed` AFTER the claim consumed the row (the row came back `pending`, `rev` +1, `reopenReason: "env_failed"`). Promised removed in 7.38.0 (#1213), deferred to this release.
|
|
95
|
+
- **Now.** `refuseRetiredModeSeed` (`src/core/checkpoint-store.ts`, called at `src/core/runner/resume-admission.ts` beside the parked-steer read) refuses a row carrying any retired member on every resume kind, before the claim — `checkpoint.unsupported_version` with `detail.reason: "seat_retired"`, the engine's form for a retired row seat — the row byte-identical and `pending`, the text naming the key and the way out. Nothing reads a retired key into a mode. `recordedModeSeed` reads the live keys (`permissionMode`, `prePlanMode`, the floor `"always"`) and keeps their own refusals (`config.permission_mode_invalid`). Rules: the migration's six-arm matrix becomes one refusal. The 7.34.0 entry's "read ONCE" sentence and the 7.38.0 entry's deferral note are closed by this release.
|
|
96
|
+
- **Who is affected.** A deployment whose checkpoint store still holds a pending row parked by 7.33.x or earlier (a durable approval, a plan review) and that upgrades to 7.39.0 without draining it. No server, cli or client-core source references the keys (read-only grep); whether a deployed store holds such a row is not knowable from here.
|
|
97
|
+
- **How to change.** Before upgrading, resume or reject the row on an engine at 7.38.x (it reads the row once; the next suspend writes `permissionMode`), or expire it (`CheckpointStore.expire`). After upgrading, the refusal names the same two ways out.
|
|
98
|
+
- **If unchanged.** The row's resume is refused with `checkpoint.unsupported_version` / `seat_retired` naming the retired key — nothing consumed, the row stays `pending` (a serving layer that classifies by `detail.reason` — server `src/http/resume-legs.ts:150`, `seat_retired: true` — keeps it parked rather than driving it terminal).
|
|
99
|
+
- **Follow-up (#1285; one refusal channel before the claim, for every door prepare would refuse a resume at).** The mode and output-style doors ran in `prepareTask`, which a resume reaches only after the claim: a re-supplied config carrying `attachments.planModeReminder` (retired by #1254), `autoModeRequested` / `approverPosture`, a `permissionMode` / `shellGate` / `outputStyle` prepare refuses, or a row whose recorded `permissionMode` / `prePlanMode` is not a mode a plan can return to, consumed the row and ended the resume `failed` — the row came back `pending`, `rev` 2, `reopenReason: "env_failed"`, a system retry that replays the recorded decision and cannot take a config correction. `refuseResumeDoors` (`src/core/mode-policy.ts`, called at `src/core/runner/resume-admission.ts` beside the retired-seat refusal) now runs prepare's own functions (`refusePermissionModeDoors`, `resolveOutputStyleFrame`, `recordedModeSeed` — one table and one function each) before the claim and leaves by the resume's one refusal channel, `CheckpointError`: code **`checkpoint.config_refused`** (`CheckpointError.code` +1), `message` = prepare's own sentence, `detail.code` = prepare's own code (`config.spec_retired_key` / `config.output_style_invalid` / `config.permission_mode_invalid` / `config.shell_gate_value_retired` — the new closed set `CheckpointConfigRefusalCode`, four words, internal: not a package export) and `detail.field` = the input that carries it, two new words (`detail.field` +2): `"taskConfig"` (the re-supplied config — correct it and resume the SAME token) or `"inheritedGate"` (the row's recorded mode — no config reaches it; `CheckpointStore.expire` is the way out). The row stays `pending` and byte-identical. A fresh `runTask` of the same bad config is unchanged: it returns a `failed` result carrying the same code. A throw out of the screen that is none of the four codes (a caller's accessor failing) is not a refusal and leaves as it was thrown. **Who is affected:** a serving layer that read these codes off `result.terminal.code` of a resume now meets a `CheckpointError` thrown before any event; one that classifies a thrown `CheckpointError` by `code` (server `src/http/resume-legs.ts:2014`, read-only grep — the code lists there are by name, so the new code lands in its non-listed arm: inferred, not run) must route `checkpoint.config_refused` by `detail.field` — `"taskConfig"` to the retriable class (the checkpoint is pending and redeemable once the config is corrected), `"inheritedGate"` to the terminal class — or the resume is driven terminal while the checkpoint stays pending. **How to change:** read `detail.field`; correct the config and resume the same token, or expire the row. **If unchanged:** the resume is refused with the new code, nothing consumed. Not covered (measured on this branch): the other doors in the prepare stretch — `config.limit_invalid`, `config.limit_unknown_key`, `config.attachment_invalid`, `config.interaction_posture` — still end the resume `failed` after the claim, the row `pending` at `rev` 2 with `reopenReason: "env_failed"`; and the nested seats the screen reads by reference (`outputStyle`, `attachments`): an object the caller mutates, or an accessor that answers differently, between the admission read and prepare's own read passes the screen and is refused after the claim as before (an accessor-flip probe on `outputStyle.name`: `failed` / `config.output_style_invalid`, row `pending` at `rev` 2, `env_failed`) — closing it means carrying the screened values into the resumed prepare, a separate change. Pins: `test/backlog1285-resume-doors-preclaim.test.ts` — ①–④ fifteen shapes, each (a) rejects with a `CheckpointError` `checkpoint.config_refused` whose sentence is prepare's own (a fresh `runTask` of the same config returns the `failed` result with that sentence), `detail.code` and `detail.field` (baseline: all failed after the claim, row `env_failed`) and (b) leaves the row byte-identical with a corrected resume redeeming it, ⑥ six positive controls, ⑦ a failing caller accessor leaves as thrown; `test/backlog1213-legacy-mode-seed-refusal.test.ts` ②.invalid re-pinned (a recorded `permissionMode` outside the set: now thrown before the claim, `detail.field: "inheritedGate"`). Mutations D1392, D1393a–c, D1396 (the wrapper removed), D1397 (a wrong `field`), D1398 (any `Error` admitted into the channel).
|
|
100
|
+
- Pins: `test/backlog1213-legacy-mode-seed-refusal.test.ts` — ① nine retired shapes: the resume rejects with the retired-seat code naming the key and the way out (a), the row byte-identical and pending for an allow and for a deny (b) (baseline: shapes 1–7 resumed and the Write ran; shapes 8–9 failed after the claim, row `env_failed`); ② the rows written since 7.34.0 resume as before; ③ no reference to the function, its result type or the thrown code in `src/`. Re-pinned: `backlog525` ③b, `backlog1111` §6.2 matrix, `inherited-gate-registry-hygiene` carriage text; ratchets banked (`legacy-residue-gate` `src/core` 132→116, `src/core/runner` 78→73). Mutations D1213a–h.
|
|
101
|
+
|
|
102
|
+
### The migrated-seed notice code is deleted: `resume.legacy_mode_seed_migrated` leaves `ENGINE_NOTICE_CODES` (#1213; closed set −1; hard-breaking; +0 exports) (@server @client-core @sdk @test)
|
|
103
|
+
|
|
104
|
+
- **Before (v7.38.0).** The catalog listed `resume.legacy_mode_seed_migrated` (audience `operator`, minter `engine`), minted by the migration at the resume leg's prepare.
|
|
105
|
+
- **Now.** The migration is gone, so is its only mint site: the code is removed from `ENGINE_NOTICE_CODES`, `NOTICE_AUDIENCE` and `NOTICE_MINTER`, and from the `EngineNotice.code` documentation; the thrown code `resume.legacy_mode_seed` (never a catalog member) is retired with it.
|
|
106
|
+
- **Who is affected.** A consumer that pins the catalog's count, hand-mirrors its rows or its audience / minter tables, or switches on the code. Named: client-core `src/engineNoticeCodes.ts:786`, `:1072-1147` (the `readLegacyModeSeedMigrated` reader and its table row) and `src/generated/engineNoticeTables.ts:36`, `:120` (the code list and the audience row). Server and cli: no reference found.
|
|
107
|
+
- **How to change.** Regenerate the mirrored tables from this release's catalog, or delete the row and its reader; nothing replaces it (the retired-key refusal arrives as `checkpoint.unsupported_version` / `seat_retired`, not as a notice).
|
|
108
|
+
- **If unchanged.** A count pin is red, or the mirror carries a row for a code the engine never mints again.
|
|
109
|
+
- Pins: `test/governance-codes.test.ts` (the catalog, audience and minter tables lose the row; the non-member list loses `resume.legacy_mode_seed`); `test/backlog1213-legacy-mode-seed-refusal.test.ts` ③.
|
|
110
|
+
|
|
111
|
+
### A machine's answer is the deployment policy's: `policy_allowed`, `who.party: "policy"`, and the party closed set (#1265; closed set +1 and a new closed set; behavior (a policy's allow that was refused now runs; a record's party word moves); +3 exports) (@server @client-core @cli @sdk @test)
|
|
112
|
+
|
|
113
|
+
- **Before (v7.38.0).** The approver's object arm had a word for a machine (`settledBy: "policy"`) on a refusal only: `{allow: true, settledBy: "policy"}` was refused as a contradiction (`approver_contract`, the tool not run) at both doors (`onAsk`, `createApprovalPolicy.approve`), and a durable decide with `hostDecision.decidedBy: "policy"` + `allow` was refused pre-CAS (the row stayed pending). A host answering by class could say yes only as `true` — a person's approval — so the record read `human_allowed` / `who.party: "person"` for a decision no person made. The policy's refusal named nobody (`policy_refused`, `who.party: "none"`) and dropped the channel's `approver`.
|
|
114
|
+
- **Now.** A settlement names the party that ended the wait. The two DECIDERS — a person and the deployment's own policy — each answer either way, through one mint (`settledByDecider`, internal). `{allow: true, settledBy: "policy", approver?}` settles `policy_allowed` (`who: { party: "policy", approver? }`) at both doors, and so does a durable `decidedBy: "policy"` + allow; the tool runs. The edit (`updatedInput`), the `readRootGrant`, an ancestor's grant to its child's gate and every lane after the gate treat it exactly as a person's allow, and the model reads the same tool result — with one exception that is about the party itself: an ask carrying `requiresRealApproval` ("a person, not a configuration") refuses a policy's allow exactly as it refuses the blanket `onAsk: "allow"` seat — `blanket_allow_refused` (no new word), the call not run, the model told that a person's approval is required — and a durable decide `decidedBy: "policy"` + allow on a row carrying the real-approval bit is refused pre-CAS (`checkpoint.invalid_outcome`, `detail.field: "hostDecision"`), the row left pending for a person. The same holds for a durable policy decide that carries an EDIT whose re-check raises a question demanding a person: refused pre-CAS at the resume entry the same way, and refused at the resumed leg's execution belt (vetoed by `policy`, not run) if the policy's answer turns between the two (one predicate, two stations). And a durable decide that is not a person's is held against the org's CURRENT verdict about the call it will run — the edited args, else the shown ones (#1281): an org ask — a published ask rule, or one the call cannot be read against, including one the org published while the row waited, or one the decide's edit moves the call under — demands a person (the synchronous gate stamps `requiresRealApproval` on every org ask), so `decidedBy: "policy"` + allow there is refused at the resume entry through the gate's own org lane, judged under the row's own tool name (`checkpoint.invalid_outcome`, `detail.field: "hostDecision"`, the row byte-identical and pending for a person); what the call's aliases resolve to is the resumed roster's fact, so a question only an alias raises is the execution belt's. A read the entry cannot finish (the gate's 15 s bound, or a store that throws) refuses nothing there; the resume cancelled while it waits is `checkpoint.resume_aborted`, the row pending. The execution belt re-reads the org after the claim under the live face and is the backstop for an org that turned between the two reads, one the entry could not read, or an alias-only question: not run, vetoed by `org` (the decide's `policy_allowed` settlement rides unchanged), the approval spent, the model told that the org rule requires a person's approval and that the recorded decider was the deployment's policy. A person's decide runs as before, and the entry takes no org read for it; a deny and unreadable governance keep their belt arms. `policy_refused` moves to `who.party: "policy"` and keeps the attribution; the sentence the model reads is unchanged. Only a word that names an elapsed window cannot approve: `{allow: true, settledBy: "timeout"}` (synchronous) and `decidedBy: "sla_timeout"` + allow (durable) are still refused. `true`, and an object naming no word, still read as a person. `screenGateOutcome` reads `SETTLEMENT_IS_REFUSAL` for I3 / I4 (an approval is any kind that is not a refusal) and refuses a `who.party` outside the new closed set. Exported: `SETTLEMENT_PARTIES` (frozen: `person`, `policy`, `engine`, `host`, `none`), `SettlementParty`, `isSettlementParty`.
|
|
115
|
+
- **The reference.** CC 2.1.280 records a host's answer (its permission prompt tool) on the permission record as the channel itself — `decisionReason: { type: "permissionPromptTool", permissionPromptToolName, toolResult }`, allow and deny alike, the allow carrying `updatedInput` and `updatedPermissions` as a person's does — and has no machine word in its telemetry classification (`user_temporary` / `user_permanent` / `user_reject`; a host allow with no classification is counted `user_temporary`). `policy` is this engine's settlement word, beside the existing `policy_refused`; it is not a word of the reference. The model's side is unchanged.
|
|
116
|
+
- **Who is hurt.** An audit reader that identified a policy's refusal by `who.party === "none"` (it reads `"policy"` now); a projection that holds a settlement to a hand-copied party set or kind table and withholds a record whose word it does not know (named: server `src/trace/project.ts:506` `SETTLEMENT_PARTIES`, which withholds both `policy_allowed` and every `policy_refused` record until it reads the package's set; client-core `src/gateOutcome.ts` `SETTLEMENT_KIND_WORDS`, whose vocabulary test reds on the new word, and its doc lines `:170` / `:472` saying the party is `"none"`); a TypeScript host with an exhaustive `Record<SettlementKind, …>` table or its own copy of the `Settlement` union; a host that answers an ask carrying `requiresRealApproval` with `settledBy: "policy"` + allow (refused, where the same answer spelled `true` runs — the bit asks for a person, and the policy word says none decided); a host whose durable `decidedBy: "policy"` + allow lands on a row the org now asks about, or whose edit moves the call under an org ask rule (#1281: rejected at the resume entry with the row pending — route it to a person; if the org turns after that read, refused at the execution belt, `deniedBy: "org"`, the approval spent); a policy decide on an org-governed deployment now waits on one org read before the claim (bounded at 15 s).
|
|
117
|
+
- **Compensation.** Read `isSettlementParty` / `SETTLEMENT_PARTIES` and `isSettlementKind` from the package instead of copying them; add the `policy_allowed` row (not a refusal) to an exhaustive kind table. An unattended seat that answers by class returns `{ allow, settledBy: "policy", approver: "<its seat word>" }` instead of a bare boolean (`docs/INTEGRATION-CORE.md` §4.6e), and routes the classes whose asks carry `requiresRealApproval` to a person.
|
|
118
|
+
- **If unchanged.** A projection holding the hand-copied party set withholds every policy record; a host still answering `true` keeps filing a person's record for a machine's decision; a TypeScript build with an exhaustive kind table is red at the table.
|
|
119
|
+
- Consumers: server — `src/unattended-permission-mode.ts:69` returns `{ allow: true, settledBy: "policy", approver: "unattended_class_answer" }` for the class it answers, `src/trace/project.ts:506` reads `isSettlementParty`; S-953 and the 7.109.0 disclosure close. client-core — `SETTLEMENT_KIND_WORDS` +1 (`policy_allowed`); the party reader is an open-set read (no change). cli — reads `settledBy` on the deny side only; no reader of the party found. sdk — the `Settlement` union gains the policy arm and `policy_allowed`. test — the shapes below.
|
|
120
|
+
- Pins: `test/backlog1265-policy-allowed.test.ts` — ① a policy's allow at both doors (`policy_allowed`, party `policy`, the attribution verbatim, run; the model reads what a person's yes produces; an edit re-adjudicated and run for either word; a `readRootGrant` widening the run's reads for either word; ①.7 an ask carrying `requiresRealApproval`: a policy's allow refused as `blanket_allow_refused` on the lane (not run, the sentence asks for a person) and pre-CAS on the durable leg, a person's `true` clears it, the policy may still refuse); ①.8 a durable policy decide whose edit's re-check demands a person (refused pre-CAS; a person's decide with the same edit runs; the policy's edit onto an ordinary value runs; the post-CAS belt refuses when the answer turns between the two stations); ② a policy's refusal (party `policy`, the attribution kept, the sentence byte-identical); ③ an allow settled by `timeout`, both doors; ④ a person's allow on every spelling (control); ⑤ the durable decide (`policy` + allow runs and records on the frame and the row; `sla_timeout` + allow refused pre-CAS, the row byte-identical; `policy` + deny; `person` + allow control); ⑥ the attribution screen on the policy arm, both legs; ⑦ the party set (frozen, the root export is the defining module's, the record screen reads it; the kind set's two rows); ⑧ the store kit's decider-pair case; ⑨ delegation — an ancestor's policy allow covers the child's gate as a person's does (one card where the classes agree, two where they do not). Baseline 34 of 48 red. `test/backlog1281-durable-policy-vs-org-ask.test.ts` (#1281) — ① a policy decide on a row whose org question turned to ask after parking: rejected at the entry, the row byte-identical and pending (the unreadable-call form too); ② a person's decide runs, and the entry reads the org for a policy's decide only; ③ a policy edit onto an org-asked command, published while the row waited or standing at park: rejected at the entry; the person's and an ordinary edit's controls; ④ an org deny after park (both refused at the belt); ⑤ unreadable governance, both mint forms (unchanged); ⑥ the belt's backstop: an entry read that never answers (fake timers past the bound), one that throws, an org that turns between the reads (not run, `deniedBy: "org"`, the row spent with the denial recorded), a person's decide on the turn runs; ⑦ the synchronous org ask × policy allow (`blanket_allow_refused`); ⑧ the predicate's org reading; ⑨ cancellation during the entry's read (`checkpoint.resume_aborted`, the row pending); ⑩ a tool aliased `Bash`: the entry judges its own name, the belt the live face (vetoed while the alias is its own; runs when a separately mounted `Bash` took the alias on the resumed leg). Baseline (before this fix) 7 of 17 red in the first cut: the policy's decide ran the call in each hole form. Re-pinned: `test/module-size-ratchet.test.ts` (resume-apply 422 → 432, permission-rule-lanes 515 → 528). The kit's case runs on the in-memory, file and Pg backends. Re-pinned: `test/backlog1016-policy-refusal-is-not-the-users-words.test.ts` ①–⑤ (the party word, the closed set's count, the window-only screen on both legs), `test/backlog1020-approve-accepts-ask-outcome.test.ts` P2 / P3 / P5 / P5b, `test/ask-deny-resolution.test.ts` (the closed set), `test/backlog812-settlement-note-fold.test.ts` (the mint's name), `test/gate-negative-controls.test.ts` (a policy-party literal is a second mint).
|
|
121
|
+
|
|
122
|
+
### A prompt a `userPromptSubmit` hook kept back ends `blocked` with a machine-readable cause (#1277; behavior (one optional key on the `blocked` terminal); a new closed set; +4 exports) (@server @cli @client-core @sdk @test)
|
|
123
|
+
|
|
124
|
+
- **Before (v7.38.1).** A task whose `userPromptSubmit` hook decided block, threw, did not answer within its `timeoutMs` bound, or was cut off by the task's cancellation while it screened, ended `terminal: { kind: "blocked", reason }` — the same two keys as the model's own `ReportBlocked` ending. `reason` carried the engine's reminder-framed sentence and nothing on the terminal said "your prompt was not submitted" as against "the agent reported it cannot go on"; a consumer could not offer the first its own way out. (The steer and the resume-wake roads of the same hook already carry a code, `steering.blocked_by_hook`; the task's first prompt did not.)
|
|
125
|
+
- **Now.** `terminal.detail = { kind: "prompt_blocked_by_hook", cause, hookReason? }` (`TerminalBlockedDetail`, exported; `kind` closed). `cause` is one of `PROMPT_BLOCK_CAUSES` (`block` — the hook answered and refused; `crashed` — it threw or rejected; `timeout` — it did not answer within its bound; `cancelled` — the task was cancelled while it screened; `isPromptBlockCause` reads the set), minted by one function (`holdPrompt`, `src/core/runner/prompt-block.ts`) that the hook seat calls where its own answer forks (`src/core/runner/run-leg.ts`) — one cause per ending, written to the run's blocked-reason holder together with the model-facing sentence — and carried into the terminal's `blocked` arm (`src/core/runner/assemble-result.ts`). `hookReason` — on `block` only, and only when the hook gave words — is those words folded to one line, bounded at 240 code points, with the engine's envelope tags defused (the untrusted-text inline lane); it carries no reminder frame and no session mark. `reason` is byte-unchanged: the same reminder-framed sentence, the hook's raw words inside it. ABSENT `detail` = the model reported (`ReportBlocked` mints none) or an engine from before this key, and every other terminal. In all four endings the prompt was NOT submitted — the objective never became a message and the loop never started. The steer road's and the wake road's `steering.blocked_by_hook` throw is unchanged.
|
|
126
|
+
- **Who is hurt.** A consumer that compares a `blocked` terminal exactly (one more key on the hook's four endings). A serving layer's three faces over the terminal (the same three #1229 crossed): `sealResultForExit` (server `src/terminal.ts:101-119`) passes a `blocked` arm through, so `detail` rides; the member-keyset fence (server `src/trace/core-keyset-guard.ts:1337-1339`) already lists `detail` for the failed arm, so a `blocked.detail` raises no compile error there and is forwarded; **the redaction point (server `src/observability/run-terminal.ts:610-612`) redacts `blocked.reason` only, and `:631-636` shapes `detail` on the `failed` arm only — so `blocked.detail.hookReason`, the hook's own words (which can quote the prompt it refused), reaches the wire without the redaction its twin `reason` gets, until it is added to the same pass** (read, not run). A consumer holding a closed table over detail kinds: one new kind.
|
|
127
|
+
- **Compensation.** Read `terminal.kind === "blocked" && terminal.detail?.kind === "prompt_blocked_by_hook"` as "the prompt was not submitted — it may be sent again": `block` — change it (the hook's words are `hookReason`); `crashed` / `timeout` — the filter did not clear it (fail-closed); `cancelled` — the task was ended. A `blocked` terminal with no `detail` is the model saying it cannot go on. Render `hookReason` as the hook's text, redacted wherever `reason` is. Read the cause set from `PROMPT_BLOCK_CAUSES` / `isPromptBlockCause`, not a copy.
|
|
128
|
+
- **If unchanged.** The hook's four endings keep reading as "the agent gave up" on the consumer's face, with no resend offer; a serving layer that adds nothing forwards `hookReason` unredacted.
|
|
129
|
+
- Consumers: server — the three faces above (`src/terminal.ts:101`, `src/trace/core-keyset-guard.ts:1337`, `src/observability/run-terminal.ts:610`); the steer and wake roads' 422 `steering.blocked_by_hook` (`src/http/routes/runs.ts:979` / `:1364`) are unchanged. client-core — `src/adapter/downstream/terminalToSdkResult.ts:1851-1861` reads a blocked terminal's `reason` only ("Run blocked: …"), and `src/runTerminal.ts:279-286` reads the flat plane's `blockedReason` only; neither reads `terminal.detail` (no reader of `TerminalFailureDetail` or `terminal.detail` in `src/`). cli — reads through client-core. sdk — the `TerminalCause` union gains the optional key. test — the shapes below.
|
|
130
|
+
- Pins: `test/backlog1277-prompt-blocked-detail.test.ts` — ① the hook decides block (`cause: "block"`, `hookReason` = the hook's words; hostile text defused on one line with no reminder tag and no session mark; whitespace folded to single spaces; a long reason bounded, counting code points (300 astral characters fold to 240 plus the ellipsis); a whitespace-only reason: cause, no `hookReason`; `reason` byte-unchanged on all four endings); ② a synchronous throw and an asynchronous rejection (`crashed`, no `hookReason`); ③ a hook that never answers under a small bound (`timeout`); ④ the task cancelled while the hook screens (`cancelled`); ⑤ control — the model's `ReportBlocked` mints no `detail` key (keys `kind`, `reason`), and a hook that answered (nothing; context only) before a model report mints none; ⑥ `runTask`, the stream's `done` event and `stream.result()` read the same terminal for every ending, and the same when the hook rides `spec.hooks`; the plane projection is unchanged, the terminal crosses a JSON boundary whole; ⑦ the closed set (the four words, frozen; `isPromptBlockCause` true for each, false for `"Block"`, `""`, `undefined` and others); ⑧ the package root (identity with the defining module, the smoke probe's two lists, the source's export line). Baseline 21 of 26 red on stage-7390 (the hook's ending and the model's ending are both `{ kind, reason }`).
|
|
131
|
+
|
|
132
|
+
### `judgeShellRead` takes the home list its boundary's rows were compiled under: `ShellReadJudgeInput.readDenyHomes` (#1276; behavior (an optional input seat; the views follow the table); +1 optional type key; +0 exports) (no sibling consumer)
|
|
133
|
+
|
|
134
|
+
- **Before (v7.38.1).** The public judgment was its own placement: its home views read the environment's declared home and the account home looked up for that call (`declaredReadDenyHomes`). A host compiles the boundary's deny seat itself (`compileReadDeny` over a home list) and hands it over; the input carried no list, so a home of the host's list that the lookup did not answer (a listed home that is not the account's, or a lookup that fails) had no view: a path through a bind mount or a firmlink of it was judged as spelled and met no row. Measured on stage-7390: a boundary compiled at `[H, A-link, A-real]`, the lookup failing, `cat <alias of A>/.sema/settings.json` ⇒ `{ finding: "none", disposition: "vouch" }`; the lookup answering another home B ⇒ the same, with B probed. (KL-1244-d of v7.38.1.)
|
|
135
|
+
- **Now.** `ShellReadJudgeInput.readDenyHomes?: readonly string[]` — the same name and meaning as `HandsToolkitOptions.readDenyHomes`. Present: the judgment's home views are that table's — the environment's declared home is always among them (its directory chain read for the call) and every other entry is read once, as the placement reads an auxiliary home (one 2000 ms bound shared by its canonical spelling and its directory chain; what did not answer is lost for that home alone) — the account database is not asked and no home is added. The list goes through the toolkit seat's own check (`checkedReadDenyHomes`): anything but an array of absolute paths rejects naming `readDenyHomes`. A view is anchored by IDENTITY, not by spelling (a directory on a listed home's chain, matched by `fileIdentity`): a listed home whose canonical spelling answered but whose own identity did not answer in the bound has no view, and what guards its real path is then the row the host placed at that canonical spelling — not a view. `[]` lists no home: the views are the declared home's alone (the rows of a table compiled over `[]` hold their home-anchored run at every location, as `compileReadDeny` says). Absent: the judgment is its own placement exactly as before. One reader serves both: `placeReadDenyAtHome` (the toolkit's and the Runner's placement) and the views over a list fixed now (`homeViewsAt`, the judgment's) take their auxiliary homes from `readAuxiliaryHomes` (`src/tools/fs/read-deny-home.ts`); the judgment names its home set at one place (`src/tools/fs/shell-read-check.ts`, `judgeShellRead`). `judgeShellReadLexically` (no environment, no views) is unchanged and takes no seat.
|
|
136
|
+
- **Who is hurt.** No one: `judgeShellRead` / `ShellReadJudgeInput` have no reader in server `src/`, cli `src/` or client-core `src/` (grep, 0 files each). A caller that does not pass the key reads what it read on v7.38.1.
|
|
137
|
+
- **Compensation.** Pass the list the boundary's rows were compiled under: the list handed to `HandsToolkitOptions.readDenyHomes`, or what `readDenyHomesOf(env)` answered when the host compiled the rows.
|
|
138
|
+
- **If unchanged.** The judgment keeps reading the declared home and the account home of that call (KL-1244-d of v7.38.1; retired by this section).
|
|
139
|
+
- Pins: `test/backlog1276-judge-shell-read-homes.test.ts` — ① `[H, A-link]` with the lookup failing, and with it answering another home: `cat` through A's bind-mount alias refused, the account asked 0 times, B (not listed) never probed; ② absent: the lookup asked, an answered A viewed, a failed lookup leaves A without a view, the declared home's alias refused; ③ A's own identity answered and a level above it not answered in the bound: the alias refused within the bound (③b); A's own identity not answered in the bound: the rows the host placed at the canonical spelling refuse the real path (③a, control); ④ `[H]` and ⑤ `[]`: the declared home viewed, the account not asked, A (answered by the lookup) not added; ⑤b `[]` end to end: rows compiled over `[]` hold their run at every location (the project's own settings file refused too); ⑥ the toolkit's placement and the judgment agree on Read ⇔ `cat` over every spelling of every home and the project's settings, and the two view sources answer alike; ⑦ the lexical face unchanged; ⑧ four bad lists reject naming `readDenyHomes`. Baseline: 8 of 11 red on stage-7390 (②, ③a, ⑦ are controls).
|
|
140
|
+
|
|
141
|
+
### A model-worded index line is stale only when the entry changed and the line did not (#1270; behavior (a line the model re-worded survives an entry change); +0 exports) (@test)
|
|
142
|
+
|
|
143
|
+
- **Before (v7.38.2).** The rebuild that closes a turn replaced a model-worded `MEMORY.md` line with the mechanical row (name, description, age) whenever its entry's rev had moved since the last rebuild, without asking whether the model had re-worded the line in that same turn. A model that changed an entry's page and its line, carrying the new value, in one turn lost the line (`harvest` warning "stale wording dropped"); the injected index of the next session carried the page's description, which describes the page and not the value, so the value was gone from the only memory text read by default. Measured on v7.38.2 with the real engine: session 1 writes the entry (`HERON-311`) and its line, session 2 changes the entry to `OSPREY-742` and re-words the line to say so — `MEMORY.md` after the harvest is `- [callsign](callsign.md) — the active callsign (today)`, the injection has no `OSPREY-742`.
|
|
144
|
+
- **Now.** One predicate, `indexLineIsStale(snapshot, entryRev, line)` (`src/core/memory-engine/layout.ts`, internal, not a package export), judges a model-worded line: stale = the snapshot's rev differs from the entry's rev AND the snapshot's line equals the line on disk. A line that differs from the recorded text is the model's own current word (its line wins over the mechanical row): kept byte for byte, no warning, and the record follows it. Both sides are compared in whitespace-normal form (`normalizeIndexLine`: trimmed, every run of whitespace one space; case and every visible character stay), and the record keeps the line in that form — a difference of whitespace only is not a re-wording, a case, emphasis-mark or punctuation change is. The control-plane record `index-revs.json` holds `{ rev, line }` per target — the line is the one the rebuild left in the index (the model's, or the mechanical row it wrote). A record in the old shape (a bare rev string per target) reads as `{ rev }`, line unknown: nothing is judged stale, the line is kept once and the file is rewritten in the new shape — the keep-once a missing record always gave. The predicate is applied to every row that names a target, before the duplicate shortcut: with the entry changed, the recorded row is dropped wherever it stands, and a differing row beside it is the model's own and stays (in either order). Only the entry changing (the line untouched) still replaces the line with the mechanical row, with the same warning. The mechanical row's format, the entry's description, the content-gated write (an unchanged store leaves the file's mtime alone) and the orphan / duplicate / shadow / challenge families are unchanged.
|
|
145
|
+
- **Who is hurt.** A consumer that relied on a changed entry always getting the mechanical row for its line: none in server / cli / client-core (none reads the index text or the record). A model that re-words a line without carrying the entry's new value now keeps that line, its own wording standing (KL-1270-b). The reference implementation has no index healing at all (the file is the model's text); this repair does not move that boundary.
|
|
146
|
+
- **Compensation.** None on the wire. A host reading the injected index sees the model's current wording; to see the page's own description, read the entry.
|
|
147
|
+
- Pins: `test/backlog1270-index-line-staleness.test.ts` — ① the entry and its line changed in one turn (line kept byte for byte, no warning, injection carries the new value, record `{ rev, line }`, a second untouched entry keeps its line and gains a line record; baseline: line replaced, injection without the value); ② only the entry changes (replaced, warning, positive control); ③ only the line changes (kept, no warning; the next entry-only change replaces the re-worded line); ④ the description carries the value and only the body changes (the row carries the value) and ④b two harvests with no mount between (the row written by the first is the stale wording of the second); ⑤ an old-shape record + the entry changed (kept once, file rewritten as `{ rev, line }`; baseline: replaced) and ⑤b the reader's normalisation of every shape; ⑥ no record (kept once, `{ rev, line }` written, the law from then on); ⑦ two sessions over an unchanged store leave the file's mtime alone; ⑧ the law's edge, the line re-worded then written back to the recorded text in the same turn (KL-1270-a); ⑨ two rows for one target, in either order, the entry changed (only the recorded row dropped; baseline: both replaced by the mechanical row); ⑩a the line differing from the recorded text by whitespace only (trailing / leading space, a doubled space, a tab, a no-break space) with the entry changed ⇒ replaced, ⑩b a case change, an emphasis mark or a punctuation change ⇒ kept byte for byte (baseline: replaced), ⑩c a line written with irregular whitespace is kept as written and recorded trimmed and single-spaced, a later whitespace-only reformat with the entry changing is stale. Baseline 10 of 21 red (①, ⑤, ⑤b, ⑥, ⑨ ×2, ⑩b ×3, ⑩c). `test/a005-memory-engine-nofollow.test.ts` "a refused index write does not advance the durable revision snapshot" reads the record's `rev` member.
|
|
148
|
+
- Known limits: KL-1270-a (a line re-worded and put back to the recorded text in one turn reads as untouched), KL-1270-b (a re-worded line that does not carry the entry's new value is kept), KL-1270-c (a row that links the entry by another spelling, `./callsign.md`, is never judged — the same on v7.38.2).
|
|
149
|
+
|
|
150
|
+
### A child leg's unconsumed steer or follow-up is announced on its parent's session too (#1279; behavior (one added row per delegated leg that ended with input owed); +0 exports) (@server @cli @client-core @test)
|
|
151
|
+
|
|
152
|
+
- **Before (v7.38.1).** The leg-end account for input a person handed a leg that no turn consumed (`task.user_steer_undrained` / `task.user_followup_undrained`) was minted once, from the leg's own ids. For a delegated `Agent`-tool child (the delegation lane's `childInternals` carries the spawner's `parentSessionId` / `parentTaskId`, `src/agents/subagent.ts`) that row addressed the CHILD's session — and a host registers its notice mouth for the sessions it opened, the parent's. A person who steered a child and watched it end saw nothing on the session in front of them: zero frames there. The engine already says a child's folded permission mode twice (`config.permission_mode_unavailable`, `by: "inherited_ceiling"`: the child's stream, then the parent's with `childSessionId`); this account said it once.
|
|
153
|
+
- **Now.** One mint, one more row per family when the leg has a parent: `undrainedUserInputNotices(counts, taskId, sessionId, runId, parent?)` (`src/core/engine-notice.ts`) returns the leg's own rows exactly as before, then — when the spawner's trusted internals carry a parent session (`parentLegIds`, read off `RunInternals.parentSessionId` / `parentTaskId`) — a copy of each row addressed to the parent: `detail.sessionId` = the parent's, `detail.taskId` = the parent run's when the spawner carried it (omitted otherwise), `detail.childSessionId` = the child's, `cause` unchanged (`leg_end`), the count key unchanged, message tail `(a child leg, <childSessionId>)`, and no `runId` (nothing carries the parent's run id; the row never invents one). Both stations that close the account use it: the leg's end (`src/core/runner/run-identity-wiring.ts`) and the deferred settlement that a durable pause / a resumed leg's undelivered continuation takes (`settleOwedUserInput`, `src/core/engine-notice.ts`, called from the two stations in `src/core/runner/run-settle-and-teardown.ts`). A top-level leg — or internals that name a parent task but no parent session, or name the leg's own session as its parent (a host that reuses one session id: that session already hears the row) — has no one else to address and announces one row, unchanged (the parent must be an object naming a non-empty session other than the leg's own — `null`, a non-object, a missing or empty session mint no copy and the mint does not throw on them; `parentLegIds` drops an empty session at the source); a child that parks with the input carried onto the checkpoint announces nothing on either session (the input is not lost). Only the DIRECT parent hears: a grandchild's rows reach the session of the leg that spawned it, not the root's (the permission-mode fold's own reach). No new code, no change to `USER_STEER_UNDRAINED_CAUSES`, `NOTICE_AUDIENCE` or `NOTICE_MINTER` (both codes stay audience `user`, minter `engine`; `ENGINE_NOTICE_CODES` still 85).
|
|
154
|
+
- **Who is hurt.** A consumer that counts these two codes per run, or per code, across a delegation tree sees one more row per delegated leg that ended with input owed. A consumer that joins the rows on `detail.runId` finds the child's row only — the copy has none. A de-duplication keyed on `code` + `sessionId` (+ `taskId`) folds the copy into the parent's OWN row when the parent also ended with input owed (same session, same taskId): the two differ by `detail.childSessionId` and by `runId`. Only the direct parent: a host that expected the root's session to hear a grandchild's loss will not. A workflow agent's leg carries the host session as its `parentSessionId` and the host task as its `parentTaskId` (`src/orchestration/workflow.ts`, the shared spawn base) when the host names its originating session, so its copy goes to the host session — read in the code, not pinned.
|
|
155
|
+
- **Compensation.** Read `detail.childSessionId` as "a child reported this" and render it as such (the message tail says so); key the rows that carry `detail.runId` per run and the copies per `childSessionId`; do not fold a copy into the parent's own row.
|
|
156
|
+
- **If unchanged.** Nothing breaks; the parent's session keeps hearing nothing of a child's lost input, as before.
|
|
157
|
+
- Consumers (not read in this car — inferred): server — a notice sink that routes on `detail.sessionId` reaches the parent's stream with no change; the accepted-input ledger it keeps for a child's steer names the parent run. cli / client-core — a `"user"`-audience row with a session is projected like any other; `childSessionId` is a new optional member of `detail` on these two codes. test — the shapes below.
|
|
158
|
+
- Pins: `test/backlog1279-child-undrained-to-parent.test.ts` — ①u the factory (own rows byte-equal to the no-parent call, then one copy per row; no `taskId` key when the spawner carried none; no copy when the leg cannot name its own session or the parent is that very session; nothing owed ⇒ nothing); ① a real `Agent`-tool child steered while its tool runs and capped at one turn: two rows, the child's with the baseline's five keys, the parent's with `childSessionId` and the `(a child leg, …)` tail (baseline: the child's row only); ② a follow-up that the stop seat leaves queued: the same two rows; ③ control — a top-level leg, internals with a parent task but no parent session, and a parent that is the leg's own session: one row, no child key; ④ control — a child that parks durably with the steer riding the checkpoint: zero rows on either session; ⑤ two levels: the grandchild's row and the middle leg's copy, nothing on the root's session; ⑥ control — the closed sets (85 codes, audiences, minter, causes) unchanged; ⑦ the settlement arm: a resumed child leg that owes a frame (a delivered deny spends the row): the child's row as before and the parent's copy (a resumed top-level leg: one row); ⑦b the source rule — each station passes `parentLegIds(internals)`, and no other file calls either function; ⑧ an unusable parent — `null`, a string, a number, an empty / missing / non-string session, an empty own session — is no addressee: only the leg's own rows, no throw (baseline of this cell group on the code before it: 10 red), `parentLegIds` normalizes an empty session and an empty task id away, and a leg run with an empty `parentSessionId` announces once. Baseline (pristine stage-7390, measured before the fix): 7 red / 8 green of 15. Mutations (Part 1279): the parent copy not minted, carrying the child's ids, or without `childSessionId`; a top-level leg minting a second row; the copy also going to the root; the settlement arm (and its teardown backstop) or the leg-end sink not handing the parent over; the park arm not deferring; a new code in the list; a copy without the child's session, or to the leg's own session; a guard that falls back to `!== undefined`, or a `parentLegIds` that lets an empty session through; a family minted with nothing owed; the child's own row wearing `childSessionId`; a third caller — each red on the cells named in `.gate-logs/criteria-7.39.0-mutations.md`. The settlement's teardown backstop (it runs only when the lane threw on its way to the first station) has no behavioral fixture; ⑦b pins it on the source.
|
|
159
|
+
|
|
160
|
+
## 7.38.3 — 2026-10-02
|
|
161
|
+
|
|
162
|
+
**Hard-breaking changes:** none.
|
|
163
|
+
|
|
164
|
+
**Closed sets:** none. **Package exports:** +0 / −0.
|
|
165
|
+
|
|
166
|
+
### The JSONL tail repair reads the file's last segment, not the whole file (#1286; behavior (widen: a store file of 2 GiB or more opens and takes appends; an unterminated last segment past the engine's string bound no longer throws at open); +0 exports) (@server @test)
|
|
167
|
+
|
|
168
|
+
- **Before (v7.38.2).** `AppendLog` repaired a JSONL file's tail at three stations (open, the lazy reopen after a compaction swap, the append door when the file had moved off a record boundary) by reading the whole file to look at its last line. A file of 2 GiB or more could not be opened at all (`ERR_FS_FILE_TOO_LARGE`), so an append-only dataset that grows without bound (an outcomes log) refused every write once it crossed that size; an unterminated last segment whose text was longer than V8's maximum string (`buffer.constants.MAX_STRING_LENGTH`, 536,870,888 characters; for ASCII that is 512 MiB) threw `ERR_STRING_TOO_LONG` at open.
|
|
169
|
+
- **Now.** One function, `readTailSegment(fd, size)` (`src/stores/file/fs-atomic.ts`), walks back from the end in 64 KiB blocks to the last newline and returns the segment after it; the three stations read their tail through it, so a repair costs the length of the last line, not of the file. What the segment IS stays `jsonlTextFormOf`'s call, unchanged: a whole record is ended with its newline, a half record or a non-object is cut back to the last newline, a file that ends on a newline is not touched. A segment longer than that bound, MEASURED IN BYTES (536,870,888 bytes), is not judged: it is ended as a line (one `\n` appended, its bytes untouched, never cut) and one `console.warn` line names the file; the next append stands on its own line behind it, where leaving it open would have glued the next acknowledged record onto it.
|
|
170
|
+
- **Who is hurt.** Nobody who worked before: every file that opened still gets the same repair, byte for byte, with one exception: an unterminated last segment of more than 536,870,888 BYTES of multi-byte text (up to about three times that) could still be decoded before, and a torn one was cut back; it is now ended as a line unjudged like an ASCII one, so a torn one stays as an unparseable line (a reader skips it) instead of being cut. A file that used to refuse to open (2 GiB or more, or an ASCII last segment above the string bound) now opens and appends. The repair is only half of what such a file needs: a store that replays its whole file when it boots (`readJsonlRecords`, still one whole-file read) still cannot read a file of 2 GiB or more, nor a line above the string bound; only a consumer that appends without replaying (a sink) benefits end to end. That read is tracked as its own backlog row (1287), not changed here.
|
|
171
|
+
- **Compensation.** None needed; there is no switch for the old whole read.
|
|
172
|
+
- **Known limit (KL-1286-a).** A last segment longer than the string bound is ended as a line and not judged: if it was a real record, it stays on disk and is not read by the repair (a reader meets it as an ordinary line; what the whole-file read does with it is backlog row 1287's). The bound is the runtime's `buffer.constants.MAX_STRING_LENGTH` applied to the segment's length in BYTES: a segment within it always decodes (a byte is at most one UTF-16 unit), one past it might still decode when it is multi-byte text, and is left unjudged either way (judging by decoded length would mean reading and decoding up to three times the bound to find out). A record that long is half a gigabyte of text: this store's writers do not produce one in practice, so the shape is a foreign writer's or damage.
|
|
173
|
+
- **Known limit (KL-1286-b).** The repair is not serialized with an appender that is writing the same file: it samples the last segment, judges it, then cuts by offset. A peer that completes the half record after the sample and before the cut has that record cut with the remnant. The window is the same read-to-cut one v7.38.2 had (it read the whole file first); it is now the length of the last line, not of the file. The directory fence (one writer per ledger directory) is what keeps a peer from being there; closing the window itself needs the repair to run under that fence.
|
|
174
|
+
- Pins: `test/backlog1286-jsonl-tail-large-file.test.ts` — a 2.2 GB sparse file (nothing is written; `truncateSync` extends it) with ① a whole unterminated last line (ended, one byte added, the next append is its own line), ② a half last line / a non-object tail (cut back), ③ a clean file (not one byte moved), and the other two stations on it (lazy reopen after a swap; append door met by a peer's whole / half tail); ④ tails around read-block edges (100 KiB; every length one byte either side of one and two blocks), ⑤ a line longer than three blocks, ⑥ a file with no newline at all (small, and larger than a block), whole and torn; ⑦ a segment one byte past the string bound (ended, never cut, one warning, the next append not glued); ⑨ the repair reads at most a couple of blocks and never goes through `readFileSync` on the log; ⑪ the present form of KL-1286-b (a peer completing the half record between the read and the cut loses it); `readTailSegment` itself (segment starts, newline at a block's first / last byte, the bound judged at its exact edge). `test/backlog1278-jsonl-tail-newline.test.ts` PIN 14 (a multi-byte character straddling a read-block edge does not tear a whole record) and PINs 1–13 unchanged and green.
|
|
175
|
+
|
|
1
176
|
## 7.38.2 — 2026-10-02
|
|
2
177
|
|
|
3
178
|
**Hard-breaking changes:** none.
|
|
@@ -48,7 +223,6 @@
|
|
|
48
223
|
|
|
49
224
|
- `USER_STEER_UNDRAINED_CAUSES` member `crashed` now reads "accepted by the serving layer and not confirmed consumed before the process died" (was "never consumed"). A live leg gives the serving layer no evidence that a steer reached the model, so the arm cannot claim the steer was never consumed; a consumer must not tell the person to re-send without checking the run's transcript. No code, word or shape change. The consumption evidence itself is a separate change (#1273).
|
|
50
225
|
|
|
51
|
-
|
|
52
226
|
## 7.38.0 — 2026-10-01
|
|
53
227
|
|
|
54
228
|
**Hard-breaking changes:** none.
|
|
@@ -101,7 +275,6 @@ The engine's own screen for an ask-class member list — the one `isAskClass`-an
|
|
|
101
275
|
- **Compensation.** Copy the list (`[...ASK_CLASSES]`) before editing a copy.
|
|
102
276
|
- Pins: `test/closed-sets-frozen-exports.test.ts`; `test/backlog1212-mode-release-reasons.test.ts` R1 re-pinned (the write throws). KL-closed-set-arrays closed.
|
|
103
277
|
|
|
104
|
-
|
|
105
278
|
### One call per id per assistant turn: a repeated id in one response is renamed at admission, and a stored pair is cut to the first call in the request view (#1234 write side, #1241 read side; behavior + closed sets +1 / +1; +0 exports) (@server @cli @client-core @test)
|
|
106
279
|
|
|
107
280
|
- **Before (v7.37.1).** Every provider lane requires a request's call ids to be unique per turn. A provider answering one response with two calls under one id (`call_0` twice — gateway numbering) had both admitted verbatim; the engine keys a call by its id, so the two executions ran under one id, and the next request carried two `tool_use` / `tool_calls` / `function_call` with that id and one folded result — rejected on all three lanes. A stored message already carrying the pair (written before this release, a host brain's message, a side-query caller's history) went out the same way: the request view folded results by id but sent every call.
|
|
@@ -1527,7 +1700,6 @@ The engine's own screen for an ask-class member list — the one `isAskClass`-an
|
|
|
1527
1700
|
`test/backlog1047-file-history-disabled-word.test.ts` (four cells, near-miss spellings, the once-per-arm
|
|
1528
1701
|
memo, and the package-root `FileStorageBackend` + `Runner` loading path).
|
|
1529
1702
|
|
|
1530
|
-
|
|
1531
1703
|
### Agent teams, final form: the discovery face, the inbox wake, the durable terminal frame, the hand-back facets (#969 / #1024 / #1025 / #1034 / #1035 / #1036; minor)
|
|
1532
1704
|
|
|
1533
1705
|
**BREAKING (settings vocabulary, three sentences).** `crossSessionInbound` is now `wake | next-turn | hold | refuse`;
|
|
@@ -1777,7 +1949,6 @@ repeats); the end-of-task settle compaction is not counted in `TaskResult.compac
|
|
|
1777
1949
|
- **Sentence changes, verdict unchanged.** `|&` is refused as "`|&` pipes standard error as well as standard output" (was the backgrounding sentence; bash 4 pipes both streams, bash 3.2 errors — the lexer records `pipesStderr`, the predicate refuses it by name). `time -- cmd` / `time -p -- cat f` read the words after `--` as the command (bash 5's `TIMEIGN`; bash 3.2 runs a program named `--` — the reading that judges more): the station vouches or judges `cat f` where 7.28.1 refused "`time` is given the option `--`". A subscript carrying a substitution (`A[$(cat .env)]=v true`) is an assignment word whose value is judged and whose body is a nested plan: refused on the real lane at zero cards when the body reads a deny row, vouched when it does not (7.28.1: an unknown program, a mandated card either way). KL-993e and KL-993f close.
|
|
1778
1950
|
- **Measured.** Eight lexical gaps pinned against bash 3.2.57 and 5.3.15 by xtrace in a sandbox (`test/backlog994-s1-gaps-real-shells.test.ts`; a missing bash is a red test). Dual run over 4383 inputs (every string literal of the shell-reading test files plus the adversarial lists, the HOME-rebinding family included) × 10 faces against the v7.28.1 dist: 221 inputs differ, every difference under one of twelve attributions on a closed list (`test/backlog994-s1-dual-run.test.ts`; the report with one before/after per class is in the car's intake record). A difference on an input that reads or may rebind the home (the rebinding question is the tree's own `homeMayBeRebound`, handed to the attribution — no second copy) is screened before any attribution: one that widens a verdict, or that places the declared home for a text that may rebind the name, is unrecorded whatever else the input carries (the first cut's regression was explained by a substring match on `HOME`; with the screen it reds the pin). Export surface +0/−0. Per-fixture mutation table: 13 mutations, each named by the pins it reds.
|
|
1779
1951
|
|
|
1780
|
-
|
|
1781
1952
|
### Shell text a builtin keeps for later is a shell re-entry, as `eval`'s text is; a keyword function definition no longer swallows its body (7.30.0 read boundary; behaviour NARROWING; +0 exports; +0 wire keys; @cli @test)
|
|
1782
1953
|
|
|
1783
1954
|
- **NARROWING — code-body operands join the re-entry set.** The action of `trap`, every `alias NAME=VALUE` value (an empty one included), and the `-C` callback of `mapfile` / `readarray` / `compgen` (separated or attached `-C'…'`; an empty one included) are shell text the shell runs later (compgen: at once). They are treated as `eval`'s operand is: the segment re-enters the shell, so the whole command is at least `unestablishable` (mandated on `bash_off` and `bash_classify`), and each body is its own nested plan whose deny hits refuse. The builtin's own words stay the candidates they were, and the body is read both as the text the shell runs (a quoted leading `~` stays the home) and as its folded path spelling, so no command's verdict falls below the pre-change tree (measured over 95 hand forms × both seats and the 4383-input dual run). Before: a body that computed a name (`x=OME; trap 'read H$x <<< DIR' DEBUG; cat ~/k`), moved the directory (`trap 'cd DIR' DEBUG; cat k`) or computed a path ran at zero cards and read a deny-listed file on the real lane. No body is no re-entry: `trap - SIG`, `trap '' SIG`, `mapfile` without `-C`, `compgen -F NAME` (a function name) stay as they were. The readers live in `src/tools/fs/shell-reentry-text.ts` (one table, `CODE_BODY_OPERANDS`).
|
|
@@ -2128,7 +2299,6 @@ with the next package release.
|
|
|
2128
2299
|
- **R1.** The erase sweep ends a torn row's lineage for EVERY torn row it removes, and the row always goes (a crash artifact must not outlive a delete that reported success): a mark that names no session is bumped as found; a torn row with NO mark leaves a lost-lineage tombstone (`{__hwm: 0, __gen: 0, __lineage: "lost"}`) in the mark's place — without evidence a minted generation could repeat one a leg still enforces (round 2: an erased-once key's enforced row is already generation 1) — and a torn row whose mark is corrupt leaves that mark as it is. A write to a key carrying the tombstone, or an unreadable row and no mark, is refused (`SessionPolicyError("corrupt")`) rather than guessed; deleting the mark is the operator's restart, as the mark's own contract says. Before the fix a session-less mark was skipped (the recreation continued the enforced generation) and a missing one was minted.
|
|
2129
2300
|
- **R2 (#1014).** A traversal's root-grant offer names what the environment said the target IS, not what its provenance was: `EstablishedRead.node` (`"directory" | "file"`, set only by the walk — the root by its own listing, a member by whether the walk listed it) and `readRootGrantCandidateOf` grants a file through its containing directory. Before the fix `grep -R needle /abs/file.txt` and a recursive read descending a link onto an outside file offered the FILE as a directory grant (`{ dir: …/report.txt }`); a link onto a directory is still granted as that directory (control). The same derivation serves the gate and `judgeShellRead`, so both faces change together.
|
|
2130
2301
|
|
|
2131
|
-
|
|
2132
2302
|
## 7.26.0 — 2026-09-21
|
|
2133
2303
|
|
|
2134
2304
|
### Six root exports the neighbours were hand-copying (#995; additive; +6 exports; @server @cli @client-core)
|
|
@@ -2179,7 +2349,6 @@ with the next package release.
|
|
|
2179
2349
|
- **R2/R3.** The posture directive now stands in EVERY request's context, not only the first turn's: (i) the moment a compaction lands — at all three compaction sites (turn boundary, forced/guard pass, end-of-task) — the runner asks the first-turn question of the compacted context (`statedReminderBodiesOf`, one reader) and appends the missing segments as one engine-minted frame, the git frame's post-compaction carrier; (ii) a request clipped to the window keeps or reinserts the newest posture carrier through the same protection slot the git frame rides. Before the fix only the first-turn branch asked: a compaction before a durable suspend, a compaction inside the resumed leg (the approved call's large result), and a per-request clip each left the model without the directives while the checkpoint still carried the posture. The reference implementation re-mints its attachment whenever the message array no longer holds one; a compaction or clip is where that happens here, so the restatement rides the same events (cadence stated in the ship post as a divergence candidate). A context that still holds the frame mints nothing (unchanged; never doubled).
|
|
2180
2350
|
- Pins: `test/backlog7260-review-fixes.test.ts` (R1 + control; R2 compaction + clip before a suspend; R3 compaction inside the resumed leg + control), each red on the merged tree before its fix; `backlog1002` P7 restated (restate at once, never doubled). Headless: R1 changes a parked row's offers only where a mandate is declared; R2/R3 change only what a run with a declared posture sends the model.
|
|
2181
2351
|
|
|
2182
|
-
|
|
2183
2352
|
## 7.25.1 — 2026-09-20
|
|
2184
2353
|
|
|
2185
2354
|
### A bare `NAME+=value` word is the assignment bash reads, not a program name (#993; P2; read boundary; narrowing on deny rows, widening on the rest; no wire key, no export)
|
|
@@ -432,8 +432,10 @@ export type ToolMarkSource = "deployment" | "mcp_annotation" | "a2a_default";
|
|
|
432
432
|
* words, which are single-winner projections and hide the members behind them. The array ORDER decides one
|
|
433
433
|
* thing only: which member is the DISCLOSED word (`askClass`) when several hold — the door-before-word,
|
|
434
434
|
* most-immune-first order; every lane reads the folded row ({@link foldArbitrationRow}), never the word.
|
|
435
|
+
* `shell_floor` is the operator's `shellGate: "always"` floor on a shell-running seat — the question the `operator_always`
|
|
436
|
+
* mandate word discloses, as a class of its own so a set that holds another forced question still holds it.
|
|
435
437
|
*/
|
|
436
|
-
export declare const ASK_CLASSES: readonly ["content_question", "hook", "ancestor", "unverifiable_delete", "transcript_write", "org_rule", "governance_unavailable", "rule_unreadable", "ask_rule", "denial_limit_fallback", "operator_forced", "write_protection_unresolved", "probe", "write_protection", "tool_marks_mcp", "tool_marks_a2a", "policy_rra", "artifact_shared_read", "mode", "ordinary"];
|
|
438
|
+
export declare const ASK_CLASSES: readonly ["content_question", "hook", "ancestor", "unverifiable_delete", "transcript_write", "org_rule", "governance_unavailable", "rule_unreadable", "ask_rule", "denial_limit_fallback", "operator_forced", "shell_floor", "write_protection_unresolved", "probe", "write_protection", "tool_marks_mcp", "tool_marks_a2a", "policy_rra", "artifact_shared_read", "mode", "ordinary"];
|
|
437
439
|
export type AskClass = (typeof ASK_CLASSES)[number];
|
|
438
440
|
/** Membership test for {@link AskClass} — the word crosses the wire on `tool_end.gate.disposition`. */
|
|
439
441
|
export declare function isAskClass(v: unknown): v is AskClass;
|
|
@@ -541,6 +543,13 @@ export declare const MODE_ARBITRATION: {
|
|
|
541
543
|
readonly ruleMayClear: false;
|
|
542
544
|
readonly hookAskFloor: false;
|
|
543
545
|
};
|
|
546
|
+
readonly shell_floor: {
|
|
547
|
+
readonly bypassImmune: true;
|
|
548
|
+
readonly classifierEligible: true;
|
|
549
|
+
readonly sandboxAdmission: true;
|
|
550
|
+
readonly ruleMayClear: false;
|
|
551
|
+
readonly hookAskFloor: false;
|
|
552
|
+
};
|
|
544
553
|
readonly write_protection_unresolved: {
|
|
545
554
|
readonly bypassImmune: true;
|
|
546
555
|
readonly classifierEligible: false;
|
|
@@ -608,8 +617,8 @@ export declare const MODE_ARBITRATION: {
|
|
|
608
617
|
/** The fence over the arbitration table: `never` while every class has a row. */
|
|
609
618
|
export type ArbitrationTableCoversEveryClass = AssertAllKeysHandled<Exclude<AskClass, keyof typeof MODE_ARBITRATION>>;
|
|
610
619
|
/** The classes whose own mint puts the `requiresRealApproval` bit on the ask — a bit one of them explains is not
|
|
611
|
-
* the bare bit `policy_rra` names. The mark classes (probe / write protection / tool marks / operator forced
|
|
612
|
-
* mint no bit and explain none. */
|
|
620
|
+
* the bare bit `policy_rra` names. The mark classes (probe / write protection / tool marks / operator forced / the
|
|
621
|
+
* shell floor) mint no bit and explain none. */
|
|
613
622
|
export declare const RRA_EXPLAINERS: ReadonlySet<AskClass>;
|
|
614
623
|
/** The classes a permission mode can RELEASE (the `bypassImmune: false` rows) — the only words that can appear on
|
|
615
624
|
* `GateDisposition.released.askClass`; every other class reaches a person under every mode. */
|
|
@@ -737,10 +746,10 @@ export type Arbitration = {
|
|
|
737
746
|
classes: readonly AskClass[];
|
|
738
747
|
};
|
|
739
748
|
/**
|
|
740
|
-
* The ONE mode read point. `dontAsk` refuses every ask
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
749
|
+
* The ONE mode read point. `dontAsk` refuses every ask — one value for every class — but a hook's that no member of the
|
|
750
|
+
* set floors (`hookAskFloor`: a hook's ask beside a rule's or a safety check's ask is refused, as the reference's full
|
|
751
|
+
* pipeline refuses it; a hook's ask alone is the reference's forced decision, which never reaches that conversion);
|
|
752
|
+
* `bypassPermissions` refuses a write whose protection could not be judged (a person cannot be asked, and
|
|
744
753
|
* releasing would be looser than the allow the judge already refuses), keeps every immune class and releases the
|
|
745
754
|
* rest; `acceptEdits` answers an ORDINARY ask itself on a call the caller vouches is one of the mode's file commands
|
|
746
755
|
* (`call.fileCommand`, `accepted`); `plan`, `default` and `auto` hand every ask on (plan's own questions were raised by
|
package/dist/core/ask-origin.js
CHANGED
|
@@ -178,6 +178,7 @@ export const ASK_CLASSES = Object.freeze([
|
|
|
178
178
|
"ask_rule",
|
|
179
179
|
"denial_limit_fallback",
|
|
180
180
|
"operator_forced",
|
|
181
|
+
"shell_floor",
|
|
181
182
|
"write_protection_unresolved",
|
|
182
183
|
"probe",
|
|
183
184
|
"write_protection",
|
|
@@ -204,6 +205,7 @@ export const MODE_ARBITRATION = {
|
|
|
204
205
|
ask_rule: { bypassImmune: true, classifierEligible: false, sandboxAdmission: false, ruleMayClear: false, hookAskFloor: true },
|
|
205
206
|
denial_limit_fallback: { bypassImmune: true, classifierEligible: false, sandboxAdmission: false, ruleMayClear: false, hookAskFloor: false },
|
|
206
207
|
operator_forced: { bypassImmune: true, classifierEligible: true, sandboxAdmission: true, ruleMayClear: false, hookAskFloor: false },
|
|
208
|
+
shell_floor: { bypassImmune: true, classifierEligible: true, sandboxAdmission: true, ruleMayClear: false, hookAskFloor: false },
|
|
207
209
|
write_protection_unresolved: { bypassImmune: true, classifierEligible: false, sandboxAdmission: true, ruleMayClear: false, hookAskFloor: true },
|
|
208
210
|
probe: { bypassImmune: false, classifierEligible: true, sandboxAdmission: false, ruleMayClear: false, hookAskFloor: false },
|
|
209
211
|
write_protection: { bypassImmune: false, classifierEligible: true, sandboxAdmission: true, ruleMayClear: false, hookAskFloor: true },
|
|
@@ -292,7 +294,7 @@ export function classifyAsk(d, facts) {
|
|
|
292
294
|
classes.add("rule_unreadable");
|
|
293
295
|
const marks = facts.mandateMarks;
|
|
294
296
|
if (marks.shellGated === true && marks.irreversibility === "always")
|
|
295
|
-
classes.add("
|
|
297
|
+
classes.add("shell_floor");
|
|
296
298
|
const toolMarked = marks.shellGated !== true && (marks.egress === true || marks.irreversibility === "always" || marks.irreversibility === "maybe");
|
|
297
299
|
if (toolMarked) {
|
|
298
300
|
const sources = facts.toolMarkSources;
|
|
@@ -351,7 +353,7 @@ export function arbitrateByMode(mode, c, disclosure, call) {
|
|
|
351
353
|
const classes = ASK_CLASSES.filter((k) => c.classes.has(k));
|
|
352
354
|
switch (mode) {
|
|
353
355
|
case "dontAsk":
|
|
354
|
-
return
|
|
356
|
+
return has("hook") && !c.row.hookAskFloor ? { kind: "ask" } : { kind: "denied_by_mode", mode, askClass: c.askClass, classes };
|
|
355
357
|
case "bypassPermissions":
|
|
356
358
|
if (has("write_protection_unresolved"))
|
|
357
359
|
return { kind: "denied_by_mode", mode, askClass: c.askClass, classes };
|