@sema-agent/core 7.17.7 → 7.19.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 +233 -13
- package/dist/agents/send-message-tool.js +1 -0
- package/dist/agents/subagent.d.ts +1 -0
- package/dist/agents/subagent.js +5 -0
- package/dist/agents/teacher.js +1 -0
- package/dist/agents/verify.js +3 -0
- package/dist/brain/anthropic.js +6 -3
- package/dist/brain/open-responses.js +10 -4
- package/dist/brain/openai.js +5 -2
- package/dist/brain/reasoning.d.ts +13 -13
- package/dist/brain/reasoning.js +2 -2
- package/dist/brain/route-adjudicator.d.ts +18 -0
- package/dist/brain/route-adjudicator.js +16 -0
- package/dist/brain/stream-engine.js +2 -1
- package/dist/brain/stream-shared.d.ts +3 -0
- package/dist/brain/stream-shared.js +1 -0
- package/dist/core/agent-definition.d.ts +10 -0
- package/dist/core/artifact-host.d.ts +41 -4
- package/dist/core/artifact-host.js +18 -2
- package/dist/core/ask-origin.d.ts +16 -0
- package/dist/core/ask-origin.js +8 -0
- package/dist/core/auto-mode-arming.d.ts +6 -0
- package/dist/core/auto-mode-arming.js +5 -5
- package/dist/core/auto-mode-defaults.d.ts +43 -15
- package/dist/core/auto-mode-defaults.js +4 -1
- package/dist/core/auto-mode.d.ts +184 -59
- package/dist/core/auto-mode.js +188 -13
- package/dist/core/checkpoint-store.d.ts +23 -2
- package/dist/core/checkpoint-store.js +2 -2
- package/dist/core/engine-notice.d.ts +77 -1
- package/dist/core/engine-notice.js +7 -0
- package/dist/core/gate-fold.js +5 -1
- package/dist/core/gate-lanes.js +9 -8
- package/dist/core/gate-outcome.d.ts +42 -2
- package/dist/core/gate-outcome.js +23 -0
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +14 -0
- package/dist/core/hook-seats.d.ts +66 -0
- package/dist/core/hook-seats.js +37 -0
- package/dist/core/hooks.d.ts +1 -1
- package/dist/core/read-root-candidate.d.ts +12 -0
- package/dist/core/read-root-candidate.js +24 -0
- package/dist/core/roles.d.ts +9 -1
- package/dist/core/roles.js +4 -0
- package/dist/core/runner/advertised-writable-dirs.d.ts +8 -3
- package/dist/core/runner/assemble-result.d.ts +2 -1
- package/dist/core/runner/assemble-result.js +1 -1
- package/dist/core/runner/classifier-ladder.d.ts +227 -0
- package/dist/core/runner/classifier-ladder.js +214 -0
- package/dist/core/runner/contracts.d.ts +71 -13
- package/dist/core/runner/decide-continuation.d.ts +1 -3
- package/dist/core/runner/decide-continuation.js +1 -7
- package/dist/core/runner/deferral-census.d.ts +52 -0
- package/dist/core/runner/deferral-census.js +44 -0
- package/dist/core/runner/denial-limit-arms.js +2 -2
- package/dist/core/runner/derived-route-fallback.d.ts +8 -4
- package/dist/core/runner/derived-route-fallback.js +8 -3
- package/dist/core/runner/gate-exit.d.ts +12 -0
- package/dist/core/runner/gate-exit.js +6 -3
- package/dist/core/runner/prepare-artifact.d.ts +10 -2
- package/dist/core/runner/prepare-artifact.js +5 -4
- package/dist/core/runner/prepare-ask-lane.js +1 -0
- package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -2
- package/dist/core/runner/prepare-caps-and-workflow.js +43 -55
- package/dist/core/runner/prepare-config-doors.d.ts +7 -1
- package/dist/core/runner/prepare-config-doors.js +13 -13
- package/dist/core/runner/prepare-defer-classify.d.ts +13 -19
- package/dist/core/runner/prepare-defer-classify.js +8 -3
- package/dist/core/runner/prepare-gate-stations.d.ts +26 -0
- package/dist/core/runner/prepare-gate-stations.js +1 -1
- package/dist/core/runner/prepare-hands-readface.d.ts +24 -7
- package/dist/core/runner/prepare-hands-readface.js +60 -24
- package/dist/core/runner/prepare-inherited-gate.d.ts +17 -4
- package/dist/core/runner/prepare-inherited-gate.js +10 -2
- package/dist/core/runner/prepare-lsp.d.ts +2 -2
- package/dist/core/runner/prepare-lsp.js +1 -1
- package/dist/core/runner/prepare-policy-chain.js +2 -0
- package/dist/core/runner/prepare-project-context.d.ts +5 -1
- package/dist/core/runner/prepare-project-context.js +7 -4
- package/dist/core/runner/prepare-prompt-inputs.d.ts +4 -2
- package/dist/core/runner/prepare-prompt-inputs.js +4 -1
- package/dist/core/runner/prepare-run-refs.d.ts +9 -1
- package/dist/core/runner/prepare-run-refs.js +3 -2
- package/dist/core/runner/prepare-suspend-saga.d.ts +5 -0
- package/dist/core/runner/prepare-suspend-saga.js +5 -2
- package/dist/core/runner/prepare-task.js +19 -14
- package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +5 -0
- package/dist/core/runner/prepare-tool-disclosure-mount.js +10 -2
- package/dist/core/runner/prepare-turn-wiring.d.ts +8 -4
- package/dist/core/runner/prepare-turn-wiring.js +3 -2
- package/dist/core/runner/prepare-wiring-manifest.d.ts +5 -5
- package/dist/core/runner/prepare-wiring-manifest.js +5 -2
- package/dist/core/runner/resume-apply.js +3 -3
- package/dist/core/runner/resume-checkpoint-screen.js +7 -0
- package/dist/core/runner/run-harness-handlers.js +4 -1
- package/dist/core/runner/run-identity-wiring.js +6 -2
- package/dist/core/runner/run-leg.d.ts +2 -0
- package/dist/core/runner/run-leg.js +45 -45
- package/dist/core/runner/run-notification-lane.js +55 -27
- package/dist/core/runner/run-settle-and-teardown.js +1 -0
- package/dist/core/runner/run-terminal-adoption.js +16 -13
- package/dist/core/runner/run-turn-boundary.js +15 -5
- package/dist/core/runner/runtask.d.ts +6 -5
- package/dist/core/runner/runtask.js +26 -14
- package/dist/core/runner/skill-base-dir.d.ts +54 -0
- package/dist/core/runner/skill-base-dir.js +42 -0
- package/dist/core/runner/synthetic-tools.d.ts +83 -48
- package/dist/core/runner/synthetic-tools.js +69 -32
- package/dist/core/runner/tool-defer-gate.d.ts +22 -9
- package/dist/core/runner/tool-defer-gate.js +15 -5
- package/dist/core/runner/tool-disclosure.d.ts +5 -0
- package/dist/core/runner/tool-disclosure.js +2 -0
- package/dist/core/runner/tool-progress-lane.d.ts +42 -0
- package/dist/core/runner/tool-progress-lane.js +61 -0
- package/dist/core/runner/turn-attachments.d.ts +43 -12
- package/dist/core/runner/turn-attachments.js +58 -14
- package/dist/core/runner-deps.d.ts +48 -7
- package/dist/core/sensitive-path-policy.d.ts +58 -17
- package/dist/core/sensitive-path-policy.js +33 -5
- package/dist/core/skill-listing-budget.d.ts +112 -0
- package/dist/core/skill-listing-budget.js +58 -0
- package/dist/core/swappable-deps.d.ts +9 -7
- package/dist/core/swappable-deps.js +7 -3
- package/dist/core/task-event.d.ts +96 -29
- package/dist/core/task-notification.d.ts +142 -16
- package/dist/core/task-notification.js +106 -0
- package/dist/core/task-result.d.ts +9 -1
- package/dist/core/task-spec.d.ts +46 -3
- package/dist/core/tighten-task-spec.js +7 -5
- package/dist/core/tool-model-gate.d.ts +1 -1
- package/dist/core/tool-policy.d.ts +19 -4
- package/dist/core/tool-policy.js +6 -7
- package/dist/core/tool-search-setting.d.ts +78 -0
- package/dist/core/tool-search-setting.js +90 -0
- package/dist/core/tool-spec.d.ts +45 -4
- package/dist/core/types.d.ts +2 -2
- package/dist/core/types.js +1 -1
- package/dist/core/wiring-manifest.d.ts +46 -0
- package/dist/core/wiring-manifest.js +9 -1
- package/dist/core/write-protect.d.ts +8 -3
- package/dist/core/write-protect.js +8 -5
- package/dist/engine/execution-env/node-execution-env.js +10 -4
- package/dist/engine/harness/agent-harness.d.ts +42 -0
- package/dist/engine/harness/agent-harness.js +61 -29
- package/dist/engine/loop/agent-loop.d.ts +68 -3
- package/dist/engine/loop/agent-loop.js +109 -16
- package/dist/index.d.ts +13 -8
- package/dist/index.js +11 -7
- package/dist/internal/harness.d.ts +1 -1
- package/dist/orchestration/run-workflow-tool.d.ts +3 -0
- package/dist/orchestration/run-workflow-tool.js +6 -1
- package/dist/orchestration/workflow-governance.d.ts +7 -1
- package/dist/orchestration/workflow-governance.js +9 -5
- package/dist/orchestration/workflow-primitives.d.ts +3 -0
- package/dist/orchestration/workflow-primitives.js +6 -1
- package/dist/orchestration/workflow-types.d.ts +48 -13
- package/dist/orchestration/workflow-types.js +1 -1
- package/dist/orchestration/workflow.js +45 -17
- package/dist/prompt-assembly/epoch.js +2 -0
- package/dist/prompt-assembly/packs/sema-default.js +2 -2
- package/dist/prompt-assembly/section-usage.d.ts +26 -0
- package/dist/prompt-assembly/section-usage.js +10 -0
- package/dist/prompt-assembly/types.d.ts +8 -6
- package/dist/prompts/default.d.ts +13 -0
- package/dist/prompts/default.js +16 -8
- package/dist/tools/artifact/artifact-text.d.ts +10 -1
- package/dist/tools/artifact/artifact-text.js +6 -3
- package/dist/tools/artifact/artifact-tool.d.ts +12 -2
- package/dist/tools/artifact/artifact-tool.js +203 -70
- package/dist/tools/artifact/conformance.d.ts +56 -0
- package/dist/tools/artifact/conformance.js +349 -0
- package/dist/tools/artifact/local-stub.js +1 -1
- package/dist/tools/artifact/reject-breaker.d.ts +28 -0
- package/dist/tools/artifact/reject-breaker.js +42 -0
- package/dist/tools/fs/bash-lexer.d.ts +132 -0
- package/dist/tools/fs/bash-lexer.js +563 -0
- package/dist/tools/fs/bash-program-position.d.ts +142 -0
- package/dist/tools/fs/bash-program-position.js +125 -0
- package/dist/tools/fs/bash-readonly-classifier.d.ts +81 -16
- package/dist/tools/fs/bash-readonly-classifier.js +180 -558
- package/dist/tools/fs/containment-face.d.ts +89 -0
- package/dist/tools/fs/containment-face.js +81 -0
- package/dist/tools/fs/fs-bash.d.ts +46 -1
- package/dist/tools/fs/fs-bash.js +165 -25
- package/dist/tools/fs/fs-read.d.ts +1 -1
- package/dist/tools/fs/fs-search-tools.d.ts +3 -3
- package/dist/tools/fs/fs-write.d.ts +4 -3
- package/dist/tools/fs/fs-write.js +9 -9
- package/dist/tools/fs/index.d.ts +24 -5
- package/dist/tools/fs/index.js +18 -6
- package/dist/tools/fs/read-deny.d.ts +4 -3
- package/dist/tools/fs/repo-map.d.ts +2 -2
- package/dist/tools/fs/safety.d.ts +18 -3
- package/dist/tools/fs/safety.js +11 -4
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +99 -13
- package/wire-changes/core@7.17.7.json +49 -0
- package/wire-changes/core@7.18.0.json +1273 -0
- package/wire-changes/core@7.19.0.json +457 -0
- package/dist/tools/fs/read-face.d.ts +0 -68
- package/dist/tools/fs/read-face.js +0 -47
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,237 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.19.0 — 2026-09-16
|
|
4
|
+
|
|
5
|
+
### Fix — a refusal's note seat is filled by ONE rule, so an empty rejection reason cannot mean two different things (#812 / B-123; **narrowing** on the durable-resume path; @server @cli @test)
|
|
6
|
+
- **Before.** The `note` on a `human_refused` settlement is the batch boundary: absent = the decider said "no" and nothing else, so every never-started sibling of the same assistant batch settles `gate.batch_halted`; present = a reasoned deny that stops only the call it named. Two paths filled that seat and they folded empty text differently. The synchronous approver's `{ allow: false, reason: "" }` was folded to absent in the resolver (`tool-policy.ts`), so the batch halted. The durable decide's `{ decision: "deny", reason: "" }` was kept verbatim as `note: ""` (`resume-apply.ts` filtered only `undefined`), so the record read "reasoned" and every sibling RAN — while the model-facing continuation for that same decide, which tests the reason for truthiness, told it the call `was DENIED` with no reason at all. One deny, one reason text, opposite fates for the rest of the batch depending on which path carried it.
|
|
7
|
+
- **After.** A person's settlement has ONE composer, `settledByPerson` in `gate-outcome.ts` (beside the record it builds and its screen), and it applies the note seat's own rule, `decidersNote`: empty text is a decider who attached nothing. Both paths compose there — the resolver's human arms and the durable decide lane's person arm — and the resolver's live text now quotes the same folded note it records, so the words the model reads and the record a consumer reads cannot disagree. `maybeHumanRejectionHalt` is untouched: it still reads absence, and nothing else, as the boundary. Contract sentence `settlement.note` on the mint; registry row `settledByPerson` in `gate:single-mint` (a second inline composition of the record is red).
|
|
8
|
+
- **Narrowing (durable resume only).** A decide carrying `reason: ""` now halts the rest of the batch instead of letting the siblings run — it is read as the bare refusal the live path always read it as. Nothing widens: no deny becomes an allow, and a decide with any non-empty reason (whitespace included) behaves exactly as before. `reason: undefined` is unchanged on both paths.
|
|
9
|
+
- **Whitespace is unchanged and pinned, not promised.** `" "` / `"\n\n"` are a note today on BOTH paths (siblings run). That is pinned as current behaviour; nothing in the record's contract requires a note to carry content, and narrowing it would be its own decision with its own consumers to warn.
|
|
10
|
+
- Pins: `test/backlog812-settlement-note-fold.test.ts` (the same five reasons down both paths, compared to each OTHER rather than to a remembered number; the boundary read directly; the fold; the declaration-count pin).
|
|
11
|
+
|
|
12
|
+
### Security / narrowing — a write spelled RELATIVELY from inside a write-protected directory is now judged the same as the absolute spelling (#815; found by #804's adversarial round; every deployment)
|
|
13
|
+
- **Before.** The write-protection judge (`compileWriteProtection` / the gate's fourth tighten) was purely lexical over the string in the tool's arguments, and never read the run's live tracked cwd. So `cd ~/.ssh` followed by a bare `Write("authorized_keys")` carried no guarded segment at all and produced **zero ask**, while the same file spelled absolutely asked. The hole is not one family's: it applied to **every `segment` row of the always-on table** — `.ssh`, `.git`, `.gnupg`, `.vscode`, `.idea`, `.husky`, `.cargo`, `.devcontainer`, `.yarn`, `.mvn`, `.claude`, `.sema` — and a bare deployment (no opt-in deny policy wired) had no second line behind it.
|
|
14
|
+
- **After.** A relative spelling is ANCHORED against the call's live tracked cwd — the same ref the fs hand tools resolve with, read at judgment time, handed by the gate — before the table is read. Two spellings of one file get one verdict. This is the law RB-108 already set for the path policies (its measured example was `cd .git/hooks` + `Write("pre-commit")`); the write-protection table shipped later and had never joined it.
|
|
15
|
+
- **Scope of the change.** Anchoring only; the join is the shared `joinForFamily` mint, with no normalization, so the two-view reading and the data-root passthrough are byte-identical on an absolute target. The passthrough now also covers relative spellings from inside the data root (a memory write from `cd <dataRoot>/memory` is exempt, as its absolute twin already was).
|
|
16
|
+
- **Seat (additive, absence reading preserved).** `WriteProtectionMatcher.matchPath(path, cwd?)` and `ToolGateInput.writeProtectionCheck(toolName, args, face?, cwd?)` take an OPTIONAL trailing cwd. **Absent ⇒ the spelling is judged exactly as given, i.e. today's lexical reading byte for byte** — a host driving `runToolGate` without `trackedCwd` sees no change, and an existing caller of `compileWriteProtection` keeps its arity.
|
|
17
|
+
- **Reach.** A deployment can now see an ask where a relative spelling previously produced none. That is a narrowing in the safe direction (one explicit look, clearable by an approver or a persisted allow rule); nothing that asked before stops asking.
|
|
18
|
+
- Pins: `test/backlog804-agent-config-write-tier.test.ts` (`#815` block — `.ssh` / `.git/hooks` / `.gnupg` / `.claude` judged identically on both spellings; the absent-cwd reading; an absolute target unmoved by the cwd in both directions; the passthrough on both spellings; end-to-end through `runToolGate` with and without `trackedCwd`).
|
|
19
|
+
|
|
20
|
+
### Write protection is layered by what the file IS: agent configuration FILES deny, the configuration DIRECTORY asks (#804; @server @cli @client-core @test; deployment operators)
|
|
21
|
+
- **Before.** The opt-in write deny set (`RECOMMENDED_SENSITIVE_PATTERNS`) carried the agent configuration DIRECTORIES whole — `.claude*` and `.sema` as segment entries — so every path beneath them was deny severity. A person who told the agent to edit a file in their own library under `~/.claude/projects/<slug>/memory/*.md` got a hard refusal they could not lift: a deny outranks an ask under the deny > ask > allow fold, so the always-on ask table's `.claude` / `.sema` rows — which an approver or a persisted allow rule CAN clear — never got to answer. Measured on a live deployment (three refused Write/Edit calls on a file the user had named).
|
|
22
|
+
- **After — the law.** The class is split by what the file is, not by which paths were reported. An agent's own configuration FILE stays at deny severity (writing it re-authorizes the agent or hands the host a command line to launch); the configuration DIRECTORY around it holds the person's own working material — memory libraries, agent and skill definitions, project notes — and is guarded one severity down by `WRITE_PROTECTED_DEFAULT_TABLE`'s `.claude` / `.sema` segment rows, which are default-on and need no opt-in. Every write there still stops for one explicit look; it is now a look a person can answer.
|
|
23
|
+
- **After — one table, two judges.** The deny set's agent-configuration block is DERIVED from the read face's `agent-config` tier (`READ_FACE_BUILTIN_DENY_TABLE`, filtered) rather than spelled a second time, so the two faces cannot drift about which files are an agent's own configuration. Contract sentence `sensitive_paths.agent_config_tier_is_shared` (on `agentConfigDenyPatterns`): **the write deny set's agent-configuration block IS the read face's `agent-config` tier, row for row; the configuration DIRECTORIES (`.claude`, `.sema`) are on neither face's deny side — their write guard is the ask-side write-protection table.** A set-EQUALITY pin (not a sample) reds the moment either side gains or loses a row.
|
|
24
|
+
- **After — the ask tier is the SAME canonical judge, not only the lexical table.** `createSensitivePathPolicy` now answers TWO severities from one judge: `patterns` (deny) and a new **`askPatterns`** seat, consulted only where the deny set misses. Contract sentence `sensitive_paths.two_severities_one_judge` (on `createSensitivePathPolicy`): **a deny is a refusal no approver and no persisted allow rule can lift; an ask is one explicit look either of them may clear; lowering a path's severity never lowers its REACH** — both tiers see the real target (symlinks resolved, relative spellings resolved against the live cwd), which the synchronous write-protection TABLE deliberately does not. Without this, demoting the config directories to the lexical table alone would have traded a severity for a reach: a symlink whose spelling is innocent walks past a lexical judge (measured on the branch).
|
|
25
|
+
- **New export `RECOMMENDED_SENSITIVE_ASK_PATTERNS`** (additive; `readonly string[]` = `[".claude", ".sema"]`). **`askPatterns` ABSENT ⇒ this set** — a deployment that already wires this guard keeps the canonical reach it had over those directories **without changing a line**, at the severity a person can clear. `askPatterns: []` is the explicit no-tier posture and the only way to have none; a degenerate entry (`"/"`) throws at wiring time exactly as a degenerate deny pattern does. The data-root passthrough applies to both tiers, so the memory library is never asked about — alias included.
|
|
26
|
+
- **WIDENED (deny → ask), the whole point of the ticket.** Removed from the deny set: `.claude*`, `.sema`. Every path under a `.claude` / `.sema` directory that is NOT one of the configuration files below moves from a hard refusal to an approvable ask — `.claude/projects/**`, `.claude/agents/**`, `.claude/skills/**`, `.sema/projects/**`, `.sema/agents/**`, `.sema/skills/**`, and anything else under those directories. The two directories move to `askPatterns`' default set, so they keep their CANONICAL reach at the new severity. `.claude.json` / `.sema.json` are unaffected: `.claude.*` / `.sema.*` still deny them.
|
|
27
|
+
- **NARROWED (ask → deny), the two hook directories.** `.claude/hooks` and `.sema/hooks` join the deny set as WRITE-FACE rows of their own (hand-kept beside the derived block, absent from the read tier — reading a script is harmless, writing it is the escalation). A hook script is the command line the host runs on its next tool call, so editing one hands the host a command without touching the settings file that names it: the `.git/hooks` argument, one ecosystem over. Consequence for a deployment that wires the recommended set: a write anywhere under those two directories is a refusal no approver and no persisted allow rule can lift, where the directory relaxation above would otherwise have made a blanket `Write(~/.claude/**)` rule clear it. Two segments each, so the rest of the configuration directory is untouched and stays at ask severity.
|
|
28
|
+
- **NARROWED (allow → deny), eight patterns.** Taking the whole shared tier brings eight rows the write set did not carry. A write to any of these is now a refusal where it used to be an ordinary write (an approvable ask, or no friction at all):
|
|
29
|
+
- `.ai-agent/.env` — the engine host's data-root key file under the default root;
|
|
30
|
+
- `.sema/engine-data/.env` — the same file under the shell's layout;
|
|
31
|
+
- `.codex/config.toml` — `[mcp_servers.<name>] env` takes literal values;
|
|
32
|
+
- `.cursor/mcp.json` — MCP server definitions (`.cursor/rules` stays writable);
|
|
33
|
+
- `.continue/config.json` and `.continue/config.yaml` — `apiKey` fields, both serializations;
|
|
34
|
+
- `.aider.conf.yml` — `openai-api-key` / `anthropic-api-key` keys;
|
|
35
|
+
- `.gemini/settings.json` — `mcpServers` env.
|
|
36
|
+
Each is an agent configuration file whose `env` / `mcpServers` block is a credential or a command line the host launches — the argument the already-present `.mcp.json` row makes, applied to the same class by the same table.
|
|
37
|
+
- **Deployment operators.** This changes the RECOMMENDED set only. A deployment that sets `SENSITIVE_WRITE_PATTERNS` (or otherwise passes its own `patterns` to `createSensitivePathPolicy`) does NOT inherit the two deny-set changes — but it DOES get the ask tier: `askPatterns` absent = `RECOMMENDED_SENSITIVE_ASK_PATTERNS` (`.claude`, `.sema`), so a symlink alias or a relative spelling into those directories that was silently allowed before now asks once (on a headless lane: refuses). Pass `askPatterns: []` to opt out. The deny tier still ships no implicit default list.
|
|
38
|
+
- **Unchanged.** The data-root passthrough (HRD-PRM-10) and its exemption asymmetry; the `!canon.ok ⇒ deny` fail-closed arm for a target the guard could not resolve; the READ face in every respect (its table, its tiers, its verdicts — pinned as a negative control); the export `RECOMMENDED_SENSITIVE_PATTERNS`, whose name and type (`readonly string[]`) are byte-identical in shape — only its contents moved; `.sema-notifier-build`, the one directory of the brand namespace that keeps deny severity (its contents are compiled into an app the shell launches).
|
|
39
|
+
- **Two arms the ask tier's own review turned up, both fixed in it.** (i) The policy's "nothing to judge" early exit keyed on the DENY list alone, so an ASK-ONLY deployment (`patterns: []` with an ask set) silently got no protection — it now exits only when BOTH tiers are empty. (ii) The ask message named the model-authored path, and an ask's text is the auto-mode classifier's `askMessage`, so a newline-bearing filename could forge a turn inside the sentence the call is judged by; the ask now names the matched PATTERN only (the deny arm still names the path — a refusal reaches no classifier, and the approval card shows the real arguments either way).
|
|
40
|
+
- **Known gap, declared (#817).** The always-on write-protection TABLE — the floor a deployment gets without wiring this guard at all — is a synchronous LEXICAL judge with no canonical reach: a symlink whose spelling is innocent produces no ask there, and always has (`notes.txt -> .bashrc`, measured, unchanged by this ticket). That is a property of the whole table, not of these two rows; unifying the sync-lexical tighten seat with the async-canonical policy seat is a design question, filed rather than patched here.
|
|
41
|
+
- **@server (the one consumer of the recommended set).** No code change is required: `askPatterns` absent reads as the recommended directory set, so a deployment that wires `RECOMMENDED_SENSITIVE_PATTERNS` recovers the canonical reach automatically. Pass `askPatterns: []` to decline the tier, or your own list to replace it.
|
|
42
|
+
- Pins: `test/backlog804-agent-config-write-tier.test.ts` (the reported path and its class; the configuration files on both brand spellings plus the seven third-party rows; the data-root passthrough; a persisted allow rule silencing the ask and failing to touch the deny; the headless lane on both severities; the read face as a negative control; the derivation set-equality; and the ask tier — a symlink alias onto a config directory asks while the same alias onto a settings file still denies, `askPatterns: []` as the explicit no-tier posture with the remaining lexical gap pinned honestly, the passthrough through an alias, a replacement set, and the loud degenerate entry). Re-cut: `backlog549-sema-config-root-guard` (the config FILES keep their pins; the directory assertions say which half they mean; the passthrough-asymmetry fixtures now site their root beneath `.ssh`, a segment row still on the list), `p4-sensitive-patterns-completeness` (the coverage table follows the list — near-misses are now mostly a NON-config file in the very same directory, which is the law under test).
|
|
43
|
+
|
|
44
|
+
### The `<skills>` disclosure block's budget is a DECLARED seat, sized to the model's window (#806; ruling C-R41; @server @cli @client-core @test)
|
|
45
|
+
- **Before.** The `<skills>` listing was capped at a flat 8 000 UTF-8 bytes, hard-coded, with no way for a deployment to say anything about it — and the per-skill description cap (1 536) was equally fixed. The three degradation tiers (full / priority-ordered descriptions / name-only + overflow count) were already in place and are unchanged.
|
|
46
|
+
- **After.** Two optional `TaskSpec` seats, CC 2.1.259's (`Jar`/`yTe`): `skillListingBudgetFraction` (absent ⇒ `0.01`, domain `(0, 1]`) and `skillListingMaxDescChars` (absent ⇒ `1536`, positive integer). The budget is `(contextTokens ?? contextWindow ?? 200 000) × 4 × fraction` bytes. Both are read at the config door, before session acquisition; a value outside its domain REFUSES the prepare with `config.skill_listing_budget_invalid` rather than folding back to the default.
|
|
47
|
+
- **Narrowing (behaviour face, no key required).** A model declaring a window SMALLER than 200 000 tokens now gets a proportionally smaller listing: 128 000 tokens ⇒ 5 120 bytes, 32 000 ⇒ 1 280, where all of them previously got 8 000. Every skill is still listed; the lower-priority ones (declaration order) lose their descriptions first and the `Skill` tool still serves all of them. At 200 000 tokens and above the default resolves to exactly the old 8 000, so the default render is byte-for-byte unchanged — pinned against the published 7.18.0 dist.
|
|
48
|
+
- **The fraction is ONE-WAY in this engine (`@contract skills.listing_budget_ceiling`).** The block is the body of the `skills_listing` turn attachment, and a bundle over `ATTACHMENT_BYTE_CAP` is clipped; a clipped frame never satisfies the intact-survival commit predicate, so it would replay at every boundary forever. The resolved budget is therefore clamped to 8 000 bytes: a raised fraction lowers nothing and raises nothing, a lowered one takes effect exactly. When the clamp costs a skill its description, `skills.listing_truncated` carries `requestedBudgetBytes` beside `budgetBytes` so both numbers are legible.
|
|
49
|
+
- **ONE RULER FOR EVERY FRAME — the drift frame is measured too.** The boundary's "New skills are now available" frame rendered every added line at full length with no block budget at all, and the removal arm listed every departed name however many there were. A drift that added ten 1 536-character descriptions produced a ~15 KB body (and a single 10 000-character description under a wide `skillListingMaxDescChars` produced a 10 KB one): over `ATTACHMENT_BYTE_CAP`, so the bundle CLIPPED it, so the intact-survival commit predicate could never match, so the same frame was re-rendered and re-clipped at every boundary for the life of the task. Both arms and the joiners are now priced against the SAME resolved budget the initial block uses, and the add arm degrades through the same three tiers (full → name-only in declaration-rank order → an honest overflow count). The frame's size is now the declared budget or, when even the two arms' floors do not fit it, a small constant — never a function of the roster. Stated cost: a departed name the removal arm cannot fit is not announced (the count line says how many), against a frame that would otherwise replay forever.
|
|
50
|
+
- **New notice `skills.listing_truncated`** (closed-set addition to `ENGINE_NOTICE_CODES`; audience `operator`): emitted by the lane that DELIVERS a listing frame — the leg's first-frame producer, or the turn boundary a drift (or a resume leg's initial listing) rides — once per delivered frame the budget actually degraded. `detail: { budgetBytes, requestedBudgetBytes, descMaxChars, total, kept, nameOnly[], dropped[], sessionId }` — every count is read off the frame that shipped, so `total` is the whole roster on an initial listing and the newly announced set on a drift frame. Three consequences of announcing at the delivery points rather than at the config door (#819): a deployment that turned the listing off (`attachments.skillsListing: false`) is no longer told that a listing it does not get would not fit; a session running many tasks hears the fact per delivered listing instead of per prepared task; and a **durable-resume leg**, whose first-frame producer never runs, is no longer silent about a listing the budget degraded on its way in (found by the adversarial round).
|
|
51
|
+
- **Performance.** The overflow (tier C) leg recomputed a prefix sum — and re-sliced the name array — on every candidate, making it quadratic in the roster size. One prefix-sum pass replaces it, byte-identically: 2 000 / 10 000 / 20 000 skills now render in 17 / 64 / 133 ms against 407 / 6 645 / 24 499 ms before.
|
|
52
|
+
- Pins: `test/backlog806-skill-listing-budget.test.ts` (six byte/sha fixtures against the published 7.18.0 dist; the budget boundary at exactly-equal and one byte under; the ceiling clamp and the renderer's re-clamp; eight + six bad-value refusals; the disclosure's silence and its detail; the multi-turn no-replay run; a source census that every render site is handed a budget; the tier-C rewrite against a naive reference plus its timing; the drift frame under both arms at 20 / 2 000 / 20 000 skills, the wide-description-cap construct, the removal flood, a durable-resume seed, and the delivered frame's own counts against the notice's).
|
|
53
|
+
|
|
54
|
+
### One rejected-value describer, not two (#809; no wire face)
|
|
55
|
+
- The config-doors phase carried a second local `describeRejectedValue` built on `JSON.stringify`, beside the shared one every other config seat uses. Same three defects: the rejected value narrated its own refusal through `toJSON` (an illegal object described itself as a legal word), `NaN` and `Infinity` both serialised to `null`, and a circular value degraded to `a object value`. The local copy is deleted and the door imports the shared describer; codes and sentence structure are unchanged.
|
|
56
|
+
- Pins: `test/backlog809-shared-rejected-value-describer.test.ts` (the three value shapes through the attachments door; one description across four seats; a source census that `src/` declares exactly one describer).
|
|
57
|
+
|
|
58
|
+
### The out-of-root read approval card names the ONE directory that would end the asking (#803; additive; @server @cli @client-core @test)
|
|
59
|
+
- **Before.** A read outside the session's declared directories raised a MANDATED ask — a persisted allow rule cannot clear it, so the card offered no "don't ask me again" either. Everything a person could do about it was say yes, and say yes again on the next call. The configuration that WOULD end it existed (`TaskSpec.additionalReadDirectories`, the read-only half of the `--add-dir` parity) and the option's label minter existed (`formatOutOfRootReadApprovalOption`); the ask simply never said which directory to name. Two contract sentences stated the gap as settled — `ReversibilityVerdict.mandated` ("no clearing channel in this version") and `AskRequest.ruleOffersAbsence`'s `"mandated"` arm ("must not point at a directory GRANT either").
|
|
60
|
+
- **After.** The ask carries `readRootCandidate: { dir, clearsThisAsk: true }` (new export `ReadRootGrantCandidate`) when the read boundary can name one directory whose addition to the session's read containment would make the same call run with no card at all. The same member rides `ReversibilityVerdict`, so a deployment's own `reversibilityProbe` can speak it; the gate validates it at the seam and a malformed value costs the OFFER only, never the ask. The two sentences above now say what is true.
|
|
61
|
+
- **Internal, no behaviour face.** The structural containment gate (`classifyOutOfRootReadGate`) asked the walk to mint a grant candidate and then discarded it — its answer has two members and neither is the candidate — so every out-of-root read paid for a second full walk that nothing read. It now asks for no candidate; the offer is minted by the verdict face, whose caller can put it on a card.
|
|
62
|
+
- **@contract `shell.read_boundary.grant_candidate_is_the_grant`.** The string a card DISPLAYS is byte for byte the string a deployment ADDS. A surface may shorten it for the eye and must send this spelling back when the person accepts. Pinned end to end: the directory read off the card, handed back verbatim as `additionalReadDirectories`, turns the same command into a zero-ask call.
|
|
63
|
+
- **One rule, not a table of arms.** The candidate rides the SAME arm as the read walk's containment sentence and is dropped wherever that sentence is dropped. So everything that would make `clearsThisAsk` a lie refuses by construction: a sensitive-path deny row and an unresolvable operand return before that arm; an unexpanded glob, a recursive walk and a `..`-traversing spelling are tested on it; a shape refusal (`tail -f <out-of-root>`) takes the sentence and the candidate together. A heredoc, a line break, a substitution and an argument-as-program read (`python3 -c …`) are refused whole by the grammar gate, which never reaches the walk at all.
|
|
64
|
+
- **Deliberately narrow.** The candidate is the deepest directory that PROPERLY contains every out-of-root path the walk resolved, folded lexically (this file is zero-I/O by contract, so an operand that IS a directory contributes its parent — `ls /etc/ssl` ⇒ `/etc`, one level wider than the reference implementation's stat-then-dirname step; the card shows what it grants, so a person grants what they see). Refused rather than widened when the result would be a filesystem root, or would contain the declared home or one of the declared roots. A backgrounded call and an ask another layer already raised get the mandate and NOT the offer — widening the roots would leave both standing.
|
|
65
|
+
- **The offer is withdrawn when a layer ABOVE the tighten is also asking** (adversarial round 1, measured hole). The persisted-rule and org lanes run AFTER the tighten that names the directory, and either can leave an ask standing that no grant answers: with a person's own standing `Bash(cat:*)` ask row, `cat <out-of-root>` carried the offer and the same call after declaring the directory was still asked about. The withdrawal is ONE rule at the ONE derivation that already answers "why is this an ask" — the ask-ORIGIN ladder: a disclosure speaking for the tighten alone survives only the two rungs the tighten produces (`shell_gate_tighten` / `safety_tighten`), and every rung above (a standing `ask` row, an unreadable rule store, an org verdict, a hook, the denial-limit fallback) takes it with them. The three origin-stamp sites now share one named act, `stampAskOrigin`.
|
|
66
|
+
- **The claim is MEASURED, not argued** (adversarial round 2, measured hole). The first form derived the offer from a conjunction of guards, and `cd /opt/data && cat id_rsa` passed every one — no glob, no unresolvable operand, no deny row, because a BARE operand outside the roots is not walked at all — while the grant brought the file into range of the deny judge and left the ask standing. The walk is pure and zero-I/O, so it now simply runs AGAIN with the proposed root in place and offers the directory only when that walk has no objection left. The guard list is gone: the replay sees exactly what the next call will see.
|
|
67
|
+
- **The probe seat enforces its declared shape** (adversarial round 2). `readRootCandidate.dir` is documented as an absolute, lexically folded directory; the validator checked length and control characters but not that. A deployment-supplied probe could put `../../..` on a card, which resolves to the filesystem root where the deployment applies it. Absolute / already-folded / not-a-family-root are now enforced family-aware with the same primitives the boundary compares with, and a value that fails is dropped rather than repaired.
|
|
68
|
+
- **The common-directory search is LINEAR** (adversarial round 3, measured hole). Climbing one directory at a time re-folded the whole remaining path on every step: one 32 KB command naming a 16 000-segment operand cost **4.9 s** of synchronous work — on the walk that runs BEFORE the gate arms its probe deadline, so the deadline could not interrupt it. The paths are already folded, so the answer is their common prefix cut at a separator; the family-aware comparator still confirms the result, and a prefix it cannot confirm contains both paths yields no candidate. Same answer, **9 ms** at that depth, which is the cost the same command had before this seat existed.
|
|
69
|
+
- **A Win32 ALIAS of another directory is never named** (adversarial round 4, measured hole ×2). Win32 trims a trailing dot or space from every path component, so `x. ` and `x` are one directory to the OS and two strings to a lexical comparator — a fact this repo already stated in its UNC key minter and now reads from one predicate (`hasWin32AliasedComponent`). Two ways it bit: an alias of the DRIVE ROOT reached the card (`C:\.. ` passes absolute + folded + not-a-root and resolves to `C:\` wherever the OS strip runs), and a byte cut BETWEEN two aliased spellings (`…\reports.` beside `…\reports`) landed one level above the directory they share, offering the parent's other children along with what was read. Both boundaries now withhold; POSIX is untouched (a trailing dot there is an ordinary filename character) and the UNC anchor keeps its exemption (a host's trailing dot is DNS, not the client-side strip).
|
|
70
|
+
- **A device namespace is never named** (adversarial round 5). The validator accepted `\\.\C:\Windows`, which the read boundary's own namespace screen refuses outright and which does not even contain itself — a deployment-supplied probe could advertise a grant the boundary can never match. It now consults that one screen; the screen's other verdict (a spelling it would rewrite) needs no test, because every such spelling is one the fold check already changed.
|
|
71
|
+
- **Reach: zero decision moved.** Advisory display metadata, read by nothing in the engine; the ask's prose, the rule-offer door, the shadowed-rule disclosure and the deny tier are all byte-unchanged, and the deny tier is judged by pattern rather than by root so no grant reaches it. WRITE face and the DURABLE PARK row are out of scope in this version — a parked out-of-root ask renders the confirmation alone, as before.
|
|
72
|
+
- Pins: `test/backlog803-read-root-candidate.test.ts` (28: the arm table in both directions, both probe seats, the closed loop for every offered command on both seats, the deny tier under the widest possible grant, the gate's carry and its refusal to merge onto someone else's ask, the validator's refuse-don't-repair posture, and an end-to-end negative control over the whole `AskRequest` member list).
|
|
73
|
+
|
|
74
|
+
### Fix — adapter truthfulness: a stream that carried text and then ended without its finish frame is a torn stream, not a clean `stop` (#805 adversarial rounds 5–6; every run; @server @cli @test)
|
|
75
|
+
- **Before.** All THREE stream parsers marked only the EMPTY torn stream as an error — openai (no content, no `finish_reason`), anthropic (no content, no `message_delta.stop_reason`) and the Responses adapter (no content, no terminal `response.*` frame); a body that closed cleanly after a `No` delta with no finish frame finalised as a clean `stop` with no `partialFinalized` — indistinguishable downstream from a complete answer. The auto-mode classifier's bare-answer arm read such a cut `No` (of a `No, this must be blocked`) as ALLOW and executed the gated tool (reproduced through the real openai brain).
|
|
76
|
+
- **After.** All three parsers stamp `partialFinalized: true` and the CC-verbatim `Connection closed mid-response. The response above may be incomplete.` on content-without-finish — the same marker and the same words the design/124 mid-stream promotion stamps when the body reader throws (one fact, one word; the sentence is now single-sourced in `stream-shared.ts`). Consumers already in place act on it: the loop's bounded continue nudge (`partialFinalized && stopReason === "stop"`), and the classifier's tag-only rule (the cut reply is re-asked, never a bare verdict).
|
|
77
|
+
- **Reach (behaviour face, every run).** A gateway that ends a stream with `[DONE]`/EOF but never sends `finish_reason` (openai form), `stop_reason` (anthropic form) or a terminal `response.completed`/`.incomplete`/`.failed` frame (Responses form) now reads as incomplete on every turn — the conforming shape sends one, and this is the fail-closed direction (an incomplete turn is marked incomplete; a complete turn is untouched). The mid-stream promotion path (body throw) is unchanged: the parser's own finalize under `truncated: true` leaves the stamp to the facade, so the warning is never doubled.
|
|
78
|
+
- **Round 6 (an independent review of the rebased tree), two shapes on the SAME wire.** (i) Round 5 had said "both parsers" and patched two of the three: on the Responses wire a cut `No` still finalised as a clean `stop` and the gated tool RAN. (ii) The Responses wire can also declare a turn unfinished WITHOUT dropping the connection — `response.incomplete` whose `incomplete_details.reason` is absent, null, or a word this client does not map (`max_output_tokens` ⇒ `length` and `content_filter` ⇒ the refusal arm are the two it does) — and that terminal SETS the status the first arm keys on, so it read as a clean `stop` too. The provider's own verdict outranks the absence of its metadata: same `partialFinalized` marker, its own honest sentence (`The response above may be incomplete: the endpoint reported status "incomplete" with no reason this client recognises.` — nothing was dropped, so the torn-stream words would misdescribe it). The SAME terminal with nothing delivered joins the empty-torn arm instead: `stopReason: "error"` with the existing `[stream_torn]` sentence naming which shape it was (no new error code — the outcome is the same, the response was lost), which is what the two mapped reasons already do (`max_output_tokens` ⇒ `length_empty`, `content_filter` ⇒ the refusal arm).
|
|
79
|
+
- Pins: `test/backlog805-adapter-torn-stream.test.ts` (all three brains, torn vs whole; the four reasonless `incomplete` shapes with content and the empty one without, `length` / `content_filter` / `completed` as controls; the real openai brain and the real Responses brain through the Runner as the classifier — the cut `No` is re-asked and the block stands, the whole `No` allows).
|
|
80
|
+
|
|
81
|
+
### The auto-mode classifier on single-model deployments — the seat learns from the reply, the reader accepts a bare answer, the card names the model (#805; ruling C-R44; @server @cli @test; deployment operators)
|
|
82
|
+
- **Before.** A catalog entry with no `reasoning`/`compat` declaration was read as "a model that never thinks" (`thinkingOffExpressible` answered `true` for a falsy `reasoning`), so the classifier ran with `reasoning:"off"` and a 256-token cap while the wire carried NO thinking key at all — the gateway's default governed, and three of four live gateways think by default. A provider error (`stopReason:"error"`) was read as a tagless reply. The parse-failure card never said which model, nothing said which model stood in for the missing `classifier` role, and the rung deadline was a sema-invented 15 s where CC runs 60 s.
|
|
83
|
+
- **After — seat.** `thinkingOffSpelled(model)` (renamed from `thinkingOffExpressible`, **BREAKING** on the export face; no sibling repo imports it) is true exactly when the wire will CARRY a disable key; a falsy `reasoning` spells nothing and reads `false`. Off spelled ⇒ `off` + 256 (unchanged); otherwise `low` + 256 + 2048 (CC 2.1.259 `MU`: spell off, or leave thinking to the provider and pay 2048). One rule, the special arm gone.
|
|
84
|
+
- **After — re-ask.** A first attempt whose reply is MISSING a verdict (empty at the cap, or text with no answer in it) is asked ONCE more on the THINK seat (`low` + 256 + `AUTO_MODE_CLASSIFIER_THINK_BUDGET_TOKENS` 8192, CC's stage-2 room), unless the run's signal is already aborted. A CONFLICTING reply (two `<block>` answers) is never re-asked; an empty reply with a non-cap stop stays CC's refusal arm. `parse_error` carries its form (`AutoModeParseFailure`: `missing` | `conflict`; a new closed set, internal). Each attempt's user message ends with CC 2.1.259's own suffix (`CLASSIFIER_ATTEMPT_SUFFIX.first` = the fast-mode "your ENTIRE response MUST begin with <block>", `.reask` = the stage-2 "use <thinking> first") — live 09-15 the first suffix took a thinking-off Qwen 3.8 from 70% tagged replies to 90% at 8 output tokens.
|
|
85
|
+
- **After — reader.** `parseAutoModeResponse` strips `<think>…</think>` beside `<thinking>…</thinking>`, and when NO `<block>` tag is present reads a reply that IS a bare answer — the whole thinking-stripped text, nothing else: `no` / `No.` / `**no**` / `block: no` / `Answer: yes` / `Verdict = NO` (a closed spelling set; a question followed by its answer, two words, a `?`, or prose with a bare `no` in it is still `parse_error`). Divergence D-805-1 (CC reads the tag only), narrowed from the ruling's "any yes/no" on live evidence: the last-line form read a prose `no` under a soft-block conclusion as ALLOW. A TAG-ONLY reply — a `length` stop, a stream cut (`partialFinalized`), a tool-calling reply (`toolUse` stop or a tool-call block; the classifier has no tools) — never reaches the bare arm: the leg judges it with `parseAutoModeResponse(text, { bareAnswer: false })` (additive optional parameter) and hands the decider an empty text unless a `<block>` tag ruled, so a cut `No` is re-asked, never allowed (the adapters now mark every such cut — see the adapter-truthfulness fix above).
|
|
86
|
+
- **After — attribution.** A reply with `stopReason:"error"` (other than the cap cut `length_empty`) or `"aborted"` is thrown by the leg and becomes `unavailable`/`error` (ladder cause `error`, the "temporarily unavailable" card), as the sibling side-channel legs already read it.
|
|
87
|
+
- **After — the card.** `classifierParseFailureDenyMessage(attribution?)` replaces the constant `CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE` (**BREAKING**, export face): with a ladder wired the tail reads `— the classifier model "<modelUsed>" replied without a verdict.`; the CC stem is unchanged and stays a prefix. Divergence D-805-2.
|
|
88
|
+
- **After — role default.** No `classifier` role that NAMES a model (`roleSpecNamesModel`, exported from `roles.ts`: a string ref, a Model object, `{model}`, `{select}`; a preset-only `{thinking}` does not) ⇒ with tiers declared and a main model on a tier, the classifier is the nearest tier strictly BELOW the main's (over the Runner's expanded tier keys; the main's tier = the lowest its route sits on; a different route required), else the main itself; a main bound to no tier keeps the static `flash` row; no tiers keeps the `summarize → default` chain. New notice `classifier.role_defaulted` (operator, once per run, after the route pre-flight; `detail: { model, reason ∈ tier_below_main|tier_table|role_fallback|main_only|role_unresolvable, thinkingOffSpelled }`; a role naming a model the catalog cannot resolve falls back to the main model as the old leg did, now disclosed as `role_unresolvable`). Divergence D-805-4. The `roles.side_channels_deployment_only` sentence is narrowed for this one seat: the classifier default follows the run's main model.
|
|
89
|
+
- **After — loud.** New notice `classifier.contract_broken` (operator, once per run per ladder RUNG — keyed by position and id, since two rungs may share a wire id; `detail.rung`): the same rung answered `AUTO_MODE_CLASSIFIER_CONTRACT_BROKEN_AFTER` (2) consecutive classifications without a verdict (each already re-asked); a ruling from it resets the count, an `unavailable` round neither counts nor resets. Fires before the denial limit's third block hands the user a card. Both codes join `ENGINE_NOTICE_CODES` / `NOTICE_AUDIENCE` (**BREAKING** for a total table over the set; an unknown code must never be fatal to a consumer).
|
|
90
|
+
- **After — timeout (behaviour face, deployment operators).** `AUTO_MODE_DEFAULT_TIMEOUT_MS` 15_000 → 60_000 (CC 2.1.250 `oue` = 2.1.259 `j7e`, the per-attempt base). The deadline bounds ONE RUNG — both attempts, the transcript read, the credential resolution — so the worst-case walk is 60 s × 2 rungs = 120 s. An explicit `RunnerDeps.autoMode.timeoutMs` is unchanged. Arming recipes now MATERIALISE `timeoutMs` (a recording states its bound), and a recording that carries none — one written before this version — folds as the 15 s it ran under (`AUTO_MODE_RECORDED_ABSENT_TIMEOUT_MS`; contract `auto_mode.arming.absent_timeout_reads_recorded_default`): the wider default reaches NEW recordings only, never an ancestor's bound at redemption.
|
|
91
|
+
- **Contract sentence** `classifier.budget_is_room` (on `classifierRungSeat`): every classifier output cap is ROOM, never a bound the engine can vouch for — a gateway may exclude reasoning tokens from `max_tokens` (measured), so an over-long think is cut by the round-trip timeout (`unavailable`/`timeout`), not by the cap (`parse_error`), and the two are attested as two different facts.
|
|
92
|
+
- **After — the seat is the model that was RESOLVED (round 6).** Route identity (`id` + base URL) names the REASON the ladder folds to one rung; it never substitutes the `Model` object. Two catalog entries may match on it while carrying different headers, credential, api family or `compat` — and `compat.thinkingFormat` is what decides this seat's reasoning word — so the resolver returns the model the role chain resolved and only labels it `main_only`.
|
|
93
|
+
- **Not changed.** Verdict semantics of a tagged reply; the denial limit; the ladder's walk rule; the classifier system prompt (SHA-locked asset) — the suffixes ride the user message.
|
|
94
|
+
- Pins: `test/backlog805-classifier-robustness.test.ts` (seat predicate and seats through the real Runner; the re-ask's trigger, exceptions and suffixes; the reader's bare-answer grammar with the live false-allow counter-example; error-stop attribution; the tier-relative seat incl. a main bound to two tiers; both notices; the card). Re-pinned: `backlog661-classifier-cap-retry` (the re-ask lands on THINK; a missing text is re-asked, a conflict is not), `backlog618-*` (an undeclared model is on the `low` seat), `backlog742` (the seat is asked twice before the walk moves), `design503-parked-automode-arming` (materialised and legacy recordings). Design note and live tables: the design note lives in the internal design register (`core/design/805-classifier-robustness.md`; not part of this repository).
|
|
95
|
+
|
|
96
|
+
## 7.18.0 — 2026-09-15
|
|
97
|
+
|
|
98
|
+
### The WRITE face becomes a declarable containment seat — `writeFace: "open" | "roots"` (#748; @server @cli @test)
|
|
99
|
+
- **Before.** Containment had exactly one declarable seat, `readFace`. The write band (`edit_file` / `write_file` / `notebook_edit`) was fenced to the task roots unconditionally: a deployment whose own permission model has a mode that does not fence writes had no way to say so, and the band answered a write outside the roots with `path_not_in_root` while the full `bash` — which was never fenced — performed the same write. The boundary therefore stopped half the tools and none of the effect.
|
|
100
|
+
- **After.** `TaskSpec.writeFace`, `RunnerDeps.writeFace` and `HandsToolkitOptions.writeFace` are the write band's own containment seat, resolved by the SAME order the read seat runs — one resolver body, five rows, two dialects: read-only (verifier) mount → roots (a task-seat `"open"` refuses loudly, a deployment-seat `"open"` clamps and is announced), org governance (the task layer may only tighten; the deployment seat may still open), explicit spec ?? deps, bash-less mount → roots, default roots. Absent ⇒ `"roots"`, i.e. today's behaviour byte for byte.
|
|
101
|
+
- **The two seats are INDEPENDENT and that is the contract** (`fs.containment_face.seat_independence`): each band hands its OWN resolved word to the shared roots-containment step, a seat nobody declared resolves to `"roots"` on its own, and no other gate is touched by either word. A wide read face widens no write; a wide write face widens no read. Both directions are pinned.
|
|
102
|
+
- **`"open"` skips the roots-containment step and NOTHING else.** The write-side sensitive-path policy, the blocked-device / special-file gates, the UNC out-of-set refusal, the read-before-edit requirement and the deployment's approval policy all run exactly as before, in both faces.
|
|
103
|
+
- **No permission-mode vocabulary enters core.** A deployment that wants one of its own modes to mean "writes are not fenced" translates that mode into this seat itself; core reads a word, never a mode.
|
|
104
|
+
- **The seat travels the delegation subtree on the read seat's laws, on its own axis**: `tightenTaskSpec` refuses `roots → open` and folds the stricter value; the delegation carrier (`ToolExecuteContext.writeFace`) ships only the tightening value `"roots"`; the subagent wake clamp (`writeFaceRoots`), the workflow lane (`parentWriteFace`, stricter-wins), the advisor legs and the verify gate's follow-on legs all carry it beside `readFace`. The governed-workflow whitelist admits `writeFace` only as `"roots"`, announcing anything else as a stripped non-tightening value.
|
|
105
|
+
- **BREAKING (type-only, the containment family is renamed).** `ReadFace` → `ContainmentFace`, `ReadFaceInputs` → `ContainmentFaceInputs`, `resolveReadFace` → `resolveContainmentFace`, `deploymentReadFaceClampNotice` → `deploymentFaceClampNotice(kind)`; the module `tools/fs/read-face.ts` → `tools/fs/containment-face.ts`. One type, one order, two seats — no alias is kept. The seat KEYS (`readFace` / `writeFace`) and every wire shape that carries them are unchanged. New on the surface: `ContainmentFaceKind`, `ContainmentFaceLayer`, `executionEnvironmentBlockFor`.
|
|
106
|
+
- **New engine-notice codes** (registered in `ENGINE_NOTICE_CODES` / `NOTICE_AUDIENCE`, audience `operator`): `config.write_face_swapped` and `config.write_face_deployment_clamped` — separate codes rather than one code with a face field, because the audience table and every consumer table are keyed BY CODE. `writeFace` joins `SWAPPABLE_DEP_SEATS`, so `Runner.swapDeps({ writeFace })` is a live seat like its twin. **@server:** `swapDeps`'s accepted key set is no longer `models | tiers | readFace` — a hand-written screen of those three words now rejects a legal seat.
|
|
107
|
+
- **Checkpoint (additive, no version stamp).** `CheckpointState.writeFace = { face }` is written only for a non-default (`"open"`) posture and folds stricter-wins on resume. It carries NO version stamp on purpose: an engine that cannot read it resumes FENCED, which is strictly stricter — the opposite direction from the read section, whose lost deny additions would widen a resume. A present-but-malformed section refuses pre-CAS as `write_face_damaged` (new `CheckpointErrorReason` member; no `_forged` twin — absence is legal at every version).
|
|
108
|
+
- **Prompt (truthful, and silent under roots).** The `# Execution environment` first bullet is now a 2×2 table over the two faces; the two historical renderings (roots/roots and open-reads/roots) are byte-identical and pinned against frozen literals on both the legacy and the composer face. `# Environment` gains a `File writes are not confined to the workspace root.` line that renders only under `writeFace: "open"`. `TaskResult.effectiveWriteFace` observes the resolved seat beside `effectiveReadFace`.
|
|
109
|
+
- **Loud-value law, both faces (`config.read_face_invalid` / `config.write_face_invalid`).** The rejected value in the refusal text goes through a TOTAL describer (`describeRejectedValue`, the one already used by the tool-search and defer seats): `writeFace: 1n` or a circular object used to replace the coded refusal with a bare `TypeError` (the code lost), and a symbol or function seat used to read back as `value undefined` — the one value this seat legally accepts.
|
|
110
|
+
- Pins: `test/backlog748-write-face.test.ts` (the five rows on the write seats with their own codes and seat names; the four total-describer shapes on both faces and both seats; the three write stations under `"open"` including the read-before-write gate, the device refusal's two-face identity and the UNC out-of-set refusal; the roots face proven unchanged on all three tools; seat independence in BOTH directions; the tighten law; the frozen prompt bytes; and the REAL loading path — `TaskSpec.writeFace` through Runner prepare into the mounted band, plus the deployment seat, the loud bad value on the hands and hands-less legs, and both clamp lines announced on their own ledgers; the persisted section's `write_face_damaged` pre-CAS refusal with its absent/legacy negative controls; the resume FOLD on each leg separately — hands and hands-less — against a deployment that flipped open between the legs; and the `writeFaceRoots` wake clamp end to end, sender seat → revived child).
|
|
111
|
+
|
|
112
|
+
### Refactor — ONE program-position reader, ONE shell lexer in two modes (#780 / #753; #781 contract clause; @test)
|
|
113
|
+
- **Before.** Six places in the read faces each read "which word is the PROGRAM, what launcher chain and options stand before it" with a loop of their own (the compound walk's control-word strip, `unwrapLauncher`, the refused-command whitelist's head scan and its second chain walker, the single-command face's option skip, the poll loop's base-move check); every launcher spelling was a six-place edit. The refused-command whitelist tokenized its input with a SECOND parser beside the strict lexer — quote-blind segmentation and word split, line-based here-documents, its own substitution walker, and a three-arm guard (`whitelistReadsTheSameWords`) naming what it could not read.
|
|
114
|
+
- **After.** `src/tools/fs/bash-program-position.ts`: `readProgramPosition` is the one reader (control words and their declared options, leading assignments, the launcher chain, the program, or a declarative `refusal` of a closed set minted into one sentence); `LAUNCHER_TABLE` is the one launcher truth (membership = keys; each row declares option arity, consulted for the launcher the line itself names — the keyword in front or the written word — never for one nested behind another, the pinned conservative asymmetry). `src/tools/fs/bash-lexer.ts`: `lexShellText` is the one lexer; the strict mode is byte-identical to the former connector scan + word split, the lenient mode records what the strict gate refuses (heredoc bodies as data, substitution bodies as commands to judge, redirections, comments, escapes, line continuations, subshell groups, backgrounding, line breaks). The whitelist reads that stream; the second parser is deleted. Every consumer of the strict faces is unchanged in behaviour (a pristine-vs-tree diff over every public face on a 10 556-string corpus: the only compound-walk differences are the refusal sentences below and the path-prefixed `time` row).
|
|
115
|
+
- **Reason sentences (wire prose).** The two former wordings of "an option where the program would be" (`… is given an option before the program it runs …` for a chain, `… is given an option where the program it runs would be …` for a keyword) are one sentence naming the owner and the option: `"<owner>" is given the option "<opt>" where the program it runs would be — which program runs is not readable without that word's option table — not auto-allowed`; the control-syntax refusal keeps its wording with the same owner rule. No test pinned the old strings.
|
|
116
|
+
- **Whitelist behaviour deltas — each is "the lexer reads what bash reads"; no delta widens onto a read bash performs, each is pinned before/after in `test/backlog753-whitelist-on-lexer.test.ts`:** (a) quoted whitespace, connectors, operators and `#` no longer split words or segments (phantom words gone); a word is bounded by bash's blanks alone (a carriage return, a vertical tab, a non-breaking space are word characters), and a `${…}` expansion is one piece of a word when its braces carry only plain text (`${PORT:-3000}`, `${#name}`) — an expansion whose default text carries a substitution, a process substitution, a nested expansion, a brace or a continuation is one the lexer does not read, so the command is not exempt (`echo ${x:-$(pwd)} > out` asks mandated where the old cut-and-judge reading exempted it: the conservative choice, stated); (b) backslash escapes and line continuations read as bash does — a continuation is removed at the point of reading, everywhere bash removes it (never inside single quotes, a comment or a quoted here-document body), so `$\<newline>(…)` opens a substitution and `echo hi \<newline>> out.txt` keeps its exemption (a pinned over-fire, retired), `.np\<newline>mrc` still hits the deny row as `.npmrc`; inside backticks a backslash protecting `$`, a backtick or a backslash is removed as bash removes it, so `` `echo \$(…)` `` carries a live substitution; `>&word` is read after the whole operand (a number, `-` or `1-` duplicates a descriptor; anything else is the file form); (c) a here-document delimiter is the whole quote-aware word, its terminator is the exact line (`<<-`: tabs stripped) with `\<newline>` joined inside an UNQUOTED body as bash joins it; (d) a launcher chain that names no program (`env`, `sudo`, `exec 3< f`) is judged like an unlisted segment and moves no base (`echo x > f; env` is the boundary's non-objection, `exec 3< in-root` keeps its exemption); (e) a redirection standing between a launcher and its program no longer breaks the chain (`env >out cat secret` is read as `cat secret`: a listed reader's operand, mandated like the bare spelling); (f) `(`/`)` are bash tokens, `{`/`}` are structure only as whole words; (g) redirection words leave the interpreter/script-slot argument list; (h) an unbalanced quote follows the strict faces' quote-blind fallback; (i) a comment INSIDE `$(…)` runs to its line end before a `)` can close the substitution (`$(echo # )⏎cat /etc/passwd⏎)` is judged — the old walker closed on the commented `)`), a `${…}` body inside it is skipped whole, and a body carrying a `case` pattern or a here-document is one this lexer cannot delimit — not exempt (the old walker closed on the pattern's `)` and its quote-blind split then caught the tail by accident); (j) an assignment whose value spells a substitution literally (`a='x[$(…)0]'`) is not exempt, wherever the assignment stands (an arithmetic subscript expands it later); (k) `<(…)` is a process substitution only unquoted and only outside a here-document body; (l) a nine-deep launcher chain no longer hides a `cd` from the whitelist's base-move reading (the old second walker stopped at eight).
|
|
117
|
+
- **Single-command face (`classifySimpleCommandReadBoundary`), inputs its precondition excludes** (a first word that is not a bare allowlisted name): an option-shaped first word now refuses whole; a control word or assignment in front is read past to the program for the argument-as-program family; a path-prefixed or quoted `time` reads its own options; `time time -p …` reads both. None is reachable through `bash_readonly`, whose coarse check admits only a bare listed name.
|
|
118
|
+
- **Retired:** `withoutHeredocBodies`, `heredocHeaderOf`, `splitSubstitutions`, `whitelistReadsTheSameWords`, `headIndex`, `runProgramOf`, `unwrapLauncher`, `launcherOptionSpan`, `LAUNCHER_OPTION_GRAMMAR`, the hand-kept `COMMAND_LAUNCHERS` set, `splitWordsQuoteAware`, `foldQuoteRemovalToken`, `withoutRedirectionWords`. Public surface: `ShellSegmentConnector` and `NOT_AUTO_ALLOWED` are re-exported from their new homes (same names, same kinds); nothing removed.
|
|
119
|
+
- **#781 (contract clause, no behaviour change).** `@contract shell.read_boundary.friction_floor` states the program-file slot of an unlisted verb (`awk -f prog.awk`, `jq -f`, `python3 script.py`) is vouched as the in-root operand it is — the floor's stated reach; the safety boundary is the sandbox.
|
|
120
|
+
- Pins: `test/backlog780-program-position-reader.test.ts`, `test/backlog753-whitelist-on-lexer.test.ts`; one existing assertion re-pinned (`test/backlog758-unlisted-reader-boundary.test.ts`, the continuation over-fire, now exempt).
|
|
121
|
+
|
|
122
|
+
### Feature — `tool_progress`: one liveness frame for every running tool call, and the shell yields the turn when a person's message is waiting (#741; @server @cli @test)
|
|
123
|
+
- **Before.** `ExecutionEnv.exec` settles once, so a five-minute build occupied a turn while the only thing a shell had to render was the unchanging `tool_start`. Nothing on the wire said a call was still alive, still less what it had printed; a person watching a long command had a spinner and no facts. And a message typed while that command ran waited for the command, because the turn boundary it would be delivered at was behind the exec.
|
|
124
|
+
- **After — ONE frame, two cadences, one mint.** Additive `TaskEvent` member `tool_progress{toolCallId, toolName, elapsedTimeSeconds, output?, totalLines?, totalBytes?}`, minted between a call's own `tool_start` and `tool_end`, on the run's own queue and under the same ordering law as every other content frame. The SHELL ticks about once a second, carrying the last twenty lines (and at most 16 KiB) of what its command has written plus the running totals; EVERY OTHER in-flight tool call is beaten every 30 s with the three output keys absent. The output keys ride or stay away TOGETHER, and their absence is a fact rather than a gap: this call has produced nothing THROUGH THIS ENVIRONMENT — it printed nothing, or the environment's `exec` is buffered and never streams.
|
|
125
|
+
- **NO new `exec` seat, in either half.** The streaming callbacks (`onStdout`/`onStderr`) and the mid-run adoption signal (`detachSignal` → `ExecResult.detached`) already existed; this work uses both and adds neither. A custom `ExecutionEnv` implementer has nothing to implement, and an environment that cannot stream degrades to elapsed-only ticks with no capability declaration and no `instanceof` anywhere — it simply never calls the callbacks it was handed. With no progress seat wired at all (a standalone toolkit build), the exec options are byte-identical to what they were, which is pinned directly rather than described.
|
|
126
|
+
- **The shell yields the turn (CC 2.1.259 `backgroundedToDeliverMessage`).** On the SAME beat, an armed command asks the harness whether a person's input is waiting; if it is, the engine fires the EXISTING detach verb — the very controller ctrl+b aborts — and the command is adopted as a background task so the message is delivered this turn instead of after the command. The receipt says which of the two engine reasons it was, because the model must not read a queued-message adoption as a timeout: "still running and was moved to the background so a queued message could be delivered … It was NOT stopped and nothing about it failed".
|
|
127
|
+
- **The trigger widens the armed set by ZERO.** It reads the same predicate the self-timeout arm reads — the deployment's explicit opt-in (`RunnerDeps.hands.autoBackgroundOnTimeout`, default `false`, which sema's own Runner still does not set) AND this command's own eligibility (`canAutoBackground`). A deployment that opted in for one trigger has opted in for both; there is deliberately no second knob, because "may the engine move this command to the background" is one question and answering it twice is how two triggers drift apart. The field's NAME is older than its second trigger — rename tracked for the next BREAKING wave (#798).
|
|
128
|
+
- **The predicate is a PERSON's input, not the queue's depth.** The harness's read face counts only what a person sent (steer and follow-up, queued or in flight), never an engine-authored reminder — stepping a build aside for a reminder nobody is waiting on would fire on nearly every long command — and an unwinding run answers `false`, because a teardown delivers nothing.
|
|
129
|
+
- **Live only.** Core never persists the frame (no session-tree entry, no checkpoint state, no resume replay) and nothing reaches the model, no gate, no policy, no memory path. A durable record of a long call keeps `elapsedTimeSeconds`; `output` is untrusted process bytes bounded for a pane, neither complete nor stable enough to store (`@contract tool_progress.ephemeral`). A child's frames reach a parent's pane under the SAME opt-in as its `tool_start`/`tool_end` (`forwardSubagentEvents`), and not otherwise.
|
|
130
|
+
- **One clock, one cadence, one mint.** `elapsedTimeSeconds` is stamped by the LANE, from one origin per call (the call's entry into execution), because the two producers do not start their clocks at the same instant — a shell command's own clock could only start at its spawn, after the gate and the boundary recheck, so with two origins a 30 s heartbeat could be followed moments later by a shell tick reporting 1 s. The per-call cadence floor is enforced there too: a producer's own timer is a SAMPLING clock, and the contract holds across producers rather than inside each one. A call the harness station never registered (a resume leg executing an approved call, a tool band mounted outside a Runner) registers on its first frame, so it has an origin like any other.
|
|
131
|
+
- **This file's own cwd epilogue never reaches a pane, or the totals.** The shell appends a marker+path epilogue to stderr while the command's stdout keeps arriving, and the liveness window merges both; a merged-text search for the marker fails three ways (stdout landing between two marker fragments hides it, cutting at a found marker discards later stdout, and byte eviction can drop the opening marker while keeping the payload). It is removed on the stderr stream alone by a stateful chunk filter, before the merge and before the byte and line counts — and only on the leg that appends one.
|
|
132
|
+
- **A dying child is no longer adoptable — one latch at one door (behaviour face, and it predates this work).** The environment's adoption door checked only whether the exec had settled, which is false for the whole grace window after a kill is issued; adopting there hands the caller a shell id for a process the outstanding kill is about to reap and reports it as "still running, it was NOT stopped". The timeout arm already refused on that condition through a latch of its own, and the fallback it takes when adoption is refused (background capacity full) set no latch at all. There is now ONE latch meaning "a termination of this child has begun", set by every kill path and read at the single door, so **every** detach trigger answers the same way — including a person's mid-flight `ctrl+b`, which previously could succeed falsely during a kill and now refuses. This engine's own trigger is what made the window reachable (it samples it once a second instead of racing it once), so the fix is at the door rather than in the trigger. The latch is read on BOTH sides of the adoption's first act, because that act is not inert: the door hands the environment's unread output to the caller's streaming callbacks so the background entry starts where the foreground one stopped, and a callback that throws is a `callback_error` — it kills the command from inside the door. A latch read taken only on the way in was stale by the adoption below it, and the exec then settled `ok: true, detached` for a process it had just signalled; the timeout arm's fallback no longer re-kills a tree whose death it started that way either.
|
|
133
|
+
- **Not fixed here, and filed: #802.** The SUBAGENT-forwarding sink (`RunInternals.onForwardEvent`) is invoked bare at five sites, and it carries the same `=> void` type that an async function satisfies — so a rejecting transport there ends the worker. Reproduced on this branch with a sink that rejects only on `tool_start`, a type the engine already forwarded before this work and with zero `tool_progress` frames in play: the hole is the sink contract's, engine-wide, and predates this ticket. Patching the one line #741 touched would have made the rule true for one event type's path and false for the other five.
|
|
134
|
+
- **The trigger reads the lane a turn boundary actually SERVES.** "Is a person's input waiting for the next boundary" was counted over BOTH input lanes, and the follow-up lane's contract is the opposite: a `"later"` steer rides it precisely because it is delivered at the run's natural would-otherwise-stop seat, not at a boundary. Backgrounding a running command cannot move such a frame one instant closer — and because the frame stays queued, the predicate kept answering yes, so the NEXT armed command stepped aside too, and the one after: a queue of detached commands with nobody served. The trigger now reads the STEER lane alone; the undrained-input account still reads both, because "accepted and never consumed" is a different question.
|
|
135
|
+
- **The observer seat is fault-contained in BOTH shapes, at ONE door.** The seat is declared `(body) => void`, and in TypeScript an ASYNC function satisfies that too — so "the display sink failed" has a second shape the synchronous guard could not see: a rejected promise, which reaches the process's unhandled-rejection exit and ends the worker over a frame nobody needed (reproduced on the real process: two unhandled rejections per command). Both shapes are now absorbed at a single helper every seat on this path goes through — the full shell's, the read-only verifier leg's, and the producer's own tick — so the containment is one rule rather than one per face, and delivery is still never awaited. Synchronous behaviour is unchanged: a throwing sink still never reaches the environment's output callback, where a throw is a `callback_error` that kills the command.
|
|
136
|
+
- **A command that turns on shell TRACING no longer loses its `cd`, and no longer leaks the marker.** `set -v` echoes every line as the shell READS it and `set -x` every command as it EXPANDS it, both to stderr — so a command that turns either on made the shell print this leg's own epilogue, marker and all, before the epilogue ran. That echo carries the delimiter TWICE, and the capture took the first pair it found: its payload is the unexpanded `' "$(pwd)" '`, which fails the form check, so the tracked directory silently stayed where it was while the real record remained in the stderr a person and the model read. Both halves are fixed and each is pinned alone, because either one masks the other: the epilogue now silences tracing on its OWN line before it runs (verbose echoing happens as each line is read, so the epilogue proper is never echoed; `$?` is captured on the line before, and the group's own redirect swallows xtrace's trace of the `set`), and the capture takes the LAST marked span and removes EVERY one — which is the rule the streaming filter already used, so the two readers of this delimiter now agree instead of differing. Measured before the fix on the real shell: `set -v; cd "sub dir"` and `set -x; cd "sub dir"` both returned exit 0 with the directory untracked and the sentinel in the output.
|
|
137
|
+
- **The working-directory epilogue marker is minted PER EXECUTION.** It was a fixed constant, which made it a delimiter the command could also print — and a command that printed it decided where the engine believed its working directory was, and (with the new pane) swallowed the stderr behind it. A fresh suffix per call removes the class from the capture as well as from the pane; the filter's overflow branch restores the whole rejected candidate, opening marker included, rather than silently editing the command's stderr.
|
|
138
|
+
- **The lane's clock is monotonic.** Every predicate here is a difference of two readings, so a backward wall-clock step — an NTP correction, a container's clock settling after a resume — made each difference negative and silenced both producers until it caught up. A resumed approved call, which this engine executes directly and closes with its own `tool_end`, now registers and retires in the lane around that execution instead of relying on a later turn boundary.
|
|
139
|
+
- **Divergence from CC (PENDING ratification).** CC 2.1.259 ships two frame shapes for this — a generic 30 s `tool_heartbeat` and the shell's own `bash_progress`. This engine keeps CC's cadences (and CC's 30 s constant) but mints ONE shape, so the observable behavior matches and only the frame count differs; a consumer branches on the presence of `output` rather than on a frame name.
|
|
140
|
+
- Pins: `test/backlog741-tool-progress.test.ts` (39 cases; the fake-clock arms name `performance` in `toFake` explicitly, because the lane's clock is monotonic and the default set does not patch it — measured: the same pins passed on one host and produced zero frames on another — cadence, monotonic elapsed, both tail bounds, the silent command, the non-streaming environment, the sub-interval command, the byte-unchanged negative, the read-only leg, the epilogue split across streams and interleaved with real output, a throwing sink, the heartbeat's one rule, the single origin, the central cadence floor, the unseen-call registration, five auto-background negative controls including an aborted call inside the kill window, the real-Runner stream with the ordering claim, the ephemerality claim, the forwarding claim in both directions, a handover that kills the command mid-adoption, an async sink whose promise rejects on each shell face counted against the real process, a queued `later` steer that must NOT make a command step aside beside a plain steer that must, and the two tracing arms pinned separately — the command the shell is handed carries ONE marker pair, and a reader facing an echoed epilogue anyway still takes the last).
|
|
141
|
+
|
|
142
|
+
### BREAKING — a pre-`parks` run record has a resume path, and a record whose admission never completed says so (#755 · clay ruling C-R29 "走 a″"; @server @cli @test)
|
|
143
|
+
- **Before.** The resume admission refused ANY prior record whose `parks` array was absent (`workflow.park_truth_unreadable` / `parks_unreadable`). Absence is a real state — a record written before the field existed (the park lane predates `parks` by several minors), or one a store's projection stripped — and the blanket refusal left a deployment no migration path: every workflow run parked before the upgrade was unresumable, decidable only through the approval channel. The same word also covered a record whose OWN admission had refused, so two unrelated states wore one refusal.
|
|
144
|
+
- **After (which key cache speaks is decided once, and none of them is believed).** Three arms, in one rule: ① the record CARRIES `parks` ⇒ that list is its word ALONE, exactly as before (an `agents` array a size degrade emptied beside a present list is a degrade artifact, never "no park", so the rows are not read as a second opinion); ② the list is ABSENT ⇒ the candidates are derived from the union of the journal's `parked` arm and the `parkedCheckpointToken` of the record's own `parked` agent rows, and that union must come back NON-EMPTY; ③ the list is ABSENT and either the journal yielded nothing or the union came back empty ⇒ still refused (`parks_unreadable`, narrowed to exactly those conditions). Zero positive evidence is not evidence of zero, twice over: an empty journal load is engine-indistinguishable from a pruned or unreadable one, and an empty union does not mean "no park" — the two derived caches only ever hold a park the run MADE, so one it INHERITED and never reached (a successor whose script returned before the parked ordinal) is recorded in the missing list and nowhere else. Admitting there would spawn live beside the pinned child, which is the whole defect #652 exists to prevent (reproduced in the adversarial round before it was closed). Where two caches speak for one ordinal they must agree on token, session and call (`workflow.park_binding_broken`) — about what each of them actually NAMES: a `parked` row the record carries WITHOUT its optional token or session (the shape the oldest records and a stripping projection both leave) names the ordinal and no key, so it is no second opinion to disagree with and never displaces a cache that has the key, while an ordinal NO cache can key is refused at the read rather than replayed live. An ordinal the prior's own journal settled is dropped as before. **Every derived candidate is proven exactly like a recorded one — one `checkpointStore.get(token)`, whose word is the truth** — so a derived key buys discovery and no trust: an absent row, a row bound to another session, and a resolved / expired / reaped checkpoint refuse or settle precisely as they do for a `parks` entry. An `agents` row is POSITIVE evidence only; it is never read as evidence that there is no park.
|
|
145
|
+
- **BREAKING — closed set gains a member.** `PARK_TRUTH_UNREADABLE_REASONS` (`ParkTruthUnreadableReason`, `detail.reason` of `workflow.park_truth_unreadable`) mints **`admission_incomplete`** (6 members → 7; every existing member keeps its meaning). It arrives when the prior record carries `WorkflowRun.resumeAdmissionIncomplete`, and its remedy sentence differs from every other arm's: resume the run IT resumed. `parks_unreadable` is NOT retired — its condition narrows to arm ③ above, so a consumer's existing branch stays live but fires on fewer records. The mint-site floor in `docs/CLOSED-SETS.md` moves 6 → 7 with the new site.
|
|
146
|
+
- **BREAKING — a record with no `parks` but a readable journal is admitted where it was refused.** A deployment that relied on the refusal as a wall (an operator workflow built around "pre-upgrade runs cannot be resumed") now sees such a resume ADMITTED and its parks proven against the checkpoint store. This is the migration the ruling asked for; what replaces the refusal is the store read, not the record.
|
|
147
|
+
- **Contract (additive).** `WorkflowRun.resumeAdmissionIncomplete?: true` — set on a resume run's record at CREATION, removed the moment admission completes. Presence means NOT A RESUME BASE: the engine refuses (`admission_incomplete`) and derives no candidate from it. Same projection obligation as `parks`: a store that drops it turns a refused record back into an admissible one. `WorkflowAgentRun.parkedCheckpointToken` gains the contract sentence that makes it a first-class key cache (written in the same store write as the `parks` entry; a candidate of equal standing on the derive path; proven the same single way).
|
|
148
|
+
- **Residual, disclosed.** A park whose record write AND journal append were BOTH lost is provable by nobody — but the admission REFUSES there (arm ③) rather than running the prefix live. The migration therefore reaches a pre-`parks` record that positively shows a park and NOT one that shows none: resuming a pre-upgrade run that never parked is refused exactly as it is today. Both pinned (`test/backlog755-resume-migration.test.ts`).
|
|
149
|
+
- **Consumers.** @server: **your own `parks === undefined` pre-check is the one that now has to move.** `parked-decide` (S-252) answers 409 `decide.workflow_parks_unreadable` for a record with no `parks` list, on the stated premise that core refuses such a resume outright — this version retires that premise, so the pre-check, not core, is what would refuse the migration C-R29 asked for; either drop it and let core's admission decide (the redemption key for such a record is derived at admission and is not readable from the record), or recompute core's three arms server-side (not recommended — it is the double-read this change unified). Then: keep the `parks_unreadable` branch (narrowed, not gone) and add `admission_incomplete`; keep projecting `parks` verbatim and project `resumeAdmissionIncomplete` beside it. A size degrade that empties `agents[]` is safe while the list is projected — it is the derive path (list absent) where the `parked` rows are the second cache, which is the S-267 shape. @cli: the refusal text changed for the pre-`parks` case with a readable journal (it no longer refuses). @test: pre-upgrade parked runs are now resumable end-to-end — the black-box arm is a hand-forged pre-`parks` record plus a readable journal plus a pending checkpoint.
|
|
150
|
+
|
|
151
|
+
### BREAKING — the run record's `parks` describes only facts that have been established (#757; @server @cli @test)
|
|
152
|
+
- **Before (two writes ahead of their fact, one root).** ① The resume admission assigned the inherited set (`run.parks = inherited`) BEFORE proving a single candidate: a candidate read that threw then left a FAILED record carrying inherited parks beside an EMPTY journal — a record that answers "I own these parks" to anyone picking a resume base by `parks`, while replaying none of the prefix the original had completed, so a whole completed prefix re-ran live with its side effects. ② `parkAgentLeg` persisted the park key with `originRunId` already claiming an origin — this run for a new park, the INHERITED run for a key it replaced — before awaiting the journal append. `originRunId` means "the run whose journal holds this park's `parked` entry", so a failed append left that claim standing on disk forever: a new token beside an origin whose journal holds no such park.
|
|
153
|
+
- **After.** ① `parks` lands when admission COMPLETES; a record refused before that carries none and says why (`resumeAdmissionIncomplete`, above), and the run it resumed is untouched and still the base. The establishment carries the same finalized-guard every settle path does, so a checkpoint store that answers AFTER the run's deadline abandoned the body cannot mark a dead run's record as a completed admission. ② The key still lands before the append is awaited — that ordering is what keeps the record and the journal in step, and reversing it would let a landed append beside a lost persist read as two caches naming different tokens — but the entry now says which state it is in.
|
|
154
|
+
- **Contract (additive, on the `parks` entry).** `originUnconfirmed?: true` — present while no journal has been read or written carrying this token's `parked` entry; `originRunId` then names the run the park was MADE in (where the material belongs, not where it has been seen) and **a consumer must not read it as confirmed**. REMOVED (never written `false`) the moment a journal confirms, so absence is the confirmed state and every record written before this member reads exactly as it did. A re-park of the SAME token keeps a confirmed origin — the marker states a missing confirmation, not a blanket "unknown". Core's own re-park material read identity-checks the entry it finds (token, session, call), so an unconfirmed origin degrades to the checkpoint row rather than mis-binding.
|
|
155
|
+
- **Consumers.** @server: both new members are OPTIONAL and absent on the established path, so an existing reader is byte-unchanged; a `/decide`-style join that resolves a park's material through `originRunId` must treat `originUnconfirmed: true` as "this journal may not hold it" and fall back to the checkpoint row. @test: the two-way pins are `test/backlog757-parks-persist-order.test.ts` (failed append ⇒ marker stands and travels; successful append ⇒ marker absent and the entry carries exactly its four established members; a late store answer after the deadline ⇒ the dead record stays marked incomplete).
|
|
156
|
+
|
|
157
|
+
### Feature — the auto-mode classifier falls back to the task's main model when its own seat cannot rule (#742; @server @cli @test)
|
|
158
|
+
- **Before.** The classifier round was one call to one model. A classifier ROLE that names a model the gateway does not serve (a misspelled id, a retired catalog entry) therefore failed identically on every gated call of every run: each one denied with the CC "the auto-mode classifier is temporarily unavailable" card, which names no model and no cause, and nothing in the record said which model was asked. CC 2.1.250's only knob here (`unavailableOuterRetries`) re-asks the SAME model, which answers a transient outage and never a misconfigured seat.
|
|
159
|
+
- **Now.** A round is a walk down a CANDIDATE LADDER: the classifier seat, then — under the new `RunnerDeps.autoMode.classifierFallback` (default `"main"`) — the run's own main model. The walk advances on exactly one condition, the rung produced no RULING (the leg threw / rejected, the round-trip cap elapsed, or the reply carried no verdict); the first `allow` / `block` is the round's verdict. When no rung rules, the LAST rung's verdict stands, so every deny the gate produced before is reached unchanged. `classifierFallback: "none"` is one rung — today's shape and today's cost, exactly, and now also the posture of the pre-flight one layer earlier (see ONE KNOB, BOTH LAYERS below).
|
|
160
|
+
- **Visible.** Additive on both faces: `GateDisposition.classifier` (`{ modelRequested, modelUsed, fallback?: { from, to, cause } }`) rides the `tool_end.gate` record and the `permissionDenied` payload on every round THIS run's own gate classified, allow included (the durable checkpoint row carries no attribution: a classifier allow does not park and a classifier block denies, so no classifier round ever reaches one). A round decided on an ANCESTOR's inherited chain carries the FORM (`GateDisposition.cause`) but no attribution yet — one of the three absence causes `gate-outcome.ts` documents, and the reason an absent `classifier` reads UNKNOWN rather than "the seat answered"; the first move per (rung, cause) per run announces the operator notice `classifier.fallback` (`detail: { from, to, cause, sessionId?, runId? }`). The `auto_mode.classified` trace frame's `model` is now the model the round RAN ON (which is what its declaration always said). An `unavailable` deny whose ladder moved and still got nothing names both models and both causes; a round that did not move keeps the CC sentence byte for byte.
|
|
161
|
+
- **A round whose QUESTION did not assemble is not a ladder move.** The prompt and transcript window are one shared build per classification, so a transcript-store failure fails every rung identically with NO MODEL DISPATCHED — and the walk used to record `fallback: {from: seat, to: main, cause: "error"}` and deny with a sentence naming two models that were never asked, pointing an operator at a gateway when the fault was a store. The classify leg now marks that failure (`auto_mode.round_not_asked`), the walk stops, and the classification is attributed to nobody whichever rung noticed: same fail-closed `unavailable` deny, in the CC sentence, with no fallback on the record and no `classifier.fallback` notice. A build that OUTRUNS a rung's cap is still recorded as that rung's `timeout` — the cap bounds the whole classify call, assembly included, and nothing there distinguishes a slow store from a slow model (#807, 7.19.x).
|
|
162
|
+
- **ONE KNOB, BOTH LAYERS.** The engine already fell the classifier seat back to the main model one layer earlier — the credential/URL pairing PRE-FLIGHT, which swaps the seat before any call and announces `route.fallback_to_primary`. That layer ignored the new knob, so a deployment that set `classifierFallback: "none"` to deny rather than fall back was still classified on the main model whenever its pairing was broken. The pre-flight now takes its posture from the same word: under `"none"` it KEEPS the declared seat and announces the new operator notice `route.classifier_unroutable` (`detail: { seat, from, cause, fixHint, action: "refused", sessionId?, runId? }` — no `to`, because nothing moved), and the seat's own calls are refused at the brain's request gate, i.e. the denial the knob asked for. Under `"main"` (the default) the pre-flight is unchanged, byte for byte. What the split still costs is stated where it lives (`classifier-ladder.ts`): after a pre-flight swap the ladder was built from the main model, so `GateDisposition.classifier.modelRequested` names THAT model and the swap's record is the notice, not the gate row.
|
|
163
|
+
- **Closed sets.** `AUTO_MODE_FALLBACK_CAUSES` (`error` / `timeout` / `parse_error` — why the ladder moved) and `AUTO_MODE_CLASSIFIER_FALLBACKS` (`main` / `none`) are exported with their membership guards; `ENGINE_NOTICE_CODES` gains `classifier.fallback` and `route.classifier_unroutable` (both audience `operator` — **@cli:** a consumer deriving its surfaceable set from that table takes both automatically and must decide their tier); a `classifierFallback` value outside the set refuses the prepare loudly (`config.auto_mode_classifier_fallback_invalid`), never clamped and never read as the default.
|
|
164
|
+
- **Details that are contract.** Two rungs collapse to one only on ROUTE identity (id AND normalized endpoint — `Model.id` alone is not an address, and two catalog entries may serve one wire id from two endpoints); the rung is dispatched by POSITION, so a shared id cannot alias one rung onto another. The prompt and transcript window are built ONCE per classification and every rung consumes that same build — "the same prompt, re-asked" is literal. The attribution is frozen at the mint, before any observer sees it. `AutoModeClassifyFn` gained a third parameter, declared OPTIONAL: existing one- and two-argument callers and wrappers keep compiling.
|
|
165
|
+
- **Divergence from CC (ruled).** Walking to another model rather than retrying the same one is deliberate: the main model is the same trust tier (CC's own classifier is a member of the same model family), the prompt and transcript window are identical, and the engine already falls the classifier seat back to the main model one layer earlier when the derived ROUTE is unusable (under `"main"`; that layer now honours `"none"` too). Cost, stated: while a seat is broken each gated call pays a second model round-trip (worst case two `timeoutMs` caps).
|
|
166
|
+
|
|
167
|
+
### A background task's terminal notification is addressed to the SESSION, not to the run that started it (@cli @server @test)
|
|
168
|
+
- **Before.** A background shell / watch / delegation settle notifies through the injector of the run that mounted it. Once that run ended, its lane was torn down and the frame could only PARK: the session's next run drained it at turn-open. A person who started a task in one turn and kept talking saw nothing for the whole of the run that was live when the task actually finished — the completion surfaced only at the START of the run after that one.
|
|
169
|
+
- **Now.** The destination is judged by SESSION. This run's own lane still takes the frame while it is live (unchanged, byte for byte); once it is not, the frame goes to whatever run the session has live at that moment and is injected at that run's NEXT TURN BOUNDARY — the same seat a steer lands on, with the same tier routing, the same dedup key and the same frame shape. Only a session with NO live run parks, and that leg is unchanged (next run, turn-open).
|
|
170
|
+
- **The seat.** A single per-Runner registry (`SessionLiveNotificationLanes`, living beside `PendingSessionNotifications` as its `liveLanes` — both are internal to the engine, not package exports): a run registers its lane when its session anchor is bound and withdraws it when the lane stops, including the abort/throw paths, backstopped by the run's outermost teardown. The session id is the key, so a peer session's live run is never a destination and a delegated child's session is not its parent's.
|
|
171
|
+
- **A lane is a destination only while it can deliver.** The route picks a run that is in a turn and not unwinding, judged on the harness's own state (`AgentHarness.acceptsInjections`, a new read-only accessor with three conjuncts: a turn exists, the run is not unwinding past `abort()`'s latch, and it is not stopping after `halt()` / `requestStopAfterTurn()` — the phase reads `"turn"` through all of those). A run that is still starting — its parked-store intake already read, its first prompt not yet issued — or already interrupted or halted is passed over rather than accepting a frame it could only refuse asynchronously, which would strand that frame behind the intake for a whole run. This gate applies to FOREIGN frames only: a run's own producers keep the long-standing accept-then-maybe-park shape, byte for byte. Measured against the previous release on both windows: identical.
|
|
172
|
+
- **One owner per frame, one window per session.** A key the session already owns — parked, or routed onto a live lane — is never taken a second time: a producer retrying it folds, exactly as the parked store's own dedup has always folded a parked repeat. The routed half is remembered at SESSION level because the destination run's own queue dedup dies with that run, and is released at the next run's intake, so both halves share one window rule. Ownership is written by the LANE THAT DELIVERS, at the boundary that took the frame, and by nobody else: choosing a destination records nothing, because a destination that accepted a frame can still refuse it at its boundary and parks it then, and because the receipt channel handed to a destination may itself belong to a nested reroute.
|
|
173
|
+
- **One decision, two addresses.** A run's own session and the delegation tree's root anchor ask the same function (`routeOrParkNotification`), so the escalation arm cannot drift from the arm beside it.
|
|
174
|
+
- **A route always terminates.** A lane that could not deliver a frame and handed it to the route answers "unavailable" for that frame from then on, so two overlapping runs — reachable, because a run started with no session id takes no session lock — cannot bounce one completion between them until a queue-level dedup refusal is mistaken for a delivery. That memory is kept only while the lane is live, released when it stops, and bounded exactly like the injection queue's own dedup set: a producer that outlives its run (a persistent watch) retains nothing through it.
|
|
175
|
+
- **Same rule at the delegation tree's root.** The descendant-terminal escalation's root-anchor arm now takes the same live-lane-else-park route, so it agrees with the uplink arm beside it (which has always delivered to a live ancestor immediately) instead of being able only to park.
|
|
176
|
+
- **Consumer-visible.** Frames that used to appear at a run's turn-open now appear mid-run, at a turn boundary, on the run that was live when the task settled — a shell that assumes "a task notification arrives at the start of the next run" must drop that assumption. Frame keys, tiers and dedup are untouched; a session with no live run behaves exactly as before. `SystemInjection.onDisposition` is unchanged (`"queued" | "parked"`); the injector's own three-word receipt is unchanged.
|
|
177
|
+
|
|
178
|
+
### Feature — the deferral gate gains a POLICY SEAT, and the leg says out loud what it deferred (#779; @server @cli @test)
|
|
179
|
+
- **Before.** Whether a request carried forty full MCP schemas or forty name-only placeholders was decided by ONE hard-coded number — 10% of the model's context window — with no way for a deployment to say otherwise. A forty-tool remote roster of about 11K estimated tokens against a 128K window never reached the line, so every request inlined it in full; a deployment that wanted the round-trip (or that wanted never to pay for one) had no seat to say so. And what the gate decided was never on the wire at all: the leg's deferral was visible only in an internal trace, so a context view could not tell a small request from a deferred one.
|
|
180
|
+
- **After — the seat.** `RunnerDeps.toolSearch` takes three states: `true` (the engine's candidate set defers on every request), `false` (it never defers, and with no caller list no ToolSearch is mounted), or `{mode:"auto", thresholdPercent:N}` (the threshold at N% of the window). **Absent ⇒ `{mode:"auto", thresholdPercent:10}`, byte-for-byte the gate that shipped before** — pinned field-for-field against the explicit spelling, not asserted. A value outside the domain fails the leg at prepare with `config.tool_search_invalid` and never falls back to the default gate — a `thresholdPercent` that is not a finite number in `[0,100]`, and an UNKNOWN MEMBER on the object form: `{mode:"auto", thresholdPct:5}` is a refusal, not a silent 10%, because a misspelled key is the same fold one level down (the member set is `mode` + `thresholdPercent`, closed).
|
|
181
|
+
- **The three states are three values of ONE number, and that is the contract.** `resolveToolSearch` lowers the seat to a single threshold fraction (`true` ⇒ `0`, so every candidate byte is over budget; `false` ⇒ `Infinity`, so no candidate set ever reaches it; `auto:N` ⇒ `N/100`) and the gate keeps its one comparison, `gatedChars ≥ thresholdChars`. Deferral still has TWO rules and only two: this seat is rule ②'s gate policy, `RunnerDeps.deferMode` is still the orthogonal knob that widens rule ②'s candidate set, and rule ① is untouched — `TaskSpec.deferTools` defers under every state, `alwaysLoadTools` still pins and is still judged first. The pins for the boolean arms and for the `auto:5` / `auto:10` boundary sit on the SAME candidate set, so re-implementing a state as its own code path is what goes out of step.
|
|
182
|
+
- **After — the disclosure.** A new additive `tool_disclosure` frame carries the leg's deferral census: `policy`, `thresholdPercent` (present iff `policy === "auto"` — under `always`/`never` no threshold was consulted, and an absence must not be read as "the default percent"), the `deferred` names and the `activated` names. It **exists only on a leg that defers at least one tool** — absence reads as "nothing was deferred", never as "unsupported" — and every emission is a WHOLE SNAPSHOT (a consumer replaces its copy): one at leg start, ordered right after `wiring_manifest`, and one after every activation that changes the set, whichever lane activated it. `deferred` is frozen at prepare; both lists are sorted.
|
|
183
|
+
- **The threshold is TOTAL, which the first cut was not.** `window × fraction` is NaN for `0 × Infinity` and for a window the engine cannot read, and a NaN threshold compares false in both directions — i.e. it silently means "never". That is right for `false` and the exact opposite of what `true` promises, so an explicit `toolSearch: true` under a model declaring a non-finite (or absent) context window would have turned into its opposite with nothing said. When the product says nothing the deployment's fraction decides: `0` ⇒ threshold `0` (defer whatever the window is), anything else ⇒ an unreachable threshold — which is what an unbounded or unreadable window already meant on the default arm, so that arm's verdict is unchanged (only its reported number stops being `NaN`).
|
|
184
|
+
- Pins: `test/backlog779-tool-search-seat.test.ts` (38 cases — the fixture straddles the 5% and 10% lines so the two percents can disagree on one candidate set; absence compared field-for-field to the explicit default; `false` held against a window where the default gate opens AND against an unknown window, where a NaN threshold would keep the arm but misreport it; nine bad-value spellings refused by code; the Runner path for every state including the caller's list surviving `false`; the activation snapshot).
|
|
185
|
+
|
|
186
|
+
### Feature — the wiring manifest discloses the hook seats and the LSP seam (#789; @server @cli)
|
|
187
|
+
- **Before.** The manifest disclosed every optional seam the assembly has — ask, question, elicit, park, durability, fleet, governance, MCP, the tool roster — except the one whose code the engine AWAITS on the critical path with a fail-closed posture. "Is a hook wired here" is the first thing a person diagnosing a refused call or a refused prompt needs, and it was the one wiring question the disclosure could not answer. The LSP seam was invisible for the same reason.
|
|
188
|
+
- **After.** `WiringManifest.hooks[]` carries one entry per wired callback seat, in the vocabulary's own order, with the seat that supplied the record (`spec` / `deps`) and — on the `preToolUse` entry only — the deployment's `observational` declaration. `WiringManifest.lsp` carries `{mounted, lane}` where `mounted` is the ROSTER fact (a wired manager whose tool was excluded reads `false`, which is exactly the state that otherwise looks like a missing capability) and `lane` says whether the diagnostics push lane armed.
|
|
189
|
+
- **Both are PRESENCE-gated, so a deployment that wires neither is byte-identical** — no empty array is ever emitted, and the `configFingerprint` does not move (pinned as a whole-object comparison, not a spot check). **Neither field enters the fingerprint at all**, families and the `observational` flag included: both are LEG-LOCAL reads (`lsp` projects the roster, and the hook record is a per-task seat), and legs of one assembly legitimately differ on them — the delegation lane composes a child's own record onto its spec seat, while a root whose hooks sit on its own spec delegates the screening as an inherited constraint and the child carries no local record at all. Hashing either would split a root from its own children, which is the cross-leg equality that value advertises. **A consumer correlating hook wiring must read `hooks` directly; the fingerprint will not move when it changes.**
|
|
190
|
+
- **The LSP `lane` is the PRODUCT of two independent facts, not a scale on the mount.** The diagnostics push lane arms on the manager, write-capable hands and the absence of an opt-out — never on the tool being rostered — so `excludeTools: ["LSP"]` leaves it pushing. A first cut reported that as no lane at all, i.e. a live lane hidden behind an absent tool; the word set gains `diagnostics`, and `lane` is absent only when neither lane is live.
|
|
191
|
+
- **A present NON-FUNCTION hook seat now REFUSES the leg (`config.hooks_invalid`) — allowed → refused.** `hooks: { preToolUse: null }` (a string, a bare object, any non-callback) used to prepare, arm the tool gate on the seat's PRESENCE, and then answer every tool call `isError: true`: a configuration that blocked every call while the manifest disclosed no hooks at all. It now fails at prepare with `terminal.code = "config.hooks_invalid"`, naming the family and the type received, at `turns === 0` — the same refusal the sibling seams already give (`resolveQuestionSeam` / `resolveElicitSeam`), and the reason callability and presence cannot diverge on the disclosure. An ABSENT seat is untouched: unwired, silent, byte-identical. **@server @cli, deployments and headless lanes:** a leg that starts failing at prepare with this code was failing every one of its tool calls before it, so the fix is the same either way — omit the key, or wire a function. A stray property that is not a family (`{ onX: fn }`) is neither refused nor reported: the vocabulary is the judge, not the record's own keys.
|
|
192
|
+
- The families are a closed set (`HOOK_FAMILIES`) fenced against the `Hooks` interface itself: a new callback member is red at `tsc` until it has a word, so a disclosure cannot silently under-state what runs on the critical path. Absence of the field means this leg wired no hook RECORD OF ITS OWN — not that no hook runs: a delegated child with no local record can still be screened by an ancestor's `preToolUse` through the gate's constraint fold, which this face deliberately does not call a hook of the child.
|
|
193
|
+
- Pins: `test/backlog789-wiring-manifest-hooks-lsp.test.ts` (16 cases — byte identity and fingerprint stability for the unwired deployment, the vocabulary checked against every callback the type allows, a present non-function seat refusing the whole leg by code at `turns === 0` while a stray property is neither refused nor reported, the fingerprint in/out split, and the Runner path including the excluded-tool `mounted:false` state).
|
|
194
|
+
|
|
195
|
+
### Feature — `context_usage` breaks the system prompt down by section (#790; @server @cli)
|
|
196
|
+
- **Before.** The frame carried the three trigger numbers and nothing about WHAT was occupying the window. A context view could say "72% full" and nothing about which part of the prefix was big.
|
|
197
|
+
- **After.** `context_usage.sections[]` carries one entry per rendered pack section — `id` (the same wordlist `prompt.assembled.sections[].id` uses), `kind` (the semantic slot), and `tokens` estimated from rendered characters. Absent when the leg assembled no section IR (a provider that replaced the prompt wholesale ships one opaque block), which reads as "no section view", never as "the sections weigh nothing".
|
|
198
|
+
- **It is a DIFFERENT COORDINATE from `usedTokens`, and the contract says so in the negative**: never subtract the sum from `usedTokens`, never compare an entry to `compactAtTokens`, never read a change across frames as growth — the value is prepare-frozen and re-reported verbatim at every boundary of the leg. It answers "what is my system prompt made of", which is the only question a per-section number can answer honestly.
|
|
199
|
+
- Pins: `test/backlog790-context-usage-sections.test.ts` (9 cases — the projection's absent/empty/no-slot/short-section arms, section stability across the boundaries of one leg, and the two coordinates shown to be different numbers on a live run).
|
|
200
|
+
|
|
201
|
+
### Feature — a skill can state its own directory, and the result says where it is (#786; @cli)
|
|
202
|
+
- **Before.** A `SkillSpec` carried a body and inline attachments and no path. A body that said "see `reference.md`" left the model to find it, and a bundle ported from a product that spells `${CLAUDE_PLUGIN_ROOT}` arrived with the placeholder intact.
|
|
203
|
+
- **After.** `SkillSpec.baseDir` states the skill's own directory. With it set, the `Skill` result opens with `Base directory for this skill: <baseDir>` and every `${SKILL_ROOT}` / `${CLAUDE_PLUGIN_ROOT}` in the body AND in every attachment's content is replaced by it; `files[].path` is then read relative to it. The substitution covers the CALLER'S material only — the model-supplied `args` section is delivered as written — and core still neither resolves, reads nor validates the directory (the brain-outside rule `SkillSpec.files` already states).
|
|
204
|
+
- **A present `baseDir` must be a NON-EMPTY STRING, or the leg refuses (`config.skill_base_dir_invalid`).** Both bad shapes were silent: `""` is not "no directory" — it resolved the body's `${SKILL_ROOT}` to nothing, delivering the filesystem-root path `/ref.md` under an opening line naming no directory at all; a non-string made the delivered length `NaN`, and `NaN <= limit` is false, so the load gate dropped the skill and reported "content is NaN chars" — a configuration typo reading as an over-limit body. One screen, at the load gate every skill passes through on every leg. Core still neither resolves nor reads the directory: this screens the VALUE, not the path.
|
|
205
|
+
- **Absent ⇒ the result is byte-identical to before**, pinned on the body, the attachments section and the args section; a body that spells a placeholder without a directory keeps the placeholder verbatim, because an unresolved name is visible and a wrongly resolved one is not.
|
|
206
|
+
- **Both size doors now measure what the model RECEIVES, not what the caller typed.** Substitution is the one step here that grows text without bound — a placeholder is 13 or 21 characters and a directory can be any length — so the attachments budget charges the substituted length and never materializes past the remaining budget, and the 1MB load limit counts the delivered body and refuses an over-limit skill whole at spec assembly, naming both numbers. Before that, a body of placeholders under a long directory passed a raw-length check and then delivered many times the limit; a big enough one exceeded the runtime's own string length before offload or retention was ever consulted. A skill with no base directory is measured exactly as it was.
|
|
207
|
+
- Pins: `test/backlog786-skill-base-dir.test.ts` (12 cases — byte identity on three faces, both spellings, every occurrence, attachment content, the args exemption, a directory containing `$&`, and the budget charged after substitution).
|
|
208
|
+
|
|
209
|
+
### BREAKING — Artifact surface, slice 2: the host DECLARES its commit protocol; shared reads ask first; reject breaker; conformance kit (design/379 §9 slice 2; #726 #731; exports +6 values / +7 types, additive; @server @cli @test @client-core)
|
|
210
|
+
- **BREAKING (seam, `ArtifactHostCapabilities`)** — a new REQUIRED member `commit: "throw_means_uncommitted" | "uncertain"` (closed set `ARTIFACT_COMMIT_CERTAINTIES`; disposition table `ARTIFACT_COMMIT_THROW_OUTCOME` → `not_executed` / `unknown`, fenced). It states what a throw out of `publish` proves about the host's commit point. An absent or foreign word is `config.artifact_host_invalid` (not mounted) — never read as either word. Every host implementing the seam must add the member: `localArtifactStub` declares `throw_means_uncommitted` (its write order: the ledger rename is the commit point and nothing fallible follows it); a hosting plane whose answer can be lost after the write declares `uncertain`. @server: the only public seam consumer; no server host exists yet (zero consumption today), so this is a declaration to make when the hosting seat lands.
|
|
211
|
+
- **Behaviour (host-fault wording, #726)** — the `artifact.host_error` result is worded from the declaration and from nothing else about the host: a throw under `throw_means_uncommitted` ⇒ `details.outcome: "not_executed"` (“Nothing was published; the call can be repeated”); a throw under `uncertain`, and an abort or malformed answer under EITHER word ⇒ `details.outcome: "unknown"` with the sentence “it may already have been committed, so do not simply repeat the call” (+ the `action: "read"` verification hint only when read is declared and the url is known). `details.commit` carries the declared word. The `plane === "local-stub"` special case is gone (special cases −1, declared axes +1). attemptId minting moves to one key per HOST CALL (see the `ArtifactCallContext.attemptId` bullet below); a model-level retry is a new key either way.
|
|
212
|
+
- **Behaviour (pre-read consent on a shared target — upstream form, Claude Code 2.1.259 `cli259.js` L8484-8489)** — reading another person's artifact asks the person ONCE, and the approval covers re-reads of that artifact for the rest of the run. The seam has no metadata pre-check, so the gate sits between the host's `read` answer and its delivery: an `isolatedSummary` answer is withheld until the run's effective approver (the frozen `spec.onAsk ?? deps.onAsk` seat — the one the tool gate resolves at, riding the mount dep like the workflow mount's `parentOnAsk`) says yes, through the ONE ask resolver (`resolveAsk`, `requiresRealApproval: true`, `args: { action: "read", url }`). No approver (headless) or a no ⇒ `artifact.refused` with `details.sharedRead: "not_approved"`, content withheld — never silently delivered; a blanket `onAsk: "allow"` posture does not stand in for a person; an approval that EDITED the request (`updatedInput`) does not authorize the original url; parallel reads of one url share one ask. The file arms (`list_files` / `read_file`) deliver only to a target whose OWNERSHIP this run has established — its own (a publish, a `mine` listing row, a full-text read) or a shared one the person consented to; an unknown url is established through the one ownership-bearing outcome (a probing `read`) when read is declared, and refused fail-closed (`details.sharedRead: "ownership_unknown"`) when it is not. @cli: a NEW ask source on the card face — `toolName: "Artifact"`, message “Read a shared artifact: <url> (<title>), version <v> — … approving covers re-reads of it for the rest of this run.”; render it as any other ask. The post-read notice form the design sketched for slice 2 does NOT ship (the consent gate is the CC form; design/379 §9/Q8 erratum recorded by the main session).
|
|
213
|
+
- **Behaviour (reject breaker — upstream form, `cli259.js` L61405-61407)** — consecutive rejections of one target (key = action|file_path|url, each ≤1024 chars, omitted action = publish) for the same reason (`details.code`, or the invalid-arguments mark) with the same message are counted; from the THIRD on, the rejection text is prefixed with the banner (“IMPORTANT: Artifact calls for this target have now been rejected 3 or more times …” — the refusal form for `artifact.force_refused` / `artifact.forbidden`, the retryable form otherwise) and `details.rejectStreak` carries the count; any successful Artifact call clears every streak. Per run. `src/tools/artifact/reject-breaker.ts` (exports none; constants internal).
|
|
214
|
+
- **Behaviour (batch concurrency)** — `isConcurrencySafe`: `read` / `list` / `list_files` / `read_file` may ride a parallel batch; `publish` (explicit or the materialized default) never. Table `ARTIFACT_ACTION_CONCURRENCY_SAFE` (closed-set row, fenced).
|
|
215
|
+
- **Behaviour (manifest arms)** — the `files` manifest is judged BEFORE any local byte is read: entry shape and the 255-entry cap are enforced in the body too (schema-bypassing callers), and the main file's published name goes through the one published-path grammar (NFC) that every `files` entry already did. `MAX_FILES_PER_PUBLISH` is now exported from the tool module (not on `index`).
|
|
216
|
+
- **Rule domain (no code change, pinned)** — `Artifact(action:read|list|list_files|read_file)` ask/deny rules and `Artifact(url:<pattern>)` rules reach the read family through the existing param-rule compiler; the card face is the catalog's (`action` primary; `action`/`file_path`/`url` summary). NOT closed: an ALLOW rule that silences a classifier-tier ask for Artifact — the persisted-rule lane speaks the shell and path grammars only (`ruleToolGrammarOf`), and a param-level allow is not a rule form; a design ticket for an action-multiplexed tool's rule grammar is proposed in the car receipt.
|
|
217
|
+
- **Behaviour (seam contract, `ArtifactCallContext.attemptId`)** — `attemptId` is now minted per HOST CALL, not per tool call: a tool invocation that reaches the host twice (the ownership-probing `read` and the `list_files` / `read_file` it gates) hands the host two distinct keys. The seam's replay rule is keyed on the value ALONE — `ArtifactCallContext` permits one attempt ledger for all methods — so a shared key entitled a host to answer the gated call with the probe's outcome (the tool then read that as a malformed answer and reported an unknown-outcome host fault on a pure read). `artifactAttemptId(runId, toolCallId, occurrence)` is unchanged in shape and stays stable for any retry the host makes of one call; `occurrence` now counts prior host calls under that call id in the run (it counted prior executions), so a gateway that reuses a call id across turns still never replays. @server @cli: no host change — a host keyed on `attemptId` alone is now CORRECT, and one that keyed on (attemptId, method) to work around this stays correct. The one thing that moved is PREDICTION: a test that computes the value the engine will use for the Nth call of a tool-call id must count host calls, not executions (the 7.17.0 wording said executions).
|
|
218
|
+
- **Contract (#731)** — `capabilities.storageDir` is judged fully qualified under its OWN spelling family (`C:\…` / UNC / `/…`; node's platform-bound `isAbsolute` is gone), and the mount-time containment check compares the store's physical path with a run root only when both spell the same family (a root of the other family names a filesystem the store cannot be on).
|
|
219
|
+
- **Description** — the tool description is composed from a plane-keyed opening table (`ARTIFACT_PLANE_OPENING`, fenced) plus action-keyed paragraphs; the comments disclosure now rides EVERY face (no host declares a comments action in this version), so the hosted description gained one paragraph.
|
|
220
|
+
- **Conformance kit (design/379 §4.4; exports +3 values / +6 types, tier advanced)** — `ARTIFACT_CONFORMANCE_VECTORS` (20 vectors, data), `artifactConformanceVectors(host)` (the declaration-conditioned subset), `runArtifactConformance(host, { hooks?, largePayloads?, runId? })` → report (`pass` / `fail` / `not_applicable` / `skipped`, the last two named). Vector families: fresh/update exclusivity, last-write-wins without baseVersion, CAS (stale conflict with `currentVersion` + snapshot|`"reread"`, force over a plain version, `force_refused` over a page-saved version via the `pageSavedVersion` hook), snapshot completeness, a rejected publish leaves readable bytes unchanged (`publish.conflict_preserves_read` / `_read_file`), attemptId replay (fresh / update / refusal — the refusal replays over a request that would otherwise succeed), `url_foreign`, atomicity under a pre-commit fault (`precommitFault` hook), both byte limits (over-limit arms always; at-limit arms behind `largePayloads`), list scope words + limit/truncated, read own/unknown, list_files/read_file. @server: run it in CI against the hosting-plane host — `const r = await runArtifactConformance(host, { hooks }); assert(r.failed === 0)`; the local stub passes its declared subset (first host under test).
|
|
221
|
+
- Exports (additive): `ARTIFACT_COMMIT_CERTAINTIES`, `ARTIFACT_COMMIT_THROW_OUTCOME`, `ARTIFACT_ACTION_CONCURRENCY_SAFE`, `ARTIFACT_CONFORMANCE_VECTORS`, `artifactConformanceVectors`, `runArtifactConformance`; types `ArtifactCommitCertainty`, `ArtifactConformanceHooks`, `ArtifactConformanceOptions`, `ArtifactConformanceStatus`, `ArtifactConformanceVector`, `ArtifactConformanceResult`, `ArtifactConformanceReport`.
|
|
222
|
+
- Pins: `test/artifact-surface.test.ts` (commit axis both planes + negative control; consent gate unit six arms + Runner e2e four arms; breaker; rule reach; G7b: two host calls of one tool call carry two keys, over a host that replays on `attemptId` alone; card face; isConcurrencySafe; manifest), `test/artifact-conformance.test.ts` (stub declared subset green with a read-only-directory fault; reference host all green; five breached clauses fail exactly their vectors), `test/artifact-reject-breaker.test.ts`, `test/prepare-artifact.test.ts` (cross-family root), `test/artifact-local-stub.test.ts` (declaration). Wire rows: `docs/wire-consumption/unreleased-465s2.md`.
|
|
223
|
+
|
|
224
|
+
### BREAKING — a durable resume SETTLES the suspended batch: the approved call's siblings execute on resume, the `[DEFERRED]` deferred-reissue close is retired (#554; @cli @client-core @server @test)
|
|
225
|
+
- **Before.** When one call of a multi-call assistant batch parked for durable approval, the resume leg executed only that call; every other unanswered sibling was closed with a synthetic `tool_start` + an `isError` `tool_end` whose output was the `[DEFERRED] This tool call shared a batch with a call that suspended for durable approval, so it was NOT executed on resume…` text, the same text was appended to the transcript as the sibling's tool result, and the continuation prompt told the model to re-issue them. From the person's seat an approval ran nothing they had not already seen approved-and-skipped; from the model's seat every batch that mixed one gated call with plain reads cost a re-issue round (and each re-issue could park again).
|
|
226
|
+
- **After.** The resume leg resolves the gated call as before (`allow` executes it once / `deny` injects the denial), then the continuation prompt's leg settles the transcript's trailing unanswered batch through the loop's OWN batch pipeline before the prompt is delivered: `AgentHarness.prompt(text, { settleUnansweredToolCalls })` → `settleUnansweredToolCalls` (`src/engine/loop/agent-loop.ts`), which reuses `executeToolCallsPartitioned` / `executeToolCallsSequential` verbatim — the same partition rule (consecutive concurrency-safe calls run as one capped batch, unsafe calls one by one), the same source order, the same preflight gate per call (hooks, policy, the ask, the durable park), the same result commit. CC 2.1.259 form: a pending permission prompt blocks only its own call (`J3`, the per-call runner) — a concurrent block's siblings proceed, a serial block's siblings wait and then run; nothing is ever handed back to the model as "deferred".
|
|
227
|
+
- **A sibling that needs its own approval parks again** — the second checkpoint is the sibling's (`pendingAction.toolName` = the sibling; `batchToolCallIds` unchanged; `completedCallIds` now lists the approved call), and the chain-base rule for `maxSuspends` already counts an executed approval as progress. **A person's BARE refusal** of the gated call (a `deny` with no `reason`) halts the rest of the batch through the SAME seat the live gate uses (`maybeHumanRejectionHalt`): the siblings settle `gate.batch_halted`, `notExecuted`, nothing runs. A refusal WITH a reason is not a batch boundary — the siblings settle through their own gates, exactly as a live noted refusal does.
|
|
228
|
+
- **The settled batch is a turn of the loop.** It runs under the same turn-scoped interrupt seat a live batch runs under (a `halt()` or an immediate steer landing mid-settlement cuts the executing sibling and settles the rest interrupted, exactly as live), and the suspended turn's boundary — which never ran, the park aborted it — runs before the continuation's request — `prepareNextTurn` (the `turn_boundary` hook: `postToolBatch` sees the siblings as their OWN batch, attachments, the context rebuild) and the stop consult — exactly where a live batch's boundary sits. The continuation prompt (and the harness's next-turn queue splice, which CONSUMES queued engine notes) is taken by the loop only when the prompt is actually delivered: a settlement that ends the run first leaves the queue untouched.
|
|
229
|
+
- **Undelivered continuation content survives a re-park, and what no re-park carries is ANNOUNCED.** The continuation prompt carries the checkpoint's parked steer queue, the caller's own wake message and the orphaned-background-task obituary; when the settlement parks the run again (or its boundary stops it) the prompt is never delivered, so what it carried is still owed — ONE seat (`Prepared.undeliveredContinuationRef`, set before the prompt with EVERY input frame the prompt carries, emptied at delivery) hands it to the new pause: the suspend saga folds the orphans into the new snapshot ahead of the live rows (announced once, on the leg that reaches the model), and the terminal adoption re-parks the steers through the existing `setPendingSteer` migration (issue order kept; `inputId` kept, `seq` re-minted; a steer the redelivery screen withheld is not carried — its account is closed). The steers' `human_input … applied` frames are published at delivery, before the carrying message commits. A re-park is not the only way a settled leg can end, so the owed frames are also on the run's **undrained-input account**: a leg that ends with NO committed pause (a bare halt, a run abort, an interrupted final turn), and a park that could carry only part of what it owed, announce the remainder through the same `task.user_steer_undrained` family the harness's live queue uses (one book for "what this leg still owes a person": the two lanes are summed into ONE row per family per run — the at-`agent_end` sink now defers its own counts to this settlement whenever the tail still owes something, so a run's loss is never split into two rows that each undercount). The original checkpoint row is already `resolved` by the resume CAS, so an owed frame that is not carried exists nowhere else: silence there was a lost instruction. The one exclusion is the frame a lane can still hand back — a leg that neither re-parked nor STARTED its approved action has its consumed row reopened (pending again, parked queue intact), so the CHECKPOINT's own frames ride that row to the next resume rather than being announced as lost; the caller's wake message, which lives on no row, is owed either way. **Disclosed residual:** whether that reopen happens is settled one lane later than this count, so a leg that never started its action and is NOT reopened (a delivered deny, a store without reopen support, a refused reopen) stays silent about its row-borne frames — closing it means settling the account after the compensation lane, and it is filed rather than patched.
|
|
230
|
+
- **A bare refusal is the RUN's halt fact on the resume path too.** The resumed settlement raises `Prepared.batchHaltRef` where it mints the halt directive, exactly as the live gate does at its own mint site — one judgment, one effect, so the fact's consumers (the stop gate, the boundary's attachment/steer suppression, the notification lane's engine-frame park, the result stamp) cannot answer differently depending on which gate carried the refusal. Spent at the continuation's own provider request, the same rule the live fact follows.
|
|
231
|
+
- **Wire face (BREAKING for frame consumers).** The synthetic sibling pair is gone: a resumed batch's siblings now emit the live loop's own `tool_start` (real args) and `tool_end` (the tool's real `output`, `isError` as executed) — or, under a bare refusal, an `isError` `tool_end` with `errorCode: "gate.batch_halted"` and the halt text. No frame or transcript entry carries `[DEFERRED]` any more; the continuation prompt no longer says "N other tool call(s) from that batch were returned as deferred". `docs/wire-consumption/unreleased-554.md` carries the row set. @cli @client-core: a card renderer keyed on the `[DEFERRED]` text or on "resume closes siblings as errors" has no input any more — siblings are ordinary executed calls. @server: `Checkpoint.pendingAction.completedCallIds` keeps its meaning (calls answered before THIS park) and now grows across a re-park chain. @test: the black-box shape is in the criteria cells.
|
|
232
|
+
- **Exports (engine/internal face, NOT `src/index.ts`)**: `settleUnansweredToolCalls`, `findUnansweredToolBatch`, types `TrailingToolBatchSettlement` / `RunAgentLoopSeams` (`src/engine/loop/agent-loop.ts`); `AgentHarness.prompt` options `settleUnansweredToolCalls?: TrailingToolBatchOptions` and `onDelivered?: PromptDeliveredCallback` (called the moment the prompt is delivered; not called when a settlement ends the run first), types `TrailingToolBatchOptions` / `PromptDeliveredCallback` (re-exported by `src/internal/harness.ts`); `runAgentLoop`'s ninth argument `RunAgentLoopSeams` (`settle` + `onPromptsDelivered`) and its `prompts` argument now also accepts a thunk; `maybeHumanRejectionHalt` (`src/core/runner/prepare-gate-stations.ts`). Removed: `DEFERRED_REISSUE` (`src/core/runner/decide-continuation.ts`, never on `src/index.ts`).
|
|
233
|
+
- Pins: `test/backlog554-resume-batch-settlement.test.ts` (late approval runs the siblings in source order with paired real frames; a sibling parks on its own gate; the re-park carries the undelivered steer; a delivered continuation re-parks nothing and owes nothing; a settlement that ends the run with no committed pause announces the steer it could not deliver; a bare refusal halts the siblings `gate.batch_halted`; a noted refusal does not; and the source-level pin that the owed seat is fed by the leg's own frame list while both mint sites of the bare-refusal halt raise the run-level fact), `test/durable-checkpoint.test.ts` (the two former deferral pins re-pinned as settlement). Golden frame `resume_deferred_sibling_tool_end` retired from `test/golden-frames/durable-approval-5.9.0.json`.
|
|
234
|
+
|
|
3
235
|
## 7.17.7 — 2026-09-15
|
|
4
236
|
|
|
5
237
|
### Narrowing — an INTERPRETER whose program is argument text: the argument-as-program family gains its fourth member (#791; @server @cli @test)
|
|
@@ -20,6 +252,7 @@
|
|
|
20
252
|
- **Before.** `time -p xargs cat <<< <path>` was vouched under the default doctrine while `xargs cat <<< <path>` was mandated. The launcher option grammar — the one table that declares which tokens belong to a control word rather than naming the program behind it — was read by the boundary walk's control-word strip and by the single-command boundary face, but NOT by the refused-command whitelist's own head scan. There `-p` was read as the program word, the segment was exempted as an ordinary unlisted program, and the argv re-entry standing behind it was never asked about. The same silence covered `time -f %e xargs cat <<< <path>` and `time -o <file> xargs cat <<< <path>`.
|
|
21
253
|
- **After (a reuse point, not a second table).** The whitelist's head scan reads the same declared grammar, so all three program-word readers give one answer, and beneath it a floor the walk has always had: an option-shaped word is never read as a program name. A spelling the grammar deliberately does not carry — a FUSED long option (`time --format=%e …`), or an option of a launcher with no row at all (`nice -n 5 …`, `env -u X …`) — therefore leaves which program runs unreadable and is refused, which is the answer the boundary walk already gave for that condition. The readers that resolve a launcher CHAIN (`env time -p …`) deliberately still refuse rather than resolve: resolving there would turn a whole refusal into an ordinary walked segment, which is the widening direction, while every reader that does read the table only ever finds MORE program to judge.
|
|
22
254
|
- **Behaviour, for consumers.** `time -p xargs cat <<< <path>` and its valued spellings are now one mandated ask on both doctrines (a refusal on a headless deployment), as the bare `xargs` spelling already was; `time -p cd <dir>` is recognized as the directory change it is; and `time --format=%e cat <file>` — a spelling the boundary walk already called unreadable — now gets that answer from the whitelist too instead of being quietly exempted. Unchanged and pinned: `time -p ls`, `time -p cat <file>`, `time -f %e cat <file>`, `time -p echo hi`, `time -o <file> ls`.
|
|
255
|
+
|
|
23
256
|
### Narrowing — an APPLET DISPATCHER is a launcher, and the shell re-entry member is spelled by the shell (#791, the complement pass; @server @cli @test)
|
|
24
257
|
- **Before.** A multi-call binary runs the applet named in its first argument, and it was on no launcher table — so nobody unwrapped it, and the program text behind it folds into words that all resolve in root. Measured at ZERO asks under the default doctrine: `busybox sh -c "cat /etc/passwd"` and `busybox ash -c "…"`, while the bare `sh -c "cat /etc/passwd"` was mandated; `busybox awk '{print}' <file>` slipped the interpreter table the same way. Separately, three spellings of the shell itself were outside the shell re-entry pattern and vouched on their own: `ash -c "…"`, `mksh -c "…"`, `yash -c "…"`.
|
|
25
258
|
- **After.** The dispatcher joins the launcher table — it is already on the rule lane's own curated interpreter table, which is the list this file's launcher set is drawn from, so the pinned subset relation is unchanged — and the shell re-entry pattern spells the Almquist family and its descendants. `busybox cat <file>`, `busybox ls`, `busybox ls -la` and a bare `busybox` keep the verdict they had, and `busybox cat <outside>` is gated exactly as before. Stated over-fire: `busybox --help` puts an option where the applet name would be, which leaves what runs unreadable and refuses — one ask.
|
|
@@ -449,7 +682,6 @@ One car, one rule per family: every retired form is REFUSED by name at its read
|
|
|
449
682
|
- **Fail-open reverse check — which fact still lives only in prose after this batch?** The WHY behind a classifier unavailability (`error` vs `timeout`) is deliberately prose + trace (not a branching fact); the lexer's reason behind `ruleStoreUnreadable: "call"` is prose (the mechanism is the fact; the sentence is the rendering); a workflow body's own codeless throw stays `error` text with no code (there is no fact to structure). Everything else the two integration audits named now has a position.
|
|
450
683
|
- Pins: `test/backlog616-classifier-unavailable.test.ts` (C1, rewritten), `test/backlog688-contract-batch.test.ts` (C3 / C4 / C5 / C-a / C-b / C-c / C-d, each double-sided), re-pinned: backlog661, design503, gate-outcome-stations S7, backlog564, gate-outcome-adversarial-r3, backlog625, backlog642 (park ⇒ `workflow.agent_parked`), design383 §1.4, governance-codes, prepare-wiring-manifest, backlog529 (unchanged, green). Export surface +12 (snapshot re-baselined). Ratchet raises: gate-lanes 1425→1445, assemble-result 620→630, run-harness-handlers 583→590, prepare-caps-and-workflow 787→800.
|
|
451
684
|
|
|
452
|
-
|
|
453
685
|
### Behaviour — a child inherits the parent's RESOLVED checkpoint seat, both delegation lanes, one rule (#683; P1 design; test [6896] G-1/2/3/10; server B-060; @server @cli @test)
|
|
454
686
|
- **Before.** Both delegation lanes handed a child only the parent's OFF switch: the Task lane copied `checkpointStore: "disabled"` off `ctx.checkpointStoreDisabledForChildren`, the `run_workflow` lane off `RunWorkflowToolDeps.parentCheckpointStoreDisabled` / `buildWorkflowPrimitives(…, parentCheckpointStoreDisabled)`. A child whose seat was left unset re-resolved `RunnerDeps.checkpointStore` on its own — so a parent parking through a SPEC-level store (a deployment that wires the store per task, no store on the Runner deps) spawned children that found no store: the background child's and the workflow child's asks fell to the fail-closed deny (`delegation.ask_unresolvable`, `parkLaneExisted: false`) while the parent could park.
|
|
455
687
|
- **After (the rule, in one sentence).** The driver mints the run's checkpoint SEAT once, right after the config doors (`checkpointSeatOf(spec, resolveCheckpointStore(spec, deps))`, `src/core/checkpoint-seat.ts`): the store object the run parks in, the word `"disabled"` when the caller disarmed the run, or NO seat when the run has no store anywhere. A seat rides the trusted ctx seat `ToolExecuteContext.checkpointStoreForChildren` and the Workflow mount dep `RunWorkflowToolDeps.parentCheckpointStore` and lands on every child spec VERBATIM (Task sync / background / fork: `subagent.ts` one spread; workflow: `buildWorkflowPrimitives(…, parentCheckpointStore)`); no seat ⇒ no key, and the child's own door resolves its runner's deployment exactly as before (a split child-execution runner may carry a store the host has none of — `test/agent-park-jian4-durableapproval-forwarding.test.ts:643`, the design/153 件4 E2E chain, pins that topology and is unchanged). Rules 2 → 1: "deps baseline + only the OFF switch travels" is replaced by "a parent with a seat hands the whole seat down". Tighten-only in both directions: `"disabled"` lands UNCONDITIONALLY (a deployment baseline store does not re-arm a run the caller disarmed); a store lands only when the child's seat is unset — a baseline-pinned or trusted-script store is an explicit author and keeps winning (the baseline-principal precedent); a governed script's own `checkpointStore` is stripped and announced as before (`workflow.governance_key_stripped`, not on the whitelist). The child's own door re-reads an inherited store through the same `execution.outcome` predicate (idempotent on the object the parent already passed; no "inherited ⇒ skip" special case).
|
|
@@ -582,7 +814,6 @@ One car, one rule per family: every retired form is REFUSED by name at its read
|
|
|
582
814
|
### Fixes — a `spec.tools` entry the ToolSpec arm cannot rebuild is refused by name with a stable code (#666 ③; test [6794] G4.b; @server @test)
|
|
583
815
|
- The caller mount rebuilds a raw ToolSpec by spreading it, so an entry whose `name` / `execute` live on a prototype (a class instance, `Object.create(spec)`, `Object.create(product)`), a function carrying the brand, or a Proxy lying about its own keys mounted with `name: undefined` and failed on an unrelated roster read (`undefined.startsWith`). ONE predicate — can the spec arm construct this entry (own enumerable string `name` + own function `execute`)? — now refuses the whole family with terminal code **`config.tool_mount_denied`** (registered, terminal) and a remedy sentence shared with the rebind seal (plain-object ToolSpec / the product itself / the supported wrapper form via `stampDefineToolBrand`). The 7.11.0-era brand-inheritance arm (`inheritsDefineToolBrand`) is retired — it closed only the branded subset. Pins: the four shapes through a real Runner + the own-property control.
|
|
584
816
|
|
|
585
|
-
|
|
586
817
|
### Internal — design/393 S4: the TaskStream façade leaves `runtask.ts` as five `stream-*.ts` lanes (byte-invariant; zero behavior surface)
|
|
587
818
|
- **What moved.** `Runner.runTaskStream`'s T3–T7 — the settle backstop (the `run.catch` handler: checkpoint reopen compensation, terminal resume-failure unpin/destroy, the `failed` terminal mint, the backstop `task.end`, the owed delegation terminal, the drained `done` push), the suspended-run reap (`reapSuspended`), and the eight verb closures (`steer`; `notify` / `optOutMemoryCapture` / `compact` / `detach` / `interrupt`; `halt` / `destroy`) — moved whole to `stream-settle-backstop.ts`, `stream-reap.ts`, `stream-steer-verb.ts`, `stream-lifecycle-verbs.ts`, `stream-halt-verbs.ts` (layer 3, run lanes), each a factory behind ONE Input (`Stream…Input` / `Stream…Result`, 43 seats in all) that hands back the closure(s) the stream object carries under the same names. The driver keeps the seats (T1), the run IIFE (T2) and the eleven-name stream object; it mints the lanes' live view (`TaskStreamLiveSeat` — getters over `resultValue` / `handle` / `reapHandle`, one setter for the backstop's mint) and threads the Runner's registries and live deps seat as borrowed seats. Six seat types (`LiveHandle`, `TaskIdRef`, `ManualCompactRef`, `NotifyRef`, `CaptureOptOutRef`, `TaskStreamLiveSeat`) went down to `contracts.ts` from the driver's inline annotations. `steerChain` and `destroyOnce` moved with the only verb that read each.
|
|
588
819
|
- **Byte-invariant.** dist `runtask.js` differs from the previous release by import lines, the live-seat mint, five factory calls and eight property names only (−510 / +34); the five new files are the only additions; no other runtime file changed; the export surface is unchanged. Every await inside a moved closure sits on the tick it did: the lanes return the same functions the driver still installs in the same positions (`run.catch(onRunRejected)`, the stream object's properties), and every pre-await host read (`live.resultValue`, `live.handle`, the options bags) is the same read on the same tick. `runtask.ts` 7 565 → 6 594.
|
|
@@ -789,7 +1020,6 @@ The resume decision ladder leaves runtask.ts, and the two `runtask.ts` seams des
|
|
|
789
1020
|
registry's two G8 floors were re-measured in the same pass (rows 8 → 10, switches 1 → 3; both had drifted
|
|
790
1021
|
below the census, which is the blindness a floor exists to make loud).
|
|
791
1022
|
|
|
792
|
-
|
|
793
1023
|
## 7.8.0 — 2026-09-08
|
|
794
1024
|
|
|
795
1025
|
One roster instead of 29 name tables. `ToolSpec` already described a tool completely; the engine, the server and the shells still re-asserted each tool's identity and axes by NAME in hand-kept tables (a retired name lived on as a key in five of them). This version mints ONE effect roster per prepared leg from the mounted instances, derives every remaining table from a static catalog, and closes the mount array behind a builder.
|
|
@@ -1159,7 +1389,6 @@ Bug-fix release. No exports removed, no wire key removed; every narrowing is nam
|
|
|
1159
1389
|
### Docs
|
|
1160
1390
|
- Self-scan doc-rot batch (33 findings triaged, 13 confirmed + extensions): every rotted `file.ts:NNN` cross-reference in src re-spelled by symbol (line-anchor ratchet banks the cleanup); six orphaned JSDoc blocks reattached to their symbols (incl. the public `Runner` class and `expandTiers`); the stale `agent-harness` abort-clears-queues contract sentence corrected to the #389 truth; release-docs gate register drift fixed in seven places; REVIEW-BACKLOG stale status cells refreshed against the tree.
|
|
1161
1391
|
|
|
1162
|
-
|
|
1163
1392
|
## 7.0.2 — 2026-08-30
|
|
1164
1393
|
|
|
1165
1394
|
### Added
|
|
@@ -1249,7 +1478,6 @@ Bug-fix release. No exports removed, no wire key removed; every narrowing is nam
|
|
|
1249
1478
|
- **The Workflow tool card says what a legal `modelName` value is** (#505 ②, requested by a downstream shell): the bullet previously stated only that the field needs a configured allowlist. It now also says the legal values are the deployment's own model **catalog keys** — not generic tier words, so a bare `opus`/`sonnet`/`haiku` is refused rather than approximated — and that the field belongs on the spec (first argument), where `model` is accepted as its alias. The allowlist precondition and the omit-it-if-unsure advice are unchanged.
|
|
1250
1479
|
- **The workflow launch receipt carries the two facts its Agent-tool sibling had and it did not** ([5674] F1, #505 ③, additive): the `details` card gains `task_id` — every other face of the unified task lane spells "which task is this" that way, and this card was the one place a structured consumer had to know the lane-private `workflowRunId` spelling; `type`/`status`/`workflowRunId` are byte-untouched, so the shell's background-line marker is unaffected. The JSON body gains `handling`, the internal-metadata / do-not-echo contract the Agent tool's async-launch card has carried since RB-291 — and this is the receipt that hands back a host filesystem path (`scriptPath`) beside the ids. It is a separate key from `note` because `note` is written per notifier tier and this is true of every tier. **Deliberately NOT aligned**: the envelope GRAMMAR stays split (JSON body here, text card there) — both have shipped consumers and the Agent card is the CC-anchored form, so converting either direction would be breaking; the ruling is pinned so a later change to it is deliberate.
|
|
1251
1480
|
|
|
1252
|
-
|
|
1253
1481
|
## 5.65.0 — 2026-08-28
|
|
1254
1482
|
|
|
1255
1483
|
### Added
|
|
@@ -1329,7 +1557,6 @@ Bug-fix release. No exports removed, no wire key removed; every narrowing is nam
|
|
|
1329
1557
|
its token ceiling discloses it on the list projection as well as the full record; **#468**
|
|
1330
1558
|
`SubagentSteerHandle.steer` gains the `inputId` pass-through its workflow twin already had.
|
|
1331
1559
|
|
|
1332
|
-
|
|
1333
1560
|
## 5.63.0 — 2026-08-27
|
|
1334
1561
|
|
|
1335
1562
|
### BREAKING
|
|
@@ -2904,7 +3131,6 @@ No BREAKING changes. Narrowings disclosed below (fail-closed forms replacing sil
|
|
|
2904
3131
|
- `{scope}` erasure membership is bound rows ∪ the census's unledgered-live inhabitants — a ghost
|
|
2905
3132
|
file no longer survives a scope erase under a clean attestation.
|
|
2906
3133
|
|
|
2907
|
-
|
|
2908
3134
|
## 5.36.0 — 2026-08-15
|
|
2909
3135
|
|
|
2910
3136
|
No BREAKING changes. Upgrade note: the system-prompt shape change resets provider prompt caches
|
|
@@ -4936,7 +5162,6 @@ live `onAsk` is wired — drop the approver (or arm `forceDurableGate`) to test
|
|
|
4936
5162
|
- Platform terminals (`env.lifetime_expired` / `usage.window_exhausted`) now carry `salvagedOutput` like every other salvage-eligible terminal (the eligibility set is one closed construction point) — **a consumer pinning "salvagedOutput is empty on every non-completed result" will flip**. `TaskResult.retryAfterMs` (new, optional) rides a `usage.window_exhausted` terminal with the window's reopen hint; presence condition documented on the field — judge the cause by `errorCode`, never by this field's presence.
|
|
4937
5163
|
- `assertWorkflowDeterminism` reaches the package root alongside its two conformance siblings (the determinism battery an out-of-repo workflow runner needs; additive).
|
|
4938
5164
|
|
|
4939
|
-
|
|
4940
5165
|
- `runCascade` no longer reaches its tail with nothing dispatched. A non-finite `config.maxEscalations` is refused at the door (`Error.code = "config.cascade_invalid"`) instead of making the iteration bound `NaN` and running zero rungs; a `totalTimeoutMs` already spent at entry returns the cascade's own terminal (`status: "failed"`, `errorCode: "cascade.budget_exhausted"`, empty `attempts`, `finalRung: -1`) instead of throwing a bare `TypeError`. `CascadeRunResult.finalRung` can therefore be `-1` (no rung produced the result).
|
|
4941
5166
|
|
|
4942
5167
|
## 5.8.0 (2026-08-04)
|
|
@@ -6228,7 +6453,6 @@ A concurrency defect CLASS in the file backends (state and locks on the object,
|
|
|
6228
6453
|
- **test: the whole `stores/file/` family is now probed for this class by real concurrency**, not by reading code — CAS-type backends must have exactly one winner, collection-type backends must keep every concurrent write. Five were already correct (checkpoint / memory / workflow-run / roster / the agent store's put face); the two above were not. Both fixes are stash-verified (removing them turns the pins red again).
|
|
6229
6454
|
- **refactor (clay's ruling): the PostgreSQL reference adapters move to `src/examples/adapters/pg-adapter.ts`**, next to the TiDB one, with the layering stated at the top of the file: core owns the CONTRACTS, the deployment owns third-party backends. `@sema-agent/server` ships its own Pg/TiDB stores with connection management, retention and multi-tenant hardening that a reference adapter deliberately lacks — the two same-named classes coexist on purpose. Export surface unchanged (no BREAKING); only the path and the positioning notes changed.
|
|
6230
6455
|
|
|
6231
|
-
|
|
6232
6456
|
## 1.407.0 (2026-07-25)
|
|
6233
6457
|
|
|
6234
6458
|
Collation discipline on identity/tenant key columns + RB-2 closed by execution evidence.
|
|
@@ -6236,7 +6460,6 @@ Collation discipline on identity/tenant key columns + RB-2 closed by execution e
|
|
|
6236
6460
|
- **fix (RB-53, server [1674] class-fix): every identity/tenant key column pins its collation** — both adapters previously declared none, inheriting whatever the deployment's database defaults to. The sharpest exposure is not the tenant scope but `tool_results.ref` = `tr_<sessionId>_<toolCallId>`: a provider's toolCallId carries case (Anthropic `toolu_01ABC…`), so under MySQL 8's default `utf8mb4_0900_ai_ci` two DISTINCT tool calls collide on the primary key and one result silently overwrites the other. PostgreSQL `text` equality is byte-exact under a deterministic collation (safe by default), but a database created with a nondeterministic ICU collation folds `scope`/`token`/`ref` equality case-insensitively — a real cross-tenant read. Now pinned (`COLLATE "C"` / `COLLATE utf8mb4_bin`); `CREATE TABLE IF NOT EXISTS` is a no-op on existing tables, so this changes NEW deployments only — no migration, no behavior change for live ones. A guard pin scans the DDL source so a future key column that forgets the collation goes red (it immediately caught columns the manual sweep had missed).
|
|
6237
6461
|
- **RB-2 CLEARED by probe, not by argument** — the ledger's three "known boundaries" were adjudicated from comments; a real probe over 8 consecutive double-oversize boundaries shows `agent / skills+agent / agent / skills+agent …`, a stable two-cycle with the byte cap always fully used. ① "the pair can stay permanently uncommitted" refers to the intact-commit predicate, NOT to what the model receives: an oversize frame is clipped-and-kept with a `…[truncated]` marker, so the model knows its listing is incomplete; the residual (a genuinely oversized roster's tail never shown) is deployment-owned sizing, as the original note said — now with evidence. ② the two-cycle rotation is alive and stable (intended fairness, closed). ③ the staleness face is measured harmless: a quiet boundary neither flips the latch nor swallows that boundary's frames (closed). Four pins stay resident. Ledger path corrected too (the file lives at `src/core/runner/turn-attachments.ts`).
|
|
6238
6462
|
|
|
6239
|
-
|
|
6240
6463
|
## 1.406.0 (2026-07-25)
|
|
6241
6464
|
|
|
6242
6465
|
RB-49 partial: the two highest-leverage projection-antipattern remainders (B-1 guard, B-3 single-source).
|
|
@@ -6245,7 +6468,6 @@ RB-49 partial: the two highest-leverage projection-antipattern remainders (B-1 g
|
|
|
6245
6468
|
- **test (RB-49 B-1): a reflective guard over the bare `{...child}` spread into a subagent's host-face `details`** — deliberately NOT converted to a whitelist (the shell renders from that structured face; dropping fields would silently starve it). The guard pins the whole `TaskResult` field set, so a new field goes red and forces an explicit decision; verified it actually fires by injecting a probe field.
|
|
6246
6469
|
- RB-50 (1.405.0) and these are the CC 2.1.220 study's actionable half; B-4…B-8 and the C-level list stay recorded.
|
|
6247
6470
|
|
|
6248
|
-
|
|
6249
6471
|
## 1.405.0 (2026-07-25)
|
|
6250
6472
|
|
|
6251
6473
|
RB-50: the two prompt-shape axes resolve at ONE decision point (internal consolidation, zero behavior change).
|
|
@@ -6254,7 +6476,6 @@ RB-50: the two prompt-shape axes resolve at ONE decision point (internal consoli
|
|
|
6254
6476
|
- Pins: equivalence of the unified entry with both former paths, a four-combination orthogonality pin, and a protective E2E (one Runner running fable → non-fable → mythos in sequence keeps the mitigations axis per-task correct — guards against a refactor that caches the first task's shape).
|
|
6255
6477
|
- Study record: `anchors/2.1.220/CC-218-220-DIFF.md`; design/156 (memory-prompt convergence) opened as a follow-up; RB-51 records the capability-bundling shape for when scale warrants it.
|
|
6256
6478
|
|
|
6257
|
-
|
|
6258
6479
|
## 1.404.0 (2026-07-24)
|
|
6259
6480
|
|
|
6260
6481
|
Projection-antipattern sweep ([1630] cross-repo class-fix, core half) + the resume() symmetry fix.
|
|
@@ -6267,7 +6488,6 @@ Projection-antipattern sweep ([1630] cross-repo class-fix, core half) + the resu
|
|
|
6267
6488
|
- **fix (RB-48②): the convenience `resume()` forwards its optional 4th `internals` arg to `resumeStream`** — the thin wrapper silently dropped it, so a cross-process constraint re-supply through `resume()` always failed `resume.parent_constraint_missing` while looking wired. Red-first pin; RB-48① adjudicated closed (the test/ typecheck gate now rejects the mis-placed literal).
|
|
6268
6489
|
- RB-49 records the sweep's B/C-level remainder (bare-spread details, triple-maintained hand-written literals, duplicated payload builders).
|
|
6269
6490
|
|
|
6270
|
-
|
|
6271
6491
|
## 1.403.0 (2026-07-24)
|
|
6272
6492
|
|
|
6273
6493
|
BREAKING session-identity cleanup + the test/ directory joins the typecheck gate (424 real errors cleared).
|