@sema-agent/core 7.21.1 → 7.22.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 +123 -1
- package/dist/agents/send-message-tool.js +1 -0
- package/dist/agents/subagent.d.ts +1 -0
- package/dist/agents/subagent.js +2 -1
- package/dist/core/ask-origin.d.ts +1 -0
- package/dist/core/ask-origin.js +3 -2
- package/dist/core/auto-mode-rebuild.js +6 -2
- package/dist/core/auto-mode.d.ts +40 -18
- package/dist/core/auto-mode.js +63 -45
- package/dist/core/checkpoint-store.d.ts +8 -8
- package/dist/core/engine-notice.d.ts +49 -1
- package/dist/core/engine-notice.js +14 -1
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +4 -0
- package/dist/core/mcp.d.ts +65 -0
- package/dist/core/mcp.js +190 -41
- package/dist/core/memory-engine/consolidation.d.ts +5 -0
- package/dist/core/memory-engine/consolidation.js +7 -3
- package/dist/core/memory-engine/engine.d.ts +8 -0
- package/dist/core/memory-engine/engine.js +25 -19
- package/dist/core/memory-engine/layout.d.ts +47 -9
- package/dist/core/memory-engine/layout.js +157 -57
- package/dist/core/memory-engine/session-incarnation.d.ts +27 -0
- package/dist/core/memory-engine/session-incarnation.js +63 -0
- package/dist/core/parent-spec-seat.d.ts +15 -3
- package/dist/core/parent-spec-seat.js +5 -0
- package/dist/core/permission-rule-model.d.ts +1 -2
- package/dist/core/retention.d.ts +8 -1
- package/dist/core/retention.js +2 -0
- package/dist/core/runner/classifier-ladder.js +1 -0
- package/dist/core/runner/contracts.d.ts +11 -0
- package/dist/core/runner/mcp-redial.d.ts +109 -0
- package/dist/core/runner/mcp-redial.js +60 -0
- package/dist/core/runner/memory-consolidation-driver.d.ts +2 -0
- package/dist/core/runner/prepare-caps-and-workflow.js +9 -12
- package/dist/core/runner/prepare-hands-readface.d.ts +3 -0
- package/dist/core/runner/prepare-hands-readface.js +13 -3
- package/dist/core/runner/prepare-memory.js +3 -3
- package/dist/core/runner/prepare-protocol-tools.d.ts +6 -1
- package/dist/core/runner/prepare-protocol-tools.js +20 -38
- package/dist/core/runner/prepare-task.js +3 -3
- package/dist/core/runner/prepare-wiring-manifest.d.ts +4 -0
- package/dist/core/runner/prepare-wiring-manifest.js +2 -1
- package/dist/core/runner/run-identity-wiring.d.ts +3 -1
- package/dist/core/runner/run-identity-wiring.js +4 -1
- package/dist/core/runner/run-settle-and-teardown.d.ts +3 -1
- package/dist/core/runner/run-settle-and-teardown.js +6 -2
- package/dist/core/runner/run-terminal-adoption.d.ts +4 -1
- package/dist/core/runner/run-terminal-adoption.js +2 -6
- package/dist/core/runner/runtask.js +8 -5
- package/dist/core/runner/stream-lifecycle-verbs.d.ts +5 -1
- package/dist/core/runner/stream-lifecycle-verbs.js +23 -2
- package/dist/core/runner-deps.d.ts +16 -7
- package/dist/core/skill-listing-budget.d.ts +5 -4
- package/dist/core/task-result.d.ts +3 -2
- package/dist/core/task-spec.d.ts +2 -1
- package/dist/core/task-stream.d.ts +26 -0
- package/dist/core/tool-spec.d.ts +5 -1
- package/dist/core/trace.d.ts +5 -0
- package/dist/core/types.d.ts +2 -2
- package/dist/core/types.js +1 -1
- package/dist/core/wiring-manifest.d.ts +23 -0
- package/dist/core/wiring-manifest.js +3 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -2
- package/dist/orchestration/workflow-governance.js +1 -1
- package/dist/orchestration/workflow-primitives.d.ts +2 -2
- package/dist/tools/fs/bash-program-position.d.ts +26 -5
- package/dist/tools/fs/bash-program-position.js +63 -16
- package/dist/tools/fs/bash-readonly-classifier.d.ts +27 -17
- package/dist/tools/fs/bash-readonly-classifier.js +134 -55
- package/dist/tools/fs/fs-bash.js +43 -18
- package/dist/tools/fs/index.d.ts +4 -2
- package/dist/tools/fs/read-deny.d.ts +25 -11
- package/dist/tools/fs/read-deny.js +5 -1
- package/dist/tools/fs/safety.d.ts +1 -4
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +9 -1
- package/wire-changes/core@7.22.0.json +637 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,134 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.22.0 — 2026-09-18
|
|
4
|
+
|
|
5
|
+
### Additive — an in-session RE-DIAL for one named MCP server, on the run handle (#857; no model face; @server S-381 @client-core CC-30 @cli L-276)
|
|
6
|
+
- **Before.** Core holds no MCP connection across task legs, and `MaterializedMcp.refresh` may not dial — its contract sentence is "never dials or re-dials". So once a server's transport closed, the leg was stuck with it for good: every one of that server's tools failed fast, `RefreshMcpTools` reported `not_connected` forever, and nothing a host could call would bring it back. The only recovery was to end the task and start another one.
|
|
7
|
+
- **After.** `TaskStream.reconnectMcpServer(server)` re-dials ONE named server on a live run: it closes whatever handle that server still has and dials it again through the SAME `connectServer` the materialization used (one intake pipeline, not two — the per-server commit that makes a connected server live is now shared with the merge stage rather than copied), then swaps the server's whole `mcp__<server>__` prefix domain on the live roster through the SAME applier `RefreshMcpTools` uses. `MaterializedMcp.reconnect(server)` is that verb's implementation and is reachable directly by a library caller that materialized MCP itself. `refresh` is untouched, contract sentence included — with the re-dial reachable only from the host, "never dials or re-dials" stays true of it, and the negative control (a dead server still refreshes `not_connected`) is pinned.
|
|
8
|
+
- **One named server, required.** There is no all-servers form: the reference CLI's control-channel request (2.1.259 `mcp_reconnect`) carries a required `serverName` and has none either, and an all-servers arm would CLOSE HEALTHY connections to re-dial them — a stdio server that owns a device or a port, which is the case this verb exists for, is exactly the one that cannot be re-dialed while its predecessor still holds the device.
|
|
9
|
+
- **⚠️ A FAILED RE-DIAL LEAVES THE SERVER DOWN — stated, not implied.** The old connection is closed BEFORE the new one is dialed (no other order works for a device-owning server), so a dial that fails has spent a working connection: the server stays disconnected, **with its tools withdrawn from the model's roster** (`tools: []`, the empty roster assertion), until another re-dial or the next leg's materialize. Calls in flight on the replaced connection fail with it. Both facts are inherent to re-dialing and are the reference CLI's behaviour too; a control that offers "Reconnect" on a HEALTHY server is offering that trade.
|
|
10
|
+
- **Three outcomes, mapped.** `reconnected` / `refused` (disposed materialization · revoked server, which is never contacted · a re-dial already in flight · the dial or first listing failed — `reason` says which, `status.errorCode` keys the next action) / `not_declared`. The verb THROWS rather than answering a fourth word: `mcp.reconnect_unavailable` when the run declared NO MCP servers (a host maps that to "unsupported", not "refused"), `steering.invalid_content` on a missing or non-string name, and the steer-family `steering.not_running` once the task has finished. `McpReconnectResult.tools` is present IF AND ONLY IF the result asserts a roster for its prefix domain — the one rule the applier reads, and the new contract sentence `mcp.redial.roster_assertion`.
|
|
11
|
+
- **No model face.** There is no `ReconnectMcpServer` tool and the `RefreshMcpTools` description is unchanged. The reference CLI has no such tool either (its reconnect lives on the SDK control channel and the interactive panel), and a verb that can leave a connection down is a host act. **Headless / no-approval lanes are unaffected: no model in any permission mode can reach this.** A deployment that relays the verb to a third party is granting that party the power to take an MCP server down mid-run — relay IS authorization, as with `steer`'s `priority`.
|
|
12
|
+
- **Existing faces.** `MaterializedMcp.statuses[name]` is REPLACED by a re-dial with the new connect verdict (the "never rewritten" sentence meant *a death does not rewrite a success*; a stale `transportClosed: true` on a server that is live again would be a lie). `instructionsDelta` is RECONCILED rather than diffed: a re-dial withdraws this server's unread entries and then states its own outcome — text is one add whose text supersedes, a FAILED re-dial of a server that was shown some is one removal, and a SUCCESSFUL re-dial of a server that now advertises none is **nothing at all**. Two reasons, both stated: whether the removal a transport death queued was already DELIVERED is not knowable inside the engine, and the removal lane's own frame says the servers *have disconnected* — false of a server that just came back. The costs of not guessing: a re-dial with byte-identical text costs one redundant re-announcement, and a server that returns advertising no instructions leaves the model holding text it no longer advertises (exactly what a refresh has always left — it never touches instructions at all). `refresh` keeps its contract, and gains ONE new `failed` reason: a refresh spanning several servers awaits each listing in turn, so a re-dial landing during one of those waits leaves an already-listed server's entry holding a closed client — that entry is downgraded to `failed` (no `tools`) so the roster keeps what the re-dial made. `dispose` does NOT wait for a dial in flight (the startup timeout bounds the initialize request, not the whole connect, and waiting would let one unreachable server hold a task's teardown open unbounded); the dial closes what it dialed instead — **a stdio child can outlive `dispose()`'s resolution by the length of that attempt, closed and never leaked**. The flat `tools` / `toolAxes` / `serverInstructions` arrays are the materialize-time roster and are NOT rewritten (nor were they by a refresh — documented rather than changed; the result carries the current roster and the runner's live roster is the authority). The global `ListMcpResourcesTool` / `ReadMcpResourceTool` now route through the LIVE server array instead of a build-time filtered copy, so a re-dialed server's resources follow the new client; absent a re-dial this is byte-identical. Stated residual: a server that GAINS the `resources` capability across a re-dial acquires no route this leg, and one that LOSES it keeps a route whose calls the server itself refuses.
|
|
13
|
+
- **Disclosure.** One new notice code, `mcp.server_redialed` (`ENGINE_NOTICE_CODES` +1, audience `operator`), `detail: { server, outcome, added, removed, reason?, sessionId, runId }` — `added`/`removed` are COUNTS, never the server-authored tool names. ONE code for both directions: the outcome is data about one event, not two kinds of event. Dedup unit: once per ATTEMPT, never folded per run (two attempts on one server are two different answers, and folding the second would hide the one that says the server is still down). This re-dial's own operator warnings (an incomplete listing walk, a kept-but-defective tool schema) ride the result and are forwarded to `onError(phase:"mcp")` — the array on the handle is drained once, at prepare.
|
|
14
|
+
- **Exports (additive, no removals).** `McpReconnectResult`. Pins: `test/backlog857-mcp-redial.test.ts` (9 cases over REAL stdio MCP subprocesses — a killed server re-dialed with a changed listing, the refresh negative control, the failed-dial withdrawal, the revoked and in-flight arms, the leg-local rule, and the verb over a real `Runner` observing the MODEL's own tool list before and after).
|
|
15
|
+
|
|
16
|
+
### Widening (mandated ask → allow), the names-only half of the recursive-read arm — `find` / `ls -R` / `du` / `tree` no longer carry a card no rule can retire (#862 ①; both faces, both seats; @cli re-pin)
|
|
17
|
+
- **Before.** The demotion fired for every verb in the recursive-read form table under a wired deny judge, so `find . -name '*.ts'`, `ls -R` and `du -sh .` were MANDATED asks on all three (`tree` is not on the read-only allowlist and was never a card here: `run` on the default doctrine, a plain classifier ask on `classify`, unchanged by this change) (seat × face) combinations — a card with no "don't ask again" on it, refused outright on a headless lane — while the engine's own read-only proof of those same commands says they are read-only.
|
|
18
|
+
- **After.** The table gains a `discloses` column and the demotion reads contents-disclosing rows only. The hazard the arm answers is a walk that hands the CALLER the bytes it walked over, which the deny judge is structurally unable to pre-check; a walk that discloses names and sizes is a different disclosure with a different answer. The reference implementation draws the same line, as a verb set. The names rows stay in the table: a row is a decision, and a verb deleted from it reads as one nobody considered.
|
|
19
|
+
- **Reach.** Three everyday commands (`find`, `ls -R`, `du`) go from a mandated card to zero asks on every deployment; `tree` is unmoved. Headless lanes: three fewer refusals. @cli: a pin expecting a card for `find`/`ls -R`/`du`/`tree` under a wired deny judge is now the zero-ask spelling.
|
|
20
|
+
- **`tar`'s create mode is NOT among them, and the reason is worth stating**: it was filed as names-only on the ground that an archive is a file, and `tar -cf - .` writes the archive to STDOUT, which the tool hands straight back. Where the bytes go is not always a property of the verb, and reading `-f` per invocation is a per-program option model this zero-IO pass does not carry — so the row resolves demote-ward and `tar c` is a contents row. **Combined with the program-reading change below, `tar cf out.tar src` now asks on every deployment with a wired deny judge** (before: only where a deployment had allowlisted `tar`). @cli re-pin.
|
|
21
|
+
|
|
22
|
+
### Narrowing (allow → mandated ask), the other half of the same arm — a recursive CONTENTS walk is judged by the program bash runs, not by whether the deployment allowlisted its name (#862 ①; @cli @server re-pin; headless lanes: one more refusal per spelling)
|
|
23
|
+
- **Before.** A traversal tool the deployment never allowlisted is judged under the synthetic scan head, so the recursive question was being asked about `cat`. `rg <pattern> .`, `ag <pattern> .` and `ack <pattern> .` therefore walked the very trees `grep -r <pattern> .` was stopped for and auto-allowed — one security property with two answers, decided by whether a name is on a list. `egrep -r` / `fgrep -r` had the same hole for the opposite reason: they are the grep program under two other names and the form table carried only the `grep` spelling.
|
|
24
|
+
- **After.** The arm reads the program the chain names (the same `slotVerb` the option-slot rows already travel with), and `egrep`/`fgrep` share grep's row. A deployment's allow list says which programs may RUN; it never said what a program does with its arguments, which is the only claim the form table makes. The program's own name under the synthetic head is argv[0] and is no longer offered as a traversal root.
|
|
25
|
+
- **Reach.** `rg <pat> <dir>`, `ag …`, `ack …`, `egrep -r …`, `fgrep -r …` and `tar c …` go from zero asks to a mandated ask **only where a deny judge is wired with entries** — a deployment that selected a built-in tier, or one that mounts its own `readDenyPatterns`. The shipped default selects no tier (see the built-in read-deny switch below), so an unconfigured deployment. Headless lanes: those spellings are refused where they used to run. The account travels too: the recursive-reach cause is now minted on the arm where the segment's own sentence is a name refusal, so the card no longer explains the mandate with the wrong fact. @cli: expect a card (and a `shell.recursive_read_unbounded` probe cause) on those spellings; @server: no wire key changes.
|
|
26
|
+
|
|
27
|
+
### Additive seat (`BashReadonlyRootBoundary.denyEntries`) — the recursive arm asks whether the wired judge has anything to apply (#862 ①; absent ⇒ the reading every caller had before)
|
|
28
|
+
- The engine's own wiring feeds the seat from the SAME compiled matcher it feeds `denyMatch` from. **Empty array ⇒ the judge refuses nothing** (every built-in tier deselected and no additions — a legal posture), so a traversal under it walks past nothing and no finding is minted. **ABSENT ⇒ undeclared, which is not "none"**: the walk is reported, byte-identical to before. It is deliberately NOT the question the reference implementation asks at the same place — there a deny rule is ROOTED and "is the guarded place inside this traversal?" is answerable without touching the filesystem, while these patterns match a run of segments ANYWHERE in a judged path, so that question has no lexical answer here. What remains answerable is whether the set is empty. Registered as divergence candidate `recursive-read-demote-contents-only` (the two preconditions sema can implement, and the one it cannot).
|
|
29
|
+
- **Stated, unchanged reach:** `grep -r <pattern> .` still carries a mandated card on every deployment that keeps the built-in deny table, because the table IS that deployment's set of Read deny rules and the reference implementation asks in the equivalent configuration. The ways out are the deployment's: `readDenyBuiltinTiers` / `readDenyBuiltinExclude`, or the `ExecutionEnv` the command runs in (`shell.read_boundary.friction_floor`).
|
|
30
|
+
|
|
31
|
+
### Widening (fewer phantom path candidates), `find`'s predicates — a single-dash WORD is not an option cluster (#862 ②)
|
|
32
|
+
- **Before.** `find . -name '*.ts'` offered `<cwd>/-name`, `<cwd>/ame`, `<cwd>/me`, `<cwd>/e` and `<cwd>/*.ts` as path candidates beside the one root it names: the verb-blind payload scan reads `-name` as the option letters `n a m e` and offers every suffix. Those candidates are not free — they are the input to the full shell's execute-leg canonical recheck, which is where a workspace holding a same-named escaping link turns one of them into a refusal.
|
|
33
|
+
- **After.** `VerbOptionSlots` gains `dashWordValue` / `dashWordPlain` (a token that is a dash followed by more than one character is read against the row's word maps instead of split into letters) and `optionsArePositionIndependent` (the both-readings rule past the first operand answers GETOPT's permute ambiguity; find's grammar is `find <paths…> <expression…>`, so every predicate stands past the first operand by construction and reading each as a filename manufactures a candidate per predicate). `find` gains a row whose value kinds follow the reference implementation's own find parser: its file-valued predicates (`-newer*`, `-samefile`, `-path`/`-wholename`/`-lname` and the `i` twins) are judged as paths; `-name`/`-iname`/`-regex` values are name patterns find matches against names it already walked to and never opens; `-maxdepth`/`-size`/`-type` values are consumed and are no longer `<cwd>/2`.
|
|
34
|
+
- **One row, two grammars, and the rule that covers the overlap.** `find`'s own manual carries a getopt option section (`-H`/`-L`/`-P`/`-f <path>`) AND an expression of single-dash predicates, and where a predicate's first letter is a value-taking short letter the token has two readings: `-fstype <x>` is a primary and `-f stype`, `-follow` is a primary and `-f ollow` — and one of those readings TRAVERSES `ollow`. Three adversarial rounds each found the next token in that overlap, so the rule is the table's own disputed-arity rule read one place further: **a dash-word whose first letter is a value-taking short letter of the same row declares nothing**, and the verb-blind scan reads it exactly as before. `-f` is the only such letter find declares, so the words affected are exactly the `-f…` ones; the cost is a few phantom suffix candidates on three predicates, all in-root.
|
|
35
|
+
- **Reach.** `checkedPaths` for a `find` call shrinks to the roots it names plus its file-valued predicate values — visible to anything reading that list (a disclosure face, the execute-leg recheck). A predicate no row declares still falls to the verb-blind scan; the containment answer for a real path behind one is unchanged.
|
|
36
|
+
|
|
37
|
+
### Widening (mandated ask → allow), the everyday wrappers — a launcher row is one declaration with all its spellings (#862 ③; @cli re-pin)
|
|
38
|
+
- **Before.** The launcher table declared options for `time` alone, so `nice -n 5 npm test`, `env -u FOO npm test`, `env -i npm test`, `stdbuf -oL npm test`, `command -p npm test` and `timeout -k 5 300 npm test` were refused WHOLE and mandated — the program behind the wrapper was unreadable. `time --format=%e …` was refused by the table that declares `--format`: it knew only the exact spelling, so one program read as two.
|
|
39
|
+
- **After.** `pastLauncherOptions` honours the three spellings of an arity-1 declaration (separated, `--name=value`, `-Xvalue`) and the two of arity-0 (exact, a cluster of declared switches), and `nice` / `env` / `stdbuf` / `timeout` / `command` gain rows taken from the reference implementation's own wrapper-stripping table (plus `nice`'s traditional bare `-N`, declared per row). `command -v` / `-V` are deliberately absent: they make the call a LOOKUP that prints a name and runs nothing.
|
|
40
|
+
- **Reach.** Those spellings go from a mandated card to the verdict their unwrapped command has (zero asks for `npm test`); headless lanes: that many fewer refusals. A row makes the program READABLE and vouches for nothing — `nice -n 5 cat /etc/passwd` still mandates, with the operand's own reason. The fail-closed half is unchanged: a flag no row carries, a cluster with one undeclared letter, and an arity-1 option with nothing left to take all still refuse.
|
|
41
|
+
- **Fixed on the merged tree (two adversarial rounds).** A launcher's REQUIRED positional operand is its own word whatever it spells: `timeout DURATION`, `chrt PRIO`, `taskset MASK`, `flock FILE`, `chroot NEWROOT`, and the USER operand of `su` / `runuser` (which run a shell, not a program named after the user) now declare `requiredOperands: 1` on their row and the chain walk consumes it before reading the program; the spelling-based bare-number rule (integer durations only, then decimal fractions) is deleted. Before, a duration the rule did not recognise became the PROGRAM — `timeout 1.5 grep -r <pattern> <dir>` (and `1e1`, `+1`, `0x1p0`, `1e-1s`) under a deny judge walked a deny-listed file with zero asks, and `flock <file> grep -r …` read the lock file's name as the program (the out-of-root twins were still caught by containment). An option-shaped word at the operand slot still refuses as an undeclared flag. The same table now declares three more grammar dimensions the fourth round reproduced: `env`'s own assignment names (`env 1=x grep -r …` — `1=x` is env's operand, not the program), shell-TEXT launchers (`watch "cat /etc/passwd"`, `script …` hand their operands to a shell: no program word, refused as control syntax on both seats — before, the quoted text read as a program named after its last path segment and the read inside it was zero-ask), and implicit-shell launchers (`su USER`, `runuser USER`, `chroot NEWROOT`, `nsenter`, `unshare`, `pkexec` with nothing to run start a shell that piped text would reach — an empty program position now refuses instead of reading as none). A fifth round added `sudo`'s own assignment names, `su`/`runuser` as shell text in every form (a later `-c` included), and `firejail`'s implicit shell. Narrowing: `watch …`, `script …`, every `su`/`runuser` form and the bare implicit-shell forms take a card where they used to run. **Stated limit:** these rows are declared from the launchers' upstream manuals and were extended over five adversarial rounds on the merged tree, each finding another undeclared dimension; the table has no mechanical falsification leg against the real binaries yet (a registered design item), so a launcher not in the table, or a dimension not declared, still reads the word after it as the program — the failure direction is a zero-ask read behind that launcher under a deny judge, or of quoted shell text. Pinned at the seat for twelve duration spellings plus the three other rows, and once end-to-end on a real Runner. Stated over-fire: the walk judges a spelled absolute path wherever it stands, so `flock /tmp/lock …` still takes the containment card for the lock file's path (an in-root lock file is zero-ask) — the launcher's own semantics are not modelled, and that is the safe direction. A program word is matched to its row on its BASENAME, as the reference implementation matches its wrapper set, so a path-spelled local executable whose basename is a shell-text row (`./script`, `./watch`) takes that row's shell-text card — mandated, as `sh` itself is — while `./script.sh` does not; stated, measured on this tree. Found by the release-candidate run and fixed the same way: a row's declared options were read only at the HEAD of a chain, so `env timeout -k 5 300 npm test` / `sudo nice -n 5 npm test` refused the program as unreadable; the declaration now applies wherever the launcher stands. The falsification round on that fix then found the chain skipping an assignment-shaped word behind ANY launcher: behind a row with no assignment grammar of its own (`nice FOO=1 …`, `timeout 5 FOO=1 …`) that word is now the program word, as the binary execs it; `env` / `sudo` keep owning theirs, and the argv re-entry program ends that ownership too (`env xargs FOO=1 echo` execs `FOO=1`).
|
|
42
|
+
|
|
43
|
+
### No behaviour change, one renamed reading — xargs targets stay refused, and the reference implementation's list is pinned as the thing NOT imported (#862 ④; zero reach, stated because the ticket asked for the opposite)
|
|
44
|
+
- **What the ticket asked for.** The reference implementation's read-only table accepts a bounded xargs target list (`echo printf wc grep egrep fgrep head tail`), so that `git ls-files | xargs grep -l TODO` and `find … | xargs wc -l` would stop carrying a card no rule can retire.
|
|
45
|
+
- **It was imported and reverted, with the counter-example pinned.** `echo <path outside the roots> | xargs head` reads that file at ZERO asks: the producer's COMMAND TEXT says nothing about the producer's OUTPUT — `echo` writes text, and the text becomes a filename nobody judged. `cat paths.txt | xargs head` is the same shape one indirection further away. That implementation ships no built-in read deny table and leaves out-of-root reads unblocked by default, so the list costs it nothing; here it hands back exactly the containment and deny coverage this boundary exists to provide.
|
|
46
|
+
- **After.** `argvReentryUnjudged` takes the set of ACCEPTED TARGETS from its caller rather than reading "programs taking no path operand" itself (one reading, the caller's declaration), and the classifier declares that set as the one property that answers the question: the target cannot open a file it is handed. **Behaviour is byte-identical to 7.21.0** — `| xargs echo|basename|dirname|tr` accepted, every file-opening target refused whole, `git ls-files | xargs grep` still carded. The everyday cost stands until the operands themselves can be established, which is the same unsolved half the recursive-read arm records.
|
|
47
|
+
- **One member DID move, and it is disclosed here rather than left to the section title.** The accepted stream-target set is the same identifier the unlisted-program change extended (`NO_PATH_OPERAND_COMMANDS`, which gained `printf`), so `… | xargs printf` goes from a mandated card to zero asks on the default doctrine (a plain classifier ask on `classify`). `printf` opens nothing it is handed, so the widening is safe; the title's "no behaviour change" holds for every other target.
|
|
48
|
+
|
|
49
|
+
### Widening (silence → one disclosure) — the undrained-input account settles AFTER the reopen compensation, on what it DID (#808; @server @cli @client-core: one more `task.user_steer_undrained` on a class of resume legs)
|
|
50
|
+
- **Before.** A resumed leg carries the checkpoint row's parked steers until its continuation reaches the transcript; when the leg ends before that, whether a person LOST them depends on whether the settle lane reopened the consumed row (queue intact ⇒ the next resume delivers them). The account was closed one lane EARLIER, on a prediction of that outcome — "no new park and the approved action never started ⇒ the row comes back" — and the compensation excludes three endings the prediction calls a return: a DELIVERED negative decision (a deny carrying a reason), a store that wired no reopen, and a reopen that FAILED. In each, the row stays consumed, the frames are gone, and the account said nothing at all. (#554 fixed the other half of this with that predicate; this is the half that was ordering, not predicate.)
|
|
51
|
+
- **After.** The adoption lane CARRIES the account (`OwedUserInputAccount`: the held counts, what the park migration took, the continuation frames and their carried count) and the settle lane closes it — once — immediately after the reopen block, with the reopen's ACTUAL outcome. The counting rule is unchanged (`rowReturned` replaces `rowMayReturn` as its third input): a returned row excuses only the frames the STORE minted a `seq` for; the caller's own frames were never on a row and are owed either way. One notice per family per run, unchanged — now exactly-once by construction (the account object carries that fact) and never-throwing, so the lane's teardown closes it as a BACKSTOP when the lane threw before reaching its own settlement (adversarial rounds: result assembly can throw on a thrown value it cannot render, and the relocated disclosure would otherwise be lost with it). That backstop reads the row through the half of the downstream handler's own reopen eligibility it can see ("the approved call never started"), which leaves the exceptional path exactly as loud as it was before the settlement moved — in both directions, and with no new claim this station cannot support. KNOWN RESIDUAL (ticket): the end-of-task pass runs one lane earlier still, so a rejection there closes no account at all; the form that fixes it carries the account into the stream's own rejection handler and settles on the outcome that handler establishes. The #554 predicate that predicted the reopen is deleted — the fact it approximated is now read where it is known (regarding `suspendProgressRef.executedApproved`: it stays, its other reader is the suspend-chain base).
|
|
52
|
+
- **Reach.** A resume leg that ends before delivering its continuation now announces the row's frames unless the row really came back: `task.user_steer_undrained` (count ≥ 1) where the run was previously silent. Reachable shapes: a deny-with-reason resume interrupted mid-settlement, an approved call vetoed on resume by a session rule imported while it was parked, a store whose reopen throws. Consumers that COUNT these notices per run see one where they saw none; no new code, no detail-key change, no closed set touched. Headless / no-approver lanes: identical (this is a disclosure about a person's queued input, not a gate decision). Pinned in `test/backlog808-undrained-after-reopen.test.ts` (three run-level rows + the two arithmetic branches), with the ordering itself pinned in `test/backlog554-resume-batch-settlement.test.ts`.
|
|
53
|
+
|
|
54
|
+
### Attribution (no behavior face on the verdict) — the classifier's QUESTION is built on its own seat, outside any rung's cap (#807; @server @cli read the `auto_mode.classified` frame)
|
|
55
|
+
- **Before.** One `classify` hook did both halves of a classification — build the transcript window, then dispatch it — and ONE cap (`RunnerDeps.autoMode.timeoutMs`, default 15 s) covered both. A slow session store therefore landed as the RUNG's `timeout`: a record stating that the classifier model did not answer in time for a classification in which no model had been asked anything, and — on a two-rung ladder — a `classifier.fallback` naming a move to the main model, with `cause: "timeout"` attributed to a first rung that was never dispatched to. The gap was stated in the code and is now closed.
|
|
56
|
+
- **After.** `AutoModeDeciderOptions.assemble` builds the question ONCE per classification, before the walk, on its own turn of the same `timeoutMs` (no new knob). A build that throws OR outruns the cap is the round-not-asked fact the `auto_mode.round_not_asked` marker already named: fail-closed `unavailable` / `cause: "error"`, NO rung dispatched, NO `fallback` on the record, attribution pinned to rung 0 — exactly what a build that threw already did. The rung cap now bounds a DISPATCH alone, which is what makes its `timeout` word true. The `AUTO_MODE_UNAVAILABLE_CAUSES` closed set is UNCHANGED (`error | timeout`): "the question did not assemble" and "the leg threw" are one fact for a consumer — nothing was dispatched — and splitting them into two cause words would be two answers to one question. WHICH way the build failed rides two additive optional members instead: `AutoModeClassified.notAsked` and the `auto_mode.classified` trace frame's `notAsked: "failed" | "timeout"` (absent ⇒ a model WAS asked). Both armings follow the same law — the live one (`prepare-caps-and-workflow`) and the cross-process rebuild (`rebuildAutoModeDecider`, whose `transcript()` read was inside the leg's cap the same way).
|
|
57
|
+
- **Reach.** Worst-case classification latency grows by at most one cap (assembly), and the per-rung dispatch budget is unchanged. Operators: a run of `unavailable` / `error` classifications now says whether to look at the transcript store (`notAsked`) or the provider (no `notAsked`). Headless / no-approver lanes: unchanged — every one of these classifications fail-closed to a deny before and after; what changed is the record's attribution, not the verdict. A deployment that wires no `assemble` seat keeps assembling inside `classify`, inside the rung cap, byte-identical. Pinned in `test/backlog807-classifier-build-cap.test.ts` (five decider rows + two through the arming the gate is actually handed), each with its mutation.
|
|
58
|
+
|
|
59
|
+
### Additive export — the auto-consolidation arming resolver reaches the package root (#863 W1; @server @cli @client-core)
|
|
60
|
+
- **Before.** `autoRunOnRecommendationEffective` was module-local. A host rendering "auto-consolidation: armed / not armed" beside a session's memory state could only reach the SEAT (`RunnerDeps.autoRunOnRecommendation`), whose ABSENCE is not "off" — it reads `AUTO_RUN_ON_RECOMMENDATION_DEFAULT`, and only where `memoryConsolidation` AND a resolvable consolidation model seat are both wired. A seat-only reader therefore answered wrong in both directions: a default-armed deployment reported unarmed, and a deployment that wired no consolidation reported armed.
|
|
61
|
+
- **After.** `autoRunOnRecommendationEffective` is re-exported from the package root — the SAME function the prepare door's manifest fact and the settle-tail trigger call, never a second derivation (`memory.auto_consolidation.arming_has_one_resolver`). Exports +1/−0.
|
|
62
|
+
- **Reach.** Zero behaviour change (no call site moved). @server: this is the function to compose a memory-status `armed` cell from — its answer is a DEPLOYMENT fact, so it takes `RunnerDeps`, not a session. @cli / @client-core: render the arming from the composed cell, not from the seat.
|
|
63
|
+
|
|
64
|
+
### Narrowing (a floor, not a default) — a task that declares memory OFF hands that fact to every child, grandchild and revived cycle (#863 W3; @server @client-core)
|
|
65
|
+
- **Before.** `TaskSpec.memory.enabled === false` governed the declaring task only. A child got its memory from an agent definition's own `memory` (`Agent`/`Task` lane) or from its own spec (workflow lane), so a task that had turned memory off could delegate to a definition that turned it back on — the child mounted the `# Memory` instruction, the three `memory_*` tools and the memory home as an advertised writable root, and its harvest committed notes the declaring task had asked not to have. The same hole on the revival lane: a run whose own task declared memory off could wake someone else's retained child into a memory-mounting cycle.
|
|
66
|
+
- **After.** The fact rides the parent SPEC seat as `ParentSpecSeat.memoryOff?: true` (minted ONLY from an explicit `enabled: false` — an absent `memory` is silence, not an off statement), and `applyParentSpecSeat` stamps it over the child's own declaration as the third unconditional REMOVAL beside `oneShot: true` and `memoryPersistenceCapable: false`. It REWRITES rather than deletes: the child keeps its own scopes and gets `enabled: false` (a child with no declaration gets the bare `{ enabled: false }`, which the normalizer reads exactly like the absent spec it replaces), because the next mint reads `spec.memory.enabled === false` and a deleted `memory` would end the floor one level down. The revival lane gains the matching clamp (`currentClamps.memoryOff`), filled on the cross-run `SendMessage` wake from the sender's own seat; the spawn snapshot's declaration already rides the snapshot.
|
|
67
|
+
- **Reach.** OFF means the memory FACE is not mounted — no `# Memory` section, no `memory_*` tools, no memory home among the run's advertised writable roots. It is NOT a filesystem prohibition: a memory-less run has no memory write gate at all, so a memory root inside an authorized workspace stays reachable by the ordinary file tools under the ordinary fence. A deployment that ALREADY spelled `memory: { …, enabled: false }` on a parent sees the narrowing today: its children's own `memory` declarations no longer mount. Type surface is additive (`ParentSpecSeat.memoryOff?`, `createSubagentResume` deps' `currentClamps.memoryOff?`) — no key is removed and an absent `memoryOff` reads exactly as 7.21.x did. **Stated residual:** the floor rides the spawn lanes, the retained resume and the cross-run wake; it does NOT ride a TIER-3 DURABLE revival, which rebuilds a settled background child from the durable row's lookup keys plus the WAKING run's own mount — that row carries no policy facts at all, so every tighten-only clamp (`handsReadOnly`, `oneShot`, the two containment faces, `memoryPersistenceCapable`) is already the waker's rather than the original spawner's on that leg, and `memoryOff` joins them unchanged. A waker that declared memory off does floor the revived cycle. @server: this is the core half of a request-level memory switch; @client-core: `memoryOff` is an additive typeKey on the resume-clamp shape. Headless lanes: a child of an off parent simply has no memory face — nothing new is asked and nothing is refused.
|
|
68
|
+
|
|
69
|
+
### Narrowing (a recommendation arrives one session later) — the consolidation threshold excludes the session doing the asking (#863 G-17; @server @cli re-read)
|
|
70
|
+
- **Before.** The harvest-tail bookkeeping pushed the calling session into the dedup ring and then compared `row.sessions.count` — which by then INCLUDED that session — against `minSessionsBetweenRuns`. The snapshot door (`snapshotConsolidationCandidates`'s unforced session gate) compared its own copy of the same figure. The counted session is not material to a decision taken on its own behalf: this bookkeeping also runs on the SUSPEND path, so it may still be running, and at a terminal harvest its notes are the ones the harvest just wrote. The reference CLI filters the current session out before comparing.
|
|
71
|
+
- **After.** ONE reading, `sessionsSinceExcludingCurrent(row)` (`src/core/memory-engine/consolidation.ts`), is what both doors compare and what every sentence reports: the recommendation edge, the snapshot door's session gate, and that gate's refusal text. The ring and its `count` are UNCHANGED — they still include the calling session, which is exactly what makes a second harvest of it not a second session.
|
|
72
|
+
- **Reach.** With the shipped `minSessionsBetweenRuns: 5`, `memory.consolidation_recommended` is minted at the 6th distinct session's terminal harvest instead of the 5th (and, with the 7.21.1 default-on arming, the automatic run fires one session later too). The reported `detail.sessionsSince` narrows by one everywhere — but AT THE CROSSING it is unchanged, because the crossing is exactly the threshold: 5 before, 5 now. The unforced snapshot door moves in lockstep instead of opening a session early, and `force` bypasses it as before. @cli: a rendered "N session(s) since the last run" line is one lower away from the crossing; @server: any threshold text derived from this number. Headless lanes: unaffected — nothing here asks or refuses on a tool call.
|
|
73
|
+
|
|
74
|
+
### Additive (a deployment seat, a durable record field, a retention contract clause) — a session id stops speaking for the wrong occupant (#514; @server @cli)
|
|
75
|
+
- **Before.** The three durable session record legs — the pollution marker, the capture opt-out record, and the memory lineage ledger — were keyed by session id alone. A session id is not a unique name for an occupant: the retention contract lets a deployment delete a session tree and hand the id out again, and when it did, a standing record answered the NEXT occupant with the PREVIOUS occupant's fact — a capture opt-out that silently suppressed memory for a new user and reported it "at the user's request", or a pollution sweep that challenged another occupant's committed entries. Deleting the record at purge was no fix either: the withdrawal was then forgotten while the contributions it withdrew stayed committed and re-entered distillation.
|
|
76
|
+
- **After.** A deployment may name the occupant behind an id through the new `RunnerDeps.memorySessionIncarnation` seat (opaque token, stable for one occupant's life, different for the next). Records mint stamped with it, and ONE predicate decides every leg: **only a proven mismatch — both sides known and different — separates a record from the identity reading it**. A record set aside is kept where it is, keeps binding the occupant it belongs to, and keeps its id on the roster faces the consolidation eligibility arm reads (incarnation-blind: an id's contributions stay excluded for every occupant it ever had — the conservative superset). A new occupant's own declaration now mints its own durable record instead of answering "already recorded" over a stranger's — and an occupant the resolver could not name during an outage writes one that binds every occupant, so its declaration survives the resolver's recovery. A standing record vouches for a later mint only when it will still bind that occupant after the resolver names it; a record this layer cannot attribute never vouches.
|
|
77
|
+
- **Absence.** The seat is absent by default: no incarnation is stamped, none is asked for, the record files' bytes are unchanged, and all three legs answer exactly as in 7.21.1. Records written before this version, records imported from another memory plane, synthesized fail-closed records for corrupt files, and any non-string value in the field all read as "occupant unknown", which BINDS — the pre-#514 reading. A resolver that throws falls back to the same reading.
|
|
78
|
+
- **Loudness.** No separation is silent: `memory.session_incarnation_mismatch` (once per session id per engine) states that a standing record was set aside and names both incarnations; `memory.session_incarnation_unreadable` (once per engine) states that the resolver threw and the run is binding by id. A deployment that wires the seat WRONG — a value that varies within one occupant's life would re-enable capture for a session that asked for privacy — is therefore visible from the first resume it breaks.
|
|
79
|
+
- **Retention contract (@server).** `ManagedRetentionCapability`'s three operations named checkpoint rows, session trees and orphan tool results and were SILENT about the memory control plane's governance legs. That silence is now spelled out and machine-readable: `RETENTION_CASCADE_EXEMPT` (`session-pollution`, `session-capture-optout`, `lineage.json`) is disjoint from `RETENTION_CASCADE_DOMAINS`, pinned identical to the layout coordinates it names, and a session purge must not delete those three — deleting them turns a one-way privacy record into a revocable one. With the incarnation seat wired, retaining them can no longer bind a later occupant, so there is no compliance reason left to.
|
|
80
|
+
- **Exports.** `RETENTION_CASCADE_DOMAINS` and `RETENTION_CASCADE_EXEMPT` added (+2, additive); `SessionCaptureRecordStore.read` takes an optional second parameter (existing one-parameter carriers still conform, no code change required). No BREAKING.
|
|
81
|
+
- **Also.** The session-pollution marker takes its twin's `lstat` arbitration for absence: a DANGLING entry at a marker's pathname is "present but unreadable" (fail-closed), never clean absence — the capture opt-out record has read it that way since 6.0.0-RC, and the two legs must agree about whether an entry that exists can read as nothing. A deployment whose control plane is healthy sees no difference.
|
|
82
|
+
- **Headless.** No ask surface is touched: there is no new question, no new refusal, and no gate reads the axis.
|
|
83
|
+
|
|
84
|
+
### Widening (mandated ask → plain ask / allow), one family half — an interpreter's inline program and a verb's script slot DEMOTE the segment instead of refusing the whole command (#862 · #791 · #769; both faces; @cli @server re-pin; headless lanes: fewer refusals)
|
|
85
|
+
- **Before.** The ARGUMENT-AS-PROGRAM family had four members and one standing: shell re-entry (`sh -c …`), argv re-entry (`xargs cat`), a verb's SCRIPT SLOT (`sed -n '1r /etc/hosts' f`) and an INTERPRETER's inline program (`awk '{print}' f`, `perl -pe … f`, `jq -r .name pkg.json`) each refused the command WHOLE, which the read boundary reads as "nothing was judged" and both probe seats read as a MANDATE — no standing allow rule retires it, the auto-mode classifier may not answer it, and a headless lane refuses it. Measured on this tree: `jq -r .name package.json` was four-cell mandated while the engine's own read-only table (`#619`, the upstream transcription) judges it READ-ONLY; `awk '{print}' data.txt`, `perl -pe 's/a/b/' file.txt`, `sed -n '1r /etc/hosts' f` likewise.
|
|
86
|
+
- **After — one standing for the inline spelling and the FILE spelling.** The family's two TABLE-DRIVEN members demote the segment (never auto-allowed, the sentence unchanged character for character) and the walk goes on judging every path the command NAMES. The re-entry members are untouched: their unread text is a command line this walk MODELS, so nothing was judged and the mandate stands. The line between them is what the walk could have done about the text, and it is the line the friction-floor contract already drew for a program FILE — `awk -f prog.awk`, `jq -f filter.jq`, `python3 script.py` are judged as the in-root operands they are with the program inside them never parsed. An inline program is the same question at a different spelling; it now gets the same answer. The refused-whole word scan drops the same check for the same reason (`echo x > o; awk 'NR==1' f` is exempt where `awk -f prog.awk f` always was), and the `bash_readonly` face — which has no approval channel — still REFUSES, because its rule for every demotion is refusal.
|
|
87
|
+
- **Reach.** Classify doctrine (every permission mode but bypass): `awk '{print}' f`, `perl -pe 's/a/b/' f`, `node -e …`, `python3 -c …`, `sed -n '1r /x' f`, `sed 's/a/b/w out' f` ask ONCE and a standing `Bash(…)` rule now clears them. Default/off doctrine (the read-boundary seat alone, which bypass installs): they are VOUCHED — the same verdict that seat already gave `awk -f prog.awk`. `jq` reaches zero asks end to end, because the read-only leg it was blocked from now clears it. **Headless / no-approver lanes: these commands are executed rather than refused under the off doctrine, and are one clearable ask under classify.** **Accepted, and stated rather than implied:** a file named INSIDE the unread program text is not judged by the boundary — `sed -n '1r /etc/hosts' f` reads `/etc/hosts` at zero asks under the off doctrine, and under classify after one approval or one stored rule. That is the floor's stated reach (`@contract shell.read_boundary.friction_floor`: it is not a defence against a command written to defeat it), it is what the same spelling in a SCRIPT FILE always did, and the containment that holds it is the `ExecutionEnv` the command runs in. **Unchanged:** an out-of-root OPERAND, a deny-listed operand, or an unresolvable word beside an inline program still mandates on both faces (pinned); a path-prefixed interpreter (`/usr/bin/perl -e …`) still mandates on the program's own out-of-root path.
|
|
88
|
+
- Pins: `test/backlog791-interpreter-inline-program.test.ts` (G-1/G-1c/G-2/G-3/G-6, the arm's sentence and the seat split pinned apart), `test/backlog769-sed-script-slot.test.ts` (G-1/G-2/G-3), `test/backlog696-*` and `test/backlog854-*` G6/G6b re-pinned to the new reading.
|
|
89
|
+
|
|
90
|
+
### Widening (mandated ask → the segment's own plain ask), one word shape — a `NAME=value` ARGUMENT's value is judged only where the program's grammar says it opens one (#862 · #860; both faces and the refused-whole word scan; @cli @server re-pin; headless lanes: fewer refusals)
|
|
91
|
+
- **Before.** #860 made every `NAME=value` word offer its VALUE as a path candidate beside itself, for every program. On the roots face that put a MANDATE — an ask no standing allow rule retires, the auto-mode classifier may not answer, and a headless lane refuses — on the everyday build line: `make PREFIX=/usr/local`, `cmake -DCMAKE_INSTALL_PREFIX=/usr/local`, `docker run -e HOME=/root …`, and `make PREFIX=~/opt` (unresolved on BOTH faces). None of those programs opens the value; it is a build prefix or an environment string. The file's own text called the listed-reader case (`cat a=/etc/passwd`, whose reader opens the whole word) a stated over-fire; that one STAYS, because the exemption is an allow-list and no reader is on it — retiring it would mean naming every reader, a list whose omissions are exemptions.
|
|
92
|
+
- **After.** The value is a candidate unless the program receiving it is on a DECLARED ALLOW-LIST of programs whose `NAME=value` arguments are not paths (`make`, `gmake`, `bmake`, `cmake`, `ninja`, `meson`, `docker`, `podman`; a further one is a member, not a branch). **The polarity is the whole point and it cost four adversarial rounds to arrive at.** Listing the programs that DO open such a value means anything the walk fails to recognise is exempt — and what it fails to recognise is WHICH PROGRAM RUNS: an undeclared wrapper is read as the program (`caffeinate dd if=<path>`, `chpst dd …`, `envdir <dir> dd …`), and a launcher can carry the program inside an OPTION PAYLOAD with no word equal to it at all (`env -Sdd if=<path>`, `env --split-string=dd …`). Each was verified to execute the read, and each was found only because a differential battery was run against the previous tree rather than reasoned about. Inverted, every one of them is judged, and so is a fifth spelling nobody has thought of. The rest of the arm is untouched: the WHOLE word is still a candidate of its own (a deny row naming `secret=/work/in.txt` still gates), an OPTION word's payload still has its own arm, and the **LEADING environment assignments — the words bash strips before choosing the program, and the ones a launcher passes through (`FOO=/etc/passwd <unlisted>`, `env FOO=… <unlisted>`, the `NAME+=` spelling of either) — are judged whatever the program is**, because bash itself puts those values in the program's environment. That split is now a named seat (`HeadedSegment.argvFrom`, read by the boundary walk and the refused-whole word scan alike), so the two halves cannot disagree about one word.
|
|
93
|
+
- **Reach.** Roots face: `make PREFIX=/usr/local`, `cmake -D…`, `docker run -e HOME=/root …`, `make PREFIX=~/opt`, `make PREFIX+=…` and the same behind a resolvable launcher (`env make …`) go from a mandated ask to the segment's own plain, rule-clearable ask on the classify doctrine and to zero asks on the default/off doctrine. **Headless / no-approver lanes: one fewer refusal per such command.** Unchanged (i.e. still mandated): every program not on the list — `dd if=/etc/passwd`, `<unlisted> key=<outside>`, `cat a=/etc/passwd`, and any wrapper the reader cannot resolve — every deny-listed value, every leading environment word; and at zero asks as before, every path-less or in-roots value (`NODE_ENV=production`, `TZ=Asia/Shanghai`, `git -c core.editor=vim`). **Not a #860 case at all, measured and stated:** `cmake -D… ..` still mandates — on its `..` operand, which resolves above the root.
|
|
94
|
+
- Pins: `test/backlog860-assignment-word-payloads.test.ts` G1/G1b (the declared list and everything off it), G2/G3/G4 (the leading words and the word scan, unchanged); `test/backlog862b-*` ③ and the polarity pin (every wrapper spelling that was verified to execute the read).
|
|
95
|
+
|
|
96
|
+
### Widening (a mandated ask gains a way out), the read-root grant offer — a card whose directory would hand out the home names the ONE path it read, and a per-segment NAME refusal no longer takes the offer with it (#862 · #714 · #758; the read-boundary seat; @cli @server: one new optional key, `ReadRootGrantCandidate.covers`)
|
|
97
|
+
- **Before.** The offer was refused outright whenever the containing directory would have handed out more than was read — the filesystem root, the declared home, or a directory containing a declared root — and it was dropped wherever ANY per-segment sentence took precedence over the walk's own. Measured: `cat ~/.gitconfig`, `cat ~/notes.txt`, `cd /etc && cat passwd` and `git -C /outside status` were mandated asks with NOTHING on them to accept — a card that cannot be cleared by a standing rule, cannot be answered by the classifier, is refused on a headless lane, and offered no directory either. `npm install /opt/pkg`, `curl -o /tmp/out.bin …`, `strings /etc/hosts`, `zzunlisted /etc/passwd` and `cat /etc/passwd | xxd` were the same, because "this program is not on the read-only table" was the sentence that won.
|
|
98
|
+
- **After — one fallback, and one word that says whose sentence stands.** (a) A directory that would hand out too much is still never offered; in its place the ONE out-of-root path the walk reported is offered VERBATIM, carrying `covers: "exact"`. The claim is the same MEASURED one (the walk is replayed with the value as a root and offered only if it comes back objection-free), and two out-of-root paths still offer nothing — the fallback names a single path, it never widens. (b) A per-segment NAME refusal keeps the offer, marked as such, and each SEAT reads that word: the read-boundary seat, whose only question is the boundary's, names the directory; the classify seat holds the call for the name itself — which no grant answers — and that arm spreads nothing, exactly as its backgrounded arm already does. A refused SHAPE (`tail -f <out-of-root>`) still takes the offer with it: it is refused after any grant, on every seat.
|
|
99
|
+
- **Also — a program DECLARED to read no path reads none whether or not the deployment listed it (#758's over-fire).** The no-path-operand declaration (`echo`, `printf` — the row this table was missing — `which`, `tr`, `basename`, `dirname`, the status printers) is a property of the PROGRAM, and the reference CLI reads it the same way: its own no-path set is keyed on the program basename and consulted before, and independently of, its path-restricted command table. `printf '/etc/hosts'` and `echo /etc/passwd` no longer raise a boundary objection on the format text and the string they print. The words BEFORE the program's own argv keep every judgement they had — a path-prefixed program (`/usr/bin/printf x` still mandates on `/usr/bin`) and the leading environment assignments (`FOO=/etc/passwd printf x` still mandates). The OTHER per-verb tables stay the deployment's to declare: a pattern slot and a base move would make the walk understand something on an undeclared program's behalf, which is the direction that grants silence, and both are pinned unchanged.
|
|
100
|
+
- **Reach.** @cli @server: `readRootCandidate` is present on cards that carried none before, and it may now carry `covers: "exact"`. **ABSENT `covers` = the ordinary directory offer, byte-unchanged. PRESENT = `dir` is the path itself, and a consumer must add THAT STRING — expanding it into a directory pattern (`<dir>/**`) grants nothing and leaves the ask standing.** Headless / no-approver lanes: unchanged for (a) and (b) — the mandate is the same, only the card's exit is new; `printf`/`echo` with an out-of-root argument goes from refused to executed on those lanes. Unchanged: a deny row offers nothing (no grant moves a pattern), a glob or a recursive walk offers nothing, a `..`-traversing spelling offers nothing, the classify seat's backgrounded arm offers nothing.
|
|
101
|
+
- **Two holes the adversarial round reproduced, closed here rather than filed** (both are the same root: a declaration ABOUT A PROGRAM was keyed on the segment's head word). `env dd if=<outside>` and `env dd if=<deny-listed>` lost dd's input-path judgement entirely — vouched where the previous tree mandated — because the synthetic head recorded `env` as the verb. And `A+=x env B=<outside> <program>` hid the launcher behind the append word, because the program-position reader reads `A+=x` as a command NAME. ONE reader (`argvProgramOf`) now answers both "which program" and "where its argv begins", stripping BOTH assignment spellings before it resolves the chain. The per-verb OPTION grammar deliberately does NOT follow the launcher (that row would make the walk understand something on an undeclared program's behalf — #758's line), and the asymmetry is pinned with its measured pair.
|
|
102
|
+
- **The `clearsThisAsk` claim is now CHECKED rather than argued.** Widening where an offer may ride broke the old soundness argument ("a face names one only beside its own containment sentence") for one shape: a poll loop over an UNLISTED program is refused whole by the compound face, whose unjudged-read mandate no root grant erases, while the loop face had walked the reads and named a directory — measured: the ask survived its own offer. The seat now withholds an offer that rides a name refusal when the grammar refused the command whole and the scan says it may read; the same loop over a LISTED reader still offers and still clears, exactly as before. Pinned as a battery that grants what each command offers and requires the seat to fall silent.
|
|
103
|
+
- Pins: `test/backlog803-read-root-candidate.test.ts` (③/③b the seat split, ③c the granted-offer battery, ⑤ the home fallback, the `cd`-out card), `test/backlog714-*` (the three answers side by side: deny row / home file / the home itself), `test/backlog758-*` G-6 (the declaration follows the program; the other per-verb tables do not), `test/backlog696-*`.
|
|
104
|
+
|
|
105
|
+
### Not done, recorded with the measurement — two narrowing points handed over from the sibling car are HELD (#862 · #792 · #766)
|
|
106
|
+
- Both were implemented and then reverted, because each turned a refusal that was CARRYING a mandate into a demotion while the mandate was standing in for arms that then never ran. Neither is a judgement call this car could make alone.
|
|
107
|
+
- **An unreadable launcher option** (`nice -n 5 …`, `env -u X …`, a spelling no row carries) ⇒ a plain ask. Measured with the demotion applied: `env -u X sh -c "<command line>"` reached ZERO asks on the boundary seat — with no program identified, the shell-re-entry arm never runs and the inner command line is walked as an ordinary in-root filename — and `nice -n 5 grep -r <dir>` lost its traversal reading under a live deny judge. What the reader could not read is not one option's arity; it is WHICH PROGRAM RUNS, and every per-program judgement depends on that answer.
|
|
108
|
+
- **An `xargs` target outside the declared path-free set** ⇒ a plain ask. Measured with the demotion applied: under the default/off doctrine the boundary seat sits ALONE, so "not mandated" is VOUCHED — `echo <out-of-root> | xargs cat` and its deny-listed twin both reached zero asks, which are the exact spellings a previous P0 closed. Making this safe needs a demotion that means different things on the two seats, which is a ruling, not an implementation detail.
|
|
109
|
+
- Both measurements are pinned (`test/backlog862b-narrowing-cc-alignment.test.ts` ⑨) so they are not lost with the branch.
|
|
110
|
+
|
|
111
|
+
### Narrowed reading of one persisted cell, plus one additive key — a shell-seat raise is the `shell` safety axis, not the `irreversible` one (#862 · #858 (i-a) · #713; `CheckpointGate.kind` unchanged; @server @cli: a resolver reading `safetyAxis.irreversible` flips for the read-boundary family)
|
|
112
|
+
- **Before.** `safetyAxisOf` folded every raise onto `irreversible`, so a card minted by the engine's SHELL SEAT — a read-boundary probe's verdict, a probe consulted that did not answer, the operator's `shellGate:"always"` doctrine, that doctrine's own tighten — persisted `safetyAxis: { irreversible: true }` and reached the person as "tool X performs an irreversible action". That seat's question is about TEXT (`@contract shell.read_boundary.friction_floor`: which files the words of a command may name), so the command behind the card may be entirely benign — a `grep -rn TODO src/` the boundary could not enumerate. The tree already knew: `buildRiskDescriptor`, minting at the SAME moment from the same inputs, graded it `axes: { shell: true }`, severity 3, "NOT `irreversible` (4): the command MAY be benign". Two derivations, opposite answers, one gate row — and the one the durable record kept was the wrong one.
|
|
113
|
+
- **After — one derivation, two axes, mutually exclusive.** `safetyAxisOf` still decides WHETHER a raise happened by exactly the same rule (so `CheckpointGate.kind` is byte-identical: `irreversible_ask` for every park that had it, `human` for every park that did not, and a plain policy ask on a shell-gated tool still raises nothing at all). What it now also decides is WHICH axis the raise lands on: the shell seat's raises are `shell: true` with `irreversible: false`; a tool's OWN `always`/`maybe` declaration keeps `irreversible`, whoever asked first. `buildRiskDescriptor` drops the `!shell &&` correction it carried — there is nothing left to correct once the axis is judged once.
|
|
114
|
+
- **Reach.** **@server @cli: a resolver that reads `safetyAxis.irreversible` to decide "does this need a real person?" flips for the whole read-boundary family** — read `irreversible || shell` to keep today's answer, or read them apart to treat a question about a command's text differently from an act that cannot be undone. **The budget face is unchanged** (`kind` is what a budget resolver keys on, and it did not move), `RiskDescriptor.axes` is unchanged, and `SAFETY_AXIS_VOCABULARY.safetyAxis` already listed `shell` — the key it named now exists. **Headless / no-approver lanes: unchanged** (no ask is gained or lost; only the word on the persisted row moved). The ask card's own sentence is NOT changed by this piece — it remains the auto-mode classifier's input, and changing it is a separate question with no live evidence behind it yet (`#842`).
|
|
115
|
+
- Pins: `test/backlog713-safety-axis-by-seat.test.ts` (the derivation table row by row, the mutual-exclusion pin, and the six Runner cells A–F unchanged in `kind`), `test/backlog862b-narrowing-cc-alignment.test.ts` ⑥ (the two derivations agreeing on one mint), `test/shell-gate.test.ts`.
|
|
116
|
+
|
|
117
|
+
### Widening (refuse → read, ask → allow) — the built-in sensitive-path READ deny table now ships OFF; it is a deployment opt-in switch (#889; ruling: align with the reference CLI, which has no run-time deny table keyed on file names; @server @cli @client-core @test)
|
|
118
|
+
- **Before (≤ 7.21.1).** `READ_DENY_DEFAULT_TIERS` was "every tier except `shell-history`", so an unconfigured deployment refused reads of ~40 credential-class NAMES anywhere in a path — `.npmrc`, `id_rsa*`, `.ssh`, `.aws`, `.mcp.json`, `.claude/settings.json`, … — on the structured read faces AND on the shell read boundary, including a repo's own fixture spelled `fixtures/.ssh/key`. Because the recursive-read demotion mints on "a deny judge is wired", that table also made `grep -r`, `ls -R`, `du` and `diff -r` cost one mandated card in every project, on a deployment that had never asked for any of it.
|
|
119
|
+
- **After.** The default selection is EMPTY. An unconfigured deployment activates no built-in row: the structured faces serve an in-root `.npmrc`, the shell boundary runs `cat .npmrc` and the whole recursive family at zero cards, and nothing in that table participates in a verdict. The table itself is unchanged and fully intact — it is now the MENU. A deployment switches tiers on at the existing seat (`RunnerDeps.readDenyBuiltinTiers` / its `HandsToolkitOptions` twin, deployment-only as before), and the old posture is spelled `["credentials","browser","wallet","agent-config"]`. `readDenyBuiltinExclude` is unchanged. `[]` remains the explicit empty posture and `none`-style explicit spellings keep their meaning; absent and `[]` now resolve alike.
|
|
120
|
+
- **Independent of the switch.** Deployment and task `readDenyPatterns` are a SEPARATE set and are judged whether or not a built-in tier is active — a deployment that lists its own patterns is unaffected by this change, recursive-read card included. The CONTAINMENT boundary is untouched: an out-of-root read is still one mandated ask (bypass immunity unchanged), and the WRITE protection table (a different table, a different ruling) is untouched.
|
|
121
|
+
- **New disclosure.** `WiringManifest.readDeny?: { builtinTiers }` — effective half, PRESENT IFF the leg's configuration activates at least one built-in row, in `configFingerprint`. An empty array is never emitted, so absence is the unconfigured posture and reads as "no BUILT-IN row judges this leg" (not "no read is deny-judged": `readDenyPatterns` are deliberately not reported — pattern texts are deployment material, not an enum). The tiers named are the ones that MATERIALLY activate rows; a tier whose every row was excluded is not named. Closed set `ReadDenyBuiltinTier` is now registered in `docs/CLOSED-SETS.md` (members unchanged).
|
|
122
|
+
- **Second half, no wire key.** The shell read boundary carries its `denyMatch` seat present-iff the compiled set has entries. The recursive-read demotion reads that seat's PRESENCE as "the check a traversal would outflank is in place", so a judge compiled over an empty set must not be wired. Measured over an empty set: wiring the seat vs not changes only the recursive arm (8 of 32 probed commands, all recursive forms); every other verdict is identical. A deployment with any active row is byte-unchanged.
|
|
123
|
+
- **Reach — operators.** The switch is `readDenyBuiltinTiers` at the deployment seat; on a server that surfaces it as env, an UNSET env must now mean OFF while an explicit empty spelling keeps meaning the explicit empty posture. A deployment that relied on the shipped floor to keep a model from reading credential files in-root must now say so — and the honest floor was always the sandboxed `ExecutionEnv` plus the containment boundary, never this name table (the module said so; it is a structured-face floor, not a machine-wide boundary). Headless / no-approver lanes: OFF is strictly fewer refusals (an in-root credential-named read and every recursive read now complete instead of parking); out-of-root refusals are unchanged. A safety-configuration guide for switching tiers on is owed separately.
|
|
124
|
+
- **Reach — consumers.** `READ_FACE_DEFAULT_DENY_ENTRIES` is now `[]` (kept, still derived): a face rendering "what does an unconfigured deployment refuse" gets the honest empty answer, and a face rendering "what COULD be switched on" reads the unchanged tier-annotated `READ_FACE_BUILTIN_DENY_TABLE`. @test: the B-105 / B-107 / B-120 boundary criteria still hold verbatim WITH THE SWITCH ON — re-verify them under an explicit `readDenyBuiltinTiers`, and add an OFF-state control. Pinned in `test/backlog889-read-deny-default-off.test.ts` (the two-state matrix: OFF / ON / own-patterns × in-root / out-of-root × single / recursive × attended / headless); every other suite that judges built-in rows now names its selection at the seat.
|
|
125
|
+
|
|
3
126
|
## 7.21.1 — 2026-09-17
|
|
4
127
|
|
|
5
128
|
### Widening (default ON) — memory consolidation runs by itself once the recommendation threshold is crossed (#761 follow-up; ruling: align with the reference CLI's auto-dream; @server @cli @client-core)
|
|
6
129
|
- **Before (7.21.0).** `AUTO_RUN_ON_RECOMMENDATION_DEFAULT` shipped `false`: an ABSENT `RunnerDeps.autoRunOnRecommendation` seat only minted the `memory.consolidation_recommended` notice and the host had to run consolidation itself.
|
|
7
130
|
- **After.** The named default is `true`: on a deployment that wired `memoryConsolidation` AND a resolvable consolidation model seat, an absent seat ARMS the trigger — the terminal harvest that crosses the threshold (24h ∧ 5 sessions, the recommendation edge's own gate, unchanged) fires ONE host consolidation run at the post-task tail; the effective wiring manifest carries `autoConsolidation: { onRecommendation: true }` (in the fingerprint) on such deployments. An explicit `false` is the off switch and beats the default. A deployment without consolidation wired, or with no model seat, is NOT armed by the default (byte-unchanged).
|
|
8
131
|
- **Reach.** Memory content crosses to the configured consolidation model automatically on every recommending task — an EGRESS decision now ON by default; multi-tenant deployments turn it off with an explicit `false` on the seat. Manifest `configFingerprint` drifts once on every deployment that becomes armed by this default. Pins: absent seat + both seats wired ⇒ armed, manifest cell present; absent seat + no consolidation ⇒ not armed; explicit `false` ⇒ off.
|
|
9
|
-
|
|
10
132
|
## 7.21.0 — 2026-09-17
|
|
11
133
|
|
|
12
134
|
### Widening (ask → allow), one spelling — the shell read boundary's word scan now resolves a home-prefixed operand (#856; completes #854 for `~/x`; deployments declaring `readFace: "open"` widen, `"roots"` widens only where the home lies inside the roots; @server @cli re-pin)
|
|
@@ -1004,6 +1004,7 @@ export function createSendMessageTool(opts) {
|
|
|
1004
1004
|
...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
|
|
1005
1005
|
...(ctx.oneShot === true ? { oneShot: true } : {}),
|
|
1006
1006
|
...(ctx.memoryPersistenceCapable === false ? { memoryPersistenceCapable: false } : {}),
|
|
1007
|
+
...(ctx.parentSpecSeat?.memoryOff === true ? { memoryOff: true } : {}),
|
|
1007
1008
|
},
|
|
1008
1009
|
...(ctx.autoModeReview !== undefined ? { currentAutoModeReview: ctx.autoModeReview } : {}),
|
|
1009
1010
|
});
|
|
@@ -467,6 +467,7 @@ export declare function createSubagentResume(deps: {
|
|
|
467
467
|
interactiveTools?: false;
|
|
468
468
|
oneShot?: true;
|
|
469
469
|
memoryPersistenceCapable?: false;
|
|
470
|
+
memoryOff?: true;
|
|
470
471
|
readFaceRoots?: true;
|
|
471
472
|
writeFaceRoots?: true;
|
|
472
473
|
readDenyPatterns?: readonly import("../tools/fs/read-deny.js").ReadDenyEntry[];
|
package/dist/agents/subagent.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { isAbsolute } from "node:path";
|
|
3
|
-
import { applyParentSpecSeat, parentSpecSeatOfCtx } from "../core/parent-spec-seat.js";
|
|
3
|
+
import { applyParentSpecSeat, memoryTurnedOff, parentSpecSeatOfCtx } from "../core/parent-spec-seat.js";
|
|
4
4
|
import { childClampSeats } from "../core/child-clamp-seats.js";
|
|
5
5
|
import { stripSpawnTurnQuestionFace } from "../core/ask-question.js";
|
|
6
6
|
import { withDelegationProvenance } from "../core/tool-policy.js";
|
|
@@ -569,6 +569,7 @@ export function createSubagentResume(deps) {
|
|
|
569
569
|
...(entry.specSnapshot.memoryPersistenceCapable === false || deps.currentClamps?.memoryPersistenceCapable === false
|
|
570
570
|
? { memoryPersistenceCapable: false }
|
|
571
571
|
: {}),
|
|
572
|
+
...(deps.currentClamps?.memoryOff === true ? { memory: memoryTurnedOff(entry.specSnapshot.memory) } : {}),
|
|
572
573
|
signal: abort.signal,
|
|
573
574
|
};
|
|
574
575
|
if (deps.registry !== undefined && deps.taskId !== undefined && deps.taskAccess !== undefined) {
|
|
@@ -390,6 +390,7 @@ export declare function safetyAxisOf(marks: MandateMarks & {
|
|
|
390
390
|
}): {
|
|
391
391
|
egress: boolean;
|
|
392
392
|
irreversible: boolean;
|
|
393
|
+
shell?: boolean;
|
|
393
394
|
} | undefined;
|
|
394
395
|
/** An ASK that carries its probe's mandate — the per-call member the gate stamped (`probeMandated`), read
|
|
395
396
|
* through the same family word and lane table, so its three consumers (the inherited classifier station,
|
package/dist/core/ask-origin.js
CHANGED
|
@@ -129,9 +129,10 @@ function irreversibilityMandateOf(marks) {
|
|
|
129
129
|
}
|
|
130
130
|
export function safetyAxisOf(marks) {
|
|
131
131
|
const egress = marks.egress === true;
|
|
132
|
-
const
|
|
132
|
+
const raised = irreversibilityMandateOf(marks) !== undefined ||
|
|
133
133
|
(marks.shellGated === true && marks.irreversibility === "maybe" && marks.tightened === "shell_gate");
|
|
134
|
-
|
|
134
|
+
const shell = raised && marks.shellGated === true;
|
|
135
|
+
return egress || raised ? { egress, irreversible: raised && !shell, ...(shell ? { shell: true } : {}) } : undefined;
|
|
135
136
|
}
|
|
136
137
|
export function probeMandatedAsk(ask) {
|
|
137
138
|
return bindsEveryAutomaticLane(persistedRuleMandateOf({ probeMandated: ask.probeMandated === true }));
|
|
@@ -31,9 +31,13 @@ export function rebuildAutoModeDecider(opts) {
|
|
|
31
31
|
}
|
|
32
32
|
const decider = createAutoModeDecider({
|
|
33
33
|
...(effective.timeoutMs !== undefined ? { timeoutMs: effective.timeoutMs } : {}),
|
|
34
|
-
|
|
34
|
+
assemble: async (input) => {
|
|
35
35
|
const messages = await opts.transcript();
|
|
36
|
-
|
|
36
|
+
return renderAutoModeWindow(messages, effective.window) + renderAutoModeAction(input);
|
|
37
|
+
},
|
|
38
|
+
classify: async (_input, signal, _rung, userPrompt) => {
|
|
39
|
+
if (userPrompt === undefined)
|
|
40
|
+
throw new Error("the rebuilt auto-mode classifier was asked with no assembled question");
|
|
37
41
|
return opts.complete({ systemPrompt, userPrompt }, signal);
|
|
38
42
|
},
|
|
39
43
|
});
|
package/dist/core/auto-mode.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ import type { ToolCallRequest } from "./tool-policy.js";
|
|
|
4
4
|
* sentence's parenthetical and the `auto_mode.classified` trace frame's `cause`, so the three spell one
|
|
5
5
|
* word (the deny's structural face carries only the round's FORM, `GateDisposition.cause`). `error` = the model leg
|
|
6
6
|
* threw or rejected (a route failure at classify time reads here too — the derived-route pre-flight fell
|
|
7
|
-
* back BEFORE any decide, so there is no separate word for it
|
|
7
|
+
* back BEFORE any decide, so there is no separate word for it; so does a question that never ASSEMBLED,
|
|
8
|
+
* whether its build threw or outran its cap — nothing was dispatched either way); `timeout` = a rung's
|
|
9
|
+
* DISPATCH cap.
|
|
8
10
|
*/
|
|
9
11
|
export declare const AUTO_MODE_UNAVAILABLE_CAUSES: readonly ["error", "timeout"];
|
|
10
12
|
export type AutoModeUnavailableCause = (typeof AUTO_MODE_UNAVAILABLE_CAUSES)[number];
|
|
@@ -100,8 +102,9 @@ export interface AutoModeClassifierRound {
|
|
|
100
102
|
};
|
|
101
103
|
}
|
|
102
104
|
/**
|
|
103
|
-
* The
|
|
104
|
-
*
|
|
105
|
+
* The word for "this classification was NEVER PUT TO A MODEL" — the failure is in assembling the QUESTION (the
|
|
106
|
+
* transcript window every rung shares), not in a rung's answer. The decider raises it for its own
|
|
107
|
+
* {@link AutoModeDeciderOptions.assemble} seat; a hook that owns assembly throws an error carrying this code.
|
|
105
108
|
*
|
|
106
109
|
* WHY IT EXISTS. The ladder advances on "the rung produced no ruling", and a hook that rejects looks exactly
|
|
107
110
|
* like that. But a classification whose prompt could not be built fails IDENTICALLY on every rung (the build is
|
|
@@ -113,9 +116,8 @@ export interface AutoModeClassifierRound {
|
|
|
113
116
|
* Carried as a `code` on the thrown error rather than a class so any classify hook — including one this
|
|
114
117
|
* package never constructed — can raise it without importing a constructor.
|
|
115
118
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* a slow transcript store from a slow model.
|
|
119
|
+
* A build that OUTRUNS its cap is the same fact and takes the same exit — see
|
|
120
|
+
* {@link AutoModeDeciderOptions.assemble}, the seat that bounds it.
|
|
119
121
|
*/
|
|
120
122
|
export declare const AUTO_MODE_ROUND_NOT_ASKED_CODE = "auto_mode.round_not_asked";
|
|
121
123
|
/** Mint the marker above around whatever failed, keeping the original as `cause` for the host's own logs.
|
|
@@ -216,15 +218,28 @@ export interface AutoModeLadderRung {
|
|
|
216
218
|
readonly index: number;
|
|
217
219
|
readonly model: string;
|
|
218
220
|
}
|
|
219
|
-
/** The pluggable model leg: given the classify input
|
|
220
|
-
*
|
|
221
|
+
/** The pluggable model leg: given the classify input, the RUNG the ladder is on and whatever
|
|
222
|
+
* {@link AutoModeAssembleFn} built for this classification, return that model's raw text response.
|
|
223
|
+
* Deployment-injected; MAY throw / reject — the decider fail-closes. A rejection carrying
|
|
221
224
|
* {@link AUTO_MODE_ROUND_NOT_ASKED_CODE} says the failure was in assembling the QUESTION rather than in this
|
|
222
225
|
* rung's answer, and stops the walk instead of blaming the next rung. `rung` is `undefined`
|
|
223
|
-
* for a decider wired with no ladder
|
|
224
|
-
*
|
|
225
|
-
|
|
226
|
-
export
|
|
227
|
-
|
|
226
|
+
* for a decider wired with no ladder; `assembled` is `undefined` for one wired with no `assemble` seat (the
|
|
227
|
+
* leg then owns assembly, inside this rung's cap). All trailing parameters are OPTIONAL, not `| undefined`:
|
|
228
|
+
* a caller INVOKING it with one, two or three arguments must keep compiling. */
|
|
229
|
+
export type AutoModeClassifyFn<T = void> = (input: AutoModeClassifyInput, signal?: AbortSignal, rung?: AutoModeLadderRung, assembled?: T) => Promise<string>;
|
|
230
|
+
/** The pluggable ASSEMBLY leg: build this classification's question ONCE, before any rung is asked, on its own
|
|
231
|
+
* turn of {@link AutoModeDeciderOptions.timeoutMs}. The question is built from state that is not the model's
|
|
232
|
+
* (a transcript window, a store read), so its cost is not a rung's: a build that fails or outruns the cap is
|
|
233
|
+
* the {@link AUTO_MODE_ROUND_NOT_ASKED_CODE} fact (nothing dispatched, no rung blamed), and the rung cap
|
|
234
|
+
* bounds a DISPATCH alone — which is what makes that cap's `timeout` word true. MAY throw / reject; the
|
|
235
|
+
* decider fail-closes and does not start the walk. Its signal aborts when the run does AND at the cap.
|
|
236
|
+
* Omitted ⇒ `assembled` reaches `classify` as `undefined`. */
|
|
237
|
+
export type AutoModeAssembleFn<T> = (input: AutoModeClassifyInput, signal?: AbortSignal) => Promise<T>;
|
|
238
|
+
export interface AutoModeDeciderOptions<T = void> {
|
|
239
|
+
classify: AutoModeClassifyFn<T>;
|
|
240
|
+
/** Build the question ONCE per classification, before the walk, on its own turn of {@link timeoutMs} — see
|
|
241
|
+
* {@link AutoModeAssembleFn}. Omitted = the leg assembles inside `classify`, inside the rung cap. */
|
|
242
|
+
assemble?: AutoModeAssembleFn<T>;
|
|
228
243
|
/**
|
|
229
244
|
* The CANDIDATE LADDER this decider walks, in order — the models a classification may be asked on, NOT a
|
|
230
245
|
* second way to say what `classify` says (that hook is the DISPATCH; this is the sequence it dispatches
|
|
@@ -234,12 +249,14 @@ export interface AutoModeDeciderOptions {
|
|
|
234
249
|
*
|
|
235
250
|
* Omitted = a one-rung anonymous ladder: the leg as wired, no attribution on the verdict. An EMPTY array,
|
|
236
251
|
* or a rung that is not a non-empty string, is REFUSED loudly at construction (a deployment that computed
|
|
237
|
-
* its ladder and got nothing has a bug, not a default). COST: {@link timeoutMs} bounds ONE rung
|
|
238
|
-
* worst case
|
|
252
|
+
* its ladder and got nothing has a bug, not a default). COST: {@link timeoutMs} bounds ONE rung's dispatch
|
|
253
|
+
* and, once, the {@link assemble} call — worst case, the cap times the rungs walked, plus one.
|
|
239
254
|
*/
|
|
240
255
|
candidates?: readonly string[];
|
|
241
|
-
/** Hard cap on one classification round-trip — one LADDER RUNG, not the whole classification
|
|
242
|
-
* {@link candidates})
|
|
256
|
+
/** Hard cap on one classification round-trip — one LADDER RUNG's DISPATCH, not the whole classification
|
|
257
|
+
* (see {@link candidates}) — and, separately, the one {@link assemble} call that precedes the walk (ONE
|
|
258
|
+
* number: a second knob would be a second answer to "how long may a classification make the gate wait").
|
|
259
|
+
* Default 15_000 ms: a permission gate must not stall the whole run on a slow classifier. */
|
|
243
260
|
timeoutMs?: number;
|
|
244
261
|
/**
|
|
245
262
|
* Fired once per `decide` call with the verdict the gate is about to act on and the wall time it waited
|
|
@@ -259,6 +276,11 @@ export interface AutoModeClassified {
|
|
|
259
276
|
cause?: Extract<AutoModeVerdict, {
|
|
260
277
|
kind: "unavailable";
|
|
261
278
|
}>["cause"];
|
|
279
|
+
/** Present exactly when no model was asked because the QUESTION did not assemble: `"timeout"` = the build
|
|
280
|
+
* outran its cap, `"failed"` = it threw (also what a classify hook owning its own assembly reports). ABSENT
|
|
281
|
+
* = a model was asked. The verdict is `unavailable` / `error` either way — nothing was dispatched — so this
|
|
282
|
+
* is what tells an operator "the build is slow" from "the provider is down". */
|
|
283
|
+
notAsked?: "failed" | "timeout";
|
|
262
284
|
/** The classification's model attribution, present exactly when the decider was wired with a candidate ladder
|
|
263
285
|
* ({@link AutoModeClassifierRound}) — the same object the verdict carries. */
|
|
264
286
|
classifier?: AutoModeClassifierRound;
|
|
@@ -270,7 +292,7 @@ export interface AutoModeDecider {
|
|
|
270
292
|
/** The per-run decider: timeout + fail-closed error mapping, nothing carried between rounds. One instance per
|
|
271
293
|
* run (it binds the run's model leg and assembled prompt); a round that failed says nothing about the next,
|
|
272
294
|
* which asks the model again. */
|
|
273
|
-
export declare function createAutoModeDecider(opts: AutoModeDeciderOptions): AutoModeDecider;
|
|
295
|
+
export declare function createAutoModeDecider<T = void>(opts: AutoModeDeciderOptions<T>): AutoModeDecider;
|
|
274
296
|
/** The deployment's bounds for the denial limit (`RunnerDeps.autoMode.denialLimit`). Every member
|
|
275
297
|
* optional; an omitted member takes its CC default. A present member with a bad value is REFUSED
|
|
276
298
|
* loudly at construction (never clamped, never read as the default). */
|
package/dist/core/auto-mode.js
CHANGED
|
@@ -164,7 +164,8 @@ export function createAutoModeDecider(opts) {
|
|
|
164
164
|
const ladder = opts.candidates ?? [undefined];
|
|
165
165
|
const decide = async (input, signal) => {
|
|
166
166
|
const startedAt = performance.now();
|
|
167
|
-
const
|
|
167
|
+
const round = {};
|
|
168
|
+
const verdict = await decideUnreported(input, round, signal);
|
|
168
169
|
if (opts.onClassified !== undefined) {
|
|
169
170
|
try {
|
|
170
171
|
opts.onClassified({
|
|
@@ -172,6 +173,7 @@ export function createAutoModeDecider(opts) {
|
|
|
172
173
|
ms: Math.max(0, Math.round(performance.now() - startedAt)),
|
|
173
174
|
verdict: verdict.kind,
|
|
174
175
|
...(verdict.kind === "unavailable" ? { cause: verdict.cause } : {}),
|
|
176
|
+
...(round.notAsked !== undefined ? { notAsked: round.notAsked } : {}),
|
|
175
177
|
...(verdict.classifier !== undefined ? { classifier: verdict.classifier } : {}),
|
|
176
178
|
});
|
|
177
179
|
}
|
|
@@ -181,22 +183,22 @@ export function createAutoModeDecider(opts) {
|
|
|
181
183
|
return verdict;
|
|
182
184
|
};
|
|
183
185
|
return { decide };
|
|
184
|
-
async function decideUnreported(input, signal) {
|
|
186
|
+
async function decideUnreported(input, round, signal) {
|
|
185
187
|
const causes = [];
|
|
186
|
-
const round = { notAsked: false };
|
|
187
188
|
let verdict = { kind: "unavailable", cause: "error" };
|
|
188
189
|
let i = 0;
|
|
189
|
-
|
|
190
|
+
const assembled = await assembleRound(input, round, signal);
|
|
191
|
+
for (; round.notAsked === undefined && i < ladder.length; i++) {
|
|
190
192
|
const name = ladder[i];
|
|
191
|
-
verdict = await askRung(input, signal, name !== undefined ? { index: i, model: name } : undefined, round);
|
|
193
|
+
verdict = await askRung(input, signal, name !== undefined ? { index: i, model: name } : undefined, round, assembled);
|
|
192
194
|
const cause = rungDidNotRule(verdict);
|
|
193
195
|
if (cause === undefined)
|
|
194
196
|
break;
|
|
195
197
|
causes.push(cause);
|
|
196
|
-
if (round.notAsked || signal?.aborted)
|
|
198
|
+
if (round.notAsked !== undefined || signal?.aborted)
|
|
197
199
|
break;
|
|
198
200
|
}
|
|
199
|
-
const landed = round.notAsked ? 0 : Math.min(i, ladder.length - 1);
|
|
201
|
+
const landed = round.notAsked !== undefined ? 0 : Math.min(i, ladder.length - 1);
|
|
200
202
|
const requested = ladder[0];
|
|
201
203
|
const used = ladder[landed];
|
|
202
204
|
if (requested === undefined || used === undefined)
|
|
@@ -212,55 +214,71 @@ export function createAutoModeDecider(opts) {
|
|
|
212
214
|
}),
|
|
213
215
|
};
|
|
214
216
|
}
|
|
215
|
-
async function
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
217
|
+
async function assembleRound(input, round, signal) {
|
|
218
|
+
const assemble = opts.assemble;
|
|
219
|
+
if (assemble === undefined)
|
|
220
|
+
return undefined;
|
|
221
|
+
try {
|
|
222
|
+
return await withDeadline(timeoutMs, signal, (inner) => assemble(input, inner));
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
round.notAsked = isAutoModeTimeout(e) ? "timeout" : "failed";
|
|
226
|
+
return undefined;
|
|
224
227
|
}
|
|
228
|
+
}
|
|
229
|
+
async function askRung(input, signal, rung, round, assembled) {
|
|
225
230
|
try {
|
|
226
|
-
|
|
227
|
-
let settled = false;
|
|
228
|
-
timer = setTimeout(() => {
|
|
229
|
-
if (!settled) {
|
|
230
|
-
settled = true;
|
|
231
|
-
inner.abort();
|
|
232
|
-
reject(new AutoModeTimeout());
|
|
233
|
-
}
|
|
234
|
-
}, timeoutMs);
|
|
235
|
-
opts.classify(input, inner.signal, rung).then((v) => {
|
|
236
|
-
if (!settled) {
|
|
237
|
-
settled = true;
|
|
238
|
-
resolve(v);
|
|
239
|
-
}
|
|
240
|
-
}, (e) => {
|
|
241
|
-
if (!settled) {
|
|
242
|
-
settled = true;
|
|
243
|
-
reject(e);
|
|
244
|
-
}
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
return parseAutoModeResponse(raw);
|
|
231
|
+
return parseAutoModeResponse(await withDeadline(timeoutMs, signal, (inner) => opts.classify(input, inner, rung, assembled)));
|
|
248
232
|
}
|
|
249
233
|
catch (e) {
|
|
250
234
|
if (isAutoModeRoundNotAsked(e)) {
|
|
251
|
-
round.notAsked =
|
|
235
|
+
round.notAsked = "failed";
|
|
252
236
|
return { kind: "unavailable", cause: "error" };
|
|
253
237
|
}
|
|
254
238
|
if (signal?.aborted)
|
|
255
239
|
return { kind: "unavailable", cause: "error" };
|
|
256
240
|
return { kind: "unavailable", cause: isAutoModeTimeout(e) ? "timeout" : "error" };
|
|
257
241
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async function withDeadline(capMs, signal, call) {
|
|
245
|
+
let timer;
|
|
246
|
+
const inner = new AbortController();
|
|
247
|
+
const onOuterAbort = () => inner.abort();
|
|
248
|
+
if (signal !== undefined) {
|
|
249
|
+
if (signal.aborted)
|
|
250
|
+
inner.abort();
|
|
251
|
+
else
|
|
252
|
+
signal.addEventListener("abort", onOuterAbort, { once: true });
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
return await new Promise((resolve, reject) => {
|
|
256
|
+
let settled = false;
|
|
257
|
+
timer = setTimeout(() => {
|
|
258
|
+
if (!settled) {
|
|
259
|
+
settled = true;
|
|
260
|
+
inner.abort();
|
|
261
|
+
reject(new AutoModeTimeout());
|
|
262
|
+
}
|
|
263
|
+
}, capMs);
|
|
264
|
+
call(inner.signal).then((v) => {
|
|
265
|
+
if (!settled) {
|
|
266
|
+
settled = true;
|
|
267
|
+
resolve(v);
|
|
268
|
+
}
|
|
269
|
+
}, (e) => {
|
|
270
|
+
if (!settled) {
|
|
271
|
+
settled = true;
|
|
272
|
+
reject(e);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
if (timer !== undefined)
|
|
279
|
+
clearTimeout(timer);
|
|
280
|
+
if (signal !== undefined)
|
|
281
|
+
signal.removeEventListener("abort", onOuterAbort);
|
|
264
282
|
}
|
|
265
283
|
}
|
|
266
284
|
class AutoModeTimeout extends Error {
|