@sema-agent/core 7.24.4 → 7.25.1
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 +100 -0
- package/dist/agents/peer-session-drain.d.ts +16 -1
- package/dist/agents/peer-session-drain.js +16 -1
- package/dist/agents/send-message-tool.d.ts +10 -2
- package/dist/agents/send-message-tool.js +18 -7
- package/dist/agents/subagent.js +13 -10
- package/dist/core/arg-summary.d.ts +0 -7
- package/dist/core/arg-summary.js +2 -1
- package/dist/core/background-agent-store.d.ts +3 -3
- package/dist/core/checkpoint-store.d.ts +1 -0
- package/dist/core/delegation-frames.d.ts +5 -0
- package/dist/core/gate-fold.js +85 -0
- package/dist/core/gate-lanes.js +23 -1
- package/dist/core/gate-outcome.d.ts +8 -2
- package/dist/core/gate-outcome.js +3 -1
- package/dist/core/hooks.d.ts +29 -0
- package/dist/core/hooks.js +2 -0
- package/dist/core/mailbox-store.d.ts +5 -0
- package/dist/core/mailbox-store.js +1 -1
- package/dist/core/parent-spec-seat.d.ts +7 -1
- package/dist/core/parent-spec-seat.js +2 -0
- package/dist/core/permission-rule-consent.js +29 -47
- package/dist/core/permission-rule-provider.js +4 -3
- package/dist/core/permission-rule-store.d.ts +143 -7
- package/dist/core/permission-rule-store.js +165 -15
- package/dist/core/runner/contracts.d.ts +36 -2
- package/dist/core/runner/gate-exit.d.ts +14 -0
- package/dist/core/runner/gate-exit.js +4 -0
- package/dist/core/runner/prepare-delegation-surface.d.ts +4 -4
- package/dist/core/runner/prepare-delegation-surface.js +2 -1
- package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
- package/dist/core/runner/prepare-gate-stations.js +7 -4
- package/dist/core/runner/prepare-hands-readface.d.ts +8 -4
- package/dist/core/runner/prepare-hands-readface.js +25 -10
- package/dist/core/runner/prepare-inherited-gate.d.ts +23 -0
- package/dist/core/runner/prepare-inherited-gate.js +11 -6
- package/dist/core/runner/prepare-policy-chain.d.ts +15 -1
- package/dist/core/runner/prepare-policy-chain.js +14 -4
- package/dist/core/runner/prepare-suspend-saga.d.ts +5 -2
- package/dist/core/runner/prepare-suspend-saga.js +4 -3
- package/dist/core/runner/prepare-task.js +10 -9
- package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
- package/dist/core/runner/prepare-wiring-manifest.js +8 -4
- package/dist/core/runner/resume-apply.js +2 -0
- package/dist/core/runner/run-harness-handlers.js +1 -0
- package/dist/core/runner/run-identity-wiring.d.ts +2 -2
- package/dist/core/runner/run-leg.js +26 -0
- package/dist/core/runner/run-notification-lane.d.ts +2 -2
- package/dist/core/runner/run-notification-lane.js +2 -1
- package/dist/core/runner/run-settle-and-teardown.js +1 -0
- package/dist/core/runner/session-rule-policy.d.ts +3 -4
- package/dist/core/runner/session-rule-policy.js +11 -3
- package/dist/core/session-policy-store.d.ts +27 -3
- package/dist/core/session-policy-store.js +12 -4
- package/dist/core/store-contracts/background-agent-store-contract.js +10 -0
- package/dist/core/store-contracts/peer-directory-contract.d.ts +23 -0
- package/dist/core/store-contracts/peer-directory-contract.js +82 -0
- package/dist/core/task-event.d.ts +6 -0
- package/dist/core/task-notification.d.ts +79 -4
- package/dist/core/task-notification.js +90 -1
- package/dist/core/task-registry-agent.js +1 -2
- package/dist/core/task-registry-monitor.js +2 -1
- package/dist/core/task-registry-shared.d.ts +53 -5
- package/dist/core/task-registry-shared.js +13 -2
- package/dist/core/task-registry.js +8 -5
- package/dist/core/task-spec.d.ts +12 -5
- package/dist/core/tool-catalog-entries.js +1 -0
- package/dist/core/tool-policy.js +30 -23
- package/dist/core/tool-spec.d.ts +1 -1
- package/dist/core/wiring-manifest.d.ts +2 -2
- package/dist/engine/loop/agent-loop.js +1 -1
- package/dist/engine/loop/types.d.ts +4 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -3
- package/dist/prompts/coordinator.d.ts +2 -1
- package/dist/stores/file/adoption/adopt.js +23 -1
- package/dist/stores/file/permission-rule-store.js +3 -1
- package/dist/stores/file/session-policy-store.d.ts +31 -0
- package/dist/stores/file/session-policy-store.js +87 -7
- package/dist/tools/fs/bash-lexer.d.ts +13 -0
- package/dist/tools/fs/bash-lexer.js +61 -0
- package/dist/tools/fs/bash-program-position.d.ts +36 -29
- package/dist/tools/fs/bash-program-position.js +33 -13
- package/dist/tools/fs/bash-readonly-classifier.d.ts +26 -313
- package/dist/tools/fs/bash-readonly-classifier.js +81 -782
- package/dist/tools/fs/fs-bash.d.ts +43 -207
- package/dist/tools/fs/fs-bash.js +20 -341
- package/dist/tools/fs/index.d.ts +12 -13
- package/dist/tools/fs/index.js +3 -4
- package/dist/tools/fs/read-deny.d.ts +25 -0
- package/dist/tools/fs/read-deny.js +19 -0
- package/dist/tools/fs/safety.js +2 -6
- package/dist/tools/fs/shell-read-check.d.ts +99 -0
- package/dist/tools/fs/shell-read-check.js +229 -0
- package/dist/tools/fs/shell-read-establish.d.ts +70 -115
- package/dist/tools/fs/shell-read-establish.js +216 -102
- package/dist/tools/fs/shell-read-judge.d.ts +231 -0
- package/dist/tools/fs/shell-read-judge.js +170 -0
- package/dist/tools/fs/shell-read-plan.d.ts +3 -0
- package/dist/tools/fs/shell-read-plan.js +88 -27
- package/dist/tools/monitor.d.ts +5 -5
- package/dist/tools/monitor.js +3 -5
- package/dist/tools/task-list.d.ts +4 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +19 -3
- package/wire-changes/core@7.25.0.json +671 -0
- package/wire-changes/core@7.25.1.json +64 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,76 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.25.1 — 2026-09-20
|
|
4
|
+
|
|
5
|
+
### 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)
|
|
6
|
+
- **Before.** `A=.env` and `A+=.env` are one act to bash, and the program-position reader read only the plain spelling. The append word therefore arrived AS the program — an unrecognised one — so the segment took the verb-blind reading and its VALUE met no judge at all. Measured on the base of this entry, four faces, deny row `**/.env`: `A=.env; cat "$A"` was refused at zero cards with `read_path_denied` on all four, while `A+=.env; cat "$A"` produced ONE approval card naming no deny row on both Bash seats and a YES returned the file's bytes.
|
|
7
|
+
- **After, as one rule.** A leading assignment is the plain spelling, or the append spelling whose **prefix** — the NAME and the OPERATOR — is written bare in the raw token (`isLeadingAssignmentWord`, `src/tools/fs/bash-program-position.ts`). That is the question bash itself asks, before it removes any quoting and about those characters only: quoting any of `NAME+=` makes the whole word a program name on PATH (`'A+=x'`, `"A+=x"`, `A\+=x` — their argument stays judged), while quoting the VALUE does not (`A+=".env"` and `A+='.env'` assign `.env` exactly as `A+=.env` does — verified against the shell). A whole-word bareness test conflates the two and leaves the quoted-value spelling one quote character away from the escape this entry closes; the adversarial round found it and it is pinned as a mutation. The value channel is the same one rule: an assignment word carries TWO independent facts — what the text could not settle, and what path the text DOES name — and the append form has both, so both are handed over (`assignmentWordPayload`). A tilde-bearing or empty value still names nothing. The seat that answers "which program, and where its argv begins" (`argvProgramOf`) no longer strips the leading assignments a second time; the one reader answers.
|
|
8
|
+
- **NARROWING — who is hurt.** A deny row spelled in a bare append word is now REFUSED at zero cards on every face (`deniedBy: "read_boundary"`, `structured.read_path_denied`) where it was an approvable card: `A+=.env; cat "$A"`, `A+=".env"; cat "$A"`, `A+=.env cat x`, `A+=.env && …`, `A+=.env | …`, `B+=.env` behind another assignment, and the same word behind a launcher that owns assignment operands (`env A+=.env dd if=f`). Anyone whose command spells a deny-listed path in an append word loses the card. Compensation: none is owed — the plain spelling has had exactly this verdict since 7.25.0, and the card it replaces was one no approver could read the deny row from.
|
|
9
|
+
- **WIDENING — who is hurt.** The same alignment runs the other way for values that are merely outside the roots: `A+=/etc/passwd <unlisted>` and `A+=~/secret <unlisted>` were a MANDATED card (the verb-blind reading of a word mistaken for a program) and now vouch, because a leading environment word's value is the deny judge's alone and no containment candidate (KL-945a, the standing rule for `A=…`). A deployment that relied on that card as an out-of-root alarm on the append spelling loses it; the deny list is the channel that still fires, on either spelling.
|
|
10
|
+
- **Unchanged, pinned:** a quoted or escaped append word is a program name and its path argument is judged; `env A+=x dd if=<root>/f` reads word for word as before (the launcher owns the word, `dd` is the program); behind a launcher with no assignment grammar (`nice A+=x true`) the word is still the program it execs; `A+="$(cat <deny row>)"` is refused through the nested plan as before.
|
|
11
|
+
- **ONE question for both operators (E5; P2, narrowing; closes KL-945h / the #987 program-position格).** The predicate asks what bash asks — is the matched `NAME=` / `NAME+=` prefix written bare in the raw token — for BOTH operators. Reading the plain spelling on the FOLDED word alone conflated the two the other way: `'A=x' /outside/report.txt` is a command NAMED `A=x` to bash, and the reader took it for an assignment, stepped over the real program and read the file as a program PATH (executed, not disclosed, so no containment question at all). It is that program's operand now, and it is judged — a mandated card with a `readRootCandidate`, where there was none. Who is hurt: a command whose first word is a quoted or escaped assignment shape (`'A=x' file`, `"A+=x" file`) loses its free pass. Compensation: the card carries the way out; the bare spelling is unchanged, and so is a quoted VALUE.
|
|
12
|
+
- **A subscripted name is an assignment too (E6; P2, narrowing; closes KL-993a).** `A[0]=.env` writes one element of `A` and `${A[0]}` reads it back — bash's own reading, verified against the shell — and the grammar knew only unsubscripted names, so the whole word became a program name and its VALUE met no judge. `ASSIGNMENT_PREFIX` reads a subscript carrying no whitespace; the subscript itself is not parsed and the value is judged like any other. A quoted `'A[0]=x'` stays a program name. One element does not settle the whole variable, so the delete walk treats it as it treats an append: verifiable only where the prior content was. Who is hurt: a command spelling a deny-listed path in a subscripted assignment loses its card.
|
|
13
|
+
- **A declaration's operand is read after quote removal (G2).** `export` / `declare` and their family are BUILTINS: the word reaches them with the quotes gone, so `export 'D=/tmp/safe'` assigns what the bare spelling assigns. Requiring the word to be written bare there — the question the LEADING position asks, where the shell itself decides whether a word is an assignment at all — refused to read a word the builtin reads and left the name unregistered, which is a finding on a command that settles its target. The quoted spelling now answers exactly as its bare twin does in both directions: `export "D+=$UNKNOWN"` and `export 'D+=x'` (no prior value) revoke, `D=/tmp/safe; export 'D+=x'` does not.
|
|
14
|
+
- **The declared spelling is the same state transition (E7; P2, narrowing).** `export` / `declare` / `typeset` / `local` / `readonly` carry the same operand, and the second write site of the in-window-assignment rule kept its own copy of the grammar — `NAME=` only, `export` only. So `export D+=$UNKNOWN` left `D` verifiable while the bare `D+=$UNKNOWN` revoked it, and `rm -rf "$D"` behind the declared spelling drew no finding. Both sites read the one grammar now, and both answer "does this settle the whole name?" the same way. Who is hurt: a command that used a declaration head to keep a name verifiable through an append or a subscript now draws the finding the bare spelling always drew.
|
|
15
|
+
- **The activity summary skips every leading assignment (E8; privacy).** `primaryActivityArg` (`src/core/arg-summary.ts`) steps over a command's leading assignments to reach the command NAME, and its own copy of the grammar knew `NAME=` alone — so `PAYLOAD+=<secret> cat f` was read as a command called `PAYLOAD+=<secret>` and the VALUE was written out as the activity's name. That summary is persisted and SSE-forwarded. It reads the one grammar now; a lone assignment and a quoted one still withhold.
|
|
16
|
+
- **Same grammar, second walk (leg B; P2, narrowing).** The unverifiable-recursive-delete walk (`findUnverifiableRecursiveDelete`, `src/core/tool-policy.ts`) skips a statement's leading assignments to reach the command, and recognised them with a private copy of the grammar that knew only the plain operator — so `A+=x rm -rf $UNSET` stopped that walk on its first token and the delete behind it was never examined, while `A=x rm -rf $UNSET` reported its unverifiable target. Both walks now read ONE spelling (`ASSIGNMENT_PREFIX`, exported beside the reader). With it comes the append form's own asymmetry: `NAME+=value` hands the program what NAME already held plus this value, so a readable value settles only the tail — the name is verifiable only where its prior content was (`A+=/tmp/j rm -rf $A` reports; `A=/tmp/j; A+=/k rm -rf $A` does not), and the root mark an append carries is only ever added, never cleared. Who is hurt: a command that used one `+` in front of a delete and drew no finding now draws one — the fail-closed side, and the plain spelling's answer. A quoted prefix (`'A+=x' rm …`, a program name to bash) is unchanged on both spellings. `test/backlog993-tool-policy-leading-assignment.test.ts`.
|
|
17
|
+
- **Which `$` opens a quote form is ONE question (H; regression, fail-open).** The word scan decided it with a one-character peek behind the `$`, and the expansion scan did not look behind at all — so `cat x\\$'…'` (a literal backslash followed by a REAL ANSI-C quote: bash reads `x\\$'y'` as `x\y`) was read as a literal `$` by one and as a quote by the other. The word folded to a path INSIDE the roots while nothing reported an expansion, and the read ran at zero cards on the seat with no shape tier. Both walkers now ask one function, and it counts the backslash RUN — odd is escaped (`\$'y'` is a literal `$`), even is bare — which a peek at one character cannot do.
|
|
18
|
+
- **The same class, third walk (leg C; P2, narrowing).** `$'…'` (ANSI-C quoting) and `$"…"` are QUOTE FORMS, and the lexer folded their `$` into the word — so a deny row written `$'.env'` reached every judge as `$.env`, a spelling no filesystem has, and the read escaped into an approvable card a yes then executed (measured: one card naming no deny row on both Bash seats, sentinel returned, for `cat $'.env'`, `A=$'.env'; cat "$A"` and `A+=$'.env'; cat "$A"`). The forms are read as quotes now: a body with no backslash is its own text exactly as a single-quoted one is, `$"…"` is an ordinary double-quoted string, an unterminated `$'` is a command bash refuses to parse and is unreadable here too, and the quote walks know that `\'` does not close the region. `raw` keeps the written spelling, so every reader that asks "was this written bare" is unaffected. A `$` that OPENS a quote no longer counts as an unquoted expansion either — calling it one made the word unresolvable, which names no path for a judge to meet. **The ESCAPE SEQUENCES are deliberately not folded**: a body carrying any backslash is unreadable (the card this release started from, fail-closed). Three adversarial rounds each found a different transcription error in a decoder for them — a code point read where bash builds a byte, a UTF-16 unit encoded where bash writes a character, a control-letter formula off by one table — and each named a DIFFERENT FILE than the shell would open, so the table belongs with the single-lexer work (#994) where a differential test against `/bin/bash printf` bytes can carry it. `test/backlog993-ansi-c-quoting.test.ts`; KL-993g records the limit, KL-993d the read-only shell's own `$` grammar.
|
|
19
|
+
- **The tilde question is asked of the PREFIX, not the word (E3).** A slot expands only where its tilde-PREFIX — up to the terminating `/` or `:` — carries no quoting, which is bash's own rule. Asking it of the whole word answered it for `A=~/'.env'` and `A+=x:~/'.env'`, whose prefixes are bare and whose values are not: bash expands those (`A=~/".env"` gives `<home>/.env`, verified against the shell) and calling the value literal named a file no shell would open. Both now read unresolved and ask on both faces; `A='~'/x` — a QUOTED prefix — is the ordinary relative path it is.
|
|
20
|
+
- **The same class, fourth walk (leg D; P2, narrowing).** A tilde in an assignment value is read by POSITION, as bash reads it: expanded at the start of the value and right after each `:`, LITERAL everywhere else, and nowhere at all where the word is not written bare. Reading "contains a tilde" as "cannot be settled" threw away every value whose tilde was literal, and a value nobody names is a value no judge meets — so `A=.env~` / `A+=.env~` (the editor-backup spelling) reached a deny row spelled `**/.env*` as nothing at all, on both spellings, and asked where `cat .env~` refused. The value is now named (`.env~`), the row bites, and a quoted leading tilde is an ordinary path component spelled `./~…`. A tilde WHERE BASH EXPANDS ONE is still reported unresolved and still asks on both faces, unchanged. Who is hurt: these values lose their card where they name a deny row. KL-993b now records only what is left — such a tilde is not placed against the declared home.
|
|
21
|
+
- **Three corners of the same three legs (F1–F3).** (F1, regression) An ANSI-C body's LITERAL run was encoded by UTF-16 unit, so a character outside the basic plane became two replacement characters — bytes that decode cleanly and name a different file: `cat $'🔒/credentials.txt'` under a deny row for that directory read it at zero cards on the seat with no shape tier. Literal runs are encoded by code point now, and the decoder keeps a BOM instead of stripping bytes bash keeps. (F2) The bareness question covers the NAME and the OPERATOR and nothing else: a quoted SUBSCRIPT still names the element (`A['0']=v` assigns it), and comparing the whole prefix took that for a program name — a card where a refusal was owed. (F3) A declaration carries its own OPTIONS and as many operands as it likes; reading one word after the head word left every later name unregistered and every option-carrying declaration unread, so `export -n D=/tmp/safe; rm -rf "$D"` drew a finding on a command that settles its target. Every operand takes the same state transition now, an unreadable one revokes rather than vouches, and the transition is written once for both sites.
|
|
22
|
+
- **KNOWN-LIMITS:** KL-945h and KL-993a are CLOSED by E5 / E6 above (both operators and the subscripted name now take the one bare-prefix question). Left open: KL-993b (a tilde in an expansion slot reports "unresolved" rather than resolving against the declared home), KL-993c (a value assembled across two appends — `A+=.en; A+=v` — is not one text, so the read it feeds is the ordinary unresolvable-expansion card), KL-993d (the read-only shell's own grammar still refuses every `$` spelling, `$'…'` included), KL-993e (a value equal to the substitution placeholder text is dropped as a substitution), KL-993f (a subscript carrying whitespace is not read as one word).
|
|
23
|
+
- Reach: @server / @cli / @client-core — zero: no wire key, no notice code, no export added or removed, no card SHAPE changed. Headless: a deny row in an append word refuses (`read_boundary`) where it auto-denied an unanswerable card; an out-of-root value in one vouches where it refused. `test/backlog993-append-assignment-word.test.ts` pins P1–P5 with a positive control; `backlog860` G2 and `backlog862b` ③/⑧ carry the retired dialect's tripwire.
|
|
24
|
+
|
|
25
|
+
## 7.25.0 — 2026-09-20
|
|
26
|
+
|
|
27
|
+
### One station judges every shell read, before approval (#945 S1; BREAKING ×3; one narrowing, three widenings; #976 closed structurally)
|
|
28
|
+
- **What lands.** The gate's shell READ STATION (`src/tools/fs/shell-read-check.ts`, mounted per leg by the hands phase): ONE parse of a shell command into its read candidates (`planShellReads`, S0), each ESTABLISHED against the execution environment (`shell-read-establish.ts`: canonical target; a pattern expanded in both the kernel and the folded spelling of a `..` operand; a traversal root enumerated within `MAX_GLOB_DIRS`/`MAX_GLOB_MATCHES`; lexical under an isolated sandbox) and JUDGED once (`shell-read-judge.ts`: `judgeRead` — the deny judge over every view first, containment over the canonical target; `SHELL_READ_DECISION`, the total finding × seat table that is the only source of refuse / mandated ask / ordinary ask / vouch; `readRootGrantCandidateOf`; `rejudge`). The station is a POLICY-class member of the gate fold (`gate-fold.ts` `applyShellReadVerdict`) for every shell face — `Bash`, `Monitor`, the read-only `Bash` — under every doctrine (`shellGate` "off" installs no tier; "classify" adds only the SHAPE classifier), and re-judges an approved rewrite's reads after a person's yes (`gate-lanes.ts`, `seat: "rejudge"`); the pre-spawn re-establishment refuses a call whose targets moved while the card was open. One deny sentence for every read face (`readDenySentence`, `read-deny.ts`), with each variable part elided in its middle at `READ_DENY_SENTENCE_PART_MAX`.
|
|
29
|
+
- **BREAKING (1) — `DeniedBy` + `"read_boundary"`** (`DENIED_BY_VALUES`, `src/core/gate-outcome.ts`; `DENIED_BY_MAY_VETO.read_boundary = true`, `DENIED_BY_MAY_CARRY_CAUSE.read_boundary = false`): the station refused (a deny-listed read before any approval — D path; an approved rewrite re-judged onto a deny row — V path beside `settlement.kind: "human_allowed"`). Who is hurt: a server dashboard/alert that buckets `deniedBy` on the closed set (one bucket to add); cli if it renders `deniedBy`; client-core/sdk type rows. Headless, a deny-listed read is now `read_boundary` where it was `ask_resolution`.
|
|
30
|
+
- **BREAKING (2) — `bashReadBoundaryProbe` is removed from the package export surface** (`handoff:diff v7.24.2 HEAD`: −1, +0). The read-boundary probe seat is retired: the engine's shell seat carries the SHAPE classifier alone (`bashReversibilityProbe`, kept). Who is hurt: a library consumer wiring the probe directly (grepped across server / cli / client-core / sdk: none); cli's `shellReadBoundaryContract.ts` cites its JSDoc — the contract sentence `shell.read_boundary.friction_floor` now lives on `judgeRead` (`src/tools/fs/shell-read-judge.ts`).
|
|
31
|
+
- **BREAKING (3) — `CompoundReadonlyVerdict` (exported type) narrows to `{ reason?: string }`**: `classifyCompoundReadonly` answers shape only; the 16 read-boundary members (`outOfRootRead`, `outOfRootPaths`, `readDenied`, `readDeniedPath`, `readDeniedPattern`, `checkedPaths`, `undecidedPaths`, `checkedSpellings`, `undecidedSpellings`, `outOfRootSpellings`, `unresolvedOperands`, `refusedWhole`, `recursiveReadPaths`, `outOfRootGrantCandidate`, `outOfRootGrantCoversExactPath`, `segmentNameRefusal`) are gone with the walk that minted them. Who is hurt: a consumer reading those members (grepped: none). `ReversibilityVerdict` keeps every member; the engine no longer mints `mandated` / `boundaryDeclined` / `readRootCandidate` through it (same wire keys and values, written by the station on the decision).
|
|
32
|
+
- **NARROWING (F-row class A).** A deny-listed shell read is REFUSED on every face and both moments — no card, an approver's yes cannot release it (C-R71): the full shell under `classify` or `off` (was: a mandated card, YES read the bytes), `Monitor`, headless (was: an unanswerable card = `ask_resolution` auto-deny), and a deny row inside `$(…)`, `` ` ` ``, `<(…)`, `sh -c`, behind a launcher, or under a `<`/`>` redirection (KL-939c/d, was: a card or a zero-ask read). The refusal carries `read_path_denied` (`target`, `pattern`) on `tool_end.structured` / `errorCode` and the five-part sentence in the result text. 47 further cells keep their disposition and move the code from the tool's own refusal to the gate's result (`deniedBy: "read_boundary"`, or the containment card beside `ask_resolution` for a card nobody could answer).
|
|
33
|
+
- **WIDENING (F-row classes C / 4c / 4d, each with its headless reading; ALIGNED with CC `outsideReadsBlocked` = ask).** (C) On the full shell with an approver, a symlink / bracket-named link / pattern expansion out of the roots (`cat outlink`, `cat vendor/*`, `cat '[c]d.txt'`) is ONE mandated card naming the canonical target with `readRootCandidate` — YES runs it (was: zero asks, then the execute leg refused); headless and the read-only shell are unchanged (refused). (4c) An expansion the station cannot finish (`cat sub/[z-a]`, past the bounds, a declared-authoritative resolver that answers nothing) is a mandated card naming the cause (was: execute-time refusal); headless / read-only unchanged. (4d) An aliased spelling of an IN-root path (`/tmp/<x>` for `/private/tmp/<x>`) runs with zero cards (was: a mandated card) — and headless it RUNS where it was auto-denied (the one headless flip, listed on purpose). Also: the read-only shell's contents traversal (`grep -r`, `diff -r`, `rg`) is ENUMERATED — a deny row under the tree refuses naming it, no row runs, past the bound refuses (KL-937b reversed, KL-934b retired); `Monitor`'s in-root pattern runs with zero cards; a `cd <dir>` target is its own grant candidate.
|
|
34
|
+
- **One rule replaces two special cases (KL-945a/b).** Containment judges what a READ program opens; the deny judge meets every path-shaped word. A leading `NAME=value` word's value (`PYTHONSTARTUP=/etc/x python3`, `FOO=/etc/passwd cmd`) and a program path (`/bin/cat f`, `sudo /bin/cat f`, `~/.nvm/…/node app.js`) are no containment candidates (zero cards where the base tree mandated) and remain deny candidates (`KEY=~/.ssh/id_rsa cmd` is refused); a program word the shell rewrites (`$CMD f`) stays unestablishable.
|
|
35
|
+
- **#976 closed structurally (F-row class D).** A `<` redirection source is a read candidate established to its canonical target: `cat < outlink` / `wc -c < outlink` / `cat < vendor/outside.txt` are one mandated card on the full shell (was: a zero-ask true read through the link), refused headless and on the read-only shell; `cat < .env` is refused.
|
|
36
|
+
- **Headless park.** The station is the third source of a mandated ask, so it arms the durable park vocabulary: an unattended out-of-root read under the `off` doctrine with a checkpoint store PARKS (`irreversible_ask`, `axes: { shell }`, `shellGateDoctrine: "off"`) instead of falling to a fail-closed deny.
|
|
37
|
+
- **The two moments after the verdict.** A durable RESUME runs the station on the resumed leg before the approved shell call executes (the parked leg's admission died with it): a deny row found now vetoes the person's yes (`deniedBy: "read_boundary"` beside `settlement.kind: "human_allowed"`), an operator's edit (`updatedInput`) is judged on the `rejudge` seat (an edit onto a deny row or outside the roots is refused — KL-945c), the unedited call runs. The pre-spawn admission is bound to the judged bytes on the judged tool (a call whose executing command differs from the judged one is refused before the spawn), and the re-judge of an approved edit replaces the admission it re-judged. In the adversarial round: a traversal root that is a pattern (`grep -r x dir*`) is expanded and every match enumerated; a traversal root OUTSIDE the roots is enumerated too, so a deny row under it refuses instead of riding a card an approval could clear; a substitution nested in a segment that `cd`-ed (`cd sub && cat <<EOF\n$(cat secret)\nEOF`) is judged where the shell stands; sed's execution modes (`e`, `s///e`) are a shell re-entry; a text that reassigns `HOME` is not folded on the declared home; a no-path program's words and a launcher's declared option values (`env -u NAME`) are no candidates.
|
|
38
|
+
- **Deleted (§5 rows 2–12 of the design):** the three shell tools' execute-time read re-checks, the two probe seats' read halves, the `bash_readonly` self-judging leg, `refusedCommandMayRead` and the containment gate, `readBoundaryMandate`, `BoundaryQuestions`, the deny-account mints, the seat decline tables (`*_SEAT_READS_DECLINE`), the grant replay — `fs-bash.ts` 3 114 → 2 091 lines (banked). KNOWN-LIMITS: KL-934b / 937a retired, KL-937b reversed, KL-938b / 939c / 939d closed, KL-934a kept (lexical under an isolated sandbox), KL-945a/b added.
|
|
39
|
+
- Reach: @server (deniedBy bucket, keyset projection, autonomy-tier wording) / @cli (one test re-pin, one contract citation, an optional deniedBy row) / @client-core (type row); headless: deny hit = `read_boundary`, alias spelling of an in-root path runs, everything else unchanged. Wire table: `docs/wire-consumption/unreleased-945.md`.
|
|
40
|
+
|
|
41
|
+
### A removal's read-back tells three things apart, not two (#980 follow-on; P2; BREAKING — `RemoveResult.stillLive` is a word, not a boolean; @server @cli)
|
|
42
|
+
- **Before.** `removePersistedRule` answered `stillLive: false` whenever its add-wins read-back failed — `catch { stillLive = false }`. `false` on that arm is a claim about a rule nobody managed to look at, and it renders "we could not see it" as "it is not there": the one sentence this domain refuses everywhere else (`createLiveSessionRules` says it in as many words — "we cannot see the rules" is never "there are none"). The add entry had already been given `committed: "no" | "unknown"` for exactly this asymmetry in the version that introduced it, and named the removal's twin as a same-class observation "for the owner to rule on". This is the ruling.
|
|
43
|
+
- **After.** `stillLive` is `RemovalLiveness` — `"yes" | "no" | "unknown"`, a registered closed set (`REMOVAL_LIVENESS_WORDS`). `yes` and `no` are things the store said; `unknown` is the honest word for a read-back that threw or came back unreadable. The tombstone's landing is unaffected and still reported as `removed` with its `rev`: what the third word withholds is the add-wins observation, not the removal.
|
|
44
|
+
- **The `failed` arm gains the SAME two strengths the add's has.** Decided by WHERE the failure happened — a flag the catch arm sets, never the error's message (`new Error()` carries an empty one, and reading the strength off `lastError !== ""` called a thrown apply an answered compare-and-set, the very inference-from-prose this entry exists to stop): when every attempt lost its compare-and-set the store ANSWERED each time and the absent tombstone is the confirmation (plain sentence, unchanged). When the apply THREW, a missing tombstone cannot tell a write that never landed from one whose evidence a paired collection erased — `collectBelowFrontier` recycles a tombstone together with the adds it covered — so that arm now reads `the outcome is indeterminate, reconcile with list()`. A blind retry there re-observes the CURRENT dots and can tombstone an approval granted since.
|
|
45
|
+
- **BREAKING, three sentences.** (1) **What changed:** `RemoveResult`'s `removed` arm carries a three-word closed set where it carried a boolean, and its `failed` arm's text splits into two strengths. (2) **Who is affected:** every reader of `stillLive`. In tree, none — the field has no branch consumer outside its own mint site and pins. Out of tree: **@server** relays `RemoveResult` verbatim and puts `stillLive` STRAIGHT ON THE WIRE (`http/routes/rules.ts:600`), so a revoke's 200 body changes shape; **@cli** branches on that wire value's TRUTHINESS, and all three words are truthy strings, so an unmigrated cli renders "still live (approved again meanwhile)" for a rule that is plainly gone — silently, and in the direction that never reports a removal. **@client-core** has zero references. (3) **How to migrate:** read the word — `=== "no"` is "gone", `=== "yes"` is add-wins, `unknown` is "not observed, reconcile with `list()`". A two-way consumer folds `yes` and `unknown` together, because the default reading of an unobserved rule is **still there**; folding `unknown` into `no` re-introduces exactly the over-claim this change removes.
|
|
46
|
+
- **Reach.** No new engine behaviour, no wire KEY, no notice code, no export added or removed — one exported type's field changes type. `@server` additionally owns a decision core does not make for it: `routes/rules.ts:619` mints its own `stillLive` boolean on the `no-op` arm so both 200 bodies share a key set, and whether that arm carries the same three words is server's call (its re-list failure is the same "nobody looked" shape). `docs/wire-consumption/unreleased-980.md` carries the table. `test/backlog980-remove-readback.test.ts` pins the eight readings — the two observed words, both unobservable arms, the raw-face reconcile, the collected-tombstone hedge, the exhausted-CAS negative control, and a consumer double-control that a real delete takes the gone-branch while a read failure does not — with a six-row mutation table (the sixth restores the message-derived strength and reds exactly the empty-message arms).
|
|
47
|
+
|
|
48
|
+
### A session's rev is a high-water mark the erase cannot roll back (#982; P2; behavior-face narrowing on both bundled policy stores + one additive error code; no wire key, no export; @server @test)
|
|
49
|
+
- **Before.** `rev` counted within ONE row's lifetime: both bundled `SessionPolicyStore` backends computed it as `(prior?.rev ?? 0) + 1`, and `deleteBySession` removed the only record of the number. So the first write after an erase came back as rev 1, and — since tighten-only has no prior row to judge a recreation against — an ordinary non-operator could publish a LOOSER rule set wearing a rev a reader had already passed. A row read as ABSENT for any other reason (a torn/corrupt file, which this store deliberately fails open on) had the same effect.
|
|
50
|
+
- **After.** The number outlives the row. Every stamp is `max(current row's rev, the key's mark) + 1`, per `(sessionId, principal)` key, so the first write after an erase is strictly above every rev that key ever carried and no two rule sets of one key ever wear the same rev. `getRules` is unchanged — an erased key still reads `null`, because an absent row is absence and not a rev-0 row — and so is CAS: `expectedRev` keeps comparing against the ROW, so `0` still matches after an erase (it says "I know there is no row") while the pre-erase rev is a `conflict`.
|
|
51
|
+
- **Where the mark lives.** `InMemorySessionPolicyStore` keeps a second map beside the rows. `FileSessionPolicyStore` keeps one small file per key, `<stem>.hwm.json`, beside the row that shares its stem — written BEFORE the row (a crash between them leaves the mark one ahead, which costs a skipped number; the opposite order leaves a published rev the mark never learned, and that number can then be reissued), skipped by both directory sweeps (a mark is not a row: it never lists, it is never erased, and the unparseable-file sweep never judges it), and folded from a row's own rev at erase time so a deployment whose rows predate the mark does not restart at 1.
|
|
52
|
+
- **One additive error code.** `SessionPolicyError`'s code set gains `corrupt` (closed set +1; `conflict` / `loosen_forbidden` unchanged): a mark whose bytes cannot be read refuses the write and names the file, because reading it as absent would BE the rollback. That is the one fail-CLOSED face of a store that otherwise fails open — a corrupt ROW still degrades to absence and `getRules` still never throws on one.
|
|
53
|
+
- **Every sweep of that directory, not just the store's two.** Ownership adoption rebinds policy files by `__principal`, which a mark carries too — so the rebind leg now dispatches on the suffix it takes from the store: a mark travels with its key, two marks MERGE by maximum (a mark is a number; two ROWS still refuse to merge through a rewrite), and a mark whose row is already erased travels as well, because the adoption transfers the identity that mark belongs to.
|
|
54
|
+
- **Reach.** `@server` owns the SQL twin (S-529) and must carry the same rule — a backend that answers "no row" as rev 0 has to keep the mark past the row. `@test` black-box: the reading is "erase a session's policy, write it again, and the rev is above the old one, not 1". An operator tool that enumerates a data dir's `session-policy/*.json` must now skip `*.hwm.json`. RESIDUALS recorded as `KL-982a` (the post-erase first write is still not bound by the erased row's denies — the read side's vanished-row rule is what holds the floor), `KL-982b` (the mark is never collected) and `KL-982c` (an unreadable mark blocks that key's writes until a person removes the file). `test/backlog982-session-policy-rev-high-water.test.ts` pins eleven readings across both twins; `#978`'s G11/G14 keep their subjects with the old arithmetic taken out of their premises.
|
|
55
|
+
|
|
56
|
+
### An ancestor rule enforced mid-run reaches the GRANDCHILDREN (#981, narrowed; P2; behavior-face tightening; no wire key, no export; @server @cli @test)
|
|
57
|
+
- **Before.** #978 gave every rule source a per-gate re-read and published THIS leg's enforced row for its own children (`onEnforced` → `ownSessionRulesRef`). The ANCESTOR layers got the live reader but no publication hook, so the chain a middle leg handed its OWN children still carried the rev that leg prepared with. The middle leg itself is bound correctly — it re-reads at every gate — which is exactly why the gap hid: a grandchild that CAN read the store reaches the same row anyway. It bites in the one case the seed exists for: a grandchild whose store read fails (or that runs where no store is wired) falls back to the floor, and the floor was the older, LOOSER snapshot — so "a failed read never loosens" became false across one generation.
|
|
58
|
+
- **After.** The ancestor layer takes the same `onEnforced` the own-rules layer does, upgrading that ancestor's entry in the inherited chain to the row the layer now enforces. A grandchild spawned after the tightening seeds from the tightening. Monotonic by construction: the reader fires only when its floor ADVANCES on a strictly greater rev, so an entry can never be walked backwards, and the array is never re-ordered, appended to or replaced — the entry is a cache of the same fact getting fresher.
|
|
59
|
+
- **The upgrade is PER-LEG, and that is the load-bearing half.** It is recorded in a seat beside `ownSessionRulesRef` and overlaid on the inherited entries at spawn time; the entries themselves are never written. Writing them in place — the obvious reading of "publish it back" — is wrong in the LOOSENING direction, and was measured as such: a whole-row replacement includes relaxation, so one leg reading one store at rev 6 erased a shared snapshot's own rev-5 deny for a second leg whose store was a lagging replica (it reds the existing M17 transcript pin, which shares its snapshot object across two cases). Those entries belong to whoever built the chain — a host's re-supplied `internals.inheritedGate`, a rehydrated checkpoint, an ancestor leg's published row that every sibling it spawns is holding — so `internals.inheritedGate.ancestorRules` is unchanged by a run, exactly as before.
|
|
60
|
+
- **Two faces, ONE projection — the checkpoint half moves with the spawn half.** What a later reader inherits is what this leg enforces, and a resume is a later reader. The checkpoint writer cloned the PREPARE-TIME list, so a mid-run tightening reached a later child but not a later resume — and a cross-process resume whose ancestor store is unreadable fell back to the looser snapshot. Both faces now call the same `ancestorRulesNow()`; keeping them separate was two implementations of one security rule.
|
|
61
|
+
- **…and the list those positions index is OWNED.** The chain resolver could hand back the caller's own array verbatim (`internals.inheritedGate.ancestorRules`, or a checkpoint seed's). A position is only a fact if the list is ours, so it is copied once per leg — a host that reorders or splices its array between prepare and spawn can no longer have one layer's row land on another layer's entry. The ENTRIES stay shared, which is correct: nothing writes to them.
|
|
62
|
+
- **The seat is keyed by POSITION, not by identity.** The chain may legitimately carry two entries of the same `(sessionId, principal)` with DIFFERENT floors (a host rebuild; a seed and a live channel that both name it), each compiled into its own layer with its own floor. An identity key lets the layer that advanced overwrite the entry of the layer that did not — publishing the weaker floor over the stronger one, measured as a child inheriting no deny at all. A layer upgrades its OWN entry, and position is what "its own" means.
|
|
63
|
+
- **The other half of #981 is untouched and stays on the ledger.** A session with no rules row at prepare still sends NO `ancestorRules` at all, so a child spawned before a session's first rule inherits no entry to upgrade. That is not a missing hook: `ancestorRules` doubles as the "this leg is delegated" signal, so an empty entry cannot be seeded without re-meaning that signal for every delegated child of every store-bearing deployment. Splitting delegation identity from rule content is a separate change.
|
|
64
|
+
- **Reach.** No wire key, code, closed-set member or export moves; one hook argument. `@test` — a grandchild e2e (parent writes a deny between two spawns; the later grandchild's own read fails) is the black-box shape. `@server` / `@cli` render the refusal they already render. `test/backlog981-ancestor-enforced-publish.test.ts` pins three real generations on a real Runner with one store — the earlier grandchild runs the tool (control), the middle leg's own next call is refused (control for the re-read mechanism 7.24.4 already has), and the later grandchild is refused with the store unreachable, `probe.ran` being **2 on 7.24.4** and 1 with the hook — plus the isolation reading (two legs sharing one entry, the second against a different store, and the snapshot's own deny still standing), the duplicate-identity reading, the checkpoint reading, and a host that reorders its own chain array mid-run.
|
|
65
|
+
|
|
66
|
+
### A background poll body says how it may be read again (#977; P2; ADDITIVE — one new details key on three faces; @server @client-core @test)
|
|
67
|
+
- **Before.** Three poll faces render a background task's output and two of them mean OPPOSITE things: the monitor spool and the bash spool re-render the whole output from the registry's own spool on every poll (replace what you hold), while the bash blocked wait serves only what is new since the previous read, because it consumed the env cursor (append). Which one a consumer had was legible only from the stdout heading's prose — `stdout (full, re-readable)` versus `new stdout`. The one machine key nearby reads the two faces oppositely: `bytesDroppedBeforeCursor` rides the spool faces ONLY when bytes were actually dropped, and the blocked-wait face unconditionally (value possibly `0`). So a consumer discriminating on that key's PRESENCE — which is what server's `cursorSemantics` did — reads a spool that happened to drop bytes as an increment, and client-core's `mergeTaskOutput` appends a body that was a full re-render: the same section printed twice.
|
|
68
|
+
- **After.** `details.details.replay` — `"full" | "cursor"`, the registered closed set `TASK_OUTPUT_REPLAY_KINDS`. Present IFF the poll carries an output body, which is exactly those three faces; a `not_ready` arm, an invalid-filter refusal and a poll error carry no body and no word, so **absence means "there is no output body here", never `full`**.
|
|
69
|
+
- **One mint, and the prose comes out of it too.** The word is named once per face (`SPOOL_REPLAY` / `INCREMENT_REPLAY` in `task-registry-shared.ts`) and the stdout/stderr HEADINGS each face renders are rows of the same table (`REPLAY_FACE`) — so the banner a person reads and the key a program reads are one decision, not two that drift. The table's `stdoutWhenGapped: undefined` on the cursor face is a row, not an omission: an increment window claims nothing about completeness to downgrade, while a spool advertising itself as "full" with a disclosed gap would be saying something false. Every rendered body is byte-identical to 7.24.4.
|
|
70
|
+
- **`bytesDroppedBeforeCursor` is deliberately NOT unified.** Same name, same type, same two in-presence rules, same arithmetic. Each rule is right for its own face ("say it when there is something to say" / "always a number on this leg"); reading them as a lane discriminator was the defect, and changing a disclosure to fix a discrimination would trade one wrong reading for another.
|
|
71
|
+
- **The in-presence rule is "this answer carries a STREAM BODY", not "this poll succeeded" and not "this answer has text".** The `workflow` and `background_agent` poll faces carry a FINISHED RESULT (`result` / `finalOutput`), not a stream: polling again answers the same text, so there is no replay question to put on them and they carry no `replay`. A consumer must not append those, and must not read their missing `replay` as a pre-7.25.0 producer. Within the stream family the rule is exact: The blocked wait's poll-ERROR return salvages the increments it had already consumed from the env cursor ("output accumulated before the error") — those bytes are gone from the source, so that answer carries task output and now carries `replay: "cursor"` in the same nested place every other face puts it. One predicate decides both the salvage and the word, so they cannot drift.
|
|
72
|
+
- **Reach.** Additive — no existing key changes name, type, value or in-presence rule, no export moves, no closed-set member is re-meant. `@server` replaces the `bytesDroppedBeforeCursor`-presence test in `runs.js` with `replay === "cursor"` and retires the presence arm. `@client-core` appends iff `cursor`, replaces iff `full`, and keeps its current heuristic only where the key is absent AND a body is present (a pre-7.25.0 producer). `@test` — T-37's judging eye is `replay: "full"` BESIDE a present `bytesDroppedBeforeCursor`, the reachable pair a presence test calls a cursor. `docs/wire-consumption/unreleased-977.md` and `docs/INTEGRATION-CORE.md` §1.3a carry the obligation. `test/backlog977-replay-face.test.ts` pins seven readings across all three real registry lanes — including the misread reproduced end to end, and both halves of the error arm — with a seven-row mutation table.
|
|
73
|
+
|
|
3
74
|
## 7.24.4 — 2026-09-20
|
|
4
75
|
|
|
5
76
|
### Every rule source is re-read at every gate (#978 ①; P2; behavior-face widening of when a session rule binds; no wire key; @server @cli @test)
|
|
@@ -18,6 +89,16 @@
|
|
|
18
89
|
- **What a reader keyed on the old text sees.** The permission lane's sentence gained a head and kept its tail byte-for-byte (`denied by permission rule "Bash(rm -r:*)" (settings.permissions.deny)` still `toContain`s). The session lane's plain tool-name denial is unchanged. The containment arms and the allowlist miss are REWORDED and keep their diagnostic substrings (`cannot be path-confined`, `declares no path target`, `no resolvable path`, `outside the session-rule allowDirs`, `not in the session-rule allowlist`). The ASK sentence (`flagged by permission rule …`) is deliberately untouched: an ask is a question on a card, not a refusal in a tool result.
|
|
19
90
|
- **Reach.** No wire key, code, closed-set member or export moves; `PermissionResult.message` is a rendered string and this is a rendering change. `@cli` renders these in tool results — a fence pinned on a session-lane containment wording should re-pin on the substrings above. `test/backlog978-rule-denial-sentence.test.ts` pins the grammar, the two lanes' agreement on it, every session exit, the mint's composition, and a source scan holding the denial spellings to one module (its ceiling is stated: code lines only, and the ask / non-rule-store `denied by policy` faces are out of scope).
|
|
20
91
|
|
|
92
|
+
### A host can write a standing deny in one step, and core mints its provenance (#980; P2; 3 new exports + 1 BACKEND-CONTRACT union member; @server @cli)
|
|
93
|
+
- **Before.** The only way to land a persisted rule was the approval-record legs (prepare → confirm → redeem), and an add's provenance is DERIVED from the record's kind (`originOfRecordKind`). A host that wanted to write one standing deny on a person's own instruction had to borrow an IMPORT record, so the row came out stamped `imported-cc` — neither an import nor the other product, and a second, unverified story about where the rule came from.
|
|
94
|
+
- **After.** `addPersistedRule({ behavior, rule, scope, principal, provider })` — one step, no record, the dual of `removePersistedRule` and shaped like it (principal shorthand or the structural `RuleOwner`, the same scope and grammar validation, the same `failed`-only-after-read-back doctrine). `AddResult` mirrors `RemoveResult`: `added` (with the add's `dot`) / `no-op` (an equivalent rule is already live — no second dot) / `failed`.
|
|
95
|
+
- **Core mints the provenance; the entry takes no argument for it.** Every row this door writes is `origin: "user"`, so no host can claim a rule was imported or shipped as a starter. `RuleAddOrigin` is the same closed three-word set, `RuleAdd.origin` is still required, and a stored row is byte-identical to one the consent legs write — **zero new wire keys**.
|
|
96
|
+
- **The widening direction is not spellable.** `behavior` excludes `allow`: a standing approval is a person's yes and still takes a consent record. This is the asymmetry `removePersistedRule` has always stated — a host may narrow on a person's behalf, it may not widen — and a plain-JS caller that spells `allow` anyway meets a loud refusal rather than a silent write.
|
|
97
|
+
- **One add path, not two.** The redemption leg's optimistic write loop moved into a shared `landRuleAdd` both doors take (same 8 attempts, same read-then-CAS order, same refusal texts, same mixed-offer sibling gate judged on the anchoring snapshot), dispatching the two add arms exhaustively. `writer.apply` call sites in `src/` drop from three files to two.
|
|
98
|
+
- **`failed` now says how strong a claim it is, and two adversarial rounds set the line.** Round 1 found the read-back asking the LIVE view: an add that committed, lost its acknowledgement and was then covered by a concurrent removal read as "nothing was written" — and a caller believing that retries with a fresh dot and reinstates a restriction someone had just removed. Round 2 showed the raw face is not eternal either: a sync round carrying a collection frontier removes a tombstone together with the add dots it covered, leaving no trace at all. So the outcome is split by WHERE the failure happened. When the store ANSWERED — a read that failed before any write, a refused preflight, an epoch seal, a compare-and-set that lost all eight attempts — nothing landed and the text says so plainly, with no read-back. When the apply THREW, the read-back asks the raw face and reports `added` on a dot it finds; a dot it cannot find is **indeterminate**, never a confirmed nothing. Same-class note for the owner: `removePersistedRule`'s own "read-back confirms no tombstone was written" carries the same latent over-claim (a tombstone is collectable too) and is deliberately left unchanged here.
|
|
99
|
+
- **BREAKING (backend contract), three sentences.** (1) **What changed:** `RuleWriteDelta` gains one member, `RuleTightenAddDelta` (`kind: "tighten-add"`) — the true dual of `tighten-delete`: no authorization block, `behavior` excluding `allow` in the type; `redemption-add` is untouched, its `redemption` still required, and `allow` still rides it. (2) **Who is affected:** anyone implementing `PermissionRuleWriter.apply` OUT OF THIS REPO — their delta dispatch has no arm for the new kind; the failure is fail-closed and was measured both ways (a typed chain stops compiling and every add fails written-nothing; an untyped `if/else` chain — server's SQL twin's shape — reaches its delete arm, where `assertDeleteDeltaCarriesNoAdd` throws on the five add-only keys a tighten-add carries, before any tombstone is read). (3) **How to migrate:** add one arm with the same `assertWriteDeltaScopeDurable` + `foldDelta` as `redemption-add` and WITHOUT `assertRedemptionNotQuarantined` — that fence catches a replayed approval record, and a tightening add has no record to replay, which is a contract statement and not an omission.
|
|
100
|
+
- **Reach.** `@server` S-511 (`POST /v1/rules`, cli L-431②) is the named consumer: add the backend arm, switch the write off the borrowed import legs, and expect `origin: "user"`; the direction restriction it already applies to itself is now core's too. `@cli` reaches the store only through server. `@client-core` writes no rules. `docs/wire-consumption/unreleased-980.md` carries the table, including the `tighten-add` kind-reading rule and the quarantine-fence contract sentence. `test/backlog980-add-persisted-rule.test.ts` pins thirteen readings (including the import leg as a positive control that provenance was NOT unified) with a nine-row mutation table, two rows of which are measured by `tsc` rather than by a test run.
|
|
101
|
+
|
|
21
102
|
### A workflow agent's fleet row now says which attempt it is on (#979; P2; presence change on an existing wire key, additive; no new key, no export; @client-core @server)
|
|
22
103
|
- **Before.** `BackgroundChildEvent.seq` is contracted as "present whenever a cycle number is knowable, absent when no carrier exists". The `a*` delegation lanes have carried it on spawn / tick / terminal since #258; the `wa*` workflow lane carried it on NONE of its three frames — while it has held a carrier all along: the orchestration layer's stall/throttle retry re-runs the same logical agent on a fresh session under the SAME `wa*` id, which is exactly the "one row, two cycles" shape the field exists to separate. A fleet consumer folding rows by generation had a structurally unreachable third branch on this lane.
|
|
23
104
|
- **After.** The `wa*` row carries a generation: a fresh spawn is `1`, and each retry of the same agent bumps it, so a tick or terminal says which attempt produced it. The number is the ROW's, taken at emit — never copied from a forwarded descendant's frame, the same rule `agentType` and `model` already follow on this lane.
|
|
@@ -52,6 +133,25 @@
|
|
|
52
133
|
- **Two operand readers are exported from `bash-readonly-classifier.ts`** for the plan (`readVerbOptionSlot`, `slotValueCandidates`, `attachedOptionPayloads`, `segmentSelectsRecursiveRead`, `inlineProgramClause`, …): internal modules, not on the package's export surface (`handoff:diff` v7.24.2 → HEAD: +0 −0).
|
|
53
134
|
- Reach: @server / @cli / @client-core zero; headless zero.
|
|
54
135
|
|
|
136
|
+
### The teammate key rides every carrier; a child's "main" parks in its leader's durable box; the revive arms address the root (#969 S0; minor; additive keys ×6, widening ×2, narrowing ×1; @server @cli @client-core)
|
|
137
|
+
- **The key.** `Agent({name})` was the teammate identity on three faces (the roster row, the SendMessage name rung, the uplink `from`) and absent from the four a consumer actually joins on. It now rides them all, as `spawnName`: `task_progress` (running + terminal ticks, beside the description-backed `name`), `BackgroundChildEvent` (spawn / tick / terminal — one stamp per lane closure, revival cycles included), `HookInvocationIdentity` (every hook seat of the leg and both `DelegationLifecycleEvent` frames — with `agentType`, the RESOLVED type word, so a hook bridge composes `SubagentStart.agent_type` from the type and not from the description). Absent everywhere at once for an unnamed delegation. Rules: +0 — the same word at one more mint per carrier.
|
|
138
|
+
- **The park seat.** `routeOrParkNotification` is where a frame addressed to a session with no live run waits; it had one backend (the in-process store). It now has two, chosen by the frame: a PEER'S WORDS (the `agentMessage` carrier — a child's `SendMessage("main")`) go to the session's DURABLE box when the session's own leg mounted the session-box drain (a top-level leg over a mailbox declaring `crossProcessSafe: true`, grammatical session id — `Prepared.sessionBox`, bound by the notification lane at every mount), as the same `peer_message` record the cross-session lane appends, with `peerMeta.fromRoot` = the box owner; every other frame keeps the in-process park. A LIVE leader still receives the message queued as `<agent-message>` — only the idle case moved. An append fault falls back to the in-process park and is disclosed. Rules: +1 backend selection, +1 carrier discrimination (the second retires when S1 gives engine frames a codec — `KL-969b`).
|
|
139
|
+
- **Same tree = same admission.** The box drain applies the cross-session parity judgment (hold for a human / refuse by setting) to a PEER's words; a record whose `fromRoot` is this session is its own teammate's and skips it — the admission gate (rate / dedup / hop) still runs. Without this a bypass-mode leader would HOLD every teammate message that reached it through the box. `MailboxPeerMeta.fromRoot` is a new typed key (`readMailboxPeerMeta` admits it; a pre-7.25 core reading a box holding one fails the lease closed — `KL-969c`). Rules: +1.
|
|
140
|
+
- **The drain mounts on one judgment.** `judgeSessionBoxDrain` (mailbox cross-process-safe ∧ grammatical id ∧ NOT a delegated child) is what both the drain binding and the park-seat binding read, so "parked here" and "drained there" agree by construction. Two widenings: a top-level leg now drains its box WITHOUT a peer directory (the directory only adds the discovery faces); a delegated child leg never drains one (its addressable box is its `a*` handle; a drain per child on a box nobody writes was a lease claim per turn per agent — `KL-969g`). `config.peer_lane_unmounted` keeps its audience.
|
|
141
|
+
- **The receipt speaks the outcome and names the session.** The uplink callback (`SendMessageToolOptions.uplink` / `RunInternals.parentNotify` / `ToolExecuteContext.onTaskNotification`) now RETURNS the injector's outcome on the engine's own wiring (`NotificationInjectionOutcome`, +1 member `parked_durably`; host-built callbacks may answer anything and read as "not reported" — the parameter type is `unknown`, so no host callback stops compiling); `SendMessage("main")` reads it: `queued for session <id>` (accepted into a live lane, not yet read) / `parked durably for main (session <id>)` (claimed only after the append settled) / `parked in process …` / `not re-sent …`, with `details.disposition` and `details.session`. The session is `RunInternals.uplinkSessionId`, the ONE address `main` names, pair-minted with the injector.
|
|
142
|
+
- **The revive arms — NARROWING.** A child revived by a SIBLING child, or by a host verb with no reviver session, addresses `main` to the row's ROOT session and carries no injector: a sibling is a peer the child reaches by name, not its main, and neither reviver holds a callback into the root. Such a child's `SendMessage("main")` reads `main_not_deliverable` (naming the session); before, it was delivered live into the sibling reviver's model. The child's terminal still reaches the root through the root-anchor arm; the sibling's own completion notification is unchanged. A top-level reviver keeps its injector (it asked for the work and holds the only live lane).
|
|
143
|
+
- **Reach.** @server: SQL mailbox is cross-process-safe ⇒ upgrade day every top-level run drains its own session box (one lease claim per turn boundary) and an idle leader's teammate messages become durable; whitelist `task_progress.spawnName`; map `BackgroundChildEvent.spawnName` → fleet `agentName`; hook bridge reads `identity.spawnName` / `identity.agentType`; `parked_durably` where the injector outcome is switched on. @cli: FileMailboxStore is cross-process-safe ⇒ same drain; the cross-session frame renders with the existing folding control; the fleet label shows the spawn name once client-core passes it. @client-core: `panel_task` tick `spawnName`; `subagent_lifecycle.agentType` from the type word. Headless one-shot runs: a box nobody re-runs is aged by the mailbox retention law, not woken (`KL-969d`). Full table: `docs/wire-consumption/unreleased-969.md`; limits `KL-969a`–`KL-969h`.
|
|
144
|
+
|
|
145
|
+
### Spec-level hooks reach the teammate leg; the PeerDirectory acceptance kit (#969 S1; widening; @server)
|
|
146
|
+
- `ParentSpecSeat.hooks`: a parent's `TaskSpec.hooks` (the user's settings hooks a host mounts on the request) now ride the seat to its delegated children under the one seat law (a child's own value wins, the seat fills an unset one) — a user's `stop` seat fires on the teammate leg with the child's identity, which is CC's `SubagentStop` from the same settings. `RunnerDeps.hooks` already served every leg; only the spec-level seams stopped at the leader. A deployment that assumed "leader only" discriminates on `identity.isDelegatedChild`.
|
|
147
|
+
- `peerDirectoryContract` (+ `PeerDirectoryContractHooks`, `PEER_DIRECTORY_CONTRACT_SCOPE`): the four readings the engine relies on from a host's directory — scope wall, the full set as copies (live + dead + deleted), the engine's resolver over the listing, `restorePeerSession` CAS-on-absent — as a kit with discriminating power; core's in-memory directory passes it; a server session-table directory passes it before it is wired.
|
|
148
|
+
- Eight rows in `docs/KNOWN-LIMITS.md` (`KL-969a`–`KL-969h`): the four in-process parking points this release does not touch, the terminal frame's in-process park pending a codec, the rolling-upgrade note for `fromRoot`, the headless box with no reader, the same-session two-replica double delivery, the three hook-event gaps against CC 2.1.259, the child leg's undrained box, and `fromRoot` being the seat's statement rather than a proof.
|
|
149
|
+
|
|
150
|
+
### `teamName` retired from the background-agent row (#969 S2; BREAKING on the type face; @server @cli @client-core @sdk)
|
|
151
|
+
- **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
|
+
- **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
|
+
- **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.
|
|
154
|
+
|
|
55
155
|
## 7.24.2 — 2026-09-20
|
|
56
156
|
|
|
57
157
|
### A query whose separators are not the deployment's has no names to speak (#974; P1; security-facing narrowing of a rendered string; no wire key; @server @cli @client-core)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type MailboxCrossProcessVerdict, type MailboxStore } from "../core/mailbox-store.js";
|
|
2
|
-
import type { SystemInjectionPriority, TaskNotificationPayload } from "../core/task-notification.js";
|
|
2
|
+
import type { SessionBoxBinding, SystemInjectionPriority, TaskNotificationPayload } from "../core/task-notification.js";
|
|
3
3
|
import { type EngineNotice, type RunnerDeps } from "../core/types.js";
|
|
4
4
|
import { type PermissionModeClass } from "./cross-session-envelope.js";
|
|
5
5
|
import { type CrossSessionInboundSettingLayers } from "./cross-session-judge.js";
|
|
@@ -36,6 +36,21 @@ export declare function judgePeerLaneMount(deps: {
|
|
|
36
36
|
peerDirectory?: PeerDirectory;
|
|
37
37
|
mailboxStore?: MailboxStore;
|
|
38
38
|
}, sessionId?: string): PeerLaneMountVerdict | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* May THIS leg drain its own session box, and where is that box? The ONE mount judgment of the
|
|
41
|
+
* session-box drain, read by two seats: the wiring station binds the drain on it, the notification
|
|
42
|
+
* lane binds the park seat on it ({@link SessionBoxBinding}) — so a frame is parked into a box exactly
|
|
43
|
+
* when a leg of that session drains it. Independent of the peer DIRECTORY (the directory adds the
|
|
44
|
+
* discovery faces; the box is also where a child's `SendMessage("main")` waits while its host session
|
|
45
|
+
* is idle). A DELEGATED CHILD never drains one: its addressable box is its `a*` handle (the tier-3
|
|
46
|
+
* revival lane), and a drain per child on a box nobody writes would cost a lease claim per turn on
|
|
47
|
+
* every background agent. `undefined` on every refusal — silently: no host asked for a box here.
|
|
48
|
+
*/
|
|
49
|
+
export declare function judgeSessionBoxDrain(deps: Pick<RunnerDeps, "mailboxStore" | "onError">, leg: {
|
|
50
|
+
sessionId: string;
|
|
51
|
+
scope: string;
|
|
52
|
+
isDelegatedChild: boolean;
|
|
53
|
+
}): SessionBoxBinding | undefined;
|
|
39
54
|
/** The disposition arms of a `peer.inbound_disposition` notice (closed set; `detail.disposition`). */
|
|
40
55
|
export type PeerInboundDisposition = "refused" | "held" | "admission_refused" | "notice_unrouted" | "notice_user_only";
|
|
41
56
|
export interface PeerSessionDrainOptions {
|
|
@@ -32,6 +32,20 @@ export function judgePeerLaneMount(deps, sessionId) {
|
|
|
32
32
|
}
|
|
33
33
|
return { ok: true };
|
|
34
34
|
}
|
|
35
|
+
export function judgeSessionBoxDrain(deps, leg) {
|
|
36
|
+
const mailbox = deps.mailboxStore;
|
|
37
|
+
if (mailbox === undefined || leg.isDelegatedChild)
|
|
38
|
+
return undefined;
|
|
39
|
+
if (!mailboxCrossProcessMountVerdict(mailbox).ok || !isPeerSessionId(leg.sessionId))
|
|
40
|
+
return undefined;
|
|
41
|
+
return {
|
|
42
|
+
mailbox,
|
|
43
|
+
scope: leg.scope,
|
|
44
|
+
handle: peerSessionBoxHandle(leg.sessionId),
|
|
45
|
+
sessionId: leg.sessionId,
|
|
46
|
+
onError: (e) => deps.onError?.(e, { phase: "degraded", sessionId: leg.sessionId, classification: "session-box-park" }),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
35
49
|
export function createPeerSessionDrain(opts) {
|
|
36
50
|
const { mailbox, sessionId, runId, scope } = opts;
|
|
37
51
|
const boxHandle = peerSessionBoxHandle(sessionId);
|
|
@@ -287,7 +301,8 @@ export function createPeerSessionDrain(opts) {
|
|
|
287
301
|
settleNoticeRecord(facts, m, meta);
|
|
288
302
|
continue;
|
|
289
303
|
}
|
|
290
|
-
|
|
304
|
+
const sameTree = meta?.fromRoot !== undefined && meta.fromRoot.toLowerCase() === sessionId.toLowerCase();
|
|
305
|
+
if (!sameTree) {
|
|
291
306
|
const judged = judgeMeta(facts, meta);
|
|
292
307
|
if (judged.verdict === "refuse") {
|
|
293
308
|
disclose("refused", judged.cause, m);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import type { RunnerSelfSeat } from "../core/runner/contracts.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type TaskNotificationPayload } from "../core/task-notification.js";
|
|
4
4
|
import { type ToolCtxEnricher } from "../core/tools.js";
|
|
5
5
|
import { type TaskAccess } from "../core/task-registry.js";
|
|
6
6
|
import type { PermissionModeClass } from "./cross-session-envelope.js";
|
|
@@ -69,7 +69,15 @@ export interface SendMessageToolOptions {
|
|
|
69
69
|
*/
|
|
70
70
|
uplink?: (n: TaskNotificationPayload, opts?: {
|
|
71
71
|
priority?: "now" | "next" | "later";
|
|
72
|
-
}) =>
|
|
72
|
+
}) => unknown;
|
|
73
|
+
/**
|
|
74
|
+
* The session `"main"` names for this run ({@link import("../core/runner/contracts.js").RunInternals.uplinkSessionId}
|
|
75
|
+
* — the spawner's, or the reviver's, or on a sibling/host revival the tree root's). The uplink
|
|
76
|
+
* receipt names it so the model knows WHICH conversation "main" is; its presence without
|
|
77
|
+
* {@link uplink} marks a delegated child whose main has no live lane from here (the honest
|
|
78
|
+
* not-deliverable text names the session). Absent on a top-level run.
|
|
79
|
+
*/
|
|
80
|
+
uplinkSessionId?: string;
|
|
73
81
|
/** design/147 S1a — this run's own spawn name (teammate identity) for uplink attribution. */
|
|
74
82
|
senderName?: string;
|
|
75
83
|
/** design/147 S1c — the deployment's durable roster, consulted after the live registry misses. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
+
import { isNotificationInjectionOutcome } from "../core/task-notification.js";
|
|
2
3
|
import { defineTool } from "../core/tools.js";
|
|
3
4
|
import { normalizeAgentName, DURABLE_AGENT_HANDLE_RE, DURABLE_AGENT_HEARTBEAT_MS } from "../core/task-registry.js";
|
|
4
5
|
import { announceTranscriptIntegrityGapOnce, canAccessAgentRecord, clearRevivedRowTerminalPayload } from "../core/background-agent-store.js";
|
|
@@ -198,7 +199,7 @@ export function createSendMessageTool(opts) {
|
|
|
198
199
|
const senderId = ctx.taskId ?? opts.owner;
|
|
199
200
|
const [parentTaskId, parentSessionId] = ctx.parentTaskId !== undefined ? [ctx.parentTaskId, ctx.parentSessionId] : [opts.parentTaskId, opts.parentSessionId];
|
|
200
201
|
const senderName = ctx.spawnedAgentName ?? opts.senderName;
|
|
201
|
-
const senderIsChild = parentTaskId !== undefined || opts.uplink !== undefined || senderName !== undefined || opts.siblingRetain !== undefined;
|
|
202
|
+
const senderIsChild = parentTaskId !== undefined || opts.uplink !== undefined || opts.uplinkSessionId !== undefined || senderName !== undefined || opts.siblingRetain !== undefined;
|
|
202
203
|
const senderLabel = senderName ?? (senderIsChild ? senderId ?? "main" : "main");
|
|
203
204
|
if (senderId !== undefined && isObserverTaskId(senderId)) {
|
|
204
205
|
return { content: OBSERVER_SENDMESSAGE_SENDER_REFUSAL, details: { error: "observer_sender" }, isError: true };
|
|
@@ -414,25 +415,35 @@ export function createSendMessageTool(opts) {
|
|
|
414
415
|
}
|
|
415
416
|
const uplinkBody = message.length > UPLINK_RESULT_MAX ? `${message.slice(0, UPLINK_RESULT_MAX)}\n[uplink truncated: ${message.length} chars total — read the agent's transcript for the rest]` : message;
|
|
416
417
|
const uplinkSeq = ++uplinkSeqGlobal;
|
|
418
|
+
let outcome;
|
|
417
419
|
try {
|
|
418
|
-
opts.uplink({
|
|
420
|
+
const reported = await opts.uplink({
|
|
419
421
|
task_id: senderId,
|
|
420
422
|
task_type: "background_agent",
|
|
421
423
|
status: "event",
|
|
422
424
|
summary: `message from ${senderLabel}: ${summary}`,
|
|
423
425
|
result: `${uplinkBody}\n\n${PEER_MESSAGE_NOTICE}`,
|
|
424
426
|
seq: uplinkSeq,
|
|
425
|
-
peer: { hopChain: prospectiveChain },
|
|
427
|
+
peer: { hopChain: prospectiveChain, ...(directMountSessionId !== undefined ? { fromSession: directMountSessionId } : {}), ...(senderKey !== undefined ? { senderKey } : {}) },
|
|
426
428
|
agentMessage: { from: senderLabel, body: uplinkBody },
|
|
427
429
|
_sema_provenance: { kind: "agent_message", from: senderLabel, taskId: senderId, seq: uplinkSeq },
|
|
428
430
|
}, { priority: "next" });
|
|
431
|
+
outcome = isNotificationInjectionOutcome(reported) ? reported : undefined;
|
|
429
432
|
}
|
|
430
433
|
catch (e) {
|
|
431
434
|
return { content: `Message not sent: the parent's notification lane rejected it (${thrownText(e)}). ${DEDUP_RETRY_NOTE}`, details: { error: "uplink_failed", to }, isError: true };
|
|
432
435
|
}
|
|
436
|
+
const mainLabel = opts.uplinkSessionId !== undefined ? `session ${inlineUntrusted(opts.uplinkSessionId, 96)}` : "the spawning conversation";
|
|
437
|
+
const content = outcome === "parked_durably"
|
|
438
|
+
? `Message parked durably for main (${mainLabel}): it has no run in progress, so the message was appended to its durable session box and is read at the start of its next run (it survives a restart). Continue with your task; do not wait for a reply.`
|
|
439
|
+
: outcome === "parked"
|
|
440
|
+
? `Message parked in process for main (${mainLabel}): it has no run in progress, so the message waits in this process for its next run — a restart of this process loses it. Continue with your task; do not wait for a reply.`
|
|
441
|
+
: outcome === "dropped_duplicate"
|
|
442
|
+
? `Message not re-sent to main (${mainLabel}): it already delivered this exact message. Continue with your task; do not resend the same content.`
|
|
443
|
+
: `Message sent to main — queued for ${mainLabel} at its next turn boundary (accepted into its lane, not yet read). If that run finishes before reading it, the message may not survive. Continue with your task; do not wait for a reply.`;
|
|
433
444
|
return {
|
|
434
|
-
content
|
|
435
|
-
details: { type: "send-message", status: "uplinked", to: "main", seq: uplinkSeq },
|
|
445
|
+
content,
|
|
446
|
+
details: { type: "send-message", status: "uplinked", to: "main", seq: uplinkSeq, ...(outcome !== undefined ? { disposition: outcome } : {}), ...(opts.uplinkSessionId !== undefined ? { session: opts.uplinkSessionId } : {}) },
|
|
436
447
|
};
|
|
437
448
|
}
|
|
438
449
|
if (!senderIsChild) {
|
|
@@ -443,9 +454,9 @@ export function createSendMessageTool(opts) {
|
|
|
443
454
|
};
|
|
444
455
|
}
|
|
445
456
|
return {
|
|
446
|
-
content: `Message not sent: "main" (the spawning conversation) is not a deliverable target here. ` +
|
|
457
|
+
content: `Message not sent: "main" (${opts.uplinkSessionId !== undefined ? `session ${inlineUntrusted(opts.uplinkSessionId, 96)}` : "the spawning conversation"}) is not a deliverable target from this run — no live lane into it is wired here. ` +
|
|
447
458
|
`Your completion is reported to it automatically — finish your task and your final report will be relayed.`,
|
|
448
|
-
details: { error: "main_not_deliverable", to },
|
|
459
|
+
details: { error: "main_not_deliverable", to, ...(opts.uplinkSessionId !== undefined ? { session: opts.uplinkSessionId } : {}) },
|
|
449
460
|
isError: true,
|
|
450
461
|
};
|
|
451
462
|
}
|
package/dist/agents/subagent.js
CHANGED
|
@@ -597,7 +597,7 @@ export function createSubagentResume(deps) {
|
|
|
597
597
|
if (deps.bgSink !== undefined) {
|
|
598
598
|
reviveEmit = (event) => {
|
|
599
599
|
try {
|
|
600
|
-
deps.bgSink(event);
|
|
600
|
+
deps.bgSink(deps.rowName !== undefined ? { ...event, spawnName: deps.rowName } : event);
|
|
601
601
|
}
|
|
602
602
|
catch {
|
|
603
603
|
}
|
|
@@ -2104,6 +2104,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
2104
2104
|
parentToolCallId: ctx.toolCallId,
|
|
2105
2105
|
...(childAgentName ? { agentName: childAgentName } : {}),
|
|
2106
2106
|
...(agentName !== undefined ? { explicitAgentName: agentName } : {}),
|
|
2107
|
+
spawnAgentType,
|
|
2107
2108
|
...(reviveClaim !== undefined
|
|
2108
2109
|
? {
|
|
2109
2110
|
...(reviveClaim.row.parentTaskId !== undefined ? { parentTaskId: reviveClaim.row.parentTaskId } : {}),
|
|
@@ -2120,12 +2121,15 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
2120
2121
|
}),
|
|
2121
2122
|
...(ctx.centerArtifactDigest !== undefined ? { parentCenterArtifactDigest: ctx.centerArtifactDigest } : {}),
|
|
2122
2123
|
...(ctx.centerSourceRevision !== undefined ? { parentCenterSourceRevision: ctx.centerSourceRevision } : {}),
|
|
2123
|
-
...(
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2124
|
+
...(() => {
|
|
2125
|
+
const viaRoot = reviveClaim !== undefined && (ctx.parentTaskId !== undefined || ctx.sessionId === undefined);
|
|
2126
|
+
const address = viaRoot ? (reviveClaim.row.rootSessionId ?? reviveClaim.row.parentSessionId) : ctx.sessionId;
|
|
2127
|
+
const injector = viaRoot ? undefined : ctx.onTaskNotification;
|
|
2128
|
+
return {
|
|
2129
|
+
...(address !== undefined ? { uplinkSessionId: address } : {}),
|
|
2130
|
+
...(injector !== undefined ? { parentNotify: injector, ...(ctx.peerSelfRef !== undefined ? { parentPeerRef: ctx.peerSelfRef } : {}) } : {}),
|
|
2131
|
+
};
|
|
2132
|
+
})(),
|
|
2129
2133
|
...(ctx.subagentRetain !== undefined ? { parentRetainLedger: ctx.subagentRetain } : {}),
|
|
2130
2134
|
onForwardEvent: recordingForward,
|
|
2131
2135
|
...(worktreeDir !== undefined
|
|
@@ -2538,7 +2542,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
2538
2542
|
const bgSink = ctx.onBackgroundChildEvent;
|
|
2539
2543
|
const sinkEmit = (event) => {
|
|
2540
2544
|
try {
|
|
2541
|
-
bgSink?.(event);
|
|
2545
|
+
bgSink?.(agentName !== undefined ? { ...event, spawnName: agentName } : event);
|
|
2542
2546
|
}
|
|
2543
2547
|
catch {
|
|
2544
2548
|
}
|
|
@@ -3019,7 +3023,6 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3019
3023
|
? { rootSessionId: reviveRow.rootSessionId ?? reviveRow.parentSessionId }
|
|
3020
3024
|
: {}),
|
|
3021
3025
|
...(reviveRow.model !== undefined ? { model: reviveRow.model } : {}),
|
|
3022
|
-
...(reviveRow.teamName !== undefined ? { teamName: reviveRow.teamName } : {}),
|
|
3023
3026
|
deliveryChannel: "attaching",
|
|
3024
3027
|
toolUseId: ctx.toolCallId,
|
|
3025
3028
|
...(bg.agentStore !== undefined ? { store: bg.agentStore, durableSeed: { record: reviveClaim.row, rev: reviveClaim.rev } } : {}),
|
|
@@ -3079,7 +3082,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3079
3082
|
const bgSink = ctx.onBackgroundChildEvent;
|
|
3080
3083
|
const sinkEmit = (event) => {
|
|
3081
3084
|
try {
|
|
3082
|
-
bgSink?.(event);
|
|
3085
|
+
bgSink?.(agentName !== undefined ? { ...event, spawnName: agentName } : event);
|
|
3083
3086
|
}
|
|
3084
3087
|
catch {
|
|
3085
3088
|
}
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* design/99 — a SHORT, SECRET-SCRUBBED one-line summary of a tool call's arguments, for an OPERATOR-FACING
|
|
3
|
-
* display (the MF-W workflow monitor's `Read(path)` / `Bash(grep …)` activity line; the MF-24 permission-denial
|
|
4
|
-
* ledger's denied-tool input). Shared so both the activity sink and the denial record scrub identically — a
|
|
5
|
-
* single audited boundary. NOT `redactHostLeaks` (that is for untrusted-LLM egress and over-redacts the very
|
|
6
|
-
* path the trusted owner-scoped monitor wants to show). Reviewed: 3 blocker + 5 major findings closed.
|
|
7
|
-
*/
|
|
8
1
|
/** Known credential SHAPES + `secretKey=value` — redacted from the FINAL abbreviated string, the single scrub
|
|
9
2
|
* point before any store/SSE fan-out. Conservative (real credential formats). */
|
|
10
3
|
/** every prefix is LEFT-ANCHORED with \`(?<![A-Za-z0-9])\` — without it, ordinary words whose
|
package/dist/core/arg-summary.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { writtenAssignmentPrefix } from "../tools/fs/bash-program-position.js";
|
|
1
2
|
const ACTIVITY_ARG_MAX = 80;
|
|
2
3
|
const SENSITIVE_KEY = /token|secret|key|password|passwd|credential|auth/i;
|
|
3
4
|
export function summarizeRedactions(findings) {
|
|
@@ -234,7 +235,7 @@ export function primaryActivityArg(args) {
|
|
|
234
235
|
if (command !== undefined) {
|
|
235
236
|
const tokens = command.trim().split(/\s+/);
|
|
236
237
|
let i = 0;
|
|
237
|
-
while (i < tokens.length &&
|
|
238
|
+
while (i < tokens.length && writtenAssignmentPrefix(tokens[i]) !== undefined) {
|
|
238
239
|
if (/["'\\]/.test(tokens[i]))
|
|
239
240
|
return undefined;
|
|
240
241
|
i++;
|
|
@@ -10,7 +10,7 @@ export interface BackgroundAgentUsage {
|
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
12
|
* One durable background-agent execution row. Field discipline (design/151 §3.1, HIGH-4): revival
|
|
13
|
-
* re-hydration data is LOOKUP KEYS ONLY (`agentType`/`model`/`
|
|
13
|
+
* re-hydration data is LOOKUP KEYS ONLY (`agentType`/`model`/`name` — the CC 2.1.216
|
|
14
14
|
* `.meta.json` sidecar shape) — never a serialized spec and never closures; a tier-3 revival rebuilds
|
|
15
15
|
* the spec the same way a fresh named-teammate spawn would (roster/agent definition + the reviver's
|
|
16
16
|
* deployment assembly).
|
|
@@ -40,8 +40,8 @@ export interface BackgroundAgentRecord {
|
|
|
40
40
|
name?: string;
|
|
41
41
|
agentType?: string;
|
|
42
42
|
description?: string;
|
|
43
|
-
/** Revival lookup
|
|
44
|
-
|
|
43
|
+
/** Revival lookup key (CC meta-sidecar parity); populated when known, never required. The team
|
|
44
|
+
* key is {@link rootSessionId} — one implicit team per session, no column of its own. */
|
|
45
45
|
model?: string;
|
|
46
46
|
/** design/151 §3.1d (HIGH-3): the WRITING process instance id (random per registry instance). A
|
|
47
47
|
* fallback read of a `running` row whose writer is not the reading instance serves an HONEST
|
|
@@ -105,6 +105,11 @@ export interface BackgroundChildEvent {
|
|
|
105
105
|
progressParentTaskId?: string;
|
|
106
106
|
/** tick: the child's display name, when the frame carried one. */
|
|
107
107
|
name?: string;
|
|
108
|
+
/** ALL kinds: the EXPLICIT `Agent({name})` spawn identity, when the spawn carried one — the teammate
|
|
109
|
+
* KEY every carrier of this child spells the same way (`task_progress.spawnName`, the roster row's
|
|
110
|
+
* `name`, the uplink `from` label), so a fleet consumer binds "@name → row" by string equality
|
|
111
|
+
* without reading the description-backed {@link name}. ABSENT = an unnamed delegation. */
|
|
112
|
+
spawnName?: string;
|
|
108
113
|
/**
|
|
109
114
|
* tick (residual observability, lane B): the child's most recent tool intent as one human line ("Bash npm test",
|
|
110
115
|
* "Edit src/x.ts") — derived from the last `tool_start` on the child's stream, so a parent can show
|