@sema-agent/core 7.25.1 → 7.26.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 +59 -0
- package/dist/core/ask-origin.d.ts +84 -0
- package/dist/core/ask-origin.js +31 -3
- package/dist/core/auto-compaction.d.ts +5 -0
- package/dist/core/auto-compaction.js +7 -0
- package/dist/core/auto-mode-directive.d.ts +45 -0
- package/dist/core/auto-mode-directive.js +47 -0
- package/dist/core/checkpoint-store.d.ts +10 -1
- package/dist/core/engine-notice.d.ts +10 -0
- package/dist/core/gate-lanes.js +18 -10
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +3 -0
- package/dist/core/hooks.d.ts +3 -0
- package/dist/core/mcp-probe.d.ts +122 -0
- package/dist/core/mcp-probe.js +132 -0
- package/dist/core/memory-engine/engine.d.ts +6 -1
- package/dist/core/memory-engine/engine.js +30 -2
- package/dist/core/memory-engine/index.d.ts +1 -0
- package/dist/core/memory-engine/index.js +1 -0
- package/dist/core/memory-engine/shadowing.d.ts +58 -0
- package/dist/core/memory-engine/shadowing.js +40 -0
- package/dist/core/memory-engine/tools.d.ts +33 -1
- package/dist/core/memory-engine/tools.js +136 -31
- package/dist/core/memory-engine/types.d.ts +10 -0
- package/dist/core/memory.d.ts +60 -0
- package/dist/core/memory.js +47 -2
- package/dist/core/permission-rule-store.d.ts +51 -8
- package/dist/core/permission-rule-store.js +112 -65
- package/dist/core/reminder-mint.d.ts +41 -0
- package/dist/core/reminder-mint.js +55 -0
- package/dist/core/runner/compaction-restate.d.ts +9 -0
- package/dist/core/runner/compaction-restate.js +20 -0
- package/dist/core/runner/contracts.d.ts +18 -1
- package/dist/core/runner/denial-limit-arms.js +1 -1
- package/dist/core/runner/permission-rule-lanes.d.ts +5 -17
- package/dist/core/runner/permission-rule-lanes.js +5 -23
- package/dist/core/runner/prepare-ask-lane.js +2 -0
- package/dist/core/runner/prepare-context-lane.d.ts +4 -0
- package/dist/core/runner/prepare-context-lane.js +7 -2
- package/dist/core/runner/prepare-inherited-gate.d.ts +5 -1
- package/dist/core/runner/prepare-inherited-gate.js +4 -2
- package/dist/core/runner/prepare-memory.js +47 -6
- package/dist/core/runner/prepare-park-ask.js +1 -0
- package/dist/core/runner/prepare-policy-chain.js +8 -4
- package/dist/core/runner/prepare-suspend-saga.d.ts +2 -0
- package/dist/core/runner/prepare-suspend-saga.js +3 -1
- package/dist/core/runner/prepare-task.js +7 -4
- package/dist/core/runner/run-leg.js +9 -1
- package/dist/core/runner/run-recovery-lanes.js +2 -2
- package/dist/core/runner/run-terminal-adoption.js +2 -2
- package/dist/core/runner/run-turn-boundary.js +2 -2
- package/dist/core/store-contracts/permission-rule-write-contract.d.ts +20 -0
- package/dist/core/store-contracts/permission-rule-write-contract.js +77 -0
- package/dist/core/task-result.d.ts +11 -0
- package/dist/core/task-spec.d.ts +31 -0
- package/dist/core/tool-catalog-entries.js +3 -3
- package/dist/core/tool-policy.d.ts +38 -0
- package/dist/core/tool-policy.js +5 -1
- package/dist/core/types.d.ts +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -1
- package/dist/stores/file/permission-rule-store.js +48 -29
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +29 -1
- package/wire-changes/core@7.26.0.json +695 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.26.0 — 2026-09-21
|
|
4
|
+
|
|
5
|
+
### Six root exports the neighbours were hand-copying (#995; additive; +6 exports; @server @cli @client-core)
|
|
6
|
+
- `TASK_OUTPUT_REPLAY_KINDS` / `TaskOutputReplay` (the `background_bash` poll body's `replay` vocabulary — the server projected `cursorSemantics` from a hand-written two-word list), `REMOVAL_LIVENESS_WORDS` / `RemovalLiveness` (the removal read-back's three words — the server derived them with `Extract`), `normalizeReadRootCandidate` / `READ_ROOT_CANDIDATE_DIR_MAX` (the one validator a card renderer needs to accept a `ReadRootGrantCandidate`, with the directory-length bound — the server kept a weaker copy). Nothing moves; a consumer that pinned its own copy can delete it.
|
|
7
|
+
|
|
8
|
+
### A no-run MCP probe: connect, list, close, with the run's own rows (additive; +2 exports; @server @client-core @cli)
|
|
9
|
+
- **What lands.** `probeMcpServers(specs, opts?)` (`src/core/mcp-probe.ts`, exported from the package index with its result type `McpProbeResult`): dial every declared MCP server, read its tool list, close it again — no task, no registry entry, no session, no store, no model. It answers `{ entries, servers }`, where `entries` are the rows a prepared leg publishes on `wiring_manifest.mcp[]`, minted by the SAME projection from the SAME materialization (one mint point — nothing here assembles a row), and `servers` carries the probe-only reading per server: `connectMs` (measured wall time of that dial), `tools` (the namespaced names that would mount) and `errorCode`.
|
|
10
|
+
- **Contract — index alignment.** `entries` and `servers` always have the length of `specs` and are ordered by it: `entries[i]` and `servers[i]` describe `specs[i]`, with failed and timed-out servers keeping their slot. A consumer pairs its own declaration list by index, never by name matching.
|
|
11
|
+
- **Contract — residue bound, stated part by part.** The call does not settle while a handle it opened is undisposed, including a dial the clock abandoned (disposed when it lands, awaited before the call resolves or rejects). A dial has two request-BUDGET phases — the handshake (one request) and the initial `tools/list` (a paginated walk whose whole budget is that same number) — each carrying the startup-phase bound (`MCP_TIMEOUT`, else the MCP SDK's 60 000 ms default); the stdio close ladder then adds ≤ 4 000 ms (SIGTERM, then SIGKILL). So, **per dial**, against a peer that either answers or fails its requests: that dial's residue is gone within `max(timeoutMs, the handshake budget + the listing budget) + 4 000 ms`, and a server that exits when its input closes is gone at return. The CALL is the walk over the declarations, so its own duration is those per-dial times summed over `ceil(n / concurrency)` rounds — not a whole-call guarantee. **One part is bounded by nothing**: a transport SEND (the post-handshake `notifications/initialized`, an HTTP body read) carries no request deadline and the dial owns no cancellation, so a peer that answers the handshake and then stalls that send holds the call open. The bound belongs to the dial, not to this composition — recorded in `docs/KNOWN-LIMITS.md`.
|
|
12
|
+
- **`timeoutMs` bounds the verdict, not the transport.** A dial the clock abandoned keeps running while the walk moves on, so `concurrency` limits the verdicts pursued at once and NOT the transports open at once: with a short clock against an unresponsive fleet, in-flight dials reach one per declaration. Holding the slot instead would make one stuck server the walk's pace, which is what the clock exists to prevent.
|
|
13
|
+
- **Knobs.** `principal` (threaded exactly as a task's is), `timeoutMs` (bounds the per-server VERDICT, never the dial; whole ms in `[1, 2147483647]` — the ceiling every env-supplied MCP bound keeps, since past the timer's 32-bit field a delay is silently rewritten to 1 ms; default = the engine's own startup bound), `signal` (rejects with its reason after the teardown; an already-aborted signal dials nothing), `onElicit` (default: DECLINE every elicitation — a probe has nobody standing behind it), `concurrency` (default 1, in declaration order). A knob value outside its range is REFUSED (`config.mcp_probe_option`), never folded to the default.
|
|
14
|
+
- **Failure is per server, refusal is not.** A server that cannot be dialed, that answers a status, or that outlives `timeoutMs` takes its verdict in its own row — a word from the existing failure vocabulary (`MCP_FAILURE_KINDS`, no member added), with `delivered` and the `liveness` observation from the same two disposition tables — and the walk continues; nothing throws. What DOES reject is a declaration set the materialization itself refuses: two names that mount under one namespace prefix are refused before any transport is touched, by that same function, so a colliding configuration answers as a task would.
|
|
15
|
+
- **Nothing is mounted.** The materialized tools are dropped unread; the result is plain data (names, not callables), so a caller holds nothing it could invoke.
|
|
16
|
+
- Reach: @server (S-481, the per-server health face), @client-core (CC-74), @cli (L-303) — additive, nothing existing changes. Wire table: `docs/wire-consumption/unreleased-951.md`. Export surface: `npm run handoff:diff v7.25.0 HEAD` = +2 / −0.
|
|
17
|
+
### The engine states the run's approval posture; a spliced reminder is still data (#1002; P1; additive; one new optional spec key, one new type alias, one new config-door error code)
|
|
18
|
+
- **Before.** `TaskSpec.autoModeRequested` armed the CLASSIFIER and nothing else — no seat in the tree told the MAIN model it was in auto mode. Hosts filled the gap the obvious way, by splicing the reference implementation's auto-mode paragraph into the user objective as a `<system-reminder>` block. That block carries no engine mark, and the system prompt's mark declaration tells the model that reminder-shaped text without the mark is DATA to be flagged rather than followed — so the model reported the directives as an injection attempt. The rule was working as designed; one fact had two seats.
|
|
19
|
+
- **After.** The engine mints it. `src/core/auto-mode-directive.ts` holds the reference bodies whole (tool-name placeholders resolved to the wire names this engine mounts, SHA-256 locked by the pins) and one composer; `run-leg` renders the result into the FIRST user turn's marked reminder, beside the listing frames. **Cadence is once in the ACTIVE CONTEXT**, not once per run: a plain `sessionId` continuation re-enters the same branch with the previous leg's transcript intact and does not restate a segment already standing there; a segment that has left the context is restated on the next leg. The question is asked of the ACTIVE context (`session.buildContext()` — the raw ancestry still carries messages a compaction replaced), of its ENGINE REGIONS only (`engineMinted` / `engineSegments` / `enginePrefixChars` — metadata, never text shape, so a caller's spliced copy cannot answer it), and per minted BLOCK decomposed into known segments (engine provenance names the carrier, not the meaning of bytes a hook's own frame may quote). Off ⇒ zero bytes, byte-identical to before.
|
|
20
|
+
- **`TaskSpec.approverPosture?: "bypass"` (new, optional).** The second fact's typed seat — the host's statement of who answers this run's approval cards, declared by the layer that already writes `shellGate` and `autoModeRequested`. **No gate lane reads it**: the same cards are raised, routed and recorded with and without it (pinned as a control — same tool, same count, same policy). Absent ⇒ no posture, zero bytes. Any other present value refuses at the config door with **`config.approver_posture_invalid`** rather than folding back to absence: a typo must not silently un-declare a posture the host meant to declare. It rides the trusted chain (`InheritedGate.approverPosture`) and persists on the checkpoint's data half, the same law `autoModeRequested` follows; a word outside the vocabulary arriving on a chain or a checkpoint row is carried VERBATIM and rendered by nothing — the screens are the caller's door and the renderer, and a leg that cannot read a word must not destroy it (a checkpoint written by a newer version would otherwise lose its posture permanently on the first older-leg re-suspend).
|
|
21
|
+
- **Who is hurt.** ① A host that keeps splicing now sends two copies — its own (unmarked, data, unchanged) and the engine's (marked, authoritative). Nothing breaks; the splice can be removed. ② `UserMessage.enginePrefixChars` grows by the frame's length on an auto-mode or bypass-posture task's first message. Every derived view that cuts by that metadata (classifier window, compaction serializer, prompt-suggestion projection) cuts correctly with no change, but a consumer that hard-codes an expected first-message body for such a task sees the new head. ③ A host bucketing config-door `terminal.code` words on an exhaustive list gains one member. Compensation: none is owed for ①/③; ② is the metadata contract working as documented.
|
|
22
|
+
- **Deliberately NOT done, and why.** The reference implementation reads ONE permission mode, so its bypass arm REPLACES the auto-mode body; here the two are independent seats and a run may hold both, so each renders its own segment and both are emitted when both hold — silencing the auto-mode paragraph while the classifier is armed would withhold guidance the run is operating under. Registered divergence, pinned by a mutation. The reference's consent-flow paragraph (its batch-the-asks block) is not shipped: its upstream gate has no fact seat here — `docs/KNOWN-LIMITS.md` KL-1002a. A within-turn-loop compaction still takes the frame out of a context the run continues in (measured; restored on the next leg, and the reference implementation has the same shape) — KL-1002b.
|
|
23
|
+
- **headless.** Zero change to any headless lane: no gate decision, no card, no notice code, no wire frame key moves. A headless deployment that declares neither fact renders byte-identically to 7.25.1.
|
|
24
|
+
- `test/backlog1002-auto-mode-directive.test.ts` (15 pins, 7 mutations recorded); `docs/wire-consumption/unreleased-1002.md`; `docs/INTEGRATION-CORE.md` §4.7.
|
|
25
|
+
### A failed rule write says WHICH failure it is, and a no-op says what it saw (#996; P2; additive type face; no wire key, +0 root exports)
|
|
26
|
+
- **Before.** `AddResult.failed` / `RemoveResult.failed` carried one prose sentence for two facts a caller must branch on differently: the store ANSWERED and nothing landed (no write face, a target the type face refused, a read that failed before any write, a refused preflight, an epoch seal, a compare-and-set that lost every attempt), versus the apply THREW — which proves nothing either way, because a backend can commit and lose its acknowledgement. `RuleAddLanding.committed` had the word already; both entry points dropped it, so a consumer either parsed the sentence or answered "retry" to every failure.
|
|
27
|
+
- **After.** Both `failed` arms carry `committed: "no" | "unknown"` — one vocabulary, registered (`RULE_WRITE_COMMITTED_WORDS` / `RuleWriteCommitted`, `docs/CLOSED-SETS.md`), spelled once and read by all three sites. The add entry passes the landing's own word through rather than re-deriving it; the removal entry reads it off the flag its retry loop already set (a thrown apply, never the message text). `error` is unchanged and stays a sentence for a person.
|
|
28
|
+
- **The `no-op` arm carries `stillLive`.** `RemoveResult`'s nothing-to-remove arm reports `RemovalLiveness` through the SAME read-back the `removed` arm takes (`readRuleLiveness`, one function, both arms): a no-op is decided on a snapshot, and an approval landing in that window leaves the rule STANDING while the caller is told there was nothing to remove. A consumer that minted its own liveness word to cover that window now reads core's — one question, one answer. A read-back that could not be taken is the third word, never `no`.
|
|
29
|
+
- **Two pre-apply failures reach the union at all (adversarial round).** `removePersistedRule` awaited `writer.nextDot()` outside its error handling, so a backend that mints by WRITING (the file backend persists its counter) answered an ordinary disk failure with a REJECTED promise — the one exit that carries no word; the add entry had caught the same failure since it was written. Acquiring the write face is likewise an operation on a backend that guards its bytes with a process lock, and both entries read it outside a catch. Both are now `{ status: "failed", committed: "no" }` through one resolution (`resolveRuleWriter`), which is what "the failed arm says which failure it is" means for a failure that could not previously reach the arm. Who is hurt: a caller with a `try`/`catch` around `removePersistedRule` that relied on the rejection — the declared type was always `Promise<RemoveResult>`, and the add entry's shape is the one both now have.
|
|
30
|
+
- **Who is hurt.** Nobody who READS these results: both changes are additive members on existing arms. A consumer that CONSTRUCTS an `AddResult`/`RemoveResult` (a test double, a fake lane) adds the key. One sentence changed: a removal whose compare-and-set lost every attempt AND whose read-back then failed used to be reported "indeterminate"; it is `committed: "no"` and says the store answered every attempt — the add entry never hedged that case either (`committed: "no"` is terminal there, read-back or no read-back).
|
|
31
|
+
- Reach: **@server** — the revoke/write routes map `committed: "no"` to a terminal answer instead of 503+retry, and the no-op arm's own `stillLive` read-back (`rules.ts`, the second mint point) retires in favour of core's word; **@cli** — a revoke result's "it is gone" branch stays `stillLive === "no"`, now on both 200-shaped arms; **@client-core** — type rows only. Headless: unchanged behaviour, one more field on two arms. `test/backlog996-failed-arm-committed.test.ts`.
|
|
32
|
+
|
|
33
|
+
### An unnamed write-delta kind is refused by both backends, identically (#1001; P2; loud refusal where the file backend silently deleted)
|
|
34
|
+
- **Before.** `RuleWriteDelta`'s four arms were dispatched by an if/else chain whose LAST arm swallowed everything else: the in-memory backend sent an unnamed kind into `applySyncJoin` (which threw for want of an inbound state), while the file backend folded it as a **tighten-delete** — a delta carrying a well-formed tombstone under an unnamed kind was written to disk as a removal. Two backends, opposite answers, for an input that is DATA (a foreign caller, a replayed record, a deserialized join), not a typed call the compiler has vetted.
|
|
35
|
+
- **After.** Both backends dispatch an exhaustive `switch` with a `never` arm — the compile-time half, which reds when a fifth kind joins the union — over ONE shared refusal (`unknownRuleWriteDeltaError`), so the two cannot drift in what they say. The file backend's "unknown kind with a tombstone is a delete" arm is gone; a refused delta leaves the bucket byte-identical and the revision unmoved.
|
|
36
|
+
- **The contract kit says so out loud.** `permissionRuleWriteContract` (new, exported) is the write-face acceptance suite an out-of-repo twin runs: the named arms land, a stale revision is ANSWERED (`conflict`) rather than thrown, and a kind outside the union is refused instead of folded as the arm it resembles. The unnamed-kind clause ships WITH the positive control in the same run, because a backend that throws at everything would otherwise pass it.
|
|
37
|
+
- **What the kit MEASURES (two adversarial rounds).** Three clauses were added after wrappers passed the suite while being broken: a landed write must ADVANCE the revision and spend the token the caller held (a constant revision is a number, not a fence — such a store accepts every write against any snapshot); a declined write must move nothing in the RAW state, not merely in the live rows (a landed delete is invisible to a live-rows check); and the raw face must RETAIN the tombstoned add and the whole tombstone (that is the evidence both write entries reconcile a lost acknowledgement against, and a dropped tombstone also lets a collected add reappear through a sync join). Each clause ships with the broken backend that motivated it, in `test/backlog996-write-delta-kinds.test.ts`.
|
|
38
|
+
- **Who is hurt.** A caller that relied on the file backend folding an unnamed kind into a delete — none in-tree, and the behaviour was unreachable through a typed call. Exports +2 (`permissionRuleWriteContract`, `PermissionRuleWriteContractHooks`), removals 0.
|
|
39
|
+
- Reach: **@server** — the store twin's acceptance floor gains one suite to run (the sync kit is unchanged); **@cli** / **@client-core** — zero. Headless: a malformed delta from a foreign backend caller now throws where the file backend previously wrote a tombstone. `test/backlog996-write-delta-kinds.test.ts`.
|
|
40
|
+
### A mandate is a fact about the ask, not about a lane — `AskRequest.mandated` (#997; P2; approval face; additive, no export change)
|
|
41
|
+
- **Before.** The only way an approval surface could learn that a card was MANDATED — that no persisted allow rule and no standing posture could retire it — was `AskRequest.ruleOffersAbsence === "mandated"`, a seat that reports what the RULE-OFFER LANE has to give. That lane speaks for one tool, only where a rule store is wired, and only on a task that can hold a rule; its own contract says absence is not a claim. Measured on the base of this entry, real Runner, `cat <path outside the session roots>`, both `shellGate` doctrines: `Bash` with a store and a principal carried `"mandated"`; the SAME command on `Monitor`, on `Bash` with no store, and on an anonymous task carried nothing. One approval card in every row; the mandate — the read station's own structural demotion — present in every row.
|
|
42
|
+
- **After.** `AskRequest.mandated?: true` (and the `PermissionResult` ask-arm member it is copied from) states it on the ask. Minted ONCE, at the gate's single origin stamp, from the one derivation the card's rule-offer word is also read from (`ruleClearanceOf`): the door SET decides the bit, the incumbent display ladder decides the word. Which door a card shows is a display choice; whether the ask is mandated is not — so a card worded `shadowed` over a probe mandate is mandated all the same, which the two-enumeration shape got wrong.
|
|
43
|
+
- **The doors,** in one place: the tool seat's `egress` / `irreversibility` marks, an operator `shellGate: "always"`, the shell read station's structural demotion or a probe that timed out or threw, a write the protection judge matched or could not resolve, `requiresRealApproval`, a hook-raised ask, and an ask decided at an ANCESTOR's frozen approver. **Not** a door: a person's own explicit `ask` permission rule — the lane words that `shadowed`, because a rule they wrote is already speaking.
|
|
44
|
+
- **What the bit says, and what it does not.** It says a persisted allow RULE may not clear the card. It does NOT say an approver may not answer it: a person, a hook and a deployment's blanket `onAsk: "allow"` seat all resolve a mandated ask exactly as before, and `requiresRealApproval` remains the separate, stronger "a person, not a configuration" demand. A shell whose POSTURE seat declines to auto-answer mandated cards reads this member to know which they are — @cli L-459, @client-core CC-110 (frame mirror), @server in transit. Absent on an older engine is not the opposite claim; tell the two apart by engine version, not by this seat.
|
|
45
|
+
- **Who is hurt.** For an approval surface, nobody: the member is additive, every allow/deny/ask verdict a deployment sees is unchanged, and a surface that ignores it renders the card it rendered before. Inside the gate one thing does move, from the same predicate: the persisted-allow-rule arm and the read-only-command arm now read the shared derivation instead of enumerating the boundary a second time, so a `mandated` bit an upstream layer set (an ancestor's gate, a composing policy) is no longer cleared by a matching allow rule or by the read-only tables — measured before this entry: a policy ask carrying the bit, with a covering `Bash(npm run build)` allow rule, was allowed with nobody asked, and `ls` was cleared by the read-only arm. Tightening only, and exactly on the mandate set: the arms read the ENFORCEMENT half of the shared derivation (`ruleMayClear` — the mandate doors plus a person's explicit `ask` rule), never its display word, so `persistedRuleShadowed` — an account of a refusal that already happened, display-only by contract — does not refuse a second time. A deployment that never carries a mandate onto an ask is byte-identical. Where the new arms do refuse a carried mandate this task's own marks cannot name, the card no longer claims an ask-me-each-time rule matched (it says the call arrived already marked), which the old catch-all sentence would have.
|
|
46
|
+
- Tightening-only in both directions that matter: the derivation has no clearing arm, and the policy fold carries a folded-away ask's bit onto the survivor exactly as it already carries `probeMandated` — so the inherited (delegation) lane, which replays an ancestor's stamped ask as one layer of that fold, cannot be where a mandate goes missing. Public export surface unchanged (`handoff:diff` 0/0); `ruleOffersAbsence` and `probeMandated` are untouched.
|
|
47
|
+
|
|
48
|
+
### Merge-tree adversarial round: two fixes (R1 #997 × durable park; R2 #1002 × compaction × resume)
|
|
49
|
+
- **R1.** A mandated ask that PARKS (durable approval, no live approver) now carries the stamped mandate through the ask carry (`AskCarry.mandated`, minted by `carryOf` at every park site) into the row's rule-offer facts: the parked row offers NO rule and says `ruleOffersAbsence: "mandated"` — what the live card says for the same ask. Before the fix the park sites dropped the bit and the row of a policy-mandated `Bash npm run build` offered `Bash(npm run build)` + `Bash(npm run *)`, a standing grant solicited for an ask no rule can clear. The row itself carries no `mandated` twin (KL-997a): a durable consumer reads the mandate as the closed reason word.
|
|
50
|
+
- **R2/R3.** The posture directive now stands in EVERY request's context, not only the first turn's: (i) the moment a compaction lands — at all three compaction sites (turn boundary, forced/guard pass, end-of-task) — the runner asks the first-turn question of the compacted context (`statedReminderBodiesOf`, one reader) and appends the missing segments as one engine-minted frame, the git frame's post-compaction carrier; (ii) a request clipped to the window keeps or reinserts the newest posture carrier through the same protection slot the git frame rides. Before the fix only the first-turn branch asked: a compaction before a durable suspend, a compaction inside the resumed leg (the approved call's large result), and a per-request clip each left the model without the directives while the checkpoint still carried the posture. The reference implementation re-mints its attachment whenever the message array no longer holds one; a compaction or clip is where that happens here, so the restatement rides the same events (cadence stated in the ship post as a divergence candidate). A context that still holds the frame mints nothing (unchanged; never doubled).
|
|
51
|
+
- Pins: `test/backlog7260-review-fixes.test.ts` (R1 + control; R2 compaction + clip before a suspend; R3 compaction inside the resumed leg + control), each red on the merged tree before its fix; `backlog1002` P7 restated (restate at once, never doubled). Headless: R1 changes a parked row's offers only where a mandate is declared; R2/R3 change only what a run with a declared posture sends the model.
|
|
52
|
+
|
|
53
|
+
|
|
3
54
|
## 7.25.1 — 2026-09-20
|
|
4
55
|
|
|
5
56
|
### A bare `NAME+=value` word is the assignment bash reads, not a program name (#993; P2; read boundary; narrowing on deny rows, widening on the rest; no wire key, no export)
|
|
@@ -151,6 +202,14 @@
|
|
|
151
202
|
- **Who is hurt.** `BackgroundAgentRecord.teamName?` and `RegisterBackgroundAgentInput.teamName?` are removed — a dead column: two copy sites forwarded it and no site in `src/` ever minted a value. Readers of the core row: server 0 (`record_json` passthrough), cli 0 (its own `teamName` is its CC fork's, not this row's), client-core 0, sdk 0.
|
|
152
203
|
- **Compensation.** None needed — the column never held a value; a hand-written row holding the key still reads back unchanged (`backgroundAgentStoreContract` gains the case). The team key is `rootSessionId`, already on every roster row, registry row, fleet spawn frame and durable record: one implicit team per session (CC 2.1.259 marks `team_name` deprecated / ignored).
|
|
153
204
|
- **Contract sentence.** `task_list.partition_is_root_session`: a deployment that mounts a `TaskListStore` per session keys it by the delegation tree's ROOT session — every child of the tree (spawned live, or revived by a host verb) reads and writes the same list. In-engine children inherit the leader's tool objects and share by construction; a host's revive leg that resolves a store per call is the divergence this sentence names.
|
|
205
|
+
### The session's write home wins by name, and a deployment can state its project identity (#863 片2; L3; NARROWING of the default memory read face + 1 new export + 1 new notice code; @server @cli @client-core)
|
|
206
|
+
- **NARROWING.** A read-only memory layer's entry whose NAME the session's write home already carries leaves the DEFAULT read face: no mechanical row in the injected index (a model-worded link row pointing at it drops with a warning; linkless prose stays), no `memory_search` hit, no `memory_index` row, and a bare slug whose candidates include the write home resolves to the write home instead of refusing `ambiguous_slug`. **Nothing is written, moved or deleted**: `memory_get id=…` delivers the entry with a head note naming the shadowing one, `includeShadowed` (new, on `memory_search` and `memory_index`) retrieves it tagged `[shadowed]`, the hidden count is always disclosed, and the file's bytes are untouched.
|
|
207
|
+
- **Who is affected.** Any deployment mounting more than one layer with a write home among them — including an **org** layer, which is deliberately NOT exempt: a tenant that writes its own `deploy-steps` retires the org layer's `deploy-steps` from that tenant's default face. The disclosure is `TaskResult.effectiveMemoryScopes.scopes[i].shadowed` (new, present-iff non-zero, never on the write home's own row) and `memory_index`'s count sentence. A single-layer session, and any session whose tool faces are given no write home, are byte-identical.
|
|
208
|
+
- **Two laws authorize a shadow.** The NAME SOURCE is a committed header face — an in-session `name:` edit retires nothing before the harvest boundary admits it. The CARRIER must itself stand on the seat's own default face: withheld, superseded and undeliverable are not three conditions but three ways of not standing there. The second law is what keeps the pair honest — a write-home entry replaced by a consolidation product is committed, unchallenged and on disk, and letting it shadow took BOTH same-named answers off every enumeration at once (pinned as a three-entry world).
|
|
209
|
+
- **New export (+1):** `deriveShadowedSet` — the one predicate behind all four faces, beside `deriveSupersededSet`.
|
|
210
|
+
- **New declaration.** `TaskSpec.memory.projectKey: { scope } | { absent: "not_git" | "bare" | "unreadable" }` is what an assembly point STATES about this task's project identity. It configures nothing — the layering is still `scopes` + `writeScope`. It closes the one silent direction those cannot express: an omitted `writeScope` defaults to the LAST scope, so an assembler that derived a project key and forgot to pin the write face writes every project's entries into the shared layer, silently. A `{ scope }` arm disagreeing with `writeScope` now refuses the task (`config.memory_write_scope_mismatch`; a read-only layering is exempt); an unreadable spelling refuses (`config.memory_project_key_spelling`) rather than folding to "no key stated"; an `{ absent }` arm mints one operator notice **`memory.project_scope_absent`** (new `ENGINE_NOTICE_CODES` member, audience `operator`) at the DECLARATION rather than at the mount. Absence of the key is byte-identical.
|
|
211
|
+
- `TaskResult.effectiveMemoryScopes` (mounted) gains `projectKey?` echoed verbatim — absence means the deployment said nothing, which is not `{ absent }`, which means it looked and found none.
|
|
212
|
+
- Tool contract revisions: `memory_search` 6 → 7, `memory_get` 6 → 7, `memory_index` 1 → 2. Wire table: `docs/wire-consumption/unreleased-863p2.md`.
|
|
154
213
|
|
|
155
214
|
## 7.24.2 — 2026-09-20
|
|
156
215
|
|
|
@@ -121,6 +121,13 @@ export interface AskOriginFacts {
|
|
|
121
121
|
* record of its own layer (a tighten replaces an `allow` wholesale, so at most one fires per pass),
|
|
122
122
|
* never a read-back of `decisionReason: "safety"` off a decision a policy could have composed. */
|
|
123
123
|
readonly tightened: "shell_gate" | "safety" | undefined;
|
|
124
|
+
/** The MANDATE mark inputs of this call — the tool SEAT's marks plus the gate's own per-call records
|
|
125
|
+
* ({@link MandateMarks}), read from the same two places the lane's own derivation reads them. The
|
|
126
|
+
* per-call PROBE member is deliberately not here: it is engine-stamped on the DECISION, and
|
|
127
|
+
* {@link ruleClearanceOf} reads it there, so one fact cannot arrive twice with two values. A station
|
|
128
|
+
* holding no tool seat (the inherited lane) passes `{}` — an honest empty, since the decision's own
|
|
129
|
+
* members still answer. */
|
|
130
|
+
readonly mandateMarks: Omit<MandateMarks, "probeMandated">;
|
|
124
131
|
}
|
|
125
132
|
/**
|
|
126
133
|
* The ONE derivation of an ask's origin, from the surviving decision plus the gate's own facts. The
|
|
@@ -149,6 +156,83 @@ export declare function stampAskOrigin<T extends Extract<PermissionResult, {
|
|
|
149
156
|
}>>(decision: T, facts: AskOriginFacts): T & {
|
|
150
157
|
origin: AskOrigin;
|
|
151
158
|
};
|
|
159
|
+
/**
|
|
160
|
+
* CAN a persisted allow rule clear this ask — and, if not, which door closed? ONE derivation with TWO
|
|
161
|
+
* readers, which is the whole point of the shape: the approval card's `ruleOffersAbsence` word and the
|
|
162
|
+
* ask's own {@link import("./tool-policy.js").AskRequest.mandated} bit. They used to be two
|
|
163
|
+
* enumerations of one boundary, and the drift was live — the card's lane speaks for one tool, only
|
|
164
|
+
* where a rule store is wired and only on a task that can hold a rule, so the same mandated read asked
|
|
165
|
+
* as a mandated question on the shell tool and as an apparently ordinary one on the monitor tool.
|
|
166
|
+
*
|
|
167
|
+
* "Allow rules silence the classifier's questions, never a mandated one." The doors, as a SET:
|
|
168
|
+
* · the MARKS family — {@link persistedRuleMandateOf} over the tool seat's marks and the gate's own
|
|
169
|
+
* per-call records (the read station's structural demotion, an unanswered probe, an operator's
|
|
170
|
+
* per-call shell doctrine, the tool's own egress / irreversibility marks, a write the protection
|
|
171
|
+
* judge matched or could not resolve);
|
|
172
|
+
* · GOVERNANCE (`requiresRealApproval`) — an org verdict, a safety policy's own demand, the
|
|
173
|
+
* classifier's denial-limit fallback: only judgment clears it;
|
|
174
|
+
* · a HOOK-raised ask — the deployment's own per-call screening authority, which the lane's clearing
|
|
175
|
+
* conjuncts refuse;
|
|
176
|
+
* · ANCESTOR authority (`inheritedUnresolved` / `ancestorResolved`) — the ask belongs to an ancestor's
|
|
177
|
+
* approver and this task's rule lane never adjudicates it;
|
|
178
|
+
* · the bit already stamped on the ask, which no layer below the mint may clear (tightening-only).
|
|
179
|
+
*
|
|
180
|
+
* And the SHADOW doors, which are not mandates: a matching rule the person already wrote is speaking
|
|
181
|
+
* and is outranked (`persistedRuleShadowed`), or their explicit "ask me each time" rule matched
|
|
182
|
+
* (`matchedAskRule`). A rule minted from such a card silences nothing, so the lane offers none — but
|
|
183
|
+
* the reason is that a RULE is already speaking, not that the question is beyond rules, and a surface
|
|
184
|
+
* that auto-answers by posture is not crossing a mandate when it answers one.
|
|
185
|
+
*
|
|
186
|
+
* THREE named readings of the one pass, because three callers need three different things and taking
|
|
187
|
+
* the wrong one is how this boundary drifts:
|
|
188
|
+
* · `word` — the DISCLOSED reason, first door wins, in the display order the card has always used
|
|
189
|
+
* (governance, the shadow, the hook, the ask rule, the two ancestor doors, then the marks). Display
|
|
190
|
+
* only. A structural door the offer factory owns (no lane wired, a tool it does not speak for, a
|
|
191
|
+
* task that cannot hold a rule) is decided BEFORE this and is deliberately silent;
|
|
192
|
+
* · `mandated` — the FACT the ask carries as its own member. Read off the whole mandate set, so which
|
|
193
|
+
* door is DISPLAYED cannot change it: an ask that is both shadowed and mandated is mandated;
|
|
194
|
+
* · `ruleMayClear` — the ENFORCEMENT reading the gate's clearing arms take. It is `mandated` plus the
|
|
195
|
+
* person's explicit `ask` rule, and deliberately NOT `word !== undefined`: `persistedRuleShadowed`
|
|
196
|
+
* is an ACCOUNT of a refusal that already happened, display-only by contract, and reading it as a
|
|
197
|
+
* blocker would let a policy forwarding that metadata refuse a call nothing is blocking. A
|
|
198
|
+
* display word is never an authority.
|
|
199
|
+
*/
|
|
200
|
+
export declare function ruleClearanceOf(ask: RuleClearanceFacts | undefined, marks: Omit<MandateMarks, "probeMandated">): {
|
|
201
|
+
word?: "mandated" | "shadowed";
|
|
202
|
+
mandated: boolean;
|
|
203
|
+
ruleMayClear: boolean;
|
|
204
|
+
};
|
|
205
|
+
/** The facts one ask carries about whether a rule could clear it — the members {@link ruleClearanceOf}
|
|
206
|
+
* reads beside the tool seat's marks. Every one is either engine-stamped or, where a caller can
|
|
207
|
+
* self-declare it, tightening-only: a claim here can opt an ask OUT of automatic answering and never
|
|
208
|
+
* into it. */
|
|
209
|
+
export interface RuleClearanceFacts {
|
|
210
|
+
/** The bit a gate already stamped (see {@link import("./tool-policy.js").AskRequest.mandated}). */
|
|
211
|
+
mandated?: true;
|
|
212
|
+
requiresRealApproval?: boolean;
|
|
213
|
+
/** A matching persisted allow rule was found and did not clear the ask — carries its text. */
|
|
214
|
+
persistedRuleShadowed?: string;
|
|
215
|
+
decisionReason?: import("./tool-policy.js").DecisionReason;
|
|
216
|
+
/** An EXPLICIT `ask` permission rule matched this call (DSL, stamped at the rule policy's three
|
|
217
|
+
* matched-ask exits — bare, covering and param). */
|
|
218
|
+
matchedAskRule?: string;
|
|
219
|
+
/** The engine-stamped probe mandate (the reversibility probe declared this call's demotion
|
|
220
|
+
* structural — for the built-in shell probe, a read outside the session's allowed directories). */
|
|
221
|
+
probeMandated?: boolean;
|
|
222
|
+
/** An ancestor's approver answered "unavailable" for this call, or a durable mandate was floated here. */
|
|
223
|
+
inheritedUnresolved?: boolean;
|
|
224
|
+
/** The ask is being resolved at an ANCESTOR's frozen approver (the three inherited-lane mint sites
|
|
225
|
+
* pass it literally) — this task's rule lane never adjudicates it. */
|
|
226
|
+
ancestorResolved?: boolean;
|
|
227
|
+
}
|
|
228
|
+
/** The ask FACE's mandate member — what an approval request carries, spread at the four mint stations.
|
|
229
|
+
* The stations hand it the SAME facts object they hand the rule-offer factory, so the card's word and
|
|
230
|
+
* this bit are two readings of one derivation and cannot drift. The tool SEAT is not re-judged here:
|
|
231
|
+
* its marks were folded into the stamped bit at the gate, and a station minting a request from an
|
|
232
|
+
* ANCESTOR's decision holds no seat of this task's to judge. */
|
|
233
|
+
export declare function askMandateMember(ask: RuleClearanceFacts | undefined): {
|
|
234
|
+
mandated?: true;
|
|
235
|
+
};
|
|
152
236
|
/**
|
|
153
237
|
* Which origins IMPLY the `requiresRealApproval` bit, and WHERE that implication is verified — the
|
|
154
238
|
* consistency table {@link askOriginOf} reads. An ask of a must-carry origin exists so that no automatic
|
package/dist/core/ask-origin.js
CHANGED
|
@@ -18,11 +18,39 @@ export function askOriginOf(decision, facts) {
|
|
|
18
18
|
const TIGHTEN_ONLY_ORIGINS = new Set(["shell_gate_tighten", "safety_tighten"]);
|
|
19
19
|
export function stampAskOrigin(decision, facts) {
|
|
20
20
|
const origin = askOriginOf(decision, facts);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
const mandated = ruleClearanceOf(decision, facts.mandateMarks).mandated && decision.mandated !== true ? { ...decision, mandated: true } : decision;
|
|
22
|
+
if (mandated.probeReadRootCandidate === undefined || TIGHTEN_ONLY_ORIGINS.has(origin))
|
|
23
|
+
return { ...mandated, origin };
|
|
24
|
+
const { probeReadRootCandidate: _withdrawn, ...kept } = mandated;
|
|
24
25
|
return { ...kept, origin };
|
|
25
26
|
}
|
|
27
|
+
export function ruleClearanceOf(ask, marks) {
|
|
28
|
+
const mandated = ask?.mandated === true ||
|
|
29
|
+
ask?.requiresRealApproval === true ||
|
|
30
|
+
ask?.decisionReason === "hook" ||
|
|
31
|
+
ask?.inheritedUnresolved === true ||
|
|
32
|
+
ask?.ancestorResolved === true ||
|
|
33
|
+
persistedRuleMandateOf({ ...marks, probeMandated: ask?.probeMandated === true }) !== undefined;
|
|
34
|
+
const word = (() => {
|
|
35
|
+
if (ask?.requiresRealApproval === true)
|
|
36
|
+
return "mandated";
|
|
37
|
+
if (ask?.persistedRuleShadowed !== undefined)
|
|
38
|
+
return "shadowed";
|
|
39
|
+
if (ask?.decisionReason === "hook")
|
|
40
|
+
return "mandated";
|
|
41
|
+
if (ask?.matchedAskRule !== undefined)
|
|
42
|
+
return "shadowed";
|
|
43
|
+
if (ask?.inheritedUnresolved === true)
|
|
44
|
+
return "mandated";
|
|
45
|
+
if (ask?.ancestorResolved === true)
|
|
46
|
+
return "mandated";
|
|
47
|
+
return mandated ? "mandated" : undefined;
|
|
48
|
+
})();
|
|
49
|
+
return { ...(word !== undefined ? { word } : {}), mandated, ruleMayClear: !mandated && ask?.matchedAskRule === undefined };
|
|
50
|
+
}
|
|
51
|
+
export function askMandateMember(ask) {
|
|
52
|
+
return ruleClearanceOf(ask, {}).mandated ? { mandated: true } : {};
|
|
53
|
+
}
|
|
26
54
|
function originLadder(decision, facts) {
|
|
27
55
|
if (facts.contentQuestion)
|
|
28
56
|
return "content_question";
|
|
@@ -375,6 +375,11 @@ export interface MaybeCompactOptions {
|
|
|
375
375
|
};
|
|
376
376
|
land: () => Promise<void>;
|
|
377
377
|
};
|
|
378
|
+
/** The approval-posture directive's post-compaction restatement (the runner's `compactionRestateOptions`):
|
|
379
|
+
* runs right after `gitRestate.land`, under the same belt and for the same reason — the compacted
|
|
380
|
+
* context may have lost an engine frame the run still needs, and every reader of the new baseline
|
|
381
|
+
* (the next model request first) must see it restated. */
|
|
382
|
+
postureRestate?: () => Promise<void>;
|
|
378
383
|
/**
|
|
379
384
|
* Fixed per-request prompt overhead (system prompt + tool schemas, in tokens), applied ONLY in the
|
|
380
385
|
* anchor-less regime (design/64 §26.7, search [91] fourth candidate): when no assistant in the
|
|
@@ -466,6 +466,13 @@ export async function maybeCompact(opts) {
|
|
|
466
466
|
catch {
|
|
467
467
|
}
|
|
468
468
|
}
|
|
469
|
+
if (opts.postureRestate !== undefined) {
|
|
470
|
+
try {
|
|
471
|
+
await opts.postureRestate();
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
}
|
|
475
|
+
}
|
|
469
476
|
try {
|
|
470
477
|
opts.onApplied?.(attachedComplete, excludedReadStatePreserveKeys);
|
|
471
478
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ApproverPosture } from "./task-spec.js";
|
|
2
|
+
/** The auto-mode directive body (title + the two paragraphs). Rendered when the run's auto-mode
|
|
3
|
+
* INTENT is resolved true — the seat, the chain it inherited, or the bit a suspend recorded. */
|
|
4
|
+
export declare const AUTO_MODE_DIRECTIVE: string;
|
|
5
|
+
/** The shell-first paragraph. The reference implementation serves it under three different headings;
|
|
6
|
+
* the one heading this engine has a fact for is the bypass posture below. */
|
|
7
|
+
export declare const BASH_FIRST_DIRECTIVE: string;
|
|
8
|
+
/** The bypass-posture directive body: the reference implementation's heading over the shell-first
|
|
9
|
+
* paragraph, the exact pair its bypass arm renders. */
|
|
10
|
+
export declare const BYPASS_POSTURE_DIRECTIVE: string;
|
|
11
|
+
/** The posture words this engine accepts, as literals (the door refuses anything else loudly). The
|
|
12
|
+
* TYPE is declared beside the seat it belongs to ({@link ApproverPosture}, task-spec.ts); this array
|
|
13
|
+
* is its runtime twin, and the assignment below is the compile-time tie between them. */
|
|
14
|
+
export declare const APPROVER_POSTURES: readonly ApproverPosture[];
|
|
15
|
+
/** Value-shape check for the word — used by the spec door below and by the chain/checkpoint readers. */
|
|
16
|
+
export declare function isApproverPosture(value: unknown): value is ApproverPosture;
|
|
17
|
+
/** The SEAT's door screen, declared beside the vocabulary it screens against. The renderer keys on the
|
|
18
|
+
* exact word, so any other present value would render zero bytes and be indistinguishable from "no
|
|
19
|
+
* posture declared"; it is refused instead. The value that passed is the value RETURNED — the render
|
|
20
|
+
* reads that snapshot, never the caller's spec object again. */
|
|
21
|
+
export declare function refuseInvalidApproverPosture(spec: {
|
|
22
|
+
approverPosture?: ApproverPosture;
|
|
23
|
+
}): ApproverPosture | undefined;
|
|
24
|
+
/** The resolved posture facts of one leg: both already folded across seat, chain and checkpoint. */
|
|
25
|
+
export interface ApprovalPostureFacts {
|
|
26
|
+
/** The run's resolved auto-mode intent (seat ∨ inherited chain ∨ checkpoint seed). */
|
|
27
|
+
autoMode: boolean;
|
|
28
|
+
/** The declared approver posture, absent when the host declared none. */
|
|
29
|
+
approverPosture?: ApproverPosture;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The ONE composition, and the ONE invariant: **each segment the run's posture calls for is stated
|
|
33
|
+
* exactly once in the active model context**. `alreadyStated` is the transcript's ENGINE REGIONS (read
|
|
34
|
+
* by metadata — a caller's own bytes are never in one, so a spliced copy cannot suppress the engine's);
|
|
35
|
+
* a segment already standing there is not restated, and a segment that has left the context — compacted
|
|
36
|
+
* away, or never delivered — is. Returns `undefined` when there is nothing left to say, which is both
|
|
37
|
+
* "no posture declared" (zero bytes, byte-identical to the pre-seat shape) and "already said".
|
|
38
|
+
*
|
|
39
|
+
* Segment-wise rather than body-wise on purpose: a leg that gains the second fact must state the second
|
|
40
|
+
* paragraph without restating the first. The caller wraps the result in the engine's marked reminder
|
|
41
|
+
* shell; this module never wraps, so the mark stays owned by its own mint home.
|
|
42
|
+
*/
|
|
43
|
+
export declare function composeApprovalPostureDirective(facts: ApprovalPostureFacts, statedBodies?: readonly string[]): string | undefined;
|
|
44
|
+
/** SHA-256 of a directive body — the pins' lock port, so the lock is computed the one way. */
|
|
45
|
+
export declare function directiveDigest(body: string): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const AUTO_MODE_DIRECTIVE = "## Auto Mode Active\n\nBias toward working without stopping for clarifying questions — when you'd normally pause to check, make the reasonable call and keep going; they'll redirect you if needed. If the user, a skill, or the shape of the task suggests they want you to ask (with AskUserQuestion or otherwise), do so. And even absent that signal, it's still fine to stop when you're genuinely blocked — unclear direction, missing input, a decision only they can make.\n\nBefore any command that could discard uncommitted work — `git checkout`/`restore`/`reset`/`clean`, `rm -rf` in the repo, restoring from a snapshot — run `git status` first and stash (with `-u` for untracked) or commit anything that's there. When staging or committing, review what's included (`git status` after a broad `git add`), and if you see anything suspicious that might reveal secrets — even if the filename looks innocuous — double-check the file's contents before pushing.";
|
|
3
|
+
export const BASH_FIRST_DIRECTIVE = "Do your work through the Bash tool wherever it can accomplish the job: read files with cat, head, or sed -n, search with grep and find, and make file changes with sed, heredocs, or short scripts, rather than using the dedicated Read, Edit, or Write tools. Fall back to a dedicated tool only when Bash genuinely cannot do the job.";
|
|
4
|
+
export const BYPASS_POSTURE_DIRECTIVE = `While bypass permissions mode is active:\n\n${BASH_FIRST_DIRECTIVE}`;
|
|
5
|
+
export const APPROVER_POSTURES = ["bypass"];
|
|
6
|
+
export function isApproverPosture(value) {
|
|
7
|
+
return typeof value === "string" && APPROVER_POSTURES.includes(value);
|
|
8
|
+
}
|
|
9
|
+
export function refuseInvalidApproverPosture(spec) {
|
|
10
|
+
if (spec.approverPosture !== undefined && !isApproverPosture(spec.approverPosture)) {
|
|
11
|
+
const e = new Error(`approverPosture must be one of ${APPROVER_POSTURES.map((w) => JSON.stringify(w)).join(", ")} when present (got ${JSON.stringify(spec.approverPosture)}) — omit it to declare no posture; an unrecognized value is refused rather than read as absence.`);
|
|
12
|
+
e.code = "config.approver_posture_invalid";
|
|
13
|
+
throw e;
|
|
14
|
+
}
|
|
15
|
+
return spec.approverPosture;
|
|
16
|
+
}
|
|
17
|
+
export function composeApprovalPostureDirective(facts, statedBodies = []) {
|
|
18
|
+
const parts = [];
|
|
19
|
+
if (facts.autoMode)
|
|
20
|
+
parts.push(AUTO_MODE_DIRECTIVE);
|
|
21
|
+
if (facts.approverPosture === "bypass")
|
|
22
|
+
parts.push(BYPASS_POSTURE_DIRECTIVE);
|
|
23
|
+
if (parts.length === 0)
|
|
24
|
+
return undefined;
|
|
25
|
+
const stated = new Set();
|
|
26
|
+
for (const body of statedBodies)
|
|
27
|
+
for (const seg of postureSegmentsOf(body) ?? [])
|
|
28
|
+
stated.add(seg);
|
|
29
|
+
const owed = parts.filter((seg) => !stated.has(seg));
|
|
30
|
+
return owed.length === 0 ? undefined : owed.join("\n\n");
|
|
31
|
+
}
|
|
32
|
+
const POSTURE_SEGMENTS = [AUTO_MODE_DIRECTIVE, BYPASS_POSTURE_DIRECTIVE];
|
|
33
|
+
function postureSegmentsOf(body) {
|
|
34
|
+
const out = [];
|
|
35
|
+
let rest = body;
|
|
36
|
+
while (rest.length > 0) {
|
|
37
|
+
const seg = POSTURE_SEGMENTS.find((s) => rest === s || rest.startsWith(`${s}\n\n`));
|
|
38
|
+
if (seg === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
out.push(seg);
|
|
41
|
+
rest = rest === seg ? "" : rest.slice(seg.length + 2);
|
|
42
|
+
}
|
|
43
|
+
return out.length > 0 ? out : undefined;
|
|
44
|
+
}
|
|
45
|
+
export function directiveDigest(body) {
|
|
46
|
+
return createHash("sha256").update(body, "utf8").digest("hex");
|
|
47
|
+
}
|
|
@@ -7,7 +7,7 @@ import type { ReadEntry } from "../tools/fs/safety.js";
|
|
|
7
7
|
import type { RepairBundle } from "../agents/repair-loop.js";
|
|
8
8
|
import type { WorkspaceHandle } from "./remote-env.js";
|
|
9
9
|
import type { SessionPermissionRules } from "./session-policy-store.js";
|
|
10
|
-
import type { NestedUsageAccum, ShellGateDoctrine, TaskResult } from "./types.js";
|
|
10
|
+
import type { ApproverPosture, NestedUsageAccum, ShellGateDoctrine, TaskResult } from "./types.js";
|
|
11
11
|
import type { ActiveWorktreeSession } from "../internal/harness-types.js";
|
|
12
12
|
/**
|
|
13
13
|
* design/45 — the **durable-checkpoint** primitive: cross-process / resumable `suspend`/`resume`.
|
|
@@ -1111,6 +1111,15 @@ export interface CheckpointState {
|
|
|
1111
1111
|
* (byte-identical to the pre-bit row); an older worker that ignores it resumes un-armed, the
|
|
1112
1112
|
* narrower direction. */
|
|
1113
1113
|
autoModeRequested?: true;
|
|
1114
|
+
/** The chain's declared APPROVER POSTURE at suspend (data half, same law as `shellGate`): the word
|
|
1115
|
+
* the suspended leg was told to render — its own seat, the one its live chain carried, or the one an
|
|
1116
|
+
* earlier suspend of the same chain recorded. A resume leg reads it beside the re-supplied seat and
|
|
1117
|
+
* the waking chain, so a redemption in another process states the same posture the suspend leg did.
|
|
1118
|
+
* It authorizes nothing (the gate never reads it) and it renders nothing on a resume leg either —
|
|
1119
|
+
* the first-turn frame that carries it is already in the restored transcript; what it feeds is the
|
|
1120
|
+
* chain the resumed leg hands its OWN children. Absent on older checkpoints and on tasks that
|
|
1121
|
+
* declared no posture (byte-identical row). */
|
|
1122
|
+
approverPosture?: ApproverPosture;
|
|
1114
1123
|
/** Org-memory admission freeze (ruled 2026-08-05): the chain's admitted org-scope set at
|
|
1115
1124
|
* suspend (data half, plain strings). The resume leg folds it seed ∩ live (tighten-only) and
|
|
1116
1125
|
* re-runs admission under it — a resume must never widen the delegation freeze. Absent on
|
|
@@ -112,6 +112,16 @@ export interface EngineNotice {
|
|
|
112
112
|
* Containment is unaffected and the committed copy is still in the store, so reconciling the two is
|
|
113
113
|
* the host's act (audience `operator`). `detail: { paths: [{ path, entryId?, rev? }], sessionId? }`,
|
|
114
114
|
* `rev` = the declined generation. Dedup unit: once per containment PASS, never per path.
|
|
115
|
+
* - `"memory.project_scope_absent"` — the assembly point DECLARED that it could derive no project
|
|
116
|
+
* identity for this session's working root (`TaskSpec.memory.projectKey: { absent }`), so the
|
|
117
|
+
* layering it sent is used exactly as written: entries this session writes go to whatever the
|
|
118
|
+
* spec named, typically a layer shared with every other project. Audience `operator` — the
|
|
119
|
+
* reading is about how the deployment resolves working roots, and only an operator can change
|
|
120
|
+
* it; nothing retries and nothing is refused. `detail: { reason, taskRoot, sessionId,
|
|
121
|
+
* writeScope? }`, `reason` the deployment's own word (not_git | bare | unreadable),
|
|
122
|
+
* `writeScope` absent ⇔ a read-only layering. Minted at the DECLARATION rather than at the
|
|
123
|
+
* mount (the declaration is already the fact, and a mount that later fails must not swallow
|
|
124
|
+
* it). Dedup unit: once per prepared leg.
|
|
115
125
|
* - `"mcp.revocation_probe_failed"` (design/338) — the deployment's `mcpRevocations.isRevoked`
|
|
116
126
|
* probe threw; MCP dispatch FAILS OPEN (revocation is a tightening face) and this announces
|
|
117
127
|
* once per materialization (a resume re-materializes and may announce again).
|
package/dist/core/gate-lanes.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { attestClassifierDenyCause, classifierDenyCauseOf, decisionText, describeThrown } from "./tool-policy.js";
|
|
2
|
-
import { classifierMayAnswer, classifierMayAnswerMandate, mandateDisclosureOf, persistedRuleMandateOf, safetyAxisOf, sandboxMayAdmitMandate, stampAskOrigin } from "./ask-origin.js";
|
|
2
|
+
import { classifierMayAnswer, classifierMayAnswerMandate, mandateDisclosureOf, persistedRuleMandateOf, ruleClearanceOf, safetyAxisOf, sandboxMayAdmitMandate, stampAskOrigin } from "./ask-origin.js";
|
|
3
3
|
import { classifierParseFailureDenyMessage, classifierUnavailableDenyMessage, denialLimitFallbackMessage, unarmedWindow } from "./auto-mode.js";
|
|
4
4
|
import { inlineUntrusted } from "./untrusted-text.js";
|
|
5
5
|
import { isRuleBehavior } from "./permission-rule-model.js";
|
|
@@ -156,12 +156,21 @@ export async function runGateLanes(pass) {
|
|
|
156
156
|
let orgRealApprovalRequired = false;
|
|
157
157
|
let markedThisRound;
|
|
158
158
|
const markedUnresolvable = () => (markedThisRound ??= input.isMarkedUnresolvable?.(input.event.toolCallId) === true);
|
|
159
|
+
const mandateMarksNow = () => ({
|
|
160
|
+
egress: input.egress,
|
|
161
|
+
shellGated: input.shellGated,
|
|
162
|
+
irreversibility: input.irreversibility,
|
|
163
|
+
probeUnanswered: pass.probeUnanswered === true,
|
|
164
|
+
writeProtected: pass.writeProtected === true,
|
|
165
|
+
writeProtectionUnresolved: pass.writeProtectionUnresolved === true,
|
|
166
|
+
});
|
|
159
167
|
const askOriginFacts = (org, ruleStore) => ({
|
|
160
168
|
contentQuestion: toolName === ASK_USER_QUESTION_TOOL_NAME,
|
|
161
169
|
markedUnresolvable: markedUnresolvable(),
|
|
162
170
|
org,
|
|
163
171
|
...(ruleStore !== undefined ? { ruleStore } : {}),
|
|
164
172
|
tightened: pass.tightenedBy,
|
|
173
|
+
mandateMarks: mandateMarksNow(),
|
|
165
174
|
});
|
|
166
175
|
let orgTightenCount = 0;
|
|
167
176
|
let orgEvidence = (() => {
|
|
@@ -275,14 +284,10 @@ export async function runGateLanes(pass) {
|
|
|
275
284
|
pass.req.args = pass.policyRewrite;
|
|
276
285
|
}
|
|
277
286
|
const persistedRuleMandate = persistedRuleMandateOf({
|
|
278
|
-
|
|
279
|
-
shellGated: input.shellGated,
|
|
280
|
-
irreversibility: input.irreversibility,
|
|
287
|
+
...mandateMarksNow(),
|
|
281
288
|
probeMandated: pass.decision.action === "ask" && pass.decision.probeMandated === true,
|
|
282
|
-
probeUnanswered: pass.probeUnanswered === true,
|
|
283
|
-
writeProtected: pass.writeProtected === true,
|
|
284
|
-
writeProtectionUnresolved: pass.writeProtectionUnresolved === true,
|
|
285
289
|
});
|
|
290
|
+
const ruleMayClear = () => ruleClearanceOf(pass.decision.action === "ask" ? pass.decision : undefined, mandateMarksNow()).ruleMayClear;
|
|
286
291
|
let personalEvidence = { dotsAbsent: input.persistedRules === undefined ? "not_wired" : "not_adjudicated" };
|
|
287
292
|
if (laneAnswer?.unreadable === true)
|
|
288
293
|
personalEvidence = { dotsAbsent: "unavailable" };
|
|
@@ -316,7 +321,7 @@ export async function runGateLanes(pass) {
|
|
|
316
321
|
};
|
|
317
322
|
if (answer.hit?.behavior === "ask")
|
|
318
323
|
personalEvidence = unionDotsOf(answer.hit.rules);
|
|
319
|
-
if (hitRules !== undefined &&
|
|
324
|
+
if (hitRules !== undefined && ruleMayClear()) {
|
|
320
325
|
pass.decision = {
|
|
321
326
|
action: "allow",
|
|
322
327
|
message: hitRules.length === 1
|
|
@@ -330,7 +335,9 @@ export async function runGateLanes(pass) {
|
|
|
330
335
|
else if (hitRules !== undefined) {
|
|
331
336
|
personalEvidence = unionDotsOf(hitRules);
|
|
332
337
|
const shownRule = disclosedRuleSet(hitRules);
|
|
333
|
-
const mandateNoun =
|
|
338
|
+
const mandateNoun = persistedRuleMandate === undefined && pass.decision.matchedAskRule === undefined
|
|
339
|
+
? "this call arrived already marked as requiring confirmation (an earlier layer of this run, or the task that delegated it, judged it so) — it is cleared by confirming this call, never by a standing allow rule"
|
|
340
|
+
: mandateDisclosureOf(persistedRuleMandate);
|
|
334
341
|
pass.decision = {
|
|
335
342
|
...pass.decision,
|
|
336
343
|
persistedRuleShadowed: shownRule,
|
|
@@ -346,7 +353,7 @@ export async function runGateLanes(pass) {
|
|
|
346
353
|
const stamped = { ...unstamped, ruleEvidence: mintRuleEvidence(personalEvidence), ...(laneCoverage !== undefined ? { segmentCoverage: laneCoverage } : {}), ...(originFacts.ruleStore !== undefined ? { ruleStoreUnreadable: originFacts.ruleStore } : {}) };
|
|
347
354
|
pass.decision = stampAskOrigin(stamped, originFacts);
|
|
348
355
|
}
|
|
349
|
-
if (allowLayerMayClear() && pass.decision.action === "ask" &&
|
|
356
|
+
if (allowLayerMayClear() && pass.decision.action === "ask" && ruleMayClear() && pass.probeBoundaryDeclined !== true && pass.req.toolName === COMMAND_RULE_TOOL) {
|
|
350
357
|
const commandParam = catalogRuleFaceOf(COMMAND_RULE_TOOL)?.primaryParams[0] ?? "command";
|
|
351
358
|
const args = pass.req.args;
|
|
352
359
|
const command = typeof args === "object" && args !== null && !Array.isArray(args) ? args[commandParam] : undefined;
|
|
@@ -459,6 +466,7 @@ export async function runGateLanes(pass) {
|
|
|
459
466
|
...(d.action === "ask" && d.denialLimitFallback !== undefined ? { denialLimitFallback: unarmedWindow(d.denialLimitFallback) } : {}),
|
|
460
467
|
...(d.action === "ask" && d.origin !== undefined ? { origin: d.origin } : {}),
|
|
461
468
|
...(d.action === "ask" && d.ruleStoreUnreadable !== undefined ? { ruleStoreUnreadable: d.ruleStoreUnreadable } : {}),
|
|
469
|
+
...(d.action === "ask" && d.mandated === true ? { mandated: true } : {}),
|
|
462
470
|
});
|
|
463
471
|
if (suspendAsk && pass.decision.action === "ask") {
|
|
464
472
|
const parkArgs = [pass.req, pass.currentInput, safety, undefined, realApprovalOf(pass.decision), pass.decision.action === "ask" ? pass.decision.persistedRuleShadowed : undefined, pass.decision.action === "ask" ? pass.decision.decisionReason : undefined, pass.decision.action === "ask" ? pass.decision.probeReason : undefined, pass.decision.action === "ask" ? pass.decision.probeCause : undefined, pass.decision.action === "ask" ? pass.decision.segmentCoverage : undefined, pass.decision.action === "ask" ? pass.decision.matchedAskRule : undefined, pass.decision.action === "ask" ? pass.decision.probeMandated : undefined];
|
|
@@ -119,7 +119,7 @@ export type NoticeAudience = "user" | "operator";
|
|
|
119
119
|
* the union's catalog ({@link EngineNotice.code}). The groups below say only which family a row joins,
|
|
120
120
|
* so the catalog is not written a second time here and the two cannot drift.
|
|
121
121
|
*/
|
|
122
|
-
export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.write_protection_unresolved", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
|
|
122
|
+
export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.write_protection_unresolved", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "memory.project_scope_absent", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
|
|
123
123
|
/** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
|
|
124
124
|
* `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
|
|
125
125
|
* sink is a supported shape, and narrowing that field would break it. */
|
|
@@ -35,6 +35,7 @@ export const NON_GOVERNANCE_MEMORY_CODES = new Set([
|
|
|
35
35
|
"memory.consolidation_refused",
|
|
36
36
|
"memory.consolidation_withheld",
|
|
37
37
|
"memory.restore_withheld",
|
|
38
|
+
"memory.project_scope_absent",
|
|
38
39
|
"memory.projection_failed",
|
|
39
40
|
"memory.consolidation_disabled",
|
|
40
41
|
"memory.consolidation_gated",
|
|
@@ -152,6 +153,7 @@ export const ENGINE_NOTICE_CODES = [
|
|
|
152
153
|
"memory.consolidation_refused",
|
|
153
154
|
"memory.consolidation_withheld",
|
|
154
155
|
"memory.restore_withheld",
|
|
156
|
+
"memory.project_scope_absent",
|
|
155
157
|
"route.fallback_to_primary",
|
|
156
158
|
"route.classifier_unroutable",
|
|
157
159
|
"route.base_url_changed_key_unchanged",
|
|
@@ -229,6 +231,7 @@ const NOTICE_AUDIENCE_TABLE = {
|
|
|
229
231
|
"memory.consolidation_incomplete": "operator",
|
|
230
232
|
"memory.consolidation_refused": "operator",
|
|
231
233
|
"memory.restore_withheld": "operator",
|
|
234
|
+
"memory.project_scope_absent": "operator",
|
|
232
235
|
"route.fallback_to_primary": "operator",
|
|
233
236
|
"route.classifier_unroutable": "operator",
|
|
234
237
|
"route.base_url_changed_key_unchanged": "operator",
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -1711,6 +1711,9 @@ export interface AskCarry {
|
|
|
1711
1711
|
/** #688 C3: the mechanism behind an `origin: "rule_store_unavailable"` (`store` / `call`), persisted on the row
|
|
1712
1712
|
* so a durable card says the same thing the synchronous card does. */
|
|
1713
1713
|
readonly ruleStoreUnreadable?: import("./ask-origin.js").RuleStoreUnreadable;
|
|
1714
|
+
/** The stamped mandate ({@link import("./tool-policy.js").AskRequest.mandated}) — the park site's
|
|
1715
|
+
* rule-offer facts read it, so a parked mandated ask offers no rule, like the live card. */
|
|
1716
|
+
readonly mandated?: true;
|
|
1714
1717
|
}
|
|
1715
1718
|
/** What a durable row persists of an {@link AskCarry}: the fallback member COPIED (the row never aliases
|
|
1716
1719
|
* the gate's object; its window is already `0` — the gate's carry mint is the one writer of that
|