@sema-agent/core 7.40.0 → 7.41.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 +218 -0
- package/dist/agents/agent-transcript-tool.d.ts +10 -2
- package/dist/agents/agent-transcript-tool.js +52 -26
- package/dist/agents/list-agents-tool.d.ts +17 -2
- package/dist/agents/list-agents-tool.js +28 -16
- package/dist/agents/peer-session-drain.d.ts +3 -1
- package/dist/agents/peer-session-drain.js +1 -0
- package/dist/agents/subagent-steps.d.ts +12 -2
- package/dist/agents/subagent-steps.js +8 -4
- package/dist/agents/subagent.d.ts +1 -17
- package/dist/agents/subagent.js +12 -24
- package/dist/core/agent-definition.d.ts +19 -40
- package/dist/core/agent-state.d.ts +91 -0
- package/dist/core/agent-state.js +79 -0
- package/dist/core/ask-origin.d.ts +28 -6
- package/dist/core/ask-origin.js +9 -5
- package/dist/core/ask-unresolvable-notice.d.ts +27 -4
- package/dist/core/ask-unresolvable-notice.js +8 -0
- package/dist/core/auto-promote.d.ts +14 -27
- package/dist/core/auto-promote.js +5 -5
- package/dist/core/checkpoint-store.d.ts +19 -20
- package/dist/core/decision-receipt.d.ts +5 -2
- package/dist/core/decision-receipt.js +1 -1
- package/dist/core/engine-notice.d.ts +7 -1
- package/dist/core/gate-crash-trace.d.ts +1 -1
- package/dist/core/gate-fold.js +7 -3
- package/dist/core/gate-lanes.js +22 -3
- package/dist/core/governance-codes.d.ts +3 -1
- package/dist/core/governance-codes.js +3 -0
- package/dist/core/hooks.d.ts +20 -6
- package/dist/core/hooks.js +28 -28
- package/dist/core/mcp-tool-meta.d.ts +24 -0
- package/dist/core/mcp-tool-meta.js +17 -0
- package/dist/core/mcp.d.ts +1 -6
- package/dist/core/mcp.js +6 -7
- package/dist/core/mode-policy.d.ts +25 -0
- package/dist/core/mode-policy.js +8 -0
- package/dist/core/protocol-table.d.ts +3 -0
- package/dist/core/protocol-table.js +3 -0
- package/dist/core/reminder-mint.d.ts +21 -1
- package/dist/core/reminder-mint.js +20 -0
- package/dist/core/retired-keys.d.ts +2 -1
- package/dist/core/retired-keys.js +13 -2
- package/dist/core/rule-denial-sentence.d.ts +2 -2
- package/dist/core/runner/call-hook-stations.d.ts +106 -0
- package/dist/core/runner/call-hook-stations.js +118 -0
- package/dist/core/runner/contracts.d.ts +35 -25
- package/dist/core/runner/gate-exit.d.ts +5 -1
- package/dist/core/runner/gate-exit.js +10 -6
- package/dist/core/runner/prepare-delegation-surface.d.ts +1 -0
- package/dist/core/runner/prepare-delegation-surface.js +4 -4
- package/dist/core/runner/prepare-gate-stations.d.ts +8 -3
- package/dist/core/runner/prepare-gate-stations.js +13 -70
- package/dist/core/runner/prepare-inherited-gate.d.ts +6 -3
- package/dist/core/runner/prepare-inherited-gate.js +5 -3
- package/dist/core/runner/prepare-park-ask.d.ts +4 -0
- package/dist/core/runner/prepare-park-ask.js +4 -1
- package/dist/core/runner/prepare-policy-chain.d.ts +6 -5
- package/dist/core/runner/prepare-policy-chain.js +14 -25
- package/dist/core/runner/prepare-project-context.d.ts +15 -16
- package/dist/core/runner/prepare-project-context.js +15 -11
- package/dist/core/runner/prepare-run-refs.d.ts +8 -8
- package/dist/core/runner/prepare-run-refs.js +3 -3
- package/dist/core/runner/prepare-safety-scan.js +3 -3
- package/dist/core/runner/prepare-suspend-saga.d.ts +2 -1
- package/dist/core/runner/prepare-suspend-saga.js +2 -1
- package/dist/core/runner/prepare-task.js +14 -12
- package/dist/core/runner/prepare-turn-wiring.d.ts +3 -1
- package/dist/core/runner/prepare-turn-wiring.js +16 -13
- package/dist/core/runner/resume-admission.js +2 -1
- package/dist/core/runner/resume-apply.js +15 -26
- package/dist/core/runner/resume-checkpoint-screen.js +3 -0
- package/dist/core/runner/resume-preflight.js +3 -3
- package/dist/core/runner/run-leg.js +61 -47
- package/dist/core/runner/runtask.d.ts +1 -1
- package/dist/core/runner/session-rule-policy.d.ts +7 -19
- package/dist/core/runner/session-rule-policy.js +1 -2
- package/dist/core/runner/skill-base-dir.d.ts +10 -0
- package/dist/core/runner/skill-base-dir.js +4 -0
- package/dist/core/runner/synthetic-tools.d.ts +11 -17
- package/dist/core/runner/synthetic-tools.js +7 -8
- package/dist/core/runner-deps.d.ts +3 -1
- package/dist/core/safety-axis-vocab.d.ts +2 -2
- package/dist/core/safety-axis-vocab.js +2 -0
- package/dist/core/session-policy-store.d.ts +63 -11
- package/dist/core/session-policy-store.js +52 -6
- package/dist/core/skill-grant.d.ts +239 -0
- package/dist/core/skill-grant.js +313 -0
- package/dist/core/skills-directory.d.ts +5 -34
- package/dist/core/skills-directory.js +9 -58
- package/dist/core/task-registry-agent.d.ts +14 -2
- package/dist/core/task-registry-agent.js +23 -8
- package/dist/core/task-registry-shared.d.ts +6 -0
- package/dist/core/task-registry.d.ts +16 -0
- package/dist/core/task-registry.js +19 -6
- package/dist/core/task-spec.d.ts +10 -0
- package/dist/core/tool-face.d.ts +3 -2
- package/dist/core/tool-name-aliases.d.ts +38 -12
- package/dist/core/tool-name-aliases.js +16 -0
- package/dist/core/tool-name-readings.d.ts +6 -2
- package/dist/core/tool-name-readings.js +6 -4
- package/dist/core/tool-policy.d.ts +2 -2
- package/dist/core/tool-registry.d.ts +1 -4
- package/dist/core/tool-registry.js +0 -3
- package/dist/core/tool-spec.d.ts +11 -13
- package/dist/core/tools.d.ts +1 -1
- package/dist/core/trace.d.ts +15 -0
- package/dist/core/types.d.ts +1 -1
- package/dist/engine/harness/agent-harness.d.ts +12 -1
- package/dist/engine/harness/agent-harness.js +17 -13
- package/dist/engine/loop/agent-loop.d.ts +16 -1
- package/dist/engine/loop/agent-loop.js +9 -15
- package/dist/engine/loop/types.d.ts +2 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +4 -2
- package/dist/stores/file/adoption/adopt.js +23 -16
- package/dist/stores/file/index.d.ts +8 -2
- package/dist/stores/file/index.js +2 -2
- package/dist/stores/file/mailbox-store.js +2 -2
- package/dist/stores/file/permission-rule-adopt.js +10 -1
- package/dist/stores/file/permission-rule-store.d.ts +6 -4
- package/dist/stores/file/permission-rule-store.js +127 -59
- package/dist/stores/file/session-policy-store.d.ts +86 -14
- package/dist/stores/file/session-policy-store.js +135 -118
- package/dist/stores/file/task-list-store.d.ts +5 -0
- package/dist/stores/file/task-list-store.js +18 -4
- package/dist/stores/file/usage-window-store.d.ts +9 -1
- package/dist/stores/file/usage-window-store.js +47 -33
- package/dist/tools/fs/safety.d.ts +1 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +45 -5
- package/wire-changes/core@7.40.1.json +39 -0
- package/wire-changes/core@7.41.0.json +1865 -0
- package/dist/core/runner/active-skill-scope.d.ts +0 -58
- package/dist/core/runner/active-skill-scope.js +0 -133
- package/dist/core/skill-tool-specifier.d.ts +0 -80
- package/dist/core/skill-tool-specifier.js +0 -57
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,221 @@
|
|
|
1
|
+
## 7.41.0 — 2026-10-04
|
|
2
|
+
|
|
3
|
+
**Hard-breaking changes:** one — #1303. **#1303 — Before:** a skill that declared `allowed-tools` (or a host's `SkillSpec.manifest.allowTools`) narrowed the whole task once activated: every unlisted tool was refused (`allowPaths` fenced writes to directories), the listed ones still asked where they always asked, and activating it asked nobody. **#1303 — Who is hurt:** a deployment that relied on a skill's list to fence the model — an unlisted tool is now judged as usual (asked under an asking mode, run under `bypassPermissions` or a deployment allow) and a listed one runs without asking for the rest of the run; a host that hand-built `SkillSpec.manifest` (now refused by name: `config.spec_retired_key`) or wrote `allowPaths` (the write fence is gone), imported the deleted `SkillManifest` type or `skillScopeWriteToolNames`, passed the retired `deployedTools` option to `createSkillsFromDirectory` (refused: `config.deps_retired_key`) or matched on one of its three removed warning codes, or built an auto-promote record with `manifest` / `gate3Scoped` / `lineageId` or passed a manifest to `deriveTripwire`; an embedder with no approver under an asking mode (activating a skill that declares a list is now a card nobody answers — refused); a deployment policy that returns an unmarked `ask` (class `ordinary`) for a listed tool (the list clears it, as a stored allow rule would); a delegation tool that read `ctx.activeSkillScope()` (renamed `ctx.activeSkillGrants()`). **#1303 — Compensation:** fence tools with deny rules (a deployment deny list, a person's deny row) or a delegated agent definition's `allowTools`; fence write paths with session `allowDirs` / the write face (`createSessionRulePolicy`, the fs-write gate); mark a policy's question `mandated` / `requiresRealApproval` (or write an ask rule) when it must reach a person; with no approver, name `Skill` in an allow list or run `bypassPermissions`; move `manifest.allowTools` to `SkillSpec.allowedTools`; drop `deployedTools` (the loader copies every entry and the run's grant table reads it); give `deriveTripwire` the artifact's `{ allowedTools }` and drop `manifest` / `gate3Scoped` / `lineageId` from the promoted record. Everything else in this version adds, tightens or re-words: #1118 adds optional members and text lines; #1294 changes which hook seats fire for a call approved on a parked checkpoint (a PreToolUse seat now sees that call again on the resumed leg); #1307 / #1308 re-word request-time refusals of the file stores (no on-disk location in the sentence; the location rides the store's disclosure seat); #1309 turns a session's enumeration over its own damaged row from a short list into a refusal; #1310 and #1314 change model-facing text only; #1156 refuses a retired or prefix-less `__` tool name at the session-rules write (it used to fail every later run instead); #1328 refuses a resumed `Skill` call whose final arguments select another skill than the card named. **Closed sets:** `DecisionReason` +1 `skill`; `ASK_CLASSES` / `AskClass` +1 `requires_user_interaction` (`MODE_ARBITRATION` +1 row; `RELEASABLE_ASK_CLASSES` and `PERSISTED_RULE_MANDATES` unchanged); `ENGINE_NOTICE_CODES` +1 `skills.allowed_tool_not_granted` (audience `operator`, minter `engine`); trace kinds +1 `permission.skill_allowed`; config refusal codes +1 `config.user_invoked_skill_unknown`; `CheckpointErrorReason` +1 `skill_grants_damaged` and `CheckpointError.detail.field` +1 `boundGrantDigest` (both on the existing `checkpoint.invalid_outcome`); `SessionPolicyError.code` +1 `legacy_tool_name` (with `detail: { field, name, verdict, currentName? }`); new closed set `RULE_TOOL_NAME_VERDICTS` (`current` / `retired` / `unprefixed_namespace`). **Package exports:** +22 / −2 (the entry's count 2558 → 2578). Added — #1118: `readAgentState`, `agentStateSourceOfRecord`, `agentStateSummary`, types `AgentState`, `AgentApproval`, `AgentApprovalState`, `AgentStateRow`, `AgentStateSource`, `TranscriptStep`; #1303: types `SkillGrantRecord`, `SkillGrantHit`, `SkillGrantLane`, `SkillGateSeats`, `PermissionSeatInputs`, `PermissionSeatReading`; #1308: type `FileUsageWindowStoreOptions`; #1156: `judgeRuleToolName`, `RULE_TOOL_NAME_VERDICTS`, `assertSessionRuleNamesCurrent`, types `RuleToolNameVerdict`, `SessionPolicyStoreOptions`, `SessionRuleNameRefusal`. Removed — #1303: `SkillManifest`, `skillScopeWriteToolNames`. Members — new and optional: `TaskSpec.userInvokedSkills`, `SkillSpec.allowedTools` / `disallowedTools`, `CheckpointState.skillGrants`, the `tool_approval` pending action's `boundGrantDigest`, `ToolGateInput.requiresUserInteraction`, `UnifiedTaskOutput.agentState`, `AgentPollDetailsInput.agentState`, `ListAgentsRow.agentState`, `serveDurableAgentRowLane(row, agentState?)`, `checkpointSeat` on `AgentTranscriptToolOptions` / `ListAgentsToolOptions` / `TaskToolOptions` / `TaskPollOptions`, `TaskRegistry.agentStateSource` (new method), `FileUsageWindowStore`'s constructor options and `createFileTaskListStore`'s `onCorruptRead`, `mountedToolNames` on both session-rules stores (`FileStorageBackendOptions.sessionPolicy` forwards it), `loosenReasons`' third parameter; `AgentTranscriptToolOptions.registry` widens to `Pick<TaskRegistry, "getAccessibleTask"> & Partial<Pick<TaskRegistry, "agentStateSource">>` (a full `TaskRegistry` still fits). Removed (the hard-breaking change above): `SkillSpec.manifest` (a host still passing it is refused by name), `SkillsDirectoryOptions.deployedTools` (refused by name), three `SkillsDirectoryWarningCode` words (`allowed_tool_pattern_unsupported`, `allowed_tool_not_mounted`, `disallowed_tools_unenforced`), `PromotedRecord.manifest` / `gate3Scoped` / `lineageId`; `deriveTripwire`'s second parameter is `Pick<SkillSpec, "allowedTools">` (was `SkillManifest`).
|
|
4
|
+
|
|
5
|
+
### A skill's `allowed-tools` is a rule source for the run, not a fence (#1303; behavior (hard-breaking: the narrowing retires); closed sets +6 words) (@server @cli @client-core @test)
|
|
6
|
+
|
|
7
|
+
- **Before (v7.40.1).** A skill's list narrowed the task: activating a skill with `allowed-tools` pushed a frame that refused every unlisted tool for the rest of the task (nested skills intersected; `allowPaths` fenced writes; an unreadable inherited frame refused everything), the listed tools still asked wherever they asked (an MCP tool on the list still raised its `unstated` card), and activation itself asked nobody. A slash-invoked skill had no seat at all.
|
|
8
|
+
- **Now (the reference implementation's shape, 2.1.280).** The list is an allow-rule source and `disallowed-tools` a deny-rule source for the run the skill is activated in:
|
|
9
|
+
- **The Skill card.** Activating a skill that declares either list asks once — `toolName: "Skill"`, class `unstated`, message `Execute skill: <name>` plus, where approving releases anything in the current mode, `Approving also lets these tools be used without asking for the rest of this run: <entries>` (sanitized, ten entries at most; the auto classifier reads it). Read on the call's final arguments. A skill with no list, or empty lists, asks nothing. `Skill` now runs alone in its batch, so a call after it in the same message sees the rows.
|
|
10
|
+
- **Listed tools run without asking — only where an allow rule could.** The rows are the allow layer's member beside a person's stored allow rows, under the same predicate: they clear `unstated` and `ordinary` asks only. Deny / ask rules, organization rules, the plan mode's question, `shellGate: "always"`, a server's destructive marks, write protection, the read boundary, a hook's ask and every mandated question still stand. Entry grammar = the persisted rule grammar: tool names, `mcp__<server>` / `mcp__<server>__*` / glob, `Bash(...)` command rules (a compound only when every other segment is read-only by itself, as for a stored allow rule), `Read(//dir/**)`; the whole `Bash` and an interpreter prefix grant outside `auto` and stand down in `auto`. `*`, `mcp__*`, write paths and domains grant nothing and are announced (`skills.allowed_tool_not_granted`).
|
|
11
|
+
- **Unlisted tools are judged as usual** — asked, never refused for being unlisted.
|
|
12
|
+
- **`disallowed-tools`** (a `*` on it reads as nothing; the rest of the list still refuses) refuses for the rest of the run, read where a person's deny rows are read (the gate, an approved edit, a resumed call, the changed-files notice) — `decisionReason: "skill"`, `deniedBy: "policy"`.
|
|
13
|
+
- **Lifetime** = the runTask (a steer, live or parked, folds into the run — measured on the reference: a mid-turn message does not end the grant); the next runTask starts clean. A delegated child captures the rows at spawn and keeps them until it ends; a capture the child cannot read refuses the spawn.
|
|
14
|
+
- **`TaskSpec.userInvokedSkills`** — the slash route: the named skills' rows take effect at the start of the run, no `Skill` call, no card; an unknown name refuses the run (`config.user_invoked_skill_unknown`). Under `dontAsk` this is the only route that grants (the model route's card is refused).
|
|
15
|
+
- **Attribution.** A cleared call carries `decisionReason: "skill"` and emits `permission.skill_allowed` `{ toolName, toolCallId, skill, entries }`.
|
|
16
|
+
- **Who is hurt.** A deployment that fenced the model with a skill's list (unlisted tools now ask or run per the mode; listed ones run without asking for the run); a host that set `SkillSpec.manifest` / `allowPaths` (not read); an embedder with no approver under an asking mode (a skill that declares a list cannot be activated by the model — the card is refused with the remedy sentence); a deployment policy whose unmarked `ask` sits on a listed tool (cleared, like a stored allow rule clears it); a delegation tool reading `ctx.activeSkillScope()`; a probe that pinned "unlisted ⇒ denied by policy (safety)" or "Skill never asks".
|
|
17
|
+
- **Compensation.** Deny rules or a delegated agent definition's `allowTools` to fence tools; session `allowDirs` / the write face to fence paths; `mandated` / `requiresRealApproval` or an ask rule for a question that must reach a person; an allow-list entry naming `Skill`, or `bypassPermissions`, for an embedder with no approver; `SkillSpec.allowedTools` in place of `manifest.allowTools`; `ctx.activeSkillGrants()` in place of `ctx.activeSkillScope()`.
|
|
18
|
+
- **The directory loader copies; it judges nothing.** `createSkillsFromDirectory` reads `allowed-tools` / `disallowed-tools` into `SkillSpec.allowedTools` / `disallowedTools` entry by entry as the author wrote them — in every spelling the format uses (space, comma, flow sequence, quoted, multi-line, block scalar) — with no intersection against a roster, no projection onto a tool name, no refusal, no de-duplication and no warning; an entry naming a tool nothing mounts is carried (a grant that never fires), a repeat is carried, and an absent or empty list is an absent member. What an entry means is read in one place, the run's grant table, which announces at the start of the run what it cannot read (`skills.allowed_tool_not_granted`). Removed with the old mapping: the `SkillsDirectoryOptions.deployedTools` option (a host still passing it is refused, `config.deps_retired_key`) and the warning codes `allowed_tool_pattern_unsupported`, `allowed_tool_not_mounted`, `disallowed_tools_unenforced`. A deny list with no allow list is carried like any other (the "unenforced" notice it used to raise is gone: a deny row is enforced on its own).
|
|
19
|
+
- **A host still passing `SkillSpec.manifest` is refused, not ignored.** Any element of `TaskSpec.skills` carrying the member ends the run with `config.spec_retired_key` (`TaskSpec.skills[].manifest is retired …`, naming `allowedTools` / `disallowedTools`), before the first model call; the resume ladder refuses it before the claim (`checkpoint.config_refused`, `detail.code` the same). `SkillManifest` and `SkillSpec.manifest` are gone from the types; the `allowPaths` write fence had no reader left and goes with them.
|
|
20
|
+
- **The auto-promote fold has no scope gate.** `PromotedRecord` is `{ gate2Verdict }` (`manifest` and `gate3Scoped` removed: the per-skill runtime narrowing they described no longer exists, so there is no scope to arm or leave unpropagated; `lineageId`, which only keyed that scope, goes with them); `decideAutoPromote` is the three-gate present-and-passing fold with the same four guards; `deriveTripwire(text, declared, profileTokens?)` takes `declared: Pick<SkillSpec, "allowedTools">` and reads each entry as a rule for the tool it names (symbol-drift baseline). The module is still inert (no production caller).
|
|
21
|
+
- **Package surface.** Deleted: `SkillManifest`, `skillScopeWriteToolNames` (its one reader, the dead internal `PATH_WRITE_TOOLS`, is deleted with it; the roster member `pathTarget.skillScopeEligible` stays declared, with no reader). Added (types only): `SkillGrantRecord`, `SkillGrantHit`, `SkillGrantLane`, `SkillGateSeats`, `PermissionSeatReading`, `PermissionSeatInputs`.
|
|
22
|
+
- **Not in this release, stated.** A write-path or domain entry (`Edit(src/**)`, `WebFetch(domain:…)`) grants nothing (the narrow allow reading for those forms is a later ticket); the model route's rows across a cross-process park follow the checkpoint section that carries them; a slash command typed mid-run grants nothing. Known limits: KL-1303-a (mid-run slash) · b (write-path / domain entries) · c (a note, not a gap: `*` voids an allow list and is inert on a deny list, whose other entries still refuse) · d (a card can list an entry that stands down in `auto`) · e (a malformed `_meta` result-size declaration is ignored silently) · f (the observer digest carries the observed child's tool output) · g (a 7.40.x worker that resumes a row parked by this release does not read the grant section).
|
|
23
|
+
- **Park and resume (car C).** *Before:* a resumed leg started with an empty grant table — what the model's activation or a child's capture granted asked again, what `disallowed-tools` refused ran again, and a parked `Skill` call activated whatever the resume's skills defined. *Now:*
|
|
24
|
+
- **The run's grown rows are frozen with the row.** A park writes `state.skillGrants` — the rows the model's activations and a delegated child's capture wrote, `{ skill, behavior, entry, origin }` with `origin` `"model"` or `"inherited"`, refusals included — and the resumed leg seeds them back (a re-supplied capture is not held twice). The slash route's rows are not frozen: the re-supplied spec writes them again (drop `userInvokedSkills` from the resume and that grant is gone). Absent section = zero rows; a present one that does not read as grant rows is refused before the claim — `checkpoint.invalid_outcome`, `detail.reason: "skill_grants_damaged"`, the row pending and unchanged — never read as empty.
|
|
25
|
+
- **A parked Skill card binds the skill and the lists it was asked under.** The row of a parked `Skill` call carries `boundGrantDigest` — the card's two disclosures: the skill's name as the skill table spells it (resolved the one way the `Skill` tool resolves `skill`: exact, case-sensitive, no alias) and its two lists in canonical spelling (`Read()` / `Read(*)` read as `Read`, order and duplicates ignored); the body is not part of it — beside `boundInputHash`; only that row carries it. An approving resume recomputes it over the resume's skills as the resumed leg will serve them (read once at the resume entrance — each definition and its lists copied, so an edit to the host's own objects during the resume reaches neither the check nor the activation; first of a name; a skill the load gate rejects, or a resume that loads none, counts as absent) and the decided arguments; any difference — another skill named (a person's edit), lists wider, narrower or gone — or a `Skill` row without one is refused before the claim: `checkpoint.invalid_outcome`, `detail.field: "boundGrantDigest"`, a sentence that names the skills (no path), says which side differs ("given on the card for A, and this decision selects B" / "A no longer declares the lists this approval was asked under") and says to decide the row as deny and re-activate the skill. A deny decides as before.
|
|
26
|
+
- **The table never holds the same row twice**, keyed on the row: activating a skill whose definition changed across the park writes the new definition's rows (its new refusals included).
|
|
27
|
+
- **The binding is judged again on the bytes that run (#1328).** The resumed leg re-screens an approved call through PreToolUse (#1294) and a re-screen may rewrite it, while the claim judged only the decided bytes: a hook that answered the parked `Skill(a)` call with `updatedInput: { skill: "b" }` activated `b` and wrote `b`'s list as approved — the card the person approved showed `a`'s. The same judgement now runs once more at the resumed execution boundary on the bytes that will run (the person's rewrite, else the re-screen's, else the row's), after every station that can change them and right before the activation: a skill there that does not digest as `boundGrantDigest` — another skill than the card's (whatever its lists), one this resume does not load, or the card's skill with other lists — is not executed (the tool result is the refusal, `isError: true`; `tool_end.gate.disposition` `{ kind: "denied", deniedBy: "policy" }` beside the person's yes; the row is already consumed, so no `CheckpointError`) and writes no grant row; the sentence names both skills (no path) and says to have the model activate it again (a new card). A rewrite to the approved skill with other bytes (`args`) runs. *Who is hurt:* a deployment whose PreToolUse hook rewrites an approved parked `Skill` call to another skill — that call no longer runs, even when the other skill's lists are the same. *Compensation:* let the model activate the other skill itself; its card shows its lists. Pins: `test/backlog1328-parked-skill-rescreen-swap.test.ts` (real Runner, real stdio MCP server, both stores): ① the swap ⇒ not executed, no row, the swapped skill's tool still asks; ② no rewrite; ③ a name the resume does not load; ④ the approved skill with other bytes, ④b another skill with the same lists (refused: no row, its body never in the context), ④c same name and lists, another body (runs); ⑤ the person's edit (refused at the claim, the sentence says another skill was selected), ⑤b the card's skill widened (the sentence says its lists changed); ⑥ a card that disclosed no lists, ⑥b a row with no digest; ⑦ the model activates the other skill itself (its card); ⑧ the host edits the skills it passed between the claim and the execution; ⑨ a later station edits the handed bytes in place. Base `e262b667f`: ①③⑥⑦⑧ red on both stores, ②④⑤⑥b green (⑤'s sentence pin red there: it said "b no longer declares"); ④b red under a name-less digest; ⑨ added after, red with the check removed; seven mutations, each red on its own fixtures.
|
|
28
|
+
- *Who is hurt:* an operator who edits a skill's lists while its card is parked (the approve is refused); a `Skill` call parked by 7.40.x through a hook / rule / policy ask (its row has no digest — an approve is refused, a deny decides); a host or SDK that routes `detail.field` / `detail.reason` exhaustively or keys on the `tool_approval` arm's key set (one new word each, one new key); a 7.40.x worker resuming a 7.41.0 row (it does not read the section — the run's skill refusals are lost there; no version stamp gates it). *Compensation:* decide the parked row as deny and let the model activate the skill again (a new card shows the current lists); drain parked rows before rolling back to 7.40.x; map `skill_grants_damaged` as terminal-for-the-row (the `write_face_damaged` row) and `boundGrantDigest` as its own recovery (deny + re-activate — re-approving the same row cannot pass).
|
|
29
|
+
- Pins: `test/backlog1303c-park-resume-grants.test.ts` (real Runner, real stdio MCP server, the in-memory and the file checkpoint stores): ① the model route's grant and ② its refusal across a park; ③ a child's capture, with and without re-supply; ④ the slash route not frozen; ⑤ eight damaged shapes refused pre-claim + the empty-list control; ⑥ no rows ⇒ no section, no digest; ⑦ widened ⇒ refused; ⑦b no digest ⇒ approve refused, deny decides; ⑦c no skills loaded ⇒ refused; ⑦d / ⑦f the host edits its own skill objects inside the preflight window ⇒ neither the check nor the activation sees it; ⑦e a skill the load gate rejects ⇒ refused; ⑧ unchanged ⇒ activates; ⑨ narrowed ⇒ refused; ⑩ canonical spelling; ⑪ only the Skill row carries the digest; ⑫ both members across a fresh file store and Runner; ⑬ ⑭ the row-keyed table; ⑮ frozen rows outlive the skill's removal from the resume (the chosen reading of a park: the run keeps what it was granted and refused). Base `f65da1617`: 22 of 31 red (the nine greens are the controls ③b ④a ④b ⑤-empty ⑥ ⑧ ⑩ ⑬ ⑭); seventeen mutations, each red on its own fixtures.
|
|
30
|
+
- **Consumers.** server — skill key whitelist: drop `manifest`, accept `allowedTools` / `disallowedTools`; pass `userInvokedSkills`; map `decisionReason: "skill"` / `permission.skill_allowed`; render the `Skill` card; DEPLOY-PREREQS: a request carrying `manifest` is refused (the engine refuses it too, `config.spec_retired_key`; `spec-fields.ts`'s `Record<keyof SkillSpec, …>` goes red on the type change); park and resume — the resume refusal routing gains a reason word (`skill_grants_damaged`, terminal for the row) and a field word (`boundGrantDigest`, deny + re-activate), the park row's key set gains `boundGrantDigest` (engine-internal, not echoed on a decide), and DEPLOY-PREREQS: drain parked rows before rolling back to 7.40.x (KL-1303-g). cli — mint `userInvokedSkills` when the person types `/name`; render the `Skill` card (the message carries what approving releases). client-core — carry both lists in the skills projection (an MCP server's skills carry none), the request key, the vocabulary mirror (+`skill`, +the notice code). test — the (a)–(f) cells, `mcp__*` / `*`, S1–S13.
|
|
31
|
+
- Pins: `test/backlog1303-skill-grant-lanes.test.ts` (real Runner, real stdio MCP server): ① a listed tool runs with no card after the Skill card (+ the three covering spellings); ② an unlisted tool is asked, not refused (direct list and the directory loader's list); ③ the card iff a list is declared; ④ the card on the final arguments (policy and hook rewrites); ⑤ `Skill` + a listed tool in one message; ⑥ a new runTask starts clean; ⑦ deny / ask rules first; ⑧ shell floor / server marks / plan mode not cleared; ⑨ `*` / `mcp__*` grant nothing and are announced; ⑩ `Bash(git:*)` vs a compound (a writing second segment asks, a read-only one runs — per command the same answer as a stored allow rule); ⑪ the slash route and its unknown-name refusal; ⑫ a child captures and keeps the rows; ⑬ deny rows at the gate, the edit round and the resumed boundary, gone next run; ⑭ dontAsk's two routes; ⑮a / ⑮b a parked / live steer leaves the grant in place, ⑮c an unlisted tool still asks after it; ⑯ an unreadable capture refuses the spawn; K1 card sentence (sanitized, capped, read by the classifier); K2a whole `Bash` / interpreter prefix by mode; K2b write-path / domain entries; K6 attribution; S11 a list naming `Skill`. Base `stage-7410`: 40 of the first 42 red (③'s two list-less rows are the controls); ⑮c added red on base. `test/backlog1303-skill-grant-unit.test.ts`: the entry grammar, the matcher both ways, the table, the capture reader, the card question. `test/backlog1303b-loader-and-surface.test.ts` (loader and surface): ① the loader copies both lists entry by entry in every spelling, with no warning; ② a name nothing mounts is carried, `deployedTools` is refused by name; ③ `SkillSpec.manifest` refused at the run, at the door and at the resume ladder; ④ no `allowPaths` / `gate3Scoped` / `SkillManifest` token anywhere in `src/`, the promoted record is one member; ⑤ the six types exported, the two names deleted (entry file, runtime, frozen snapshot); ⑥ the seven known-limit rows; S the loader's output compiles to the same rows and unread entries as the same lists written by hand; D a `*` on a deny list reads as nothing and the rest of the list still refuses (three unit cells, and the loader → Runner lane ⑬a′ in `backlog1303-skill-grant-lanes.test.ts`); ③ also the delegation lane (a `manifest` on an agent definition's `skills` refuses the child). Base `stage-7410`: 30 of the first 37 cells red (the type-level cells red under `tsc`), the directory-loader cell in `backlog1303-skill-grant-lanes.test.ts` ① red against a base-shaped loader.
|
|
32
|
+
|
|
33
|
+
### What a background agent is doing right now: one reading, three faces (#1118; behavior (additive: more facts on read faces); +9 exports) (@cli @client-core @server @test)
|
|
34
|
+
|
|
35
|
+
- **Before (v7.40.0).** AgentTranscript returned a background agent's last N steps with no state and no time; ListAgents gave an agent row a status word and nothing else; TaskOutput a status word, and for a parked agent neither the approval card's id nor its deadline nor whether it had expired. A parent model asked "how far along is it?" had only the step list and answered from it: a child parked on an approval whose card had already expired was reported as "still on step 3, about halfway".
|
|
36
|
+
- **Now.** One projection, `readAgentState` (`src/core/agent-state.ts`), reads the agent's registry row (the live handle, or the durable record when no live handle answers) and, for a parked row only, the approval checkpoint the row names. Every face carries it as `agentState` — `{ status, readAt, row, lastActivityAt?, approval?: { state, checkpointId?, deadline? } }` — and renders it with the same clauses:
|
|
37
|
+
- AgentTranscript: first line `State: <status> · last activity <age> ago[ · approval <card id> <state> (deadline in <age> | deadline passed <age> ago)]`, on every answer that resolved the row (the steps answer and the `not_retained` / read-failure / transcript-gone refusals alike). Each step line starts with `[<age> ago] `, or `[started <age> ago, no result yet] ` for a call with no result; `details.steps[i]` gains `at` (epoch ms) and `inFlight`.
|
|
38
|
+
- TaskOutput (background agents): a `state: …` line right after `status:`, on the live, durable-terminal, durable-parked and durable-still-running answers.
|
|
39
|
+
- ListAgents: the activity clause and (parked) the approval clause as columns on agent rows with a live registry row.
|
|
40
|
+
The status word is the registry row's own (`SemaTaskStatus` verbatim — `parked` = suspended on a durable approval). The approval state is the checkpoint row's (`pending` / `resolved` / `expired`) or `missing` / `unreadable`. Missing facts are said, not dropped: a durable row still marked running reads `last activity unknown (recorded as running by another engine process)`. The resume token never appears; the card is named by its non-secret `checkpointId`.
|
|
41
|
+
- **Reference.** CC 2.1.280 has no model-facing mid-run state face: `TaskOutput` / `AgentOutputTool` are in its removed-tool set (`cli280.js` @14509156), the background launch result tells the model "if the user asks for progress, say the agent is still running" and "give status, not a guess" (@19756276, @16176281), and completion arrives as a `<task-notification>` with `<status>completed|failed|killed|blocked</status>` (@12580969); "Waiting for permission…" is a UI render (@31233163). UNANCHORED — no reference cell; the faces follow the reference's principle (state the facts the engine holds, invite no guess).
|
|
42
|
+
- **Not visible, stated.** An approval answered in-process (a synchronous `onAsk`) while the agent runs is not recorded by the engine — such an agent reads `running`, and on AgentTranscript the waiting call shows as `started <age> ago, no result yet`. A pending card past its deadline stays `pending` (the store's word); the deadline clause says it passed. A grandchild's row stays unreadable to its grandparent (existing access law). Roster rows with no local row carry no state (`unknown`, as before).
|
|
43
|
+
- **Who is hurt.** A probe or renderer that pins AgentTranscript's FIRST line (`Agent a… — last N tool step(s)` is now the second line), its step lines byte-for-byte (they gain a time prefix), TaskOutput's second body line for a background agent (now `state: …`), or ListAgents' agent-row column count. A consumer that judges a `details` shape by key presence sees `agentState` on these answers.
|
|
44
|
+
- **Compensation.** Read the step body after the `] ` prefix; read the header from `details.agentState` rather than the text. A host that mounts these tools itself passes `checkpointSeat` (the store it parks children into) to get the approval card; without it a parked agent reads `approval unreadable`.
|
|
45
|
+
- **Consumers.** cli (L-776) — render `details.agentState` on the agent pane (status word, `lastActivityAt` relative to `readAt`, the card id / state / deadline for a parked agent); `details.steps[i].at` / `inFlight` for the transcript view. client-core — mirror the `AgentState` shape (open `status`; `approval.state` five words). server — a durable-row serving layer can render the same state with `readAgentState(agentStateSourceOfRecord(row), seat, now)` after its access check and pass it to `serveDurableAgentRowLane(row, agentState)`. test — the running / parked-pending / parked-expired / in-process-ask cells.
|
|
46
|
+
- Pins: `test/backlog1118-child-state-one-source.test.ts` (real Runner + Agent tool background child): ① running (all three faces, epoch on details, age in text); ② parked on a pending card (card id + deadline, no token anywhere); ③ the card expired while the row reads parked; ③b a pending card past its deadline; ④ finished (positive control: today's terminal reading unchanged); ⑤ step times and the unfinished call; ⑥ the three faces equal at one instant (running and parked); ⑦ an in-process ask (running, no approval, the unfinished call); ⑧ a grandchild row stays unreadable; ⑨ presence (no seat ⇒ `unreadable`; a durable row run elsewhere ⇒ no `lastActivityAt`, said in text; no `approval` off parked); ⑩ step lines byte-identical apart from the prefix; ⑬ ListAgents reads each row's clock beside its facts (a slow card read on one row does not stamp a later row with an older instant). Baseline v7.40.0 (`stage-7400`): 10 of the first 11 red (⑧ green — the access law already held). Mutations M1–M16 per fixture in the car log.
|
|
47
|
+
|
|
48
|
+
### A background agent that keeps working is no longer stall-collected when no observer is mounted (#1118 finding; behavior (fix); +0 exports) (@server @test)
|
|
49
|
+
|
|
50
|
+
- **Before (v7.40.0 and earlier releases carrying the design/164 stall sweep).** A background agent's registry row records its last activity on each tool start / end of the child (the stall signal `TaskRegistry.reapStaleSessionBackgroundAgents(staleMs)` reads). On the plain and fork background lanes that beat was fed only when the spawn carried an observer (`onBackgroundChildEvent`); the revive lane fed it always. With no observer mounted the row's last activity stayed at its spawn time for the whole run, so a session-scoped child that was busy running tools was collected — settled `killed`, attribution `system`, its run aborted — as soon as `staleMs` had passed since it was spawned. Measured on `stage-7400` (the 7.40.0 candidate), real Runner + Agent tool, no observer, `backgroundScope: "session"`, a child running twelve 20 ms tool steps back to back, `staleMs` 120, sweep after four steps had committed: the sweep returned 1 and the row ended `killed`.
|
|
51
|
+
- **Now.** Both lanes feed the row's activity beat whether or not an observer is mounted (with no observer the fleet tick goes nowhere; the row's time still moves). Same cell: the sweep returns 0 and the child completes. A child stuck inside one call (one beat at the call's start, then nothing) is still collected once `staleMs` passes — positive control, same on both trees.
|
|
52
|
+
- **Who is hurt (before).** A deployment that calls `reapStaleSessionBackgroundAgents` and spawns session-scoped background agents without an `onBackgroundChildEvent` observer: its busy children were killed as stalled at `staleMs` after spawn, with the interrupted-run wording. A deployment with an observer mounted was not affected (its rows already moved).
|
|
53
|
+
- **Compensation.** None needed; a deployment that sized `staleMs` large to work around premature collection can size it to the real quiet stretch now.
|
|
54
|
+
- **Also visible.** The same beat is the `lastActivityAt` the three read faces show; without the fix a busy child on such a deployment read `last activity <time since spawn> ago`.
|
|
55
|
+
- Pins: `test/backlog1118-child-state-one-source.test.ts` ⑪ (no observer: the running row's last activity moves with the child's steps on all three faces) and ⑫ (no observer: a busy child is not collected; a stuck child is). Base `stage-7400`: ⑫(a) red (`{ reapedBusy: 1, status: "killed" }`), ⑫(b) green. Mutation M15 (the new beat line removed) reds ⑪ and ⑫.
|
|
56
|
+
|
|
57
|
+
### A damaged session-rules high-water mark refuses without naming where the file is (#1307; behavior (the wording of a refusal, its disclosure and its error class on one unreadable arm); +0 exports) (@server @test)
|
|
58
|
+
|
|
59
|
+
- **Before (v7.40.1 and earlier).** `FileSessionPolicyStore.putRules` over a high-water-mark sidecar (`<stem>.hwm.json`) whose bytes are not a mark refused with `SessionPolicyError` code `corrupt` (the four ways: unparseable, not a JSON object, no numeric value, a lineage that is not a whole number) and put the file's absolute path in `message` (`session rules high-water mark is unparseable: <data root>/session-policy/<hash>.hwm.json — refusing …`); the store's `onCorruptRead` sink was never told about it. A host that forwards a thrown message to its client (the server's `PUT /v1/sessions/:id/policy` returned it in a 500 body, per that deployment's probe) put the data root on the wire. The rules ROW's refusal (v7.39.2, #1299) was already path-free with the path on the sink, so one store spoke two ways about a broken file on disk. A mark that could not be READ at all (EACCES) escaped as the raw errno error, whose text names the file too.
|
|
60
|
+
- **Now.** One voice per store: a refusal thrown through a store method names the session, the fault and the repair, and the file's location goes to `onCorruptRead`. `putRules` over a damaged mark rejects with code `corrupt` and `session rules high-water mark for "<session id>" <fault> — refusing rather than restart this key's rev from zero. Repair it: fix the mark file, or delete it (deleting it gives up the number it was holding). The file's location is reported to the store's corrupt-read disclosure (onCorruptRead), not in this message.`; `<fault>` is `is unparseable` / `is not a JSON object` / `carries no numeric value` / `carries a lineage that is not a whole number`. `onCorruptRead` receives `{ kind: "file", sessionId, principal?, path: <the .hwm.json>, reason: "high-water mark <fault>" }` with the refusal (swallow-guarded as for a row). A mark that cannot be read at all is the same coded refusal (`<fault>` = `could not be read (<errno code>)`) with the same disclosure, in place of the raw errno error. The erase sweep that purges a torn row beside a damaged mark still absorbs it and leaves the mark for the operator; its disclosure now names the MARK as `path` (it named the row the sweep had just removed) and its reason reads `erase purged a torn row whose high-water mark is corrupt (it <fault>); the mark is left for the operator`.
|
|
61
|
+
- **Unchanged.** A damaged mark is still a refusal and never zero (rules only tighten; absence of a mark would restart the key's rev); the refused write leaves the row and the mark byte-identical; `getRules` and `listBySession` never consult the mark; the lost-lineage tombstone sentence; rev / lineage arithmetic. The adoption leg (`adoptLocalDataRoot`) still stops on a mark it cannot read with code `corrupt` and its message still names the file: it runs at boot / migration time, answers an operator and has no sink to carry a location. **The boundary, as one rule:** a refusal thrown through a store method (`getRules` / `putRules` / …) carries no on-disk path and the path goes to `onCorruptRead`; adoption and boot-time errors, which answer an operator, name the file.
|
|
62
|
+
- **Who is hurt.** An operator who located the damaged file from the path in the thrown message now reads the `onCorruptRead` disclosure instead (the file backend forwards it from this face; a server deployment sees it as its `file_store_corrupt_read` warn); a probe or renderer that matches `.hwm.json` or the data root in this refusal's message; a caller that branched on the raw errno error (`code: "EACCES"`) of an unreadable mark now receives `code: "corrupt"`.
|
|
63
|
+
- **Compensation.** Wire `onCorruptRead` (the file backend's one sink reaches every face of this store) and read the file from its `path`; branch on `SessionPolicyError.code === "corrupt"`. The repair is unchanged: fix the mark file or delete it (the key then restarts from the number its row still carries, or from 1 when only the mark held it).
|
|
64
|
+
- **Package exports:** +0 / −0. `readSessionPolicyMark` (a module export of `src/stores/file/session-policy-store.ts`, not of the package entry) now returns `{ mark } | { corrupt: { reason } }` instead of throwing; its three callers were adapted in the same change.
|
|
65
|
+
- Pins: `test/backlog1307-session-policy-mark-corrupt-refusal-wording.test.ts` (real `FileSessionPolicyStore` on a temp root; ① the eight damaged sidecar shapes of the four arms refuse with a message that names the session, the fault, the repair and neither the root, the file name nor `.hwm.json` · ② the sink fires once with the mark's path, with no sink wired and with a throwing sink the refusal is the same, an anonymous key discloses without a principal · ③ reads ignore the mark; the erase sweep's disclosure names the mark · ④ the row refusal is unchanged · ⑤ a healthy sidecar stamps and reads its lineage back · ⑥ both repairs work, deleting a mark-only key's mark restarts at rev 1 and deleting it beside a standing row continues from the row · ⑦ a mark-only key with a damaged mark refuses, never rev 1 · ⑧ a directory (and, off root, a chmod 000 file) in the mark's place refuses coded · ⑨ the adoption leg still names the file) and the flipped #982 pin ⑥.
|
|
66
|
+
|
|
67
|
+
### A file store's refusal no longer names where its file is on disk (#1308; behavior (the wording of request-time refusals; two stores gain an optional disclosure option; an unreadable usage-window ledger or task list is the coded refusal instead of the raw errno error); +1 export) (@server @cli @client-core @test)
|
|
68
|
+
|
|
69
|
+
- **Before (v7.40.1 and earlier).** Four bundled file stores put a disk location in the message of a refusal thrown at request time, and hosts forward those messages: `createFileTaskListStore` threw `file task-list store: corrupt document at <path>` for a document without `nextId` / `tasks` (and a raw `SyntaxError` / `TypeError` / errno error for the other damage forms); `FileUsageWindowStore` (`charge` / `read`) threw `usage-window ledger at <path> is unreadable (…)` with `code: "usage_window.store_corrupt"`, which reached a run's `terminal.message` (a server returns it in the `POST /v1/tasks` body, the `done` frame and `GET /v1/runs/:id`), and a ledger it could not read escaped as the raw errno error; the durable permission-rule bucket (`list` / `quarantined` / every write face, so `addPersistedRule` / `removePersistedRule`'s `failed.error` and a rule import's per-member reason) answered `<bucket path> is not valid JSON …` / `… failed its integrity check …` / `could not read <path>: <errno text>` and the like, its request-time directory screen named the rule directory, its `org.json` read and its adoption-marker read during owner resolution (`forPrincipal` / `forLocalOwner`) named the file or directory, and a second writer was refused with `file store: another instance (pid N) owns this data dir (<dir>)`; `FileMailboxStore`'s box-lock errors (`mailbox.box_lock_timeout` / `mailbox.box_lock_compromised`) named the lock file, which `SendMessage` hands the model in its tool result.
|
|
70
|
+
- **Now.** One rule for the family: **a refusal a file store throws from a method at request time names the object, the fault and the repair — never a disk location; the location rides the store's disclosure seat with the refusal, or a typed field the error already carries; errors raised while a data root is opened or adopted answer an operator and keep naming the file.** Per store: the task list refuses `file task-list store: the task list document <fault> — refusing rather than start an empty list, which would hand out again the task ids it reserved. Repair it: fix the file, or delete it (…). The file's location is reported to the store's corrupt-read disclosure (onCorruptRead), not in this message.` (`<fault>`: `could not be read (<errno code>)` / `is not valid JSON (torn write?)` / `is not a JSON object` / `carries no numeric nextId or no tasks map`), and `createFileTaskListStore(dir, { onCorruptRead })` (new, optional) receives `{ kind: "file", path, reason }`; the usage-window ledger refuses `usage-window ledger for key "<key>" is unreadable (<reason>). … Repair it: fix the file, or delete it (deleting it = no usage recorded for the key). The file's location is reported …` with the same code, an unreadable ledger included (`<reason>` = `read failed (<errno code>)`), and `new FileUsageWindowStore(root, { onCorruptRead })` (new, optional; `FileStorageBackend` forwards its one sink) receives the path; the permission-rule store's request-time sentences read `this owner's permission-rule bucket <fault>; …` / `the permission-rule store directory <fault>; …` / `the org state file <fault>; …` / `the permission-rule store's adoption marker <fault>; refusing to resolve whose bucket this is until it is repaired` and end `The location is reported to the store's error disclosure (onError), not in this message.`, while `onError` receives the same sentence with `(at <path>)`; a held writer lock is `FileStoreLockError` with the same `code` / `lockPath` / `ownerPid` and the sentence `another live process (pid N) holds the permission-rule store's writer lock — refusing to interleave writes with it; …` (per code); the mailbox box-lock sentences drop the path and say the location is on the error's `lockPath`, which is unchanged. A store's failures to WRITE (a full disk, a read-only mount, a failed rename or unlink) still propagate as the raw Node error (KNOWN-LIMITS KL-1308-a).
|
|
71
|
+
- **Unchanged.** Every refusal still refuses (codes, classes and fail-closed directions are the same); `FileStorageBackend`'s own boot lock, the adoption marker and the adoption legs still name the file (`adoptFilePermissionRuleStore` re-words a held writer lock for its operator and names the lock file; `writeRuleAdoptionFile` names an unsafe directory; `readRuleAdoptionFile`, the adoption helper's reader, names the directory); disclosures (`onCorruptRead`, `onError`, the operator warnings) still carry the location.
|
|
72
|
+
- **Who is hurt.** An operator who located a damaged file from the path in a refusal now reads the disclosure (`onCorruptRead` / the permission-rule `onError`) or the error's `lockPath`; a host that constructs `FileUsageWindowStore` directly and wires no `onCorruptRead` gets the refusal without any location; a probe that matches a path, `owns this data dir` (permission-rule write face) or `the box lock <path>` in these messages; a caller that branched on the raw errno error of an unreadable usage-window ledger or task-list document now receives the coded refusal; the server's pins on these sentences (S-1014) flip.
|
|
73
|
+
- **Compensation.** Wire `onCorruptRead` (on `FileStorageBackend` it reaches every face named here; a directly built `FileUsageWindowStore` / `createFileTaskListStore` takes it as an option), read `lockPath` off a lock error, keep the permission-rule `onError` wired; branch on `code`, not on the text.
|
|
74
|
+
- **Package exports:** +1 / −0 — type `FileUsageWindowStoreOptions`; members: `FileUsageWindowStore`'s constructor takes an optional second parameter, `createFileTaskListStore`'s options gain `onCorruptRead` (new, optional).
|
|
75
|
+
- Pins: `test/backlog1308-store-refusal-no-disk-location.test.ts` (real stores on temp roots; per store ① the refusal names neither the root nor the file's name and ② the disclosure seat receives the location, over four damage forms — not JSON, a JSON array, an object missing a required member, torn — plus a failed integrity check, an unsafe directory and a held writer lock for the permission-rule store, a `FileStorageBackend`-forwarded sink for the usage-window store, both box-lock errors for the mailbox, a damaged adoption marker at owner resolution for the permission-rule store; controls: the boot lock and the adoption voice still name the file, `lockPath` still carries the lock) `test/backlog1308-read-fault.test.ts` (the org-state read's directory screen failing refuses in the store's voice), and the flipped wording pins in `design385-slice2a-lock-faults`, `design385-slice2a-crossproc.e2e`, `permission-rule-store`, `design389-unified-store` and `permission-rule-adopt`.
|
|
76
|
+
|
|
77
|
+
### A damaged session-rules row belongs to its session on every face — its enumeration refuses it, another session's erase keeps it, the rebind carries it (#1309; behavior (tighten: three faces); +0 exports) (@server @test)
|
|
78
|
+
|
|
79
|
+
- **Before (v7.40.1 and earlier).** Since 7.39.2 (#1299) the keyed read and the write refused a damaged rules row, but the three faces that sweep the directory still read it as "no rules": `listBySession` skipped it and disclosed it (a session export carried that session minus a restriction — server S-1016), `deleteBySession` of ANY session unlinked a torn row whoever it belonged to (the owning session's refusing key then read `null`), and `adoptLocalDataRoot`'s rebind left a torn or non-object row under the old key (the rebound key then read `null`).
|
|
80
|
+
- **Now.** One judgment of whose a row is, shared by the three faces: the row's own `__sid` when its bytes are a JSON object that carries one, otherwise the `__sid` of its same-stem high-water mark (`<stem>.hwm.json`, which every write lands before the row). A row its session owns makes that session's `listBySession` reject with `SessionPolicyError` code `corrupt` in the keyed read's words (`session rules file for "<session>" is <grade> (<reason>) — …`; `onCorruptRead` gets the path), and another session's listing neither lists nor discloses it; `deleteBySession` removes every row its session owns, damaged or not, ending the key's lineage, and leaves every row another session owns byte-identical with its mark untouched; the rebind moves rows before marks, and an owned damaged row travels as its bytes to the new key, where the keyed read keeps refusing. A row nothing on disk attributes keeps its old fate: omitted from every listing and disclosed, purged by an erase only when its bytes are not JSON at all, left by the rebind. A mark that cannot be READ is no evidence either way: the listing and the erase refuse (`SessionPolicyError` code `corrupt`, `session rules for "<session>": a rules row's high-water mark could not be read (<errno code>) — refusing rather than guess whose rules it holds. Retry; …`, the path on `onCorruptRead`) and remove nothing (an erase acts on the one mark read its owner judgment took, never a second one), and the rebind stops with the raw error naming the file; a session-rules directory the listing or the erase cannot read refuses the same way (it was the raw errno error, path included).
|
|
81
|
+
- **Who is hurt.** A host that exported a session whose own row is damaged now gets the `corrupt` refusal instead of a short list (a server's `GET /v1/sessions/:id/sync/manifest` relays it); a deployment that relied on another session's erase to clear a torn row now clears it through the owning session's erase or by hand; the server's pins on the skip-and-disclose enumeration (S-1016) flip.
|
|
82
|
+
- **Compensation.** Repair the row (fix the file, or delete it — deleting is an explicit "no rules"), or erase the owning session; the location is on `onCorruptRead`.
|
|
83
|
+
- **Package exports:** +0 / −0. `attributeSessionPolicyRow` is a module export of `src/stores/file/session-policy-store.ts` (not of the package entry), shared with the adoption leg.
|
|
84
|
+
- Pins: `test/backlog1309-session-policy-damaged-row-attribution.test.ts` (three faces × four damage forms — not JSON, a JSON array, an object missing `rev`, torn — each with an owned cell and an unattributed cell, plus good-row controls; baseline 10 red / 21 green), `test/backlog1309-mark-read-fault.test.ts` (one failed read of the mark during an erase, a listing and a rebind, an unlistable directory, and an erase that must act on the one mark read it judged by — a second read vanishing or failing changes nothing; baseline 6 red) and the flipped pins `backlog1003-1000` P1 ×5 / P3 / P3e / P3b / P3d / P6, `file-session-policy-store` S2, `backlog1299` ⑥c.
|
|
85
|
+
|
|
86
|
+
### A stale tool name in session rules is refused at the write, and the session's owner can take a stored one out (#1156; behavior (tighten: the write refuses; loosen: deleting a stale deny entry is not a loosening); closed set +1 (`SessionPolicyError.code` `legacy_tool_name`); +6 exports) (@server @cli @client-core @test)
|
|
87
|
+
|
|
88
|
+
- **Before (v7.40.1).** A session-rules write accepted a retired tool name (`bash`, `Task`, `read_file`, …) or a `__` name with no protocol prefix (`github__delete_repository`) in `toolDeny` / `toolAllow`; every later run of that session then failed at prepare with `config.legacy_tool_name`, and the session's owner could not take the entry out: the store only tightens, and deleting a deny entry counted as a loosening — only an operator write could clear it. The prepare audit, the serving layer's write gate and the client's mirror each kept their own copy of the criterion, and they disagreed on two inputs (a peer-covering `mcp__figma`; a retired name the deployment mounts its own tool under).
|
|
89
|
+
- **Now.** One judgment, `judgeRuleToolName(name, roster)`, answers `current` / `retired` (`guidance`, and `currentName` when the retired name has a 1:1 successor — `renamed "X"` in `RETIRED_TOOL_NAMES`; a folded or removed name has none) / `unprefixed_namespace`. The roster decides whether a retired name is still a tool: the prepare audit reads it against the run's assembled roster (unchanged behavior, its two hand-written arms now one call); the session-rules stores read it against the names the deployment DECLARES it mounts its own tools under — the new construction option `mountedToolNames` on `InMemorySessionPolicyStore` and `FileSessionPolicyStore`, forwarded by `FileStorageBackendOptions.sessionPolicy` (absent = none declared):
|
|
90
|
+
- `putRules` refuses a non-`current` entry in `toolAllow` / `toolDeny` with `SessionPolicyError` code `legacy_tool_name` — operator writes included, nothing written — after the CAS check and before the tighten-only check. `detail` = `{ field: "toolAllow" | "toolDeny", name, verdict: "retired" | "unprefixed_namespace", currentName? }`. The sentence names the entry, the current name when there is one, and the way out (declare the name in `mountedToolNames` when the deployment does mount its own tool under it); it names no storage location.
|
|
91
|
+
- `loosenReasons(prior, next, mountedToolNames?)` does not count removing a `toolDeny` entry that is not `current` against the declaration — it matched no tool — so the owner's delete of a stored stale entry goes through; a write that also loosens anything else is refused, naming only that. A declared name is an ordinary name to both checks: it writes, and removing its deny is a loosening.
|
|
92
|
+
- A bad declaration (not iterable, a bare string, a non-string or empty element, a name carrying `__`) is refused at construction with one sentence, at the store and through the backend alike.
|
|
93
|
+
- **Who is hurt.** A client that wrote a retired or prefix-less name into session rules now gets the refusal at the write instead of a session that cannot run; a deployment that mounts its own tool under a retired name and narrows it through session rules must declare it (`mountedToolNames`) — undeclared, the write is refused, and a stored deny of it can be deleted by a non-operator (KL-1156-a); a host that routes `SessionPolicyError.code` exhaustively sees one new word.
|
|
94
|
+
- **Compensation.** Write the current name (the refusal carries it); declare the names your deployment mounts its own tools under; a deployment-level tool policy (roster-aware at prepare) narrows a tool under any name.
|
|
95
|
+
- **Consumers.** server — the two write legs (`PUT /v1/sessions/:id/policy`, sync/import's `policy` face) call `assertSessionRuleNamesCurrent(rules, mountedToolNames)` (or `judgeRuleToolName` per entry) in place of `legacyToolNameOf` and keep mapping to `400 rules.legacy_tool_name` (the refusal's `detail` carries field / name / currentName); the config-side check that refuses `mcp__figma` diverges from this judgment (it is `current`: a protocol-prefixed, peer-covering name) and moves to the same call; a durable store passes the same declaration to `loosenReasons`. client-core — the generated `CORE_RETIRED_TOOL_CURRENT_NAMES` and the hand-kept legacy-name projection can read `judgeRuleToolName`'s `currentName` and the refusal's `detail` instead. cli — the session-rule delete path (L-755) now succeeds for a stored stale entry without operator rights.
|
|
96
|
+
- **Package exports:** +6 / −0 — `judgeRuleToolName`, `RULE_TOOL_NAME_VERDICTS`, `assertSessionRuleNamesCurrent` (values); `RuleToolNameVerdict`, `SessionPolicyStoreOptions`, `SessionRuleNameRefusal` (types). Members: `SessionPolicyError.detail`, `SessionPolicyError.code` +`legacy_tool_name`, `InMemorySessionPolicyStore`'s optional constructor options, `FileSessionPolicyStoreOptions.mountedToolNames` (via `SessionPolicyStoreOptions`), `FileStorageBackendOptions.sessionPolicy`, `loosenReasons`'s optional third argument.
|
|
97
|
+
- Pins: `test/backlog1156-legacy-tool-name-session-rules.test.ts` (real Runner + file store; the in-memory store; a stored stale row on disk removed by its owner, the mixed write still refused; the prefix-less name; `mcp__figma` and a mounted retired name read by the one judgment; the declared roster on both stores and the pure predicate; prepare with the name mounted (positive control) and not; no storage location in the sentence; the export surface and the verdict types; bad declarations; the default loading path through `FileStorageBackend`; the structure — prepare carries no second criterion; baseline 32 red / 7 green of 39), and the re-seeded `tool-name-aliases` G-2 pin (the stored row is served, no longer written).
|
|
98
|
+
|
|
99
|
+
### A tool call approved on a parked checkpoint passes the same hook stations as a live call (#1294; behavior (hook face: more seats fire; a re-screen can veto); +0 exports) (@server @cli @client-core @test)
|
|
100
|
+
|
|
101
|
+
- **Before (v7.40.1).** When a durable approval was redeemed, the resumed leg ran the approved call and appended its result itself. The deployment's `postToolUse` / `postToolUseFailure` never fired for that call, no PreToolUse `additionalContext` reached its result, and a `preToolUse` face was not consulted on the resumed leg at all. The one exception: a face declared `preToolUseObservational`, which was called once before the claim, only when the approver had edited the input, with no identity envelope. If that observer threw, the resume was refused and the checkpoint stayed pending.
|
|
102
|
+
- **Now.** One rule for every executed call, live or approved. Before the call runs, the resumed leg re-screens it through the gate's own PreToolUse seat (`createPreToolUseScreening`: same face, same bound, same context, same sentences). The verdict folds under the decide that approved the call, which is the upper bound:
|
|
103
|
+
- a deny, a crash, no answer, or an answer the decision receipt refuses ⇒ the call is not executed; the model gets the live gate's sentence; `tool_end.gate` reads `{ disposition: { kind: "denied", deniedBy: "hook" }, settlement: <the person's yes> }`, and the row's execution record carries the same;
|
|
104
|
+
- an ask ⇒ the question the decide already answered (nothing new is parked) — unless it demands a person (`requiresRealApproval`) and the decide was the deployment's policy, in which case it is refused;
|
|
105
|
+
- a rewrite (returned as `updatedInput`, or made in place on the args object) ⇒ those bytes execute when the decide carried no `updatedInput` of its own (a person's rewrite is the upper bound and executes as approved, as in the reference: @19428202 / @14712912) — unless the inherited ancestor chain cannot judge them (no recorded chain, an opaque layer, or a live remainder). That is the rule the pre-claim edit rung already applies to an approver's edit; here it ends in a `policy` veto and the approval is spent;
|
|
106
|
+
- `additionalContext` ⇒ rides that call's result once.
|
|
107
|
+
|
|
108
|
+
After the call runs, it goes through the loop's own finalize (`AgentHarness.finalizeToolCall` → the same `tool_result` emit): `postToolUse` / `postToolUseFailure` fire once, a `postToolUse` rewrite or context lands, and a throwing post hook leaves the executed call a success with the live loop's `[post-tool processing failed (the tool already executed): …]` note. With hooks that only watch, the approved call's `tool_start` / `tool_end` frames and its transcript row are byte-identical to v7.40.1. The pre-claim observational leg is gone; a declared-observational face is now consulted once on the resumed leg for every approved call, edited or not, with the leg's identity. Its verdict is still dropped, and a throw now refuses the call on the resumed leg (approval spent) instead of before the claim (approval pending).
|
|
109
|
+
- **Stricter than the reference, stated.** If the re-screen rewrites the bytes, those bytes go through the resumed leg's existing post-claim belts like an approver's edit does. The deployment-policy belt now fires whenever the bytes about to run are not the bytes the gate judged (an approver's edit or a re-screen rewrite; it used to key on the edit alone), and a deny there vetoes the call with "A PreToolUse hook REWROTE this approved call's input; …". CC 2.1.280 executes a hook-rewritten call under the stored answer without re-adjudicating it. This is core's existing post-claim belt reaching one more source of unadjudicated bytes, not a CC-answered shape. Up to clay; judging post goes first.
|
|
110
|
+
- **Reference.** CC 2.1.280 re-runs the whole tool pipeline when a parked permission's answer arrives after the turn ended: `handleOrphanedPermission` (`cli280.js` @19426373) → `Wn` @19438235 with `canUseTool` set to the stored answer and `requireCanUseTool: true` (@19429395) → `Hn` @19400998 → `_z` @14692301 → `PYn` @14699030. That means PreToolUse (@14703930), then the stored answer (a hook allow cannot get past it, @14668164), then PostToolUse (@14721829) / PostToolUseFailure (@14726796). A PreToolUse `defer` resume logs "re-emitting … through PreToolUse" (@19425620). An approval answered in-process screens once (PreToolUse before the prompt, same function), and so does core's live gate.
|
|
111
|
+
- **Not changed, stated.** The deny observer (`permissionDenied`) does not fire on a resumed-leg veto. That matches every other post-claim belt on that leg; the live gate's engine fail-closed exit still fires it. The resumed leg's refusal frames carry their body as one string, where the live loop carries one text block, as every post-claim refusal on that leg does. The resumed thrown-tool arm keeps its `Error: ` prefix on frame and row, so the failure seat sees that text: `failure.error` is `Error: <msg>` where the live seat sees `<msg>`.
|
|
112
|
+
- **Who is hurt.** A PreToolUse face with side effects or counters sees an approved durable call once more, on the resumed leg (before: only observational faces, only on an edited approval). A face that denies, crashes or goes silent on the resumed leg now vetoes a call a person approved, and the approval is spent. A deployment that relied on approved durable calls not reaching `postToolUse` / `postToolUseFailure` now sees them. The seat payloads are a live call's shape; only the leg differs (`ctx.identity.legKind` is `"resume"`, as on every resumed-leg hook context).
|
|
113
|
+
- **Compensation.** A face that must not re-judge an approved call reads `ctx.identity.legKind === "resume"` and answers `undefined`. A post hook that counts executions counts each call once: the resumed call is executed once and seen once.
|
|
114
|
+
- **Consumers.** server — S-996 (the observation ②): the approved call now reaches a deployment's post hooks on the resumed leg; a durable redemption whose PreToolUse face refuses ends that call `denied` / `deniedBy: "hook"`. cli / client-core — no shape change; a resumed approved call's result can carry PreToolUse / PostToolUse context like a live one. test — the cells below.
|
|
115
|
+
- Pins: `test/backlog1294-approved-call-hooks.test.ts` (real Runner + `InMemoryCheckpointStore`, a policy ask, `resumeStream(…, { gate: "policy_ask", decision })`):
|
|
116
|
+
- ① approved + succeeds ⇒ `postToolUse` once, payload key-for-key a direct run's;
|
|
117
|
+
- ② approved + fails ⇒ `postToolUseFailure` once (the `Error: ` prefix pinned);
|
|
118
|
+
- ③ PreToolUse once on each leg, re-screen before the post seat;
|
|
119
|
+
- ③a a re-screen deny ⇒ not executed, the live hook-deny sentence, `deniedBy: "hook"` beside the person's yes;
|
|
120
|
+
- ③b a person's no never reaches the screen;
|
|
121
|
+
- ③c a rewrite executes; ③d a person's `updatedInput` beats a re-screen rewrite (returned or in place), and the re-screen can still deny it;
|
|
122
|
+
- ④ a direct call: one pre, one post;
|
|
123
|
+
- ⑤ the re-screen's context rides the result once; ⑤b a later call reusing the id does not carry it again;
|
|
124
|
+
- ⑥ the watch-only golden of `tool_start` / `tool_end` / the row;
|
|
125
|
+
- ⑦ a throwing post hook ⇒ success + the live note;
|
|
126
|
+
- ⑧ the observational face (edited, unedited, throwing);
|
|
127
|
+
- ⑨ the fold (an ask answered; a mandated ask under a policy decide refused; rewritten bytes, returned or in place, meet the policy belt; no deny observer on the veto);
|
|
128
|
+
- ⑩ the crash and silence sentences of the three screening sites (live phase 1, live approved-edit re-screen, resumed re-screen) from the one builder;
|
|
129
|
+
- ⑪ a rewrite under an opaque ancestor is refused, returned or made in place (and the same chain with no rewrite runs).
|
|
130
|
+
|
|
131
|
+
Base v7.40.1 (`121552fb7`): ③b / ④ / ⑥ / ⑨a / ⑩ sites 1–2 / ⑪'s control green, the other eighteen of 27 red (③d / ③d′ were added on the review tree). Mutations M1–M13 per fixture in the car log; each cell reds under at least one.
|
|
132
|
+
|
|
133
|
+
### A server tool that declares it needs a person is asked, whatever else is allowed — `bypassPermissions` included (#1303 car D; behavior (a new mandated ask class; bypass and standing grants stop clearing it); +0 exports) (@server @cli @client-core @test)
|
|
134
|
+
|
|
135
|
+
- **Before (v7.40.1).** The engine never read `_meta["anthropic/requiresUserInteraction"]` on a server's tool listing. A call to such a tool got the generic server-tool answer — the `unstated` question under the asking modes, which an allow rule, an allow list, a host policy's allow carrying a reason, a PreToolUse hook's allow or `bypassPermissions` silenced and the auto-mode classifier answered. A server that declared "a person must answer this call" ran silently in any deployment that stated or bypassed the allow.
|
|
136
|
+
- **Now.** The MCP intake stamps `mcpRequiresUserInteraction` on the mounted tool when the declaration is the boolean `true` (the reference's `=== true`, `cli280.js` @38637044). The gate tightens a surviving allow on that tool to an ask (origin `safety_tighten`, message `tool "<name>" asks for a person's confirmation (server-declared)`, the tool name neutralized), and **every** ask on the tool carries the class `requires_user_interaction` — the question tool's arbitration row cell by cell: it reaches a person under `bypassPermissions`, the classifier does not answer it, no sandbox admission, no rule clears it, a hook's ask beside it is floored. The declaration is also one of the tool's mandate marks (a tool's own declaration, like `egress`: mandate word `tool_marks` at the lanes that run before the stamp) and the origin stamp puts `requiresRealApproval: true` on every ask of the tool, so the ask is `mandated`, no persisted allow rule or standing grant retires it, the card offers no "stop asking" rule, and **only a person's answer clears it**: a blanket `onAsk: "allow"` and an approver callback reporting a policy-settled allow are refused (`blanket_allow_refused`), a durable park mints the non-budgetable `irreversible_ask` kind (a budget resolver keys on the kind) and a `decidedBy: "policy"` decide on the row is refused with the row left pending. Under `dontAsk` the call is refused by the mode with the class on `tool_end.gate.disposition`; with no approver wired it is refused; a durable park carries the class, `origin`, `mandated` and `requiresRealApproval` on the row. A malformed declaration (the string `"true"`, a number, an object, `null`) is ignored — the tool reads as undeclared — and the operator warning lane says so (`mcp.tool_schema_advisory`: `… is mounted, but its _meta["anthropic/requiresUserInteraction"] is a string, not the boolean true — the declaration is ignored …`, once per listing); `false` and absence say nothing. The seat is read per call off the live mount array, so a server that starts declaring it and is re-dialed is asked on its very next call.
|
|
137
|
+
- **Why a class of its own.** `content_question` is the reserved question tool's content-routing signal, not a permission question (`ask-origin.ts` class docs; the lanes route by tool name, not by the word). Reusing the word would have disclosed an ordinary server call as a question card on `askClass`; the new class takes that row cell by cell and says what it is.
|
|
138
|
+
- **Reference.** CC 2.1.280 reads the declaration at the server-tool mint (`cli280.js` @38637044, `requiresUserInteraction(){return A}` @38638218) and judges it in the permission pipeline after the deny and ask-rule checks (@15744281) and before the bypass flag and release (@15744797, @15745002, @15745140) and the allow-rule lookup (@15745230): bypass and allow rules never reach it. A PermissionRequest hook's allow without `updatedInput` is ignored for such a tool (@31770646); a PreToolUse hook's allow re-enters the pipeline (`ZJ` @14669235 — inferred, not traced to the ask), so a hook does not clear it here either.
|
|
139
|
+
- **Who is hurt.** A deployment that runs servers declaring the flag and relied on `bypassPermissions`, an allow rule (`mcp__<server>__*` or the exact name), an allow list, a host policy's allow or a PreToolUse hook's allow to run them unattended: each such call now raises one card that only a person answers, and with no approver wired it is refused (headless auto-deny); an unattended deployment whose `onAsk` is a blanket `"allow"`, an approver callback reporting `settledBy: "policy"`, or a budget resolver / `decidedBy: "policy"` decide on a durable row now has such calls refused (the same refusals a `requiresRealApproval` ask meets everywhere else). A host `onAsk` that answers by class refuses the new class unless it names it (it is outside `RELEASABLE_ASK_CLASSES`) and still cannot make it run by reporting a policy-settled allow; a closed-set mirror of `AskClass` (client-core, server trace) gains a word.
|
|
140
|
+
- **Compensation.** Answer the card with a person (a live approver callback answering `true` / `{ allow: true }` is one); to run such a tool unattended, do not mount the server's declaring tools (`allowTools`) — nothing a deployment configures stands in for the person the server asked for.
|
|
141
|
+
- **Consumers.** server — trace / approval card: a new `askClass` word and `classes` member; the ask carries `mandated: true`, `requiresRealApproval: true`, `origin: "safety_tighten"`; a parked row is `irreversible_ask` carrying the real-approval bit (origin `safety_tighten`) and the same members; a `decidedBy: "policy"` decide on it is refused (`checkpoint.invalid_outcome`, row pending). cli / client-core — the closed-set mirror of `AskClass` gains `requires_user_interaction`; render the question as a permission card (it is not a question-tool card). test — see the criteria.
|
|
142
|
+
- **Not changed, stated.** `_meta["anthropic/maxResultSizeChars"]` with a malformed value is still ignored silently (`resolveMcpDeclaredResultSize`); only the new declaration has a disclosure. An allow-layer grant that a skill's tool list will carry is read through the same clearing predicate (`ruleClearanceOf`) and so does not clear this ask; that interplay is pinned on the tree that carries both.
|
|
143
|
+
- Pins: `test/backlog1303d-requires-user-interaction.test.ts` (real Runner, real stdio MCP server): ① default / acceptEdits / plan / auto — one card of the class (auto: the classifier is never consulted, an undeclared sibling is); ② bypass — the card, a yes runs, a no / headless refuses, an undeclared tool under bypass asks nothing; ③ a permission-rule allow (exact and covering), an allow list, a host policy's allow with a reason, a PreToolUse hook's allow — under default and bypass the card stands; ⑤ undeclared / `false` tools read as before; ⑥ the string / number / object declarations are ignored and warned once each; ⑦ the class word on the request, on a `dontAsk` refusal's disposition, on the parked row (kind `irreversible_ask`); ⑧ the seat member at the gate, the row cell by cell, the derivation, the clearing predicate; ⑨ a re-dial that adds the declaration; ⑩ a delegated child; ⑪ a blanket `onAsk: "allow"`, a policy-settled approver and a policy decide on the parked row are refused, a person's answer runs the call. Base `4f496ddcb`: 34 of 39 red (the five green are the controls: undeclared tools, the deny, and the reader unit). Mutations M1–M10 per fixture in the car log; each reds under at least one fixture. Mutations M1–M6 per fixture in the car log.
|
|
144
|
+
|
|
145
|
+
### A child step's one-line projections no longer carry the engine's own reminder blocks (#1310; behavior (fix: the text of a projection); +0 exports) (@server @cli @client-core @test)
|
|
146
|
+
|
|
147
|
+
- **Before (v7.39.2, v7.40.0 and v7.40.1 — the projection function is the same in all three tags).** A background child's tool result that is, or begins with, a block the engine marked for the child's context — a Read of an empty file answers `<system-reminder mark="<the child's mark>">Warning: the file exists but the contents are empty.</system-reminder>` as the WHOLE result; an offset past the end and a file already in context answer the same way — projected as its first line, which is that block's opening text. `recentSteps[].outcome` carried `<system-reminder mark="…">Warning: the file exists but the ` (80 units), and the parent's task notification showed it verbatim in `<recent-steps>`. Every context holds its own mark, so the parent read "a reminder whose mark is not mine" and could judge the child's step an injection (test's real-model measure, as reported on the ticket: both arms 20/20 sent the marked block into `<recent-steps>`; the parent judged it an injection 0/20 and 3/20, a difference that is not significant; the projection is the same function in v7.39.2, v7.40.0 and v7.40.1, so this is not a regression). The same text rode `target` / `currentAction` (tool args) and the AgentTranscript tail, which share the one projection function.
|
|
148
|
+
- **Now.** The projection (`clip` in `src/agents/subagent-steps.ts`) reads in one order: strip the engine's marked reminder blocks, take the first line, trim, redact secrets, cut to 80. The strip is the mint home's reader, `stripMarkedReminderBlocks` in `src/core/reminder-mint.ts` beside the renderer `openSystemReminder` (an internal function, not a package export); subagent-steps spells no reminder tag of its own, and `gate:reminder-literal` holds that.
|
|
149
|
+
- A result that was only such a block has `outcome: ""` — a string, never absent — and the notification line renders `- Read <target>` with no arrow. A failed step keeps its prefix (`error: `).
|
|
150
|
+
- A block followed by real text projects the real text's first line (`<block>\n\nREAL` → `REAL`); real text followed by a block, the trailer shape (`REAL\n\n<block>`), is what it was.
|
|
151
|
+
- A marked open with no close (a result cut mid-block) is stripped to the end of the text: the open tag is the engine's own mint and carries the mark.
|
|
152
|
+
- The arguments of a call that carries none of the argument keys the projection reads (`file_path`, `command`, `pattern`, …) are projected as their JSON text; the strip reads each of their strings (nested ones too) BEFORE they are serialized, because serialization escapes an open tag's quotes. Arguments with no marked block serialize byte-for-byte as before.
|
|
153
|
+
- Only a tag whose mark attribute carries a mark of the engine's scheme is a block: `<system-reminder mark="<22 base64url characters>">` as the engine mints it, and the spellings a relayed copy picks up (extra whitespace between the tag name and the attribute or before the `>`, single quotes), through the next `</system-reminder>`. Reminder-shaped text WITHOUT a mark, with a mark value the scheme does not produce, with mismatched or missing quotes, with any other attribute, or in another tag case is data and is projected as it always was.
|
|
154
|
+
- The recorder does not hold the child's own mark, so the check is the scheme's shape, not an equality: a forged block that happens to carry a well-formed mark is also dropped from the one line. The full result is not touched anywhere else.
|
|
155
|
+
- **Not changed, stated.** The renderer (`renderResidual`) and the AgentTranscript renderer still print what the field holds; frames persisted by an earlier release (a durable background-agent record's `recentSteps`) keep the bytes they recorded. The child's own final report is the model's text, not a projection of a tool result: a child that quotes its own mark there is the residual `KL-1063-c` (c2-ii). The two previews of steer bodies (`redactThenCut` in the run loop's `steering_injected` frames) are another input and are unchanged. Forwarded `tool_end` events still carry the full result to the host. A marked block that contains an unmarked `<system-reminder>` tag ends at the first close tag (the inner tag's), so the projected line can read `</system-reminder>` — a shape leftover that carries no mark. A block inside a code fence projects as the fence line (` ``` `); accepted. A tag that carries a valid mark but is spelled with whitespace around the `=`, or with another attribute beside the mark, is not recognized (the engine mints neither) and would show its mark if it were a first line; recognizing a mark independently of attribute order and spacing is an attribute parse, not a spelling tolerance, and is left to a decision (review reported it as the remaining boundary). In the JSON fallback shape the strip reads the argument VALUES, not the property NAMES: a marked block spelled as a key name stays in the projected text (it takes a child that writes its reminder into a key; reported by review, not closed here).
|
|
156
|
+
- **Who is hurt.** A deployment that audits a child's steps from `recentSteps[].outcome` or the `<recent-steps>` text and counted the engine's own empty-file / out-of-range / already-in-context warnings there now reads an empty outcome for that step (the step line and its target are still there; only the engine's reminder, which was never part of the child's result, is gone). A probe that pinned the old opening-tag text as the outcome.
|
|
157
|
+
- **Compensation.** None needed for the parent: the step is listed and the child's real result lines are unchanged. A host that wants the engine's reminder text reads the forwarded `tool_end.output` (the full result, which this change does not touch); the projection never carried a result whole.
|
|
158
|
+
- **Consumers.** server / cli / client-core — no shape change; the `recentSteps` / `task_progress` text they relay is shorter for that one step shape. test — the cells below; `KL-1063-a` (the measured mark-echo residual) and `KL-1063-c` (the parent-side channel, now closed) are rewritten in `docs/KNOWN-LIMITS.md`.
|
|
159
|
+
- Pins: `test/backlog1310-recent-steps-strip-engine-reminders.test.ts` (real Runner, a parent calling the Agent tool in the background, the child using the real Read over a temp directory; the unit cells run the same recorder over events and `stepsFromMessages` over messages):
|
|
160
|
+
- ① the parent's `<recent-steps>` carries no `mark=`, not the child's mark value, and no reminder tag outside the unmarked control step;
|
|
161
|
+
- ② a result that is only a marked block reads `outcome === ""` (both engine shapes, a block-array first block, a failed step's prefix) and renders bare;
|
|
162
|
+
- ③ controls: ordinary results (first line, trim, bound, redaction before the cut, error prefix) and a real Read's first line are what they were;
|
|
163
|
+
- ④ controls: unmarked / malformed-mark / other-case reminder-shaped text is data and keeps its opening line, in the unit cells and through the real lane; spelling variants of the engine's tag (extra whitespace, a tab or newline after the tag name, single quotes) are still stripped, and mismatched quotes, an unquoted value, a glued tag name or a malformed single-quoted value stay data;
|
|
164
|
+
- ⑤ block then real text, real text then block, an unterminated open, a block longer than the bound, and the three layers composing in order;
|
|
165
|
+
- ⑥ `target` / `currentAction` (the recognized argument keys, and the JSON fallback for any other shape, nested strings included) and the pull face (`stepsFromMessages`, and the real AgentTranscript tool over a durable child session) go through the same function;
|
|
166
|
+
- structure: `clip` calls the mint home's reader (imported from there) in the nesting that is the order, and subagent-steps carries no reminder-tag literal.
|
|
167
|
+
|
|
168
|
+
Base (the v7.41.0 tree before this change — both source files at `4f496ddcb` under the final pin file, mutation M0): 9 of 27 cells green (the controls ③ ④ including the malformed-spelling control, the trailer shape, the real-lane premise and controls, the no-literal structure cell), the other 18 red. Mutations M1–M12 per fixture in the car log (`criteria-7.41.0-mutations-1310.md`): M1 no strip in `clip`, M2 / M2b strip unmarked or any-valued blocks, M3 strip after the cut, M4 `undefined` for an emptied step, M5 a second reader in subagent-steps (caught by the structure cells, `gate:reminder-literal` exit 1, and the cells where its own pattern differs), M6 unterminated left in place, M7 blank lines kept, M8 first block only, M9 ordinary text rewritten, M10 rig check, M11 the JSON fallback serialized without the strip, M12 only the canonical spelling of the open tag; every cell reds under at least one.
|
|
169
|
+
|
|
170
|
+
### A refusal nobody could be asked for carries one way out, whichever way nobody was there (#1314; behavior (the text of one refusal and the notice's `detail.remedy` on one more cause); +0 exports) (@server @cli @client-core @test)
|
|
171
|
+
|
|
172
|
+
- **Before (v7.40.1 and earlier).** A call refused because nobody could answer its approval ended in one of two sentences, by HOW nobody was there. With no approver wired the refusal's tool text ended with the caller-side remedy (`headlessApprovalRemedy`: grant it with an allow rule / run with `permissionMode: "bypassPermissions"` / wire an approver — by class and mode) and the `delegation.ask_unresolvable` notice carried the same sentence (`message` "To let it run: …", `detail.remedy`). With an approver wired that answered `"unavailable"` for the ask (a serving layer's leg that has no approval seat, a delegated child whose ancestor's approver answered `unavailable`, a question tool whose face answered `unavailable`) the refusal quoted the ask's own question and stopped — for an unstated server tool: `approval required for "mcp__s__t" but the approver reported unavailable (no reachable operator for this ask) and no durable approval gate is armed — denied fail-closed: The agent requested permission to use mcp__s__t, but it has not been granted yet.` — and the notice for it (`cause: "approver_unavailable"`) had no `remedy` and no "To let it run:" clause. The gate's deny exit read the remedy off the one cause word `no_approver_wired`.
|
|
173
|
+
- **Now.** One table over the closed `delegation.ask_unresolvable` cause set (`ask-unresolvable-notice.ts`, total: a cause with no row is a build error) says which causes take the remedy: `no_approver_wired` and `approver_unavailable` — the two that say nobody could be asked — do; `mandate_unreconstructible` does not. The deny exit calls `headlessApprovalRemedy` once for every cause the table takes, with the mode and class it already read, so the tool text and the notice carry the same bytes, and they are the sentence the no-approver arm carries for the same mode and class but for ONE clause: the approver clause follows the seat. The table names the seat per cause (`headlessApprovalRemedy` takes `approverSeat`): no approver wired ⇒ `wire an approver (\`onAsk\`) to put it to a person`; an approver wired that answered `unavailable` for this ask ⇒ `make a person reachable through the approver that is wired (it answered \`unavailable\` for this ask)` — a seat that is wired is never told to be wired. The unavailable-seat refusal for the example above now ends `… but it has not been granted yet. — ` followed by the unstated arm's sentence, `no rule, policy or hook granted this tool, and it states no permission of its own — grant it with an allow rule (…), run with permissionMode: "bypassPermissions", or make a person reachable through the approver that is wired (it answered `unavailable` for this ask)` (the full text is what `headlessApprovalRemedy` returns; abbreviated here); the notice's `detail` gains `remedy` and its message "To let it run: <that sentence>.". Every unavailable composer reaches the same exit: the resolver's own unavailable arm, a delegated child's refusal of a call whose ancestor's approver was unreachable (class `ancestor`: no mode of the child's leg releases it, so the sentence names the approver and not a mode), the question tool (class `content_question`, the same bypass-immune arm); and the refusal the inherited marker channel composes in the fold when it is at capacity, which has no classified ask at this gate and reads as the `ancestor` class (the sentence names the approver, never a mode of the leg).
|
|
174
|
+
- **Unchanged.** The no-approver arm's text and notice, byte for byte. A person's refusal and a person's yes (no remedy, no notice). Under `bypassPermissions` an unstated call still runs (nothing is asked, so nobody is unreachable). `mandate_unreconstructible` (an inherited durable mandate this leg has no park facility to honor) keeps its own sentence and no remedy: its recovery is a park facility on that leg, which neither a permission mode nor an `onAsk` supplies. The no-approver arm's sentence — tool text and notice — is byte-identical to v7.40.1 on every arm of the function (pinned against the frozen strings).
|
|
175
|
+
- **Who is hurt.** A probe that matches the unavailable refusal's tool text at its END (`… has not been granted yet.` as the last words) now reads ` — <remedy>` after it; a renderer or probe that shows or asserts `detail.remedy` / "To let it run:" only for `cause: "no_approver_wired"`; a probe that matches the approver clause (`wire an approver (\`onAsk\`) to put it to a person`) on an `approver_unavailable` refusal now reads `make a person reachable through the approver that is wired (it answered \`unavailable\` for this ask)`.
|
|
176
|
+
- **Compensation.** Match the refusal on its start (`approval required for "<tool>" but the approver reported unavailable`) or on `delegation.ask_unresolvable` `detail.cause`; read the sentence from `detail.remedy` (present for `no_approver_wired` and `approver_unavailable`, absent for `mandate_unreconstructible`); the approver clause is the one place the two sentences differ, and `detail.cause` says which seat it was written for. No wiring change is needed to get it.
|
|
177
|
+
- **Package exports:** +0 / −0. (`askUnresolvableRemedySeat` is a function of `src/core/ask-unresolvable-notice.ts` and `HeadlessApproverSeat` a type of `src/core/ask-origin.ts`; neither is in the package entry.)
|
|
178
|
+
- Pins: `test/backlog1314-unavailable-arm-remedy.test.ts` (real Runner, real stdio MCP server, real NodeExecutionEnv over a temp dir; the frames a consumer reads — `tool_end` text, the deny observer's raw text, the notice):
|
|
179
|
+
- ① an unavailable seat × default × an unstated server tool: refused, the server receives nothing, the tool text and the notice carry the remedy of (default, unstated) — the same bytes on both faces;
|
|
180
|
+
- ② the no-approver arm on the same call: the same remedy on both faces, and the two arms' texts agree from the ask's own question to the end;
|
|
181
|
+
- ③ controls: a live approver gets the `unstated` card (a yes runs the call, a no refuses with no remedy and no notice);
|
|
182
|
+
- ④ `bypassPermissions`: the unstated call runs at an unavailable seat; a bypass-immune ask (an ask rule) at an unavailable seat reads the bypass-immune sentence, byte-identical to the no-approver arm's;
|
|
183
|
+
- ⑤ the class and the mode: `ordinary`, `unstated` under `acceptEdits`, plan's own question — each the sentence `headlessApprovalRemedy` returns for it, the same on both arms;
|
|
184
|
+
- ⑥ a delegated child whose ancestor's approver answered `unavailable`; ⑦ the mandate cause carries no remedy on either face; ⑧ the table is total over the closed set, each row naming its seat's clause, and the notice carries a handed remedy for either cause; ⑨ the question tool with no face and nowhere to park; ⑪ the approver clause follows the seat: for an `unavailable` seat every arm of the function is the frozen no-approver sentence with that one clause replaced and never says `wire an approver` (also on the real lane, tool text and notice), and the no-approver arm is byte-identical to its frozen sentence on every arm; ⑩ a refusal composed in the fold (the marker channel at capacity — handed to `runToolGate` as the object the overflow arm returns, since the loop gates one call at a time and cannot overflow the channel) carries the `ancestor` class's sentence under every mode — also when an earlier ask the person approved with an edit had been classified as an ordinary ask (the refusal's own cause selects the class) — and the mandate cause composed there carries none.
|
|
185
|
+
|
|
186
|
+
Base (the pin file over the v7.41.0 tree before the fix): ①②④b⑤a⑤b⑤c⑥ and the table cell red, the no-approver arm's faces and the controls green; ⑩a and ⑩c were each red on the fix as committed before the two review-driven corrections (it advised `permissionMode: "bypassPermissions"` for an ancestor's unresolved approval). Mutations M1–M19 per fixture in the car log (`criteria-7.41.0-mutations-1314.md`): every fixture red under at least one — M1 / M1b the pre-fix literal / the table row false · M2a–d a second function that differs by one word (one per arm of the sentence) · M3 / M3b / M10a / M10b the remedy on one face only · M4 the table all true (⑦) · M5 the control arm's row false · M6 a row forgotten (tsc and ⑧a) · M7 / M8 no class / no mode read · M9 the sentence on every refusal (③b) · M11 a yes read as unavailable (③a) · M12 bypass no longer answers (④a) · M13 / M14 no ancestor class for a fold-composed refusal / the ancestor class for every refusal (⑩a) · M15 an earlier ask's class wins over the fold's refusal (⑩c) · M16 the sentence ignores the seat input (⑪a ⑪a' and every unavailable cell) · M17 the exit hands every cause the `absent` seat · M18 / M19 the table's seat row of `no_approver_wired` / `approver_unavailable` swapped (⑪b ② / ① and the unavailable cells).
|
|
187
|
+
|
|
188
|
+
## 7.40.1 — 2026-10-03
|
|
189
|
+
|
|
190
|
+
**Hard-breaking changes:** none.
|
|
191
|
+
|
|
192
|
+
**Closed sets:** none. **Package exports:** +0 / −0.
|
|
193
|
+
|
|
194
|
+
**Repo tooling (no package behaviour):** `scripts/verify-backlog-ledger.mjs` routes its CLI entry through the shared `isCliEntry` guard (the hand-rolled `import.meta.url === pathToFileURL(argv[1])` form the 7.40.0 ledger car left behind is blind to one spelling of the file; the #915 pin caught it on the first full run after that merge).
|
|
195
|
+
|
|
196
|
+
### A cancellation has one code at the resume entrance (#1304; behavior (a code re-spelled: a cancel that ended a pre-CAS seat's wait moves to the existing `checkpoint.resume_aborted`); +0 exports) (@server @cli @client-core @test)
|
|
197
|
+
|
|
198
|
+
- **Before (v7.40.0).** `runner.resume()` runs up to two deployment seats before the claim, each bounded by the resume's own `signal`: the wake message's `userPromptSubmit` screen, and `RunnerDeps.resumePreflight`. When the signal aborted while one of them was still thinking, the refusal was spelled in the SEAT's code — `steering.blocked_by_hook` for the screen, `resume.preflight_rejected` for the preflight — and only the sentence ("the resume was cancelled while …") told it from the seat really refusing. A signal already aborted on entry, and an abort during the edit re-adjudication, already answered `checkpoint.resume_aborted`; the same cancellation had three codes depending on which rung it landed on. A consumer that dispatches on `code` recorded the caller's own cancel as a hook refusal.
|
|
199
|
+
- **Now.** An abort that ends either seat's wait is `CheckpointError("checkpoint.resume_aborted")` — the code an already-aborted signal answers. The row stays `pending` and byte-identical, and the same token redeems on a live signal (the screen is consulted again then). The seats' own codes keep only what the seat did: `steering.blocked_by_hook` — the hook blocked, did not answer within its bound, or crashed; `resume.preflight_rejected` — the preflight refused, timed out, crashed, or answered an unreadable verdict. A seat's answer that settles in the very tick of the abort loses to the abort — a decision seat settles on the cancellation (`runHookSeat`'s existing rule, unchanged); the answer is discarded and is asked again when the token is redeemed. Nothing else moves: the message of the hook's real block still carries the hook's reason; the timeout and crash sentences are unchanged; no code is added.
|
|
200
|
+
- **The rest of the ladder, read.** Every other rung that can meet a cancelled signal already answers `checkpoint.resume_aborted` (the aborted-signal rung, the three edit re-adjudication races, the two post-claim re-checks) — except that a post-claim cancel whose reopen compensation itself fails answers `checkpoint.reopen_failed` (the approval consumed, or its state unprovable; existing, unchanged); the refusals that do not depend on the signal (`resume.usage_window_exhausted`, the config / identity / placement doors, `wake.*`) keep their own codes — a cancelled signal does not change what they say.
|
|
201
|
+
- **Who is hurt.** A consumer or probe that read a screen- or preflight-time cancel as `steering.blocked_by_hook` / `resume.preflight_rejected` (and told it from a real refusal by the sentence): the cancel now arrives as `checkpoint.resume_aborted`, which the same code already means on the other two rungs. A caller that retried every `resume.preflight_rejected` as "retry later" now sees its own cancel as a cancel.
|
|
202
|
+
- **Compensation.** Dispatch on `code`: `checkpoint.resume_aborted` = the caller's cancellation, re-resume with a live signal; `steering.blocked_by_hook` = change the message; `resume.preflight_rejected` = wait for the obstacle (`detail.retryAfterMs`). `docs/INTEGRATION-CORE.md` §4.3 states the rule.
|
|
203
|
+
- **Not changed, stated.** An abort landing between the entry guard and the screen (during the governance-window read) already skipped the screen and ended `checkpoint.resume_aborted` with the row pending (reopened); pinned, not moved.
|
|
204
|
+
- Pins: `test/backlog1304-resume-screen-cancel-is-resume-aborted.test.ts` — ① the signal aborts while the wake screen runs ⇒ `checkpoint.resume_aborted` (red on v7.40.0: `steering.blocked_by_hook`); ② a hook that really blocks ⇒ `steering.blocked_by_hook` with its reason; ③ a signal aborted on entry ⇒ `checkpoint.resume_aborted`, the hook not consulted, the row byte-identical; ④ a hook that never answers / one that throws ⇒ `steering.blocked_by_hook` with a live signal; ⑤ after the cancel the row is byte-identical and the same token redeems (red on v7.40.0 at the code assert); ⑥a the signal aborts while `resumePreflight` runs ⇒ `checkpoint.resume_aborted` (red on v7.40.0: `resume.preflight_rejected`); ⑥b the preflight's timeout / throw / refusal keep `resume.preflight_rejected`; ⑥c entry abort with a preflight wired; ⑦ the abort between the entry guard and the screen; ⑧ a block / crash / refusal that settles in the same tick as the abort ends `checkpoint.resume_aborted` (both seats, both orders).
|
|
205
|
+
|
|
206
|
+
**Hard-breaking changes:** none.
|
|
207
|
+
|
|
208
|
+
**Closed sets:** none. **Package exports:** +0 / −0.
|
|
209
|
+
|
|
210
|
+
### `verify-debts --rc-gate` reads a window's promise, not the dates and versions in its note (#1267; release tooling only; ZERO runtime behaviour; +0 exports; @server @cli @client-core @test)
|
|
211
|
+
|
|
212
|
+
- **Before.** `dueForRelease` took the first date and the first version ANYWHERE in a 承诺窗 cell. A cell is a promise optionally followed by a parenthetical note that carries the re-windowing history (`1.0.140(09-27 C-R152)`, `09-17(7.19.0)`, `10-05(依 C-52;09-29 改窗一次:原 09-26)`), so a date or a version that lives only in the note made the row due: `1.0.140(09-27 C-R152)` cut at 1.0.139 read as 09-27 and blocked the release it had not promised itself to.
|
|
213
|
+
- **Now.** The promise is what stands outside every parenthesis — half- or full-width, paired by depth (the outermost pair is the note); an unclosed parenthesis swallows the rest of the cell. The first date / version of that text decides, exactly as before; a cell whose only text is a parenthetical names no promise and is due (the gate's existing fail-closed arm for a window naming neither a date nor a version). The reading is the backlog-ledger gate's: the cell's leading word is the promise, the parenthetical is the note.
|
|
214
|
+
- **Measured.** The live `DEBTS-core.md` (eight open rows) names the same seven due rows before and after, at cuts 7.40.1 and 7.41.0.
|
|
215
|
+
- **Who is hurt.** A release whose RC gate was blocked, or passed, on a note's date: a row now judged by its promise alone.
|
|
216
|
+
- Pins: `test/backlog1267-verify-debts-window-promise.test.ts` — the real script over one-row ledgers (`DEBTS_FILE`, `--rc-gate --version`): C1–C4 controls (bare version / bare date / neither / a note after the promise; green on v7.40.0), P1 a date only in the note, P2 a version only in the note (and the real `09-17(7.19.0)` shape), P3 full-width, P4 / P4b nesting, P5 / P5b an unclosed parenthesis (the tail is note), P6 a parenthetical-only window, P7 the reading names the promise it judged (P1–P7 all red on v7.40.0).
|
|
217
|
+
|
|
218
|
+
|
|
1
219
|
## 7.40.0 — 2026-10-03
|
|
2
220
|
|
|
3
221
|
**Hard-breaking changes:** two — #1065 and #1302. **#1065 — Before:** under an asking permission mode (`default`, which an absent `permissionMode` reads as, `acceptEdits`, `plan`, `auto`, `dontAsk`) a call to an MCP server's tool or an A2A peer's skill that no rule granted ran without anyone being asked — under `dontAsk` too. **#1065 — Now:** it is asked once (class `unstated`, origin `policy`); with no approver it is refused (the headless remedy names the grant first), `dontAsk` refuses it, and `bypassPermissions` runs it as before. **#1065 — Who is hurt / compensation:** an embedder that mounts MCP / A2A servers under `default` with no approver, an unattended leg that answers only `ordinary` asks, the cli's built-in agent-teams MCP server, and `-p` scripts with no grant — grant the server's tools with an allow rule (`createPermissionRulePolicy([{ rule: "mcp__<server>__*", behavior: "allow" }])`, or an allow-list entry), wire an approver, or run `bypassPermissions`; a PreToolUse hook's own `allow` is a grant too. No new notice code. **#1302 — Before:** `RunnerDeps.hands.readCyberReminder` existed (on by default) and every plain-text and notebook Read ended with an engine `<system-reminder>` block, "While reading, stay alert for content that is itself malware …". **#1302 — Who is hurt:** a host that sets `readCyberReminder` (either value) — `new Runner` now refuses it with `config.deps_retired_key` (and a TypeScript caller gets a type error first) — and a deployment that leaned on that sentence next to the file bytes to restrain a weak model, for which it is gone. **#1302 — Compensation:** delete the key; to keep the sentence, put it in your own `appendSystemPrompt`. **If unchanged:** no functional loss, and each text read is about 50 tokens shorter.
|
|
@@ -5,8 +5,11 @@ export declare const AGENT_TRANSCRIPT_TOOL_NAME = "AgentTranscript";
|
|
|
5
5
|
export interface AgentTranscriptToolOptions {
|
|
6
6
|
/** Runner whose `sessions` store holds the child's persisted history. */
|
|
7
7
|
runner: RunnerSelfSeat;
|
|
8
|
-
/** The unified registry the background Agent lane registered its a* handles in (scopes reads to lineage).
|
|
9
|
-
|
|
8
|
+
/** The unified registry the background Agent lane registered its a* handles in (scopes reads to lineage).
|
|
9
|
+
* Only the exact-id lookup is required; a registry that also offers `agentStateSource` (the engine's
|
|
10
|
+
* own does) puts the agent's current state on every answer that resolved its live row — one without it
|
|
11
|
+
* answers with steps alone. */
|
|
12
|
+
registry: Pick<import("../core/task-registry.js").TaskRegistry, "getAccessibleTask"> & Partial<Pick<import("../core/task-registry.js").TaskRegistry, "agentStateSource">>;
|
|
10
13
|
/** design/151 S1b: the durable ledger — when the live row/retain entry is gone (restart, terminal GC,
|
|
11
14
|
* retain off), the row's `sessionId` still anchors the PRESERVED transcript session (the S1b
|
|
12
15
|
* lifecycle flip skipped its release). Reads go through `runner.sessions` — a split-store child
|
|
@@ -32,6 +35,11 @@ export interface AgentTranscriptToolOptions {
|
|
|
32
35
|
* the SendMessage twin): a durable row with a bound sessionId whose session store attests
|
|
33
36
|
* not_found. Announced once per (handle, process); absent ⇒ the honest per-call degrade alone. */
|
|
34
37
|
onTranscriptIntegrityGap?: (handle: string, scope: string | undefined) => void;
|
|
38
|
+
/** The run's checkpoint seat — where a PARKED agent's approval card is read from for the state line
|
|
39
|
+
* (the same seat the Agent tool parks children into). Absent ⇒ the ctx seat
|
|
40
|
+
* (`checkpointStoreForChildren`) when the mount's ctx builder supplies one, else the card reads
|
|
41
|
+
* `unreadable`. */
|
|
42
|
+
checkpointSeat?: import("../core/checkpoint-seat.js").CheckpointSeat;
|
|
35
43
|
}
|
|
36
44
|
/**
|
|
37
45
|
* Residual observability (lane C) — build the MODEL-VISIBLE **AgentTranscript** tool: read the last N tool steps of a
|
|
@@ -4,6 +4,7 @@ import { announceTranscriptIntegrityGapOnce, canAccessAgentRecord } from "../cor
|
|
|
4
4
|
import { DURABLE_AGENT_HANDLE_RE } from "../core/task-registry.js";
|
|
5
5
|
import { inlineUntrusted } from "../core/untrusted-text.js";
|
|
6
6
|
import { stepsFromMessages } from "./subagent-steps.js";
|
|
7
|
+
import { agentStateSourceOfRecord, agentStateSummary, formatAgo, readAgentState } from "../core/agent-state.js";
|
|
7
8
|
import { REPORT_FIELD_MAX } from "./subagent.js";
|
|
8
9
|
import { getSessionRetainLedger } from "./retain-ledger.js";
|
|
9
10
|
import { toolFace } from "../core/tool-catalog-entries.js";
|
|
@@ -11,6 +12,15 @@ import { thrownText } from "../core/thrown-value.js";
|
|
|
11
12
|
export const AGENT_TRANSCRIPT_TOOL_NAME = "AgentTranscript";
|
|
12
13
|
const AGENT_TRANSCRIPT_DEFAULT_N = 20;
|
|
13
14
|
const AGENT_TRANSCRIPT_MAX_N = 50;
|
|
15
|
+
function stepTimePrefix(s, readAt) {
|
|
16
|
+
if (s.inFlight === true)
|
|
17
|
+
return s.at !== undefined ? `[started ${formatAgo(readAt - s.at)} ago, no result yet] ` : "[no result yet] ";
|
|
18
|
+
return s.at !== undefined ? `[${formatAgo(readAt - s.at)} ago] ` : "";
|
|
19
|
+
}
|
|
20
|
+
function renderSteps(steps, readAt) {
|
|
21
|
+
return steps.map((s) => `- ${stepTimePrefix(s, readAt)}${inlineUntrusted(`${s.tool}${s.target ? ` ${s.target}` : ""}${s.outcome ? ` → ${s.outcome}` : ""}`, REPORT_FIELD_MAX)}`).join("\n");
|
|
22
|
+
}
|
|
23
|
+
const clipStateHeader = (state) => `State: ${agentStateSummary(state).slice(0, REPORT_FIELD_MAX)}\n`;
|
|
14
24
|
export function createAgentTranscriptTool(opts) {
|
|
15
25
|
return defineTool({
|
|
16
26
|
name: AGENT_TRANSCRIPT_TOOL_NAME,
|
|
@@ -19,7 +29,12 @@ export function createAgentTranscriptTool(opts) {
|
|
|
19
29
|
`run_in_background), so you can see precisely where it got to — useful before SendMessage-ing a follow-up, ` +
|
|
20
30
|
`or to inspect a stopped agent's tail. Returns the last N steps (tool, target, outcome); it does not re-run ` +
|
|
21
31
|
`the agent. Works for agents this run retained (retainSubagentSessions) and for agents whose durable ` +
|
|
22
|
-
`record preserved their transcript session; when neither holds you get an honest error
|
|
32
|
+
`record preserved their transcript session; when neither holds you get an honest error. ` +
|
|
33
|
+
`The first line states the agent's current state: its status (running / parked / completed / failed / killed …), ` +
|
|
34
|
+
`when it last did anything, and — only for an agent parked on a durable approval — that approval's card id, ` +
|
|
35
|
+
`state and deadline. Each step shows how long ago it happened; a call still waiting for its result shows ` +
|
|
36
|
+
`"started <age> ago, no result yet" (an approval answered in-process while the agent runs is not visible ` +
|
|
37
|
+
`here — such a wait shows only as that unfinished call). Report what these lines say rather than estimating progress.`,
|
|
23
38
|
parameters: Type.Object({
|
|
24
39
|
id: Type.String({ description: "The background agent's task_id (a…) returned by the Agent tool." }),
|
|
25
40
|
lastN: Type.Optional(Type.Integer({ minimum: 1, maximum: AGENT_TRANSCRIPT_MAX_N, description: `How many recent steps to return (default ${AGENT_TRANSCRIPT_DEFAULT_N}).` })),
|
|
@@ -35,7 +50,9 @@ export function createAgentTranscriptTool(opts) {
|
|
|
35
50
|
scope: ctx.principal ?? opts.scope,
|
|
36
51
|
...((ctx.sessionId ?? opts.sessionId) !== undefined ? { sessionId: ctx.sessionId ?? opts.sessionId } : {}),
|
|
37
52
|
};
|
|
38
|
-
const
|
|
53
|
+
const seat = opts.checkpointSeat ?? ctx.checkpointStoreForChildren;
|
|
54
|
+
const stateOf = (src) => readAgentState(src, seat, Date.now());
|
|
55
|
+
const readDurableSteps = async (live) => {
|
|
39
56
|
if (!opts.agentStore || access.scope === undefined)
|
|
40
57
|
return undefined;
|
|
41
58
|
let durableRow;
|
|
@@ -45,30 +62,36 @@ export function createAgentTranscriptTool(opts) {
|
|
|
45
62
|
catch {
|
|
46
63
|
return undefined;
|
|
47
64
|
}
|
|
48
|
-
if (!durableRow || !canAccessAgentRecord(durableRow, access)
|
|
65
|
+
if (!durableRow || !canAccessAgentRecord(durableRow, access))
|
|
49
66
|
return undefined;
|
|
67
|
+
const recordSource = agentStateSourceOfRecord(durableRow);
|
|
68
|
+
if (durableRow.sessionId === undefined)
|
|
69
|
+
return live === undefined ? await transcriptGone(recordSource) : undefined;
|
|
70
|
+
let steps;
|
|
50
71
|
try {
|
|
51
72
|
const acquired = await opts.runner.sessions.acquire(durableRow.sessionId, { requireExisting: true });
|
|
52
73
|
const branch = await acquired.session.getBranch();
|
|
53
74
|
const messages = branch.filter((e) => e.type === "message").map((e) => e.message);
|
|
54
|
-
|
|
55
|
-
if (steps.length === 0) {
|
|
56
|
-
return { content: `Agent ${id} recorded no tool steps.`, details: { type: "agent-transcript", id, steps: [] } };
|
|
57
|
-
}
|
|
58
|
-
const body = steps
|
|
59
|
-
.map((s) => `- ${inlineUntrusted(`${s.tool}${s.target ? ` ${s.target}` : ""}${s.outcome ? ` → ${s.outcome}` : ""}`, REPORT_FIELD_MAX)}`)
|
|
60
|
-
.join("\n");
|
|
61
|
-
return {
|
|
62
|
-
content: `Agent ${id} — last ${steps.length} tool step(s) (from its preserved transcript):\n${body}`,
|
|
63
|
-
details: { type: "agent-transcript", id, steps },
|
|
64
|
-
};
|
|
75
|
+
steps = stepsFromMessages(messages, lastN);
|
|
65
76
|
}
|
|
66
77
|
catch (e) {
|
|
67
78
|
if (e?.code === "not_found") {
|
|
68
79
|
announceTranscriptIntegrityGapOnce(id, access.scope, opts.onTranscriptIntegrityGap);
|
|
69
80
|
}
|
|
70
|
-
return undefined;
|
|
81
|
+
return live === undefined ? await transcriptGone(recordSource) : undefined;
|
|
71
82
|
}
|
|
83
|
+
const state = await stateOf(live?.() ?? recordSource);
|
|
84
|
+
if (steps.length === 0) {
|
|
85
|
+
return { content: `${clipStateHeader(state)}Agent ${id} recorded no tool steps.`, details: { type: "agent-transcript", id, steps: [], agentState: state } };
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
content: `${clipStateHeader(state)}Agent ${id} — last ${steps.length} tool step(s) (from its preserved transcript):\n${renderSteps(steps, state.readAt)}`,
|
|
89
|
+
details: { type: "agent-transcript", id, steps, agentState: state },
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
const transcriptGone = async (source) => {
|
|
93
|
+
const state = await stateOf(source);
|
|
94
|
+
return { content: `${clipStateHeader(state)}No transcript found for agent ${id}.`, details: { error: "not_found", id, agentState: state }, isError: true };
|
|
72
95
|
};
|
|
73
96
|
const row = opts.registry.getAccessibleTask(id, access);
|
|
74
97
|
if (!row || row.type !== "background_agent") {
|
|
@@ -87,17 +110,20 @@ export function createAgentTranscriptTool(opts) {
|
|
|
87
110
|
}
|
|
88
111
|
return { content: `No transcript: no background agent task "${id}" (unknown id, or not yours).`, details: { error: "not_found", id }, isError: true };
|
|
89
112
|
}
|
|
113
|
+
const firstLive = opts.registry.agentStateSource?.(id, access);
|
|
114
|
+
const live = firstLive !== undefined ? () => opts.registry.agentStateSource?.(id, access) ?? firstLive : undefined;
|
|
90
115
|
const runLedger = ctx.subagentRetain ?? undefined;
|
|
91
116
|
const atLedgerSessionId = ctx.sessionId ?? opts.sessionId;
|
|
92
117
|
const sessionLedger = atLedgerSessionId !== undefined ? getSessionRetainLedger(atLedgerSessionId) : undefined;
|
|
93
118
|
const entry = row.toolUseId !== undefined ? (sessionLedger?.get(row.toolUseId) ?? runLedger?.get(row.toolUseId)) : undefined;
|
|
94
119
|
if (!entry) {
|
|
95
|
-
const durable = await readDurableSteps();
|
|
120
|
+
const durable = await readDurableSteps(live);
|
|
96
121
|
if (durable)
|
|
97
122
|
return durable;
|
|
123
|
+
const state = live !== undefined ? await stateOf(live()) : undefined;
|
|
98
124
|
return {
|
|
99
|
-
content:
|
|
100
|
-
details: { error: "not_retained", id },
|
|
125
|
+
content: `${state !== undefined ? clipStateHeader(state) : ""}No transcript: agent ${id}'s session was not retained (this run did not enable retainSubagentSessions), so its history is unavailable. Its completion notification carried the recent steps.`,
|
|
126
|
+
details: { error: "not_retained", id, ...(state !== undefined ? { agentState: state } : {}) },
|
|
101
127
|
isError: true,
|
|
102
128
|
};
|
|
103
129
|
}
|
|
@@ -110,21 +136,21 @@ export function createAgentTranscriptTool(opts) {
|
|
|
110
136
|
}
|
|
111
137
|
catch (e) {
|
|
112
138
|
const code = e?.code;
|
|
139
|
+
const state = live !== undefined ? await stateOf(live()) : undefined;
|
|
113
140
|
return {
|
|
114
|
-
content:
|
|
115
|
-
details: { error: code ?? "read_failed", id },
|
|
141
|
+
content: `${state !== undefined ? clipStateHeader(state) : ""}No transcript: agent ${id}'s session could not be read (${(code ?? thrownText(e)).slice(0, REPORT_FIELD_MAX)}).`,
|
|
142
|
+
details: { error: code ?? "read_failed", id, ...(state !== undefined ? { agentState: state } : {}) },
|
|
116
143
|
isError: true,
|
|
117
144
|
};
|
|
118
145
|
}
|
|
146
|
+
const state = live !== undefined ? await stateOf(live()) : undefined;
|
|
147
|
+
const readAt = state?.readAt ?? Date.now();
|
|
119
148
|
if (steps.length === 0) {
|
|
120
|
-
return { content:
|
|
149
|
+
return { content: `${state !== undefined ? clipStateHeader(state) : ""}Agent ${id} recorded no tool steps yet.`, details: { type: "agent-transcript", id, steps: [], ...(state !== undefined ? { agentState: state } : {}) } };
|
|
121
150
|
}
|
|
122
|
-
const body = steps
|
|
123
|
-
.map((s) => `- ${inlineUntrusted(`${s.tool}${s.target ? ` ${s.target}` : ""}${s.outcome ? ` → ${s.outcome}` : ""}`, REPORT_FIELD_MAX)}`)
|
|
124
|
-
.join("\n");
|
|
125
151
|
return {
|
|
126
|
-
content:
|
|
127
|
-
details: { type: "agent-transcript", id, steps },
|
|
152
|
+
content: `${state !== undefined ? clipStateHeader(state) : ""}Agent ${id} — last ${steps.length} tool step(s):\n${renderSteps(steps, readAt)}`,
|
|
153
|
+
details: { type: "agent-transcript", id, steps, ...(state !== undefined ? { agentState: state } : {}) },
|
|
128
154
|
};
|
|
129
155
|
},
|
|
130
156
|
}, opts.enrichCtx !== undefined ? { enrichCtx: opts.enrichCtx } : {});
|