@sema-agent/core 7.31.0 → 7.32.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 +106 -0
- package/dist/agents/inherited-tool-pool.d.ts +28 -0
- package/dist/agents/inherited-tool-pool.js +15 -0
- package/dist/agents/list-agents-tool.d.ts +7 -0
- package/dist/agents/list-agents-tool.js +9 -2
- package/dist/agents/peer-session-drain.d.ts +2 -0
- package/dist/agents/peer-session-drain.js +1 -0
- package/dist/agents/send-message-tool.d.ts +17 -0
- package/dist/agents/send-message-tool.js +11 -4
- package/dist/agents/subagent.d.ts +10 -1
- package/dist/agents/subagent.js +24 -8
- package/dist/core/ask-origin.d.ts +15 -6
- package/dist/core/ask-origin.js +15 -9
- package/dist/core/background-shell.d.ts +45 -0
- package/dist/core/background-shell.js +13 -0
- package/dist/core/checkpoint-store.d.ts +5 -0
- package/dist/core/checkpoint-store.js +3 -2
- package/dist/core/engine-notice.d.ts +9 -0
- package/dist/core/gate-lanes.js +1 -1
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +5 -0
- package/dist/core/hooks.d.ts +2 -0
- package/dist/core/memory-engine/engine.d.ts +10 -1
- package/dist/core/memory-engine/engine.js +30 -6
- package/dist/core/memory-engine/file-backend.d.ts +15 -0
- package/dist/core/memory-engine/file-backend.js +53 -2
- package/dist/core/memory-engine/layout.d.ts +29 -4
- package/dist/core/memory-engine/layout.js +24 -4
- package/dist/core/read-root-grant-screen.d.ts +23 -0
- package/dist/core/read-root-grant-screen.js +17 -0
- package/dist/core/runner/contracts.d.ts +1 -1
- package/dist/core/runner/prepare-artifact.d.ts +12 -7
- package/dist/core/runner/prepare-artifact.js +10 -8
- package/dist/core/runner/prepare-ask-lane.d.ts +3 -0
- package/dist/core/runner/prepare-ask-lane.js +5 -1
- package/dist/core/runner/prepare-boundary-parks.js +5 -5
- package/dist/core/runner/prepare-delegation-surface.d.ts +18 -0
- package/dist/core/runner/prepare-delegation-surface.js +6 -25
- package/dist/core/runner/prepare-hands-readface.d.ts +12 -89
- package/dist/core/runner/prepare-hands-readface.js +14 -48
- package/dist/core/runner/prepare-inherited-gate.d.ts +3 -1
- package/dist/core/runner/prepare-inherited-gate.js +2 -1
- package/dist/core/runner/prepare-park-ask.js +6 -4
- package/dist/core/runner/prepare-policy-chain.js +4 -0
- package/dist/core/runner/prepare-task.js +5 -3
- package/dist/core/runner/read-root-grant.d.ts +66 -0
- package/dist/core/runner/read-root-grant.js +77 -0
- package/dist/core/runner/run-notification-lane.d.ts +1 -1
- package/dist/core/runner/run-settle-and-teardown.js +3 -9
- package/dist/core/runner/runtask.js +1 -1
- package/dist/core/runner/send-message-continuation.d.ts +45 -0
- package/dist/core/runner/send-message-continuation.js +27 -0
- package/dist/core/runner/session-read-file-states.d.ts +91 -0
- package/dist/core/runner/session-read-file-states.js +43 -0
- package/dist/core/secret-env.d.ts +1 -1
- package/dist/core/secret-env.js +7 -2
- package/dist/core/store-contracts/checkpoint-store-contract.d.ts +2 -0
- package/dist/core/store-contracts/checkpoint-store-contract.js +19 -0
- package/dist/core/task-registry-monitor.js +3 -0
- package/dist/core/task-registry-shared.d.ts +16 -3
- package/dist/core/task-registry.d.ts +65 -29
- package/dist/core/task-registry.js +78 -28
- package/dist/core/task-spec.d.ts +5 -3
- package/dist/core/tool-policy.d.ts +23 -0
- package/dist/core/tool-policy.js +15 -4
- package/dist/core/tool-spec.d.ts +2 -0
- package/dist/engine/execution-env/node-execution-env.d.ts +13 -3
- package/dist/engine/execution-env/node-execution-env.js +26 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/scenarios/full-body.d.ts +5 -2
- package/dist/scenarios/full-body.js +14 -7
- package/dist/tools/fs/fs-bash-background-text.d.ts +16 -0
- package/dist/tools/fs/fs-bash-background-text.js +22 -0
- package/dist/tools/fs/fs-bash.d.ts +6 -0
- package/dist/tools/fs/fs-bash.js +9 -27
- package/dist/tools/fs/fs-read.d.ts +2 -2
- package/dist/tools/fs/fs-search-tools.d.ts +3 -2
- package/dist/tools/fs/index.d.ts +8 -1
- package/dist/tools/fs/index.js +2 -3
- package/dist/tools/fs/repo-map.d.ts +2 -1
- package/dist/tools/fs/safety.d.ts +3 -1
- package/dist/tools/fs/safety.js +2 -1
- package/dist/tools/fs/shell-read-check.d.ts +3 -0
- package/dist/tools/fs/shell-read-check.js +1 -1
- package/dist/tools/fs/shell-read-establish.d.ts +3 -1
- package/dist/tools/fs/shell-read-establish.js +2 -2
- package/dist/tools/fs/shell-read-plan.d.ts +2 -2
- package/dist/tools/fs/shell-read-plan.js +15 -10
- package/dist/tools/fs/shell-read-word.d.ts +5 -4
- package/dist/tools/fs/shell-read-word.js +3 -2
- package/dist/tools/monitor.d.ts +5 -4
- package/dist/tools/monitor.js +5 -4
- package/dist/tools/todo.d.ts +3 -3
- package/dist/tools/todo.js +5 -4
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +13 -1
- package/wire-changes/core@7.31.1.json +189 -0
- package/wire-changes/core@7.32.0.json +850 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,111 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.32.0 — 2026-09-25
|
|
4
|
+
|
|
5
|
+
### The ask names WHICH mandate it stands on — `AskRequest.mandate` + `PendingAction.tool_approval.mandate` + `CheckpointSummary.mandate` (#1092; additive; +3 exports `PERSISTED_RULE_MANDATES` / `PersistedRuleMandate` / `isPersistedRuleMandate`; +4 optional type keys (`AskRequest`, the `PermissionResult` ask arm, the park row, the summary); no checkpoint-version bump) (@cli @client-core @server)
|
|
6
|
+
|
|
7
|
+
- **Who needs it.** A surface that treats mandated cards differently by their reason: `mandated: true` alone could not tell a read outside the session's directories (`probe_mandate`) from an org demand, a hook's screen or a protected write.
|
|
8
|
+
- **What it is.** One word of the closed six-word table (`probe_mandate` = a read outside the session's directories, `probe_unanswered`, `operator_always`, `tool_marks`, `write_protection`, `write_protection_unresolved`), present ⇔ that word is the `mandated` bit's WHOLE reason. Any other door — governance (`requiresRealApproval`), a hook's ask, an ancestor's approver, a policy's own `mandated` claim — leaves it ABSENT beside `mandated: true`, also when a table word holds too: absence names no word. One derivation (`ruleClearanceOf`'s fourth reading), stamped by the gate beside the bit (a policy's self-declared word is dropped), spread by the four mint stations through `askMandateMember`; the park row carries the same word (a stored-shape re-judgment that adds its own mandate drops it), the summary echoes it screened to the closed set.
|
|
9
|
+
- **Who is hurt / compensation.** Nobody: `mandated` and `ruleOffersAbsence` read exactly as before (pinned card by card), the gate decides nothing on the word, and a consumer that ignores it renders what it rendered. A consumer that pins the card's exact key list sees one more key. **Read-deny note:** the built-in read-deny tiers default to NONE (`READ_DENY_DEFAULT_TIERS = []`), so on an unconfigured deployment a read of e.g. `~/.claude/settings.json` outside the roots is an ordinary out-of-root card carrying `mandate: "probe_mandate"`, not a refusal; the word does not distinguish a credential-looking path — the deny tiers do (`readDenyBuiltinTiers`).
|
|
10
|
+
- Pins: `test/backlog1092-mandate-word.test.ts`. Wire sheet: `docs/wire-consumption/unreleased-1092.md`.
|
|
11
|
+
|
|
12
|
+
### "Yes, and add this directory" takes effect in the SAME run — `AskOutcome.readRootGrant` (#1093; additive; +1 optional outcome member; +2 `ENGINE_NOTICE_CODES` members `approval.read_root_granted` / `approval.read_root_grant_rejected`; +1 export `ReadRootGrantScreen`; +1 optional `ResolvedAsk` member) (@cli @client-core @server)
|
|
13
|
+
|
|
14
|
+
- **Who needs it.** The person on an out-of-root read card: it already named the one directory that would clear it (`readRootCandidate`), but acting on that meant declaring it for a LATER run; the run that asked kept asking about every read of that directory.
|
|
15
|
+
- **What it does.** An approver answers `{ allow: true, readRootGrant: <the card's readRootCandidate.dir, byte for byte> }`: this run's READ directories include it from then on — the shell read boundary (the read-only shell face too) and Read / Grep / Glob / RepoMap read one live set (never a copy); `covers: "exact"` adds that one path. The write tools' roots are never touched. Announced once, `approval.read_root_granted` (`detail: { dir, covers?, root, toolName, toolCallId, sessionId, runId, taskId? }`, audience user).
|
|
16
|
+
- **Bad values are loud.** No candidate on the card, another string, a non-string, a grant riding a refusal, a directory the environment can no longer resolve (or whose lookup outlives the call — bounded like the gate's other awaits and cut with it), an offered path that no longer resolves to itself (swapped for a link while the card waited), or a leg with no read face: nothing changes, the verdict the grant rode stands, and `approval.read_root_grant_rejected` says why (`reason` ∈ `on_refusal` | `not_a_string` | `no_candidate` | `mismatch` | `unresolvable` | `resolves_elsewhere` | `admits_artifact_store` | `no_read_roots`, with `offered` / bounded `requested`). The grant is screened against the engine's own copy of the card's offer, so an approver rewriting the object it was shown moves nothing.
|
|
17
|
+
- **Roots are identities.** The Runner's shell read station no longer re-resolves its roots when it builds an establisher for a root set: a root swapped for a link after it was declared or granted brings in nothing (this closes the same hole for a DECLARED `additionalReadDirectories` / `additionalDirectories` root swapped before the run's first shell judgment). The Artifact tool's local-file reader reads the live set too (a granted directory's file publishes in the same run). A policy's `createApprovalPolicy` `approve` seat has no card and refuses the member (fail-closed deny naming it).
|
|
18
|
+
- **The grant never admits the artifact store.** A granted directory that contains the wired local artifact store (`capabilities.storageDir`), lies inside it or is it widens nothing: `approval.read_root_grant_rejected` with `reason: "admits_artifact_store"` — otherwise Read reached the stub's `ledger.json` and every stored file, including what earlier runs published into a reused store. It is the judgment the Artifact mount already made (`rootAdmittingArtifactStore`, one rule at both admission points), now two-way at both — behaviour tightening: a declared root INSIDE the store (e.g. `additionalReadDirectories: [<store>/sub]`) now refuses the Artifact mount (`config.artifact_host_invalid`), where before only a store inside a root did (KL-1093e closed).
|
|
19
|
+
- **Who is hurt / compensation.** Nobody without the member (pinned: two reads, two cards, zero notices, as before). The grant lasts for THIS run: the next run, a durable resume and children spawned afterwards start from the declared `additionalReadDirectories` (KL-1093a–c) — keeping the directory is the host's write of that seat. The gate's judgment of the asking call is unchanged.
|
|
20
|
+
- Pins: `test/backlog1093-read-root-grant.test.ts`, `test/backlog1093-grant-store-exclusion.test.ts`. Wire sheet: `docs/wire-consumption/unreleased-1093.md`.
|
|
21
|
+
|
|
22
|
+
### Fix — one retain predicate for background processes; a synchronous exit-boundary hard face (#1107; behavior: the session reap no longer settles a retain-declared row without killing it; +2 type exports; +1 optional env seat; @cli @server)
|
|
23
|
+
|
|
24
|
+
- **Before.** Three places answered "is this background process kept alive" three ways. The Bash tool's description and launch receipt read only the env capability, so a task-level `TaskSpec.retainBackgroundProcesses` process was told "background processes do NOT survive the session" and then outlived it. The run teardown read spec OR env. The session reap read the row's mark, then the env capability again; for a retain-declared env row that no teardown had marked yet, it settled the row `killed` but left the process running. The only exit face was `reapAllSessionBackground()`, whose kills go through the env's `killBackground`. When that is a ladder (TERM now, KILL after a timer), a host exiting in the same tick never runs the KILL, and a shell that ignores SIGTERM survives with ppid 1.
|
|
25
|
+
- **One predicate.** `retainsBackgroundProcesses(lanes)` (`env` / `task` / row `marked`; `envDying` overrides): any declared lane retains. A shell-lane row (`background_bash`, `monitor`) is stamped `retained` at registration from the env lane and the launching task's lane (new optional `retainBackgroundProcesses` on `RegisterBackgroundBashInput` / `RegisterMonitorInput`). Passing the lane at registration (true or false) states it, and the Runner's Bash and Monitor always do. A teardown's own spec lane re-judges only rows whose launcher did not state one, such as a host tool registering directly. Every face calls it: Bash description and receipts, the Monitor receipt, teardown settle and dispose, the park-only sweeps (now one helper that also spares rows already retained on the env), the session reap, the hard face, and `list()`. The Runner builds the hands band per run, so the spec lane is known when Bash is mounted: new `HandsToolkitOptions.retainBackgroundProcesses`, which the Runner fills from the spec. A retained process is never described as not surviving.
|
|
26
|
+
- **Session reap.** A retained row is left alone (row, watcher, process). Every other session shell row is killed and settled. The settle-without-kill outcome is gone.
|
|
27
|
+
- **Hard face.** `TaskRegistry.killAllBackgroundShellsNow({ includeRetained, reason? })`: synchronous, idempotent, no default for `includeRetained`.
|
|
28
|
+
- **Rows.** It visits every shell row except `completed` — running rows, rows a softer stop settled `killed`, and `failed` rows — session-resident or not. A retained row is `retained_skipped` under `includeRetained: false` unless an explicit stop already settled it `killed`.
|
|
29
|
+
- **Escaped descendants.** A descendant that left the shell's group (its own session) is outside the seat. The session reap's ordinary kill walks the tree, so a session-resident row keeps that coverage. A row only the hard face reaches does not: a non-persistent Monitor, or a session-less direct mount. This is a residual, recorded, of never signalling an unproven pid.
|
|
30
|
+
- **Kill.** Each goes through the new optional seat `BackgroundShellCapability.killBackgroundNow(shellId)`: a group SIGKILL with no TERM and no timer. The seat's outcomes are `killed` / `already_gone` / `no_pgid`.
|
|
31
|
+
- **Receipts.** One `BackgroundShellHardKillReceipt` per row: `killed` / `already_gone` / `retained_skipped` / `no_pgid`. Only a `killed` running row is settled `killed`.
|
|
32
|
+
- **`NodeExecutionEnv` seat.** Judged from the child handle, never the entry status: one `kill(-pid, SIGKILL)` while the leader is unreaped, with no descendant-pid snapshot. Its own landed kill, or an observed exit, answers `already_gone`. A refused signal (EPERM) answers `no_pgid` and a later call retries. win32 has no synchronous hard kill: the seat answers `no_pgid` and the hard face dispatches the ordinary kill, the ladder with its MSYS group discovery. A same-tick exit on win32 can leave a job running.
|
|
33
|
+
- **No seat.** An env without the seat reads `no_pgid`, and its `killBackground` is dispatched best-effort.
|
|
34
|
+
- **Session-level.** An optional `sessionId` + `scope` pair narrows the visit to the rows `reapSessionBackground(sid, scope)` keys on, for a session that ends while the engine stays up: a session kill on a shared engine, or a failed worker. The host calls `reapSessionBackground(sid, scope)` first, then `killAllBackgroundShellsNow({ sessionId: sid, scope, includeRetained: false })`. A run named with its own task id is not reached. Omitted means the whole registry, as before.
|
|
35
|
+
- **Listing.** `list()` rows gain `retained?: true` on retained shell rows. This is the "keeps running" half of an exit listing.
|
|
36
|
+
- **Run teardown dispose (F10).** A run's teardown disposes only shells it can attribute to itself. The env-wide dispose used to spare three kinds: this session's resident rows, in-window Monitors, and retained rows. It now spares every registered shell row still running on the env (`TaskRegistry.runningShellIds`), any owner. The settle sweep has already killed this run's rows that end with it. Unregistered env processes are still disposed. So a synchronous delegated child that shares its parent's env no longer disposes the parent session's background shells.
|
|
37
|
+
- **Removed (type surface).** `TaskRegistry.sessionResidentShellIds(sessionId, env)` and `TaskRegistry.timeoutResidentShellIds(env)` are gone. They were the teardown's except-list lanes, and after F10 nothing in `src` calls them. No caller was found in the server / cli / client-core trees. Read `runningShellIds(env)` (every running shell row on the env) or `list(access)` instead.
|
|
38
|
+
- **Contract.** `docs/background-process-lifetime.md` covers the three lifetimes, which exit face to call when, the retain contract (rows are in-memory and gone with the engine; task-level retain on a piped env may die of a broken pipe after the host exits), and the seat contract.
|
|
39
|
+
- **Who is hurt.**
|
|
40
|
+
- **Unmarked retain-env row.** One reaped before any teardown marked it now stays running with its watch. It used to be settled `killed`, falsely, since its process stayed alive.
|
|
41
|
+
- **Mixed-lane session.** A row launched by the Runner's Bash or Monitor under a non-retaining run is no longer marked retained by a later retaining run's teardown, whether or not it is session-resident. It is reaped as its receipt said.
|
|
42
|
+
- **Task-retain wording.** A task-level retain run's Bash card and receipts drop the "do NOT survive" sentence. A task-retained daemon on an isolated env reads "Background processes are retained here" instead of "This environment declares retention".
|
|
43
|
+
- **Stop attribution.** `markStopSourceForOwner` skips a task-retained row from registration on. If `envDying` then kills it, the stop source is the teardown's own ("user" / "parent"), not a host's earlier mark.
|
|
44
|
+
- **Controlled exit mid-run.** A controlled exit in the middle of a retaining run now leaves that run's in-flight rows running. This covers rows whose launcher stated the task lane, which the Runner's Bash and Monitor always do.
|
|
45
|
+
- **Park sweeps.** A park-only sweep no longer reaps an earlier run's retained row on the same env.
|
|
46
|
+
- **Helper shells on a shared env (F10).** A synchronous helper's `run_in_background` shell is registered under the helper's own session. On 7.31.1 the parent run's teardown took it down as a side effect of the env-wide dispose. Now it outlives the parent run. The root session's end (`reapSessionBackground(root)` plus the session-level hard face) does not reach it; only the whole-registry hard face at host exit does. This applies to the SDK's static-env teardown lane and to server's session-level lane (KL-1107a; #1109 ties helper shells to the root session).
|
|
47
|
+
- **Pickup.** @server: implement `killBackgroundNow` on the host background driver (unimplemented ⇒ `no_pgid` for every row). Call `killAllBackgroundShellsNow` after `reapAllSessionBackground()` on the exits with no later tick. @cli: `list()`'s `retained` splits "will stop" from "keeps running".
|
|
48
|
+
- Pins: `test/backlog1107-retain-predicate.test.ts`, `test/backlog1107-exit-same-tick.test.ts`, `test/backlog1107-adoption-and-seat.test.ts`, `test/backlog1107-child-teardown.test.ts`; #1109 baseline (current behavior) `test/backlog1109-helper-shell-lifetime.test.ts`.
|
|
49
|
+
|
|
50
|
+
### Fix — SendMessage / ListAgents promise continuation by the subagent transcript tier (#1108; model-facing text; +0 exports; @server)
|
|
51
|
+
|
|
52
|
+
- **Before.** With the durable revival lane wired, SendMessage said a finished agent was continuable "even across restarts", and ListAgents labelled recorded agents "agent (durable record)". With a process-local transcript store (tier `rows`), the record survived a restart but the conversation did not, and the revival answered "no transcript found".
|
|
53
|
+
- **Now.** Both faces read one helper, `sendMessageContinuationTier`, which is `resolveSubagentTranscriptTier` over the runner's session store, gated on all three revival seats: row store, mailbox, reviver. `full` keeps the old wording. `rows` says the record outlives a restart and the conversation does not. `none` means session retention only. `createListAgentsTool` / `mountListAgents` take `continuationTier`, which the Runner fills from the same seats as its SendMessage. A direct mount without it gets the `none` wording. `createSendMessageTool` now reads the session store's subagent placement declaration at construction, and a junk declaration throws `config.store_durability_invalid`.
|
|
54
|
+
- Pins: `test/backlog1108-continuation-tier.test.ts`, `test/backlog1108-listagents-tier-mount.test.ts`.
|
|
55
|
+
|
|
56
|
+
### Fix — an Agent child inherits the leader's scenario-layer tools: the task-list family on the leader's one list, WebFetch, WebSearch, TodoWrite (#1110; behavior: widening for children, narrowed to what they inherit — a teammate's allowlist is widened for the inherited task-list family only, never for a host-composed pool; `SubagentToolOptions` +1 optional `inheritedTools`, `ToolExecuteContext` +1 optional Runner-filled `mountsCallerTool`; +0 exports; @server @cli @test)
|
|
57
|
+
|
|
58
|
+
- **Before.** `assembleCodeTools` put the task-list family (and WebFetch / WebSearch / TodoWrite) in the leader's list only, and handed its Agent tool nothing (`childPool = opts.tools ?? []`). A host that does not pass `subagent.tools` — the server's default and code scenarios — gave every child an empty pool: a teammate's `TaskList` / `TaskCreate` answered "Tool TaskList not found", so a team could not share its task list, and no child could fetch a URL.
|
|
59
|
+
- **One default, two spawn-time laws.** With no explicit `subagent.tools`, the Agent tool's pool is the bundle's surface as the SAME instances the leader mounts (`SubagentToolOptions.inheritedTools`, filled by `assembleCodeTools`): the task-list family on the bundle's one `TaskListStore`, so a teammate's task shows in the leader's `TaskList` and the other way round. (1) ⊆ parent: an entry reaches a child only when the spawning run mounts THAT instance — `ToolExecuteContext.mountsCallerTool`, identity membership in the run's `spec.tools` as the caller handed them, before the model gate. A host that removes a tool from the leader's `spec.tools` (the server drops WebFetch on a multi-tenant deployment) removes it from every child; a same-named replacement (a host's own tool, a restricted tool injected per spawn through `extraTools`) never re-admits the bundle's original, at any depth. A delegation tool driven outside a Runner (no seat) inherits nothing. (2) Spawn tier (upstream 2.1.280): a synchronous child and a named teammate get the whole surface; an anonymous background child gets WebFetch / WebSearch / TodoWrite but not the task-list family. Model-gate stamps travel with the instances, so each child's own prepare trims the family for a gate-table child model and keeps it for any other, whatever the leader's model.
|
|
60
|
+
- **Teammate definitions.** A named child whose agent definition (or the tool-level) `allowTools` is a real allowlist also keeps the task-list family it INHERITED (upstream teammate shape: the root's own first-party tools). A pool the host composed — an explicit `subagent.tools`, an `extraTools` product — is judged by the allowlist as written, named child or not: a model-chosen name never lifts a host's restriction (upstream widens only for an in-process teammate under the agent-teams switch, over the root's own tools; sema has no such switch and reads any spawn `name` as a teammate — an accepted divergence (design/969) — so the widening is confined to the inherited surface, where it keeps the upstream shape). A `denyTools` entry still removes it.
|
|
61
|
+
- **TodoWrite** (the `taskList: false` arm) keeps one list per calling session instead of one per instance, so a child's write never replaces the leader's plan. A call outside a Runner keeps one list, as before.
|
|
62
|
+
- **Precedence and what stays as it was.** An explicit `subagent.tools` is used verbatim, with no intersection and no tier trim: the one channel through which a host hands a child a tool its parent does not mount (`[]` = children inherit nothing). A per-spawn `extraTools` product still loses to an explicit `tools` entry of the same name, and now REPLACES a same-named inherited entry (explicit > injected > inherited); when the factory does not evaluate (it throws, or the spawn tree is over its call budget) the spawn inherits nothing, since the missing product may have been an override, and the existing note says so. A fork gets the inherited surface untrimmed (upstream runs a fork on the parent's exact tools); it had an empty pool before. The allow/deny subset law and the nested delegation tool (depth) are unchanged. A host's copy of a bundle tool (`{ ...tool, … }`) is another instance: the child does not inherit it. Host tools composed beside the bundle are not inherited: inheriting a host's whole `spec.tools` would carry host tools past the host's own per-spawn gates (a file-send tool gated per spawn, an environment selector).
|
|
63
|
+
- **Who is affected.** Children of `assembleCodeTools` hosts get more tools: they can call WebFetch / WebSearch, and a teammate or synchronous child can read and write the leader's task list. A child's delegation result can now carry fetched web content into the session: the memory content-origin face reads the whole inherited surface (a superset of what a given spawn is handed, so it can mark a delegation external when the leader does not mount WebFetch). A child that parks on an inherited tool's durable approval cannot be redeemed with `allow` through a host leg that drives the Agent tool without the seat: the drive refuses before it consumes anything (the approval stays pending, the refusal names the missing seat, and `deny` still resolves it); an unreadable checkpoint at that check refuses the same way. See the pickup note. `tool_end` shapes and card vocabulary are unchanged.
|
|
64
|
+
- **Pickup.** @server: nothing to change for the inherited family, WebFetch and WebSearch on the run lanes. The host's `Now` tool is not in the bundle: add `nowTool()` to the `subagentExtraTools` factory to hand it to children. The parked-revive leg (`/decide` → the Agent tool's execute) drives the tool directly with no Runner seat, so a teammate revived there inherits nothing, and an `allow` on an inherited tool is refused as above. Do NOT fill the seat from the boot-time bundle the revive tool comes from: that bundle is built with no session, so its task-list store is one private list, and filling the seat from it would make every revived teammate, across sessions and tenants, share that one list. The per-row fix (resolve the scenario for the row's root session, then pass `mountsCallerTool` over that list) is the ticket filed with this change. @cli: nothing to change. @test: A-arm shape (server-shaped assembly) for a teammate's `TaskList` vs the leader's, per tier.
|
|
65
|
+
- Pins: `test/backlog1110-agent-child-inherits-scenario-surface.test.ts`.
|
|
66
|
+
|
|
67
|
+
## 7.31.1 — 2026-09-25
|
|
68
|
+
|
|
69
|
+
### Fix — a credential slot in the host shell's `SEMA_` namespace is withheld from spawned children (#1099; security; `SecretEnvFindingKind` +1 member `namespace-rule`; +0 exports; @cli @server)
|
|
70
|
+
|
|
71
|
+
- **Before.** The D1 scrub read only a name's LAST word (`…_KEY`, `…_TOKEN`, an optional `_<n>` / `_ID` after it). The shell stores a model entry's key under `SEMA_MODEL_KEY_<ENTRY>` (a websearch provider's under `SEMA_WEBSEARCH_KEY_<PROVIDER>`): the credential word in the middle, a caller-chosen entry after it. Every such slot reached tool shells and language servers, and a model-chosen `env | grep` printed the key into the transcript.
|
|
72
|
+
- **One rule.** Inside the `SEMA_` namespace, and only there, a credential word as any `_`-separated segment makes the name a credential (`SEMA_NAMESPACE_SECRET_RE`). It reads the one credential-word alternation the suffix rule reads (`CREDENTIAL_WORD`, one definition), the count-word reading of `TOKENS` included (`SEMA_MAX_TOKENS` is kept). It is consulted after the two exact-name lists and the suffix rule, so no existing finding changes kind. The alternation no longer lists `ACCESS_KEY` / `SESSION_TOKEN`: every name they matched ends in `_KEY` / `_TOKEN`, which `KEY` / `TOKEN` match.
|
|
73
|
+
- **Unchanged.** Every name outside `SEMA_` (verdict and kind pinned against the v7.31.0 regex over a generated set of about 80,000 names); both exact-name lists; the count-word ledger. The withheld-keys notice (`config.secret_env_scrubbed`, `detail.names`) lists the slot like any other withheld name; values are never carried.
|
|
74
|
+
- **Who is hurt.** A `SEMA_` name with a credential-word segment that a child needed. The ones found in the neighbouring trees are paths to a credential file, not values: `SEMA_REGISTRY_TOKEN_FILE` (server config catalog) and `SEMA_LLM_KEY_DB` (the shell's live-test scripts) no longer reach a spawned tool shell or language server. `SEMA_CODE_*` — the shell's brand-renamed twin of CC's `CLAUDE_CODE_*` namespace — matches the same way: six non-credential knobs the shell's own code reads (`SEMA_CODE_IDLE_TOKEN_THRESHOLD`, `SEMA_CODE_API_KEY_HELPER_TTL_MS`, `SEMA_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT`, `SEMA_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR`, `SEMA_CODE_API_KEY_FILE_DESCRIPTOR`, `SEMA_SESSION_INGRESS_TOKEN_FILE`) are withheld from a nested `sema` started inside the tool shell (a `CLAUDE_CODE_` twin, when present, reseeds it), and one real credential, `SEMA_CODE_CLIENT_KEY_PASSPHRASE`, is newly withheld (@cli: check the six). An `inheritEnv` allowlist, `shellEnv` / a per-exec env, and a host-supplied LSP `env` still pass a name verbatim.
|
|
75
|
+
- **Finding kind.** `SecretEnvFinding.kind` gains `namespace-rule` (confidence `medium`). A consumer that totals over the kind (a metrics label, an exhaustive switch) sees a third value.
|
|
76
|
+
- **Pickup.** @cli: nothing to change; the slots stop reaching children once this core is in the bundle. @server: `secret_env_scrubbed_total{kind}` can carry `namespace-rule`; `SEMA_REGISTRY_TOKEN_FILE` is withheld from the model-driven host shell and from the LSP spawn env (`LSP_ENV_ALLOW` names it back if a language server needs it).
|
|
77
|
+
- Pins: `test/backlog1099-sema-namespace-secret-env.test.ts`.
|
|
78
|
+
|
|
79
|
+
### Fix — a tilde in an assignment value, a literal tilde and every member of a `:` list meet the deny rows at the declared home (#1100; security; behavior: tightening — no verdict loosens against 7.31.0, flip list below; +0 exports; @cli @server)
|
|
80
|
+
|
|
81
|
+
- **Before.** In `F=~/.ssh/id_rsa; cat "$F"` the station cannot resolve `"$F"`, so the assignment is the only place the path is in the text, and a value that expands a leading tilde was placed only when a program in the SAME segment could open it (7.26.2). The command landed on an approvable card (mandated) while `F="$HOME/.ssh/id_rsa"` and `F=/home/u/.ssh/id_rsa` were refused on the deny row. Two more gaps of the same kind: a LITERAL tilde (quoted, escaped, after an option's `=`) is a relative path to bash, but a program that expands `~` itself (npm reads `npm_config_userconfig="~/x"`, `--userconfig "~/x"`, `--userconfig="~/x"` as `$HOME/x`) opened the home file with no card at all (measured headless on 7.31.0: the sentinel leaked); and a `:` list named no candidate once a member expanded a tilde, so `F=<deny path>:~; cat "${F%%:*}"` was a card where `F=<deny path>:/x` was refused under a directory-prefix deny row (under an exact-file row the literal `:` form was a card too, and moves M → R here).
|
|
82
|
+
- **Three rules, one mechanism (the deny floor of #1064 ⑬).** (1) The opener condition is gone from the value reading (`pushAssignment` loses `opensValue`; `segmentFactsRead` / `unlistedWords` stop computing it): an environment value with a leading tilde is placed against the declared home whether or not a program opens it, and the refusal names the real file. (2) Every candidate spelled `./~…` — the one literal-tilde spelling, from an assignment value, an operand, an option value or payload, a redirection — also carries its declared-home spelling as a deny-only view (`floorSpelled`; `spelledWithHome` reads `./~` as it reads `~`): refused on a deny row, never a containment question, nothing added where no deny row matches. (3) A value holding `:` hands each member as a deny-only candidate (a tilde member placed against the home); the whole value is still no containment candidate. The plan's candidate dedupe keys on the word as written (`raw`) as well as its spelling, so two words spelling one path keep their own deny floors. With no declared home nothing is spelled: a later read stays unestablishable (mandated), as before — unless a deny row's own pattern matches the unexpanded spelling (an any-prefix row such as `**/<dir>/**` matches the relative `~/<dir>/…` itself), which refuses as any matching spelling does (criteria car, measured).
|
|
83
|
+
- **Flip list (station, open / roots × bash_off / bash_classify; deny row anchored at the home).** V → R: `npm_config_userconfig="~/<deny>" npm config list`, `export npm_config_userconfig="~/<deny>"; npm config list`, `env npm_config_userconfig="~/<deny>" npm …`, `npm --userconfig "~/<deny>" …`, `npm … --userconfig="~/<deny>"` (real lane: LEAK → refused), `npm_config_userconfig=\~/<deny> npm …`, `F+="~/<deny>"; true`, `cat "~/<deny>"`, `cat < "~/<deny>"`, `HOME=/x; cat '~/<deny>'`, `A=~/.env; true`, `A=~/'.env' true`, `A=~/.env; cat safe.txt`, `GIT_CONFIG_GLOBAL=~/.env echo hi`, `A=a:~/.env cat safe.txt`. M → R: `F=~/<deny>; cat "$F"`, `F+=~/<deny>; cat "$F"`, `F="~/<deny>"; cat "$F"`, `F+="~/<deny>"; cat "$F"`, `A[0]+="~/<deny>"; cat "${A[0]}"`, `F=<deny>:~; cat "${F%%:*}"`, `F=<deny>:~/x; …`, `F=~/<deny>:; cat "${F%:}"`, `F=/x:~/<deny>; IFS=:; cat $F`. R → R: `npm_config_userconfig+="~/<deny>" npm …` and `env A+="~/<deny>" npm …` (refused on 7.31.0; an intermediate build of this release read them V, restored here), every literal `:` form. No R → M / V and no M → V: the dual-run corpus (4,421 tree-fixture inputs × 4 cells, under its own deny configuration) moves 3 inputs, all V → R, and 19 under four deny configurations, all V → R or M → R (the car's 4,447-input run adds 26 inputs of its own; same 3); the F5 shell-read corpus reads the same on all 276 rows, LEAK 0. Controls unchanged: a benign literal tilde (`F+="~/ok"; true`, `npm_config_userconfig="~/ok" npm …`, `cat "~/ok"`) still vouches; `F+="~/ok"; cat "$F"` still asks.
|
|
84
|
+
- **Unchanged.** An environment value is still no containment candidate (KL-945a). An operand-slot value (`dd if=~/x`) still asks both ways (KL-993b ②). `$(…)` and loop variables are not touched (#1102).
|
|
85
|
+
- **Supersedes part of #1004 (7.26.2).** #1004 ruled that an unsettled environment value in front of no opener names nothing (`A=~/.env; true`, `A=~/'.env' true`, `GIT_CONFIG_GLOBAL=~/.env echo hi` vouched, `A=a:~/.env cat x` vouched). They now refuse, naming the real file. #1004's other half (a refusal names a path, never the assignment word) stands; its pins are updated in `test/backlog1004-assignment-word-not-a-path.test.ts`.
|
|
86
|
+
- **Who is hurt.** A text whose value or literal-tilde operand names a deny row under the declared home and is never read that way is now refused, as its `$HOME` and literal spellings already were. Under the built-in table the new refusals are `:` lists and literal tildes that name a deny row without reading it: `KUBECONFIG=~/.kube/config:~/.kube/dev kubectl get pods`, `HISTFILE=/tmp/h:~/.zsh_history true`, `F=~/.netrc; echo hi` (V → R); `cat "~/.ssh/id_rsa"` was already refused on 7.31.0 under the built-in `.ssh` row and stays so (cat opens the relative `./~/…`; KL-1100a); a deployment's own deny row makes `LABEL=a:<row> true` a refusal (KL-1100b). Not reached (a card, as before): a tilde set through `${X:-~/x}`, `printf -v`, `read <<<`, a brace or a pattern substitution — #1102's family.
|
|
87
|
+
- **Pickup.** @cli @server: nothing to change; the refusal is the existing `read_path_denied` shape.
|
|
88
|
+
- Pins: `test/backlog1100-tilde-assignment-deny.test.ts` (station + real lane, npm included); `test/backlog1004-assignment-word-not-a-path.test.ts` ①③④⑦, `test/backlog714-home-expansion-precise.test.ts` (one more checked path), `test/backlog7300-shell-reentry.test.ts` (`HOME=/x; cat '~/key.txt'` refuses) re-pinned.
|
|
89
|
+
|
|
90
|
+
### Fix — a memory store act is judged by where it lands: erase, delete, write and journal replay no longer follow a directory replaced by a link out of the store (#1094; security; +1 error code `memory.target_outside_store`; +0 exports; @server)
|
|
91
|
+
|
|
92
|
+
- **Before.** `FileMemoryEngineBackend` joined `<root>/<scope>/<slug>.md` and acted on it: `rmSync` (erasure, `applyPatches` delete, including the bound-path arm when the entry's file had moved) and `renameSync` of a staged write (add, update, slug rename). Nothing re-read where that spelling resolved. A directory under the store replaced by a link to a directory elsewhere kept the spelling while the act followed the link: `eraseMemoryEntries` deleted a same-named file OUTSIDE the store and answered `complete`, a delete did the same, and a write landed the entry outside (staged file and created directories included). Measured on 7.31.0 with real links: erase, bound-path delete, add under the link and slug-rename update all escaped. Journal replay was already refused at construction, by the journal validator's containment check, which is physical.
|
|
93
|
+
- **One verdict, every act.** `storeTargetEscape(physicalRoot, target)` (memory layout, internal): the target's directory, resolved through every link on its existing prefix, must be inside the physically resolved store root. The final component is not judged: `rm` removes a link standing there, `rename` replaces it, and the engine's in-place writes refuse it (`ELOOP`), so what the link points at is never touched (rm and rename complete; the write is refused). The root's physical form is taken once, when the backend opens the store: a store mounted through a link is not refused. Every face (both recovery legs, the lock-free faces' layout seat and the adoption face) first checks that the root still resolves there (the refusal says to open a new backend on the directory you mean) (both sides resolved from the normalized spelling every child path carries); a root re-pointed since refuses the face before it reads, adopts, sweeps or restores anything, and an act judges against the root as opened even when the root is re-pointed mid-call. A directory that cannot be resolved (`EACCES`, `ELOOP`, `ENOTDIR`) stops the act with that fault, thrown as itself: never reported as the containment code, and never taken as leave to act (on macOS a link whose own mode refuses resolution is still followed by `unlink` and `rename`). The verdict covers the target and, for a write, its staged file. It runs over the whole batch at the commit point, before the journal is written (`applyPatches` removes the staged files it created, matched by identity, never a same-named file reached through a directory swapped since; the refusal is clean: no journal, the account unchanged), and per op at each act: `applyPatches` staging (which creates the directory and a file at the target) and execute, erasure execute, and journal replay, where the whole journal is judged before its first act too. After the marker a refusal keeps the journal, and the journal replays once the path resolves inside the store again.
|
|
94
|
+
- **The same verdict at four of the acts the engine performs itself** (the remaining engine write sites — the scan-derived containment writes judged once at scan time by a canonical-spelling comparison, and the old `repo_file` projection's `rmSync` — are #1104; their window is in KL-1094-a). Each of these also landed through a directory replaced by a link; all four were measured on 7.31.0.
|
|
95
|
+
- `MemoryEngine`'s copy-out projection write (a non-file backend projected into the memory dir) projected entries outside the tree.
|
|
96
|
+
- The harvest's missing-file restore rebuilt a file the scan could not see (the scan never follows links, so the file read as "missing") from its committed copy, wherever the link pointed.
|
|
97
|
+
- The read-only layer restore overwrote a same-named file outside with committed content.
|
|
98
|
+
- Four `MEMORY.md` writes (index rebuild, the derived-index gate's clear, the capture opt-out residue sweep, the erasure's index rewrite) wrote through a write home replaced by a link. The fifth, the polluted-index restore, keeps its own containment check (predates #1094, unchanged): it refuses an ordinary directory link, but it compares canonical spellings from a total resolver that falls back to the lexical spelling when a component cannot be resolved (`EACCES`, `ELOOP`), where the verdict above throws the fault as itself — on macOS a directory link with mode 000 (`lchmod`) fails `realpath` yet is followed by `open`, so that site (and the engine's other canonical-spelling gates) still writes through such a link (KL-1094-f; the class fix is #1104).
|
|
99
|
+
The restores reached the file backend's zero-copy mode as well. All now pass the verdict first, judged against the mount's root: the engine takes its memory dir's physical form at its first mount, and a later mount of the same engine whose memory dir now resolves elsewhere refuses with the code ("open a new engine on the directory you mean"). The erasure's index rewrite can run on an engine that never mounted, so it is judged against the memory dir as it resolves when the rewrite runs — also on an engine that HAS mounted, where a root re-pointed since is refused by the next mount but still rewritten by an erasure (KL-1094-i; #1104 takes the pinned root when there is one). The mount's own handle re-resolves its roots after the backend's first call, so a root re-pointed inside that first mount is followed by the handle's writes (same KL). Each refusal is not performed and goes to the site's existing failure lane, carrying the code:
|
|
100
|
+
- a refused projection throws, so the mount fails loud (the runner's memory-less arm reports it through `onError`);
|
|
101
|
+
- a refused restore goes to `could NOT be restored` warnings;
|
|
102
|
+
- a refused index write goes to warnings or the sweep's failure list, except the erasure's index rewrite, whose lane (`residuals.indexUncleared`) lists paths only.
|
|
103
|
+
- **Hard links.** `writeFileNoFollow` is the engine's in-place write for restores, projections and the index. It already refused a link at the final component (ELOOP); it now also refuses a file with more than one name, because an in-place write would change those bytes under the other name too, possibly outside the store. The refusal takes the same lanes and names the file (`<path> has N names (a hard link) — …; not written`; no code). The check reads the opened descriptor before anything is truncated, so there is no window. An ordinary file is rewritten in place exactly as before: same inode, same mode, the new bytes. A file that ALREADY has a second name is refused at every in-place write until the extra name is removed: a `cp -al` snapshot of the store, or an instruction-file restore interrupted between its link and its unlink (`noReplaceRestore` leaves the `.staging` name in custody). For a copy-out projection that refusal fails the mount; the message names the file but not the remedy (KL-1094-g; #1105).
|
|
104
|
+
- **Refusal shape.** A plain `Error` with `code: "memory.target_outside_store"`; the message starts with the code and names the op, the path and where it resolves. `applyPatches` refuses the WHOLE batch (it throws; no per-patch conflict row), so a harvest reports `incident.kind: "sidecar_corrupt"` with that text. `eraseMemoryEntries` throws it with nothing deleted and nothing recorded. The code is registered in `NON_GOVERNANCE_MEMORY_CODES` (CONTROL-PLANE IO: operator attention on the store's tree, never a verdict on the caller).
|
|
105
|
+
- **Unchanged.** A tree with no links behaves byte-identically: the outcome fingerprint of writes, deletes and an erasure over the store and the control plane matches 7.31.0, as does a copy-out projection's; a file deleted inside the tree is still restored byte-for-byte. No API, journal format or ledger change. The journal validator (`journalOpInvalid`) keeps its own containment check and runs first.
|
|
106
|
+
- **Who is affected.** A deployment whose memory store holds a link under its root (placed by a session with shell access to the store, or by hand). An act that used to follow a directory link out of the store is now refused until the link is removed; a link at the final component is removed or replaced as the act requires (it used to be too). A harvest over a file-backend store whose whole directory was replaced by a link during the session used to report one `outside_root` rejection per file. It now reports `ok: false`, `incident.kind: "sidecar_corrupt"`, detail carrying the code; nothing is committed either way. A copy-out (non-file) backend still gets the per-file `outside_root` rejections. Known limits: `KL-1094-a` (check→act window), `KL-1094-b` (a replayed journal whose directory became a link surfaces as the validator's corruption error, not this code), `KL-1094-c` (a directory link that stays inside the store is not refused), `KL-1094-d` (a root re-pointed before a backend opens it is taken as the store; measured on 7.31.0 and here: an add then moves that directory's top-level files into `<dir>/<scope>/`), `KL-1094-e` (a root opened under another case spelling on a case-insensitive volume and then deleted refuses until reopened). `KL-1094-f` (a canonical-spelling gate falls back to the lexical spelling under a resolution fault), `KL-1094-g` (a file with a second name is refused in place until that name is removed), `KL-1094-h` (the mount's root check runs after the memory dir is created, so a re-pointed link gets an empty directory before the refusal).
|
|
107
|
+
- **Pickup.** @server: map `memory.target_outside_store` on every face it reaches (the wire sheet lists each and how it arrives). The NP-4 pre-check stopgap is no longer needed for this form once this core is in the bundle.
|
|
108
|
+
|
|
3
109
|
## 7.31.0 — 2026-09-25
|
|
4
110
|
|
|
5
111
|
### The durable park row carries the mandate twin — `PendingAction.tool_approval.mandated` + `CheckpointSummary.mandated` (#1079; additive; +0 exports; +2 optional type keys; no checkpoint-version bump) — and the facts of the judgment that adjudicated the stored shape (#1088; behavior: tightening only) (@cli @client-core @server)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ToolSpec } from "../core/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* What a delegated child inherits when its delegation tool has no explicit pool (`SubagentToolOptions.inheritedTools`):
|
|
4
|
+
* the parent surface, narrowed at spawn by two laws. The mint of the parent bound ({@link callerToolMembership}) and both
|
|
5
|
+
* reads live here, so the law has one home.
|
|
6
|
+
*/
|
|
7
|
+
/** The team task-list family: the pool-borne members of the upstream teammate exception set (the rest of that set,
|
|
8
|
+
* SendMessage and the Cron trio, is mounted by a child's own prepare and never rides a delegation pool). */
|
|
9
|
+
export declare const TEAM_TASK_LIST_TOOL_NAMES: readonly string[];
|
|
10
|
+
/** The run's `mountsCallerTool` seat: membership BY IDENTITY in `spec.tools` as the caller handed it (a copy of the
|
|
11
|
+
* array taken now; nothing is read off the entries, so no entry can make the mint throw). */
|
|
12
|
+
export declare function callerToolMembership(tools: readonly ToolSpec[] | undefined): (tool: ToolSpec) => boolean;
|
|
13
|
+
/**
|
|
14
|
+
* The inherited pool for one spawn.
|
|
15
|
+
* · ⊆ parent: an entry survives only when the spawning run mounts THAT instance — a same-named replacement (a host's
|
|
16
|
+
* own tool, a per-spawn injection) never re-admits the surface's original. No seat (the tool is driven outside a
|
|
17
|
+
* Runner) = no parent bound is known = nothing is inherited; a host driving the tool directly passes the seat itself.
|
|
18
|
+
* · tier (upstream `filterToolsForAgent`): an anonymous background child gets the async allowlist there — WebFetch /
|
|
19
|
+
* WebSearch / TodoWrite, not the task-list family. A synchronous child, a named teammate and a fork keep it.
|
|
20
|
+
*/
|
|
21
|
+
export declare function inheritedChildPool(surface: readonly ToolSpec[], mountsCallerTool: ((tool: ToolSpec) => boolean) | undefined, anonymousBackground: boolean): ToolSpec[];
|
|
22
|
+
/**
|
|
23
|
+
* The task-list members of a named child's INHERITED pool that stand past its allowlist (upstream: a teammate built from a
|
|
24
|
+
* custom agent type gets `[...type.tools, SendMessage, ...the four when the root has them]` — the root's own first-party
|
|
25
|
+
* tools). Only what the child inherited is widened: a pool the host composed (`tools`, `extraTools`) is judged by the
|
|
26
|
+
* allowlist as written, since a model-chosen name must not lift a host's restriction. A deny still wins.
|
|
27
|
+
*/
|
|
28
|
+
export declare function teammateInheritedFamily(inherited: readonly ToolSpec[], denyTools: ReadonlyArray<string> | undefined): ToolSpec[];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { resolveToolSubset } from "./tool-filter.js";
|
|
2
|
+
import { TASK_CREATE_TOOL_NAME, TASK_GET_TOOL_NAME, TASK_LIST_TOOL_NAME, TASK_UPDATE_TOOL_NAME } from "../core/tool-catalog-entries.js";
|
|
3
|
+
export const TEAM_TASK_LIST_TOOL_NAMES = Object.freeze([TASK_CREATE_TOOL_NAME, TASK_GET_TOOL_NAME, TASK_LIST_TOOL_NAME, TASK_UPDATE_TOOL_NAME]);
|
|
4
|
+
export function callerToolMembership(tools) {
|
|
5
|
+
const mounted = new Set(Array.isArray(tools) ? tools : []);
|
|
6
|
+
return (tool) => mounted.has(tool);
|
|
7
|
+
}
|
|
8
|
+
export function inheritedChildPool(surface, mountsCallerTool, anonymousBackground) {
|
|
9
|
+
if (mountsCallerTool === undefined)
|
|
10
|
+
return [];
|
|
11
|
+
return surface.filter((t) => mountsCallerTool(t) && !(anonymousBackground && TEAM_TASK_LIST_TOOL_NAMES.includes(t.name)));
|
|
12
|
+
}
|
|
13
|
+
export function teammateInheritedFamily(inherited, denyTools) {
|
|
14
|
+
return resolveToolSubset(inherited.filter((t) => TEAM_TASK_LIST_TOOL_NAMES.includes(t.name)), undefined, denyTools);
|
|
15
|
+
}
|
|
@@ -2,6 +2,7 @@ import { Type } from "typebox";
|
|
|
2
2
|
import type { TaskAccess } from "../core/task-registry.js";
|
|
3
3
|
import { type RosterEntry } from "./roster-store.js";
|
|
4
4
|
import { type PeerDirectory, type PeerPresence, type PeerSessionRecord } from "./peer-directory.js";
|
|
5
|
+
import type { SubagentTranscriptTier } from "../core/wiring-manifest.js";
|
|
5
6
|
export declare const LIST_AGENTS_TOOL_NAME = "ListAgents";
|
|
6
7
|
export declare const LIST_AGENTS_TOOL_ALIAS = "ListPeers";
|
|
7
8
|
/** CC `maxResultSizeChars` for this tool. */
|
|
@@ -55,6 +56,12 @@ export interface ListAgentsToolOptions {
|
|
|
55
56
|
};
|
|
56
57
|
/** The resolved freshness window ({@link isFreshPeerRow}); absent ⇒ {@link PEER_LISTING_FRESH_WINDOW_MS}. */
|
|
57
58
|
freshWindowMs?: number;
|
|
59
|
+
/** The continuation reach of the SendMessage mounted beside this tool (`sendMessageContinuationTier` —
|
|
60
|
+
* the same answer from the same seats, so the two faces cannot disagree): what a recorded agent's label
|
|
61
|
+
* may promise. `full` = the record and the conversation survive a restart; `rows` = the record survives,
|
|
62
|
+
* the conversation does not; `none` = only the recorded name. Absent (a direct mount that did not say) ⇒
|
|
63
|
+
* the `none` wording — a label never promises more than the mount can show. */
|
|
64
|
+
continuationTier?: SubagentTranscriptTier;
|
|
58
65
|
/** The clock (tests). */
|
|
59
66
|
now?: () => number;
|
|
60
67
|
}
|
|
@@ -149,15 +149,22 @@ export function createListAgentsTool(opts) {
|
|
|
149
149
|
catch {
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
|
+
const tier = opts.continuationTier ?? "none";
|
|
153
|
+
const rosterLabel = tier === "full" ? "agent (durable record)" : tier === "rows" ? "agent (record kept; conversation not kept across restarts)" : "agent (recorded name)";
|
|
154
|
+
const finishedReach = tier === "full"
|
|
155
|
+
? "while its session is retained or a durable record exists"
|
|
156
|
+
: tier === "rows"
|
|
157
|
+
? "while its session is retained or this engine process still holds its conversation"
|
|
158
|
+
: "while its session is retained";
|
|
152
159
|
const lines = rows.map((r) => {
|
|
153
160
|
const label = r.kind === "peer_session" ? formatPeerNameRef(inlineUntrusted(r.name, 64), r.ref) : inlineUntrusted(r.name, 64);
|
|
154
161
|
const age = r.kind !== "peer_session" ? undefined : r.lastActiveAt !== undefined ? `active ${formatAgo(now - r.lastActiveAt)} ago` : r.offlineAt !== undefined ? `offline ${formatAgo(now - r.offlineAt)} ago` : "offline";
|
|
155
|
-
const cols = [label, r.state, age, r.cwd !== undefined ? inlineUntrusted(r.cwd, 64) : undefined, r.presence !== undefined ? renderPresence(r.presence) : undefined, r.kind === "peer_session" ? "peer session" : r.kind === "roster" ?
|
|
162
|
+
const cols = [label, r.state, age, r.cwd !== undefined ? inlineUntrusted(r.cwd, 64) : undefined, r.presence !== undefined ? renderPresence(r.presence) : undefined, r.kind === "peer_session" ? "peer session" : r.kind === "roster" ? rosterLabel : "agent", r.address];
|
|
156
163
|
return cols.filter((c) => c !== undefined).join(" · ");
|
|
157
164
|
});
|
|
158
165
|
const finishedCount = rows.filter((r) => r.state === "finished").length;
|
|
159
166
|
const header = rows.length === 0 ? "No agents or peer sessions are listed right now." : `${rows.length} listed:`;
|
|
160
|
-
const finishedNote = finishedCount > 0 ? `\n(${finishedCount} finished agent(s): continuable by SendMessage only
|
|
167
|
+
const finishedNote = finishedCount > 0 ? `\n(${finishedCount} finished agent(s): continuable by SendMessage only ${finishedReach} — otherwise SendMessage answers with an honest refusal)` : "";
|
|
161
168
|
const fault = (directoryFault !== undefined ? `\n(peer-session directory unreadable: ${inlineUntrusted(directoryFault, 200)} — peer rows may be missing)` : "") +
|
|
162
169
|
(registryCapped ? "\n(the live task list is at its 500-row window: older background agents may be missing from this listing — address them by task_id)" : "");
|
|
163
170
|
let content = `${header}${lines.length > 0 ? `\n${lines.join("\n")}` : ""}${finishedNote}${fault}`;
|
|
@@ -237,6 +237,8 @@ export declare function mountListAgents(args: {
|
|
|
237
237
|
parentSessionId?: string;
|
|
238
238
|
registry: ListAgentsToolRegistry;
|
|
239
239
|
roster?: NonNullable<Parameters<typeof createListAgentsTool>[0]["roster"]>;
|
|
240
|
+
/** The continuation reach of the SendMessage beside it — what a recorded agent's label may promise (see the tool option). */
|
|
241
|
+
continuationTier?: NonNullable<Parameters<typeof createListAgentsTool>[0]["continuationTier"]>;
|
|
240
242
|
specTools: ReadonlyArray<{
|
|
241
243
|
name: string;
|
|
242
244
|
aliases?: readonly string[];
|
|
@@ -698,6 +698,7 @@ export function mountListAgents(args) {
|
|
|
698
698
|
access: { owner: args.hostTaskId, scope: args.scope, sessionId: args.sessionId },
|
|
699
699
|
...(args.parentTaskId !== undefined ? { parentAccess: { owner: args.parentTaskId, scope: args.scope, ...(args.parentSessionId !== undefined ? { sessionId: args.parentSessionId } : {}) } } : {}),
|
|
700
700
|
...(args.roster !== undefined ? { roster: args.roster } : {}),
|
|
701
|
+
...(args.continuationTier !== undefined ? { continuationTier: args.continuationTier } : {}),
|
|
701
702
|
});
|
|
702
703
|
}
|
|
703
704
|
export function bindPeerSessionDrain(harness, opts) {
|
|
@@ -8,6 +8,7 @@ import { type PeerDirectory } from "./peer-directory.js";
|
|
|
8
8
|
import { SubagentRetainLedger } from "./retain-ledger.js";
|
|
9
9
|
import { type SubagentSteerHandle } from "./subagent.js";
|
|
10
10
|
import { type PeerLaneConfig, type PeerInboundChainRef, type PeerSelfRef } from "./peer-admission.js";
|
|
11
|
+
import { type SubagentTranscriptTier } from "../core/wiring-manifest.js";
|
|
11
12
|
/** Model-facing name of the background-agent continuation tool (CC 2.1.2xx `SendMessage`). */
|
|
12
13
|
export declare const SEND_MESSAGE_TOOL_NAME = "SendMessage";
|
|
13
14
|
export interface SendMessageToolOptions {
|
|
@@ -237,6 +238,22 @@ export declare function clipSendMessageSummary(raw: string): string;
|
|
|
237
238
|
* second revive). The auto-mode peer-referral tighten keys on the WIRE NAME and applies to a direct
|
|
238
239
|
* mount all the same.
|
|
239
240
|
*/
|
|
241
|
+
/**
|
|
242
|
+
* The continuation promise's REACH — how far "continue a finished agent" goes on a SendMessage mount — by the
|
|
243
|
+
* deployment's subagent transcript tier: the ONE derivation (`resolveSubagentTranscriptTier`, the wiring
|
|
244
|
+
* manifest's `subagentTranscripts` cell) over the mount's own seats. Without all three tier-3 seats (row
|
|
245
|
+
* store, mailbox, reviver) there is no durable revival at all — session retention is the only lane (`none`).
|
|
246
|
+
* With them, the session store the revival runs through (`runner.sessions`) decides: `full` keeps the
|
|
247
|
+
* conversation across a restart, `rows` keeps the record but not the conversation (a process-local
|
|
248
|
+
* transcript store), so only `full` may say "across restarts". Exported so every face that describes the
|
|
249
|
+
* same reach (the ListAgents mount beside this tool) reads the same answer from the same seats.
|
|
250
|
+
*/
|
|
251
|
+
export declare function sendMessageContinuationTier(seats: {
|
|
252
|
+
agentStore?: unknown;
|
|
253
|
+
mailbox?: unknown;
|
|
254
|
+
reviveSpawn?: unknown;
|
|
255
|
+
runner: Pick<RunnerSelfSeat, "sessions">;
|
|
256
|
+
}): SubagentTranscriptTier;
|
|
240
257
|
export declare function createSendMessageTool(opts: SendMessageToolOptions): import("../internal/harness.js").AgentTool<Type.TObject<{
|
|
241
258
|
to: Type.TString;
|
|
242
259
|
message: Type.TString;
|
|
@@ -18,6 +18,7 @@ import { appendHopToken, judgePeerAdmission, peerAxisToken, resolvePeerLaneConfi
|
|
|
18
18
|
import { toolFace } from "../core/tool-catalog-entries.js";
|
|
19
19
|
import { thrownText } from "../core/thrown-value.js";
|
|
20
20
|
import { assertNoRetiredKeys } from "../core/retired-keys.js";
|
|
21
|
+
import { resolveSubagentTranscriptTier } from "../core/wiring-manifest.js";
|
|
21
22
|
export const SEND_MESSAGE_TOOL_NAME = "SendMessage";
|
|
22
23
|
let uplinkSeqGlobal = Date.now();
|
|
23
24
|
const UPLINK_RESULT_MAX = 8000;
|
|
@@ -60,9 +61,13 @@ function targetLaneKey(scope, targetId) {
|
|
|
60
61
|
return JSON.stringify([scope ?? "", targetId]);
|
|
61
62
|
}
|
|
62
63
|
const OPERATOR_CONTINUATION_CTX = Symbol("sema.operator_continuation");
|
|
64
|
+
export function sendMessageContinuationTier(seats) {
|
|
65
|
+
const tier3Capable = seats.agentStore !== undefined && seats.mailbox !== undefined && seats.reviveSpawn !== undefined;
|
|
66
|
+
return tier3Capable ? resolveSubagentTranscriptTier(true, seats.runner.sessions) : "none";
|
|
67
|
+
}
|
|
63
68
|
export function createSendMessageTool(opts) {
|
|
64
69
|
assertNoRetiredSendMessageOptions(opts);
|
|
65
|
-
const
|
|
70
|
+
const continuationTier = sendMessageContinuationTier(opts);
|
|
66
71
|
const notifyWhenIdleParameter = {
|
|
67
72
|
notify_when_idle: Type.Optional(Type.Boolean({
|
|
68
73
|
description: "Ask a peer SESSION of this engine to send you ONE notice when it next goes idle (finishes its turn with nothing queued) or exits — opt-in, one-shot, no polling. With a message: deliver it now AND subscribe. Without a message (omit it): a pure subscription that costs the other session nothing. Peer sessions only (not agents or teammates), and only from the main conversation.",
|
|
@@ -166,10 +171,12 @@ export function createSendMessageTool(opts) {
|
|
|
166
171
|
: `A finished agent is NOT announced on this mount${retrievalMounted === false
|
|
167
172
|
? ", and its result cannot be collected by this run (no TaskOutput is mounted beside this tool) — do not end your turn to wait for a notification that never comes"
|
|
168
173
|
: " — check on it with TaskOutput(task_id) where mounted rather than ending your turn to wait for a notification that never comes"}. `) +
|
|
169
|
-
(
|
|
174
|
+
(continuationTier === "full"
|
|
170
175
|
? `Continuing a finished agent works for agents with a durable record — by name or task_id, even across restarts — and for runs that retain sub-agent sessions; when neither covers it you get an honest error and should launch a new agent with the needed context instead.`
|
|
171
|
-
:
|
|
172
|
-
`
|
|
176
|
+
: continuationTier === "rows"
|
|
177
|
+
? `Continuing a finished agent works by name or task_id while this engine process still holds its conversation, and for runs that retain sub-agent sessions — its record outlives a restart but its conversation does not, so after a restart it cannot be continued; when neither covers it you get an honest error and should launch a new agent with the needed context instead.`
|
|
178
|
+
: `Continuing a finished agent requires the run to retain sub-agent sessions; when the session was not ` +
|
|
179
|
+
`retained (or is no longer held) you get an honest error and should launch a new agent with the needed context instead.`) +
|
|
173
180
|
(peerLane.active
|
|
174
181
|
? ` Peer SESSIONS — other sessions of this engine for the same user${opts.peerListingMounted === false ? "" : opts.peerListingMounted === true ? " (list them with ListAgents)" : " (list them with ListAgents where that tool is mounted)"} — are addressed by name, by \`name [ref]\` when a name is ambiguous, or by their \`session.<id>\` address. A message to a peer is parked durably in its session box and read at its next turn boundary (an offline peer reads it when it next runs); the receipt confirms the box accepted it, not that the peer acted on it.`
|
|
175
182
|
: ""),
|
|
@@ -702,8 +702,17 @@ export interface SubagentToolOptions {
|
|
|
702
702
|
* assembly time but still resolves at the child's own prepare through the runner's live table.
|
|
703
703
|
*/
|
|
704
704
|
models?: Record<string, Model>;
|
|
705
|
-
/** Tools available to the child
|
|
705
|
+
/** Tools available to the child: the deployment's EXPLICIT pool, used verbatim — it replaces {@link inheritedTools}
|
|
706
|
+
* and is the one channel that hands a child a tool its parent does not mount. `[]` = the child inherits nothing. */
|
|
706
707
|
tools?: ToolSpec[];
|
|
708
|
+
/**
|
|
709
|
+
* The parent surface a child inherits when {@link tools} is absent (`assembleCodeTools` passes its bundle's own
|
|
710
|
+
* instances, so the task-list family shares the leader's store). Narrowed per spawn by `inheritedChildPool`: only
|
|
711
|
+
* the instances the spawning run mounts (`ToolExecuteContext.mountsCallerTool`; a host's copy of an entry is another
|
|
712
|
+
* instance and is not inherited), no task-list family for an anonymous background child, and a same-named `extraTools`
|
|
713
|
+
* product replaces the entry. Model-gate stamps travel with the entries; each child's prepare re-judges them.
|
|
714
|
+
*/
|
|
715
|
+
inheritedTools?: ToolSpec[];
|
|
707
716
|
/**
|
|
708
717
|
* Tool-level allowlist (design/38 1A), used when NO `agent` is selected (a selected agent definition's
|
|
709
718
|
* own `allowTools`/`denyTools` REPLACE these). When set, the child sees only these tools from `tools`
|
package/dist/agents/subagent.js
CHANGED
|
@@ -68,6 +68,7 @@ import { LAUNCH_RECEIPT_OWN_WORDS_CLAUSE, launchReceiptNoQuoteClause } from "./l
|
|
|
68
68
|
import { ObserverDigestTap, ObserverPairing, createObserverReportToolSpec, markObserverTaskId, unmarkObserverTaskId, isObserverTaskId, ObserverResumeStateError, ObserverStoppedByUserError, observerFramingPrompt, observerSlug, resolveObserverDeclaration, } from "./observer.js";
|
|
69
69
|
import { SubagentStepRecorder } from "./subagent-steps.js";
|
|
70
70
|
import { toolFace } from "../core/tool-catalog-entries.js";
|
|
71
|
+
import { inheritedChildPool, teammateInheritedFamily } from "./inherited-tool-pool.js";
|
|
71
72
|
import { readMember, thrownText } from "../core/thrown-value.js";
|
|
72
73
|
const BG_AGENT_RESULT_MAX = 4_000;
|
|
73
74
|
const BG_AGENT_RESULT_FULL_MAX = 200_000;
|
|
@@ -1390,7 +1391,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
1390
1391
|
];
|
|
1391
1392
|
const agentToolPool = opts.extraTools !== undefined
|
|
1392
1393
|
? undefined
|
|
1393
|
-
: (opts.tools ?? []).map((t) => ({
|
|
1394
|
+
: (opts.tools ?? opts.inheritedTools ?? []).map((t) => ({
|
|
1394
1395
|
name: t.name,
|
|
1395
1396
|
...(t.aliases !== undefined ? { aliases: t.aliases } : {}),
|
|
1396
1397
|
...(t.contentOrigin !== undefined
|
|
@@ -1807,7 +1808,10 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
1807
1808
|
};
|
|
1808
1809
|
}
|
|
1809
1810
|
}
|
|
1810
|
-
|
|
1811
|
+
const inherited = opts.tools === undefined && extraToolsNote === undefined ? inheritedChildPool(opts.inheritedTools ?? [], ctx.mountsCallerTool, wantsBackground && agentName === undefined && !wantsFork) : [];
|
|
1812
|
+
if (extraToolsNote !== undefined && opts.tools === undefined && (opts.inheritedTools?.length ?? 0) > 0)
|
|
1813
|
+
extraToolsNote += " The inherited tools were withheld for this spawn as well.";
|
|
1814
|
+
let childPool = opts.tools ?? inherited;
|
|
1811
1815
|
if (injectedTools.length > 0) {
|
|
1812
1816
|
const taken = new Set();
|
|
1813
1817
|
const reserve = (t) => {
|
|
@@ -1818,20 +1822,19 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
1818
1822
|
for (const t of opts.tools ?? [])
|
|
1819
1823
|
reserve(t);
|
|
1820
1824
|
taken.add(toolName);
|
|
1821
|
-
const
|
|
1825
|
+
const accepted = [];
|
|
1822
1826
|
for (const t of injectedTools) {
|
|
1823
1827
|
const names = [t.name, ...(t.aliases ?? [])];
|
|
1824
1828
|
if (names.some((n) => taken.has(n)))
|
|
1825
1829
|
continue;
|
|
1826
1830
|
for (const n of names)
|
|
1827
1831
|
taken.add(n);
|
|
1828
|
-
|
|
1832
|
+
accepted.push(t);
|
|
1829
1833
|
}
|
|
1830
|
-
childPool =
|
|
1834
|
+
childPool = [...(opts.tools ?? inherited.filter((t) => ![t.name, ...(t.aliases ?? [])].some((n) => taken.has(n)))), ...accepted];
|
|
1831
1835
|
}
|
|
1832
|
-
const
|
|
1833
|
-
|
|
1834
|
-
: resolveToolSubset(childPool, opts.allowTools, opts.denyTools);
|
|
1836
|
+
const allowed = new Set([...resolveToolSubset(childPool, def ? def.allowTools : opts.allowTools, def ? def.denyTools : opts.denyTools), ...(agentName !== undefined ? teammateInheritedFamily(childPool.filter((t) => inherited.includes(t)), def ? def.denyTools : opts.denyTools) : [])]);
|
|
1837
|
+
const childTools = childPool.filter((t) => allowed.has(t));
|
|
1835
1838
|
const childSystemPrompt = def?.systemPrompt ?? opts.systemPrompt;
|
|
1836
1839
|
const childDefaultPersona = childSystemPrompt === undefined && !wantsFork ? SUBAGENT_PROMPT : undefined;
|
|
1837
1840
|
const defMaxTurns = typeof def?.maxTurns === "number" && Number.isFinite(def.maxTurns) && def.maxTurns > 0 ? def.maxTurns : undefined;
|
|
@@ -3330,6 +3333,19 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3330
3333
|
}
|
|
3331
3334
|
return rollbackAndThrow(e);
|
|
3332
3335
|
}
|
|
3336
|
+
if (opts.tools === undefined && (opts.inheritedTools?.length ?? 0) > 0 && ctx.mountsCallerTool === undefined && pr.outcome.gate === "policy_ask" && pr.outcome.decision === "allow") {
|
|
3337
|
+
let pending;
|
|
3338
|
+
try {
|
|
3339
|
+
pending = (await childParkStore.get(pr.ticket.token))?.pendingAction;
|
|
3340
|
+
}
|
|
3341
|
+
catch (e) {
|
|
3342
|
+
return rollbackAndThrow(e);
|
|
3343
|
+
}
|
|
3344
|
+
const name = pending?.kind === "tool_approval" ? pending.toolName : undefined;
|
|
3345
|
+
if (name !== undefined && (opts.inheritedTools ?? []).some((t) => t.name === name) && !(bgSpec.tools ?? []).some((t) => t.name === name)) {
|
|
3346
|
+
return rollbackAndThrow(new Error(`the approved tool "${name}" is one the agent inherits from its spawning run, and this resume was driven without the \`mountsCallerTool\` seat, so the resumed agent would not have it. The host must pass the seat built from the row's own session (never a boot-time bundle: its task list has no session and would be shared by every revived agent). Deny still resolves this approval.`));
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3333
3349
|
{
|
|
3334
3350
|
let seat;
|
|
3335
3351
|
try {
|
|
@@ -183,7 +183,7 @@ export declare function stampAskOrigin<T extends Extract<PermissionResult, {
|
|
|
183
183
|
* the reason is that a RULE is already speaking, not that the question is beyond rules, and a surface
|
|
184
184
|
* that auto-answers by posture is not crossing a mandate when it answers one.
|
|
185
185
|
*
|
|
186
|
-
*
|
|
186
|
+
* FOUR named readings of the one pass, because four callers need four different things and taking
|
|
187
187
|
* the wrong one is how this boundary drifts:
|
|
188
188
|
* · `word` — the DISCLOSED reason, first door wins, in the display order the card has always used
|
|
189
189
|
* (governance, the shadow, the hook, the ask rule, the two ancestor doors, then the marks). Display
|
|
@@ -195,12 +195,16 @@ export declare function stampAskOrigin<T extends Extract<PermissionResult, {
|
|
|
195
195
|
* person's explicit `ask` rule, and deliberately NOT `word !== undefined`: `persistedRuleShadowed`
|
|
196
196
|
* is an ACCOUNT of a refusal that already happened, display-only by contract, and reading it as a
|
|
197
197
|
* blocker would let a policy forwarding that metadata refuse a call nothing is blocking. A
|
|
198
|
-
* display word is never an authority
|
|
198
|
+
* display word is never an authority;
|
|
199
|
+
* · `mandate` — WHICH table word the bit stands on, only when the MARKS family is its whole reason (no
|
|
200
|
+
* governance, hook or ancestor door, and no bit stamped without a word); absent, the bit's reason is
|
|
201
|
+
* not one word.
|
|
199
202
|
*/
|
|
200
203
|
export declare function ruleClearanceOf(ask: RuleClearanceFacts | undefined, marks: Omit<MandateMarks, "probeMandated">): {
|
|
201
204
|
word?: "mandated" | "shadowed";
|
|
202
205
|
mandated: boolean;
|
|
203
206
|
ruleMayClear: boolean;
|
|
207
|
+
mandate?: PersistedRuleMandate;
|
|
204
208
|
};
|
|
205
209
|
/** The facts one ask carries about whether a rule could clear it — the members {@link ruleClearanceOf}
|
|
206
210
|
* reads beside the tool seat's marks. Every one is either engine-stamped or, where a caller can
|
|
@@ -209,6 +213,8 @@ export declare function ruleClearanceOf(ask: RuleClearanceFacts | undefined, mar
|
|
|
209
213
|
export interface RuleClearanceFacts {
|
|
210
214
|
/** The bit a gate already stamped (see {@link import("./tool-policy.js").AskRequest.mandated}). */
|
|
211
215
|
mandated?: true;
|
|
216
|
+
/** The table word a gate stamped beside that bit (see {@link import("./tool-policy.js").AskRequest.mandate}). */
|
|
217
|
+
mandate?: PersistedRuleMandate;
|
|
212
218
|
requiresRealApproval?: boolean;
|
|
213
219
|
/** A matching persisted allow rule was found and did not clear the ask — carries its text. */
|
|
214
220
|
persistedRuleShadowed?: string;
|
|
@@ -225,13 +231,14 @@ export interface RuleClearanceFacts {
|
|
|
225
231
|
* pass it literally) — this task's rule lane never adjudicates it. */
|
|
226
232
|
ancestorResolved?: boolean;
|
|
227
233
|
}
|
|
228
|
-
/** The ask FACE's mandate
|
|
229
|
-
* The stations hand it the SAME facts object they hand the rule-offer factory, so the card's word
|
|
230
|
-
*
|
|
231
|
-
* its marks were folded into the stamped bit at the gate, and a station minting a request from an
|
|
234
|
+
/** The ask FACE's mandate members — the bit and its table word, spread at the four mint stations and the
|
|
235
|
+
* park row. The stations hand it the SAME facts object they hand the rule-offer factory, so the card's word
|
|
236
|
+
* and these members are readings of one derivation and cannot drift. The tool SEAT is not re-judged here:
|
|
237
|
+
* its marks were folded into the stamped bit and word at the gate, and a station minting a request from an
|
|
232
238
|
* ANCESTOR's decision holds no seat of this task's to judge. */
|
|
233
239
|
export declare function askMandateMember(ask: RuleClearanceFacts | undefined): {
|
|
234
240
|
mandated?: true;
|
|
241
|
+
mandate?: PersistedRuleMandate;
|
|
235
242
|
};
|
|
236
243
|
/** The clearance facts of an ask the engine did not construct (a policy's verdict), each member read ONCE into
|
|
237
244
|
* an engine-owned value — for a station that keeps the answer past its own side effects, where the verdict's
|
|
@@ -341,6 +348,8 @@ export declare function classifierMayAnswer(origin: AskOrigin): boolean;
|
|
|
341
348
|
*/
|
|
342
349
|
export declare const PERSISTED_RULE_MANDATES: readonly ["operator_always", "tool_marks", "probe_mandate", "probe_unanswered", "write_protection", "write_protection_unresolved"];
|
|
343
350
|
export type PersistedRuleMandate = (typeof PERSISTED_RULE_MANDATES)[number];
|
|
351
|
+
/** Membership test for {@link PersistedRuleMandate} — the screen a row reader applies to a stored word. */
|
|
352
|
+
export declare function isPersistedRuleMandate(v: unknown): v is PersistedRuleMandate;
|
|
344
353
|
/**
|
|
345
354
|
* May a LIVE automatic lane answer an ask carrying this word? (The persisted-rule lane is not among
|
|
346
355
|
* them — every word binds it.) TWO COLUMNS, because the two lanes are not asking one question: the
|
package/dist/core/ask-origin.js
CHANGED
|
@@ -18,19 +18,20 @@ export function askOriginOf(decision, facts) {
|
|
|
18
18
|
const TIGHTEN_ONLY_ORIGINS = new Set(["shell_gate_tighten", "safety_tighten"]);
|
|
19
19
|
export function stampAskOrigin(decision, facts) {
|
|
20
20
|
const origin = askOriginOf(decision, facts);
|
|
21
|
-
const
|
|
21
|
+
const { mandate: _selfDeclared, ...unstamped } = decision;
|
|
22
|
+
const clearance = ruleClearanceOf(unstamped, facts.mandateMarks);
|
|
23
|
+
const mandated = { ...unstamped, ...(clearance.mandated ? { mandated: true } : {}), ...(clearance.mandate !== undefined ? { mandate: clearance.mandate } : {}) };
|
|
22
24
|
if (mandated.probeReadRootCandidate === undefined || TIGHTEN_ONLY_ORIGINS.has(origin))
|
|
23
25
|
return { ...mandated, origin };
|
|
24
26
|
const { probeReadRootCandidate: _withdrawn, ...kept } = mandated;
|
|
25
27
|
return { ...kept, origin };
|
|
26
28
|
}
|
|
27
29
|
export function ruleClearanceOf(ask, marks) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
persistedRuleMandateOf({ ...marks, probeMandated: ask?.probeMandated === true }) !== undefined;
|
|
30
|
+
const marksWord = persistedRuleMandateOf({ ...marks, probeMandated: ask?.probeMandated === true });
|
|
31
|
+
const doorOutsideTable = ask?.requiresRealApproval === true || ask?.decisionReason === "hook" || ask?.inheritedUnresolved === true || ask?.ancestorResolved === true;
|
|
32
|
+
const mandated = ask?.mandated === true || doorOutsideTable || marksWord !== undefined;
|
|
33
|
+
const stampedWord = ask?.mandated === true && isPersistedRuleMandate(ask.mandate) ? ask.mandate : undefined;
|
|
34
|
+
const mandate = doorOutsideTable || (ask?.mandated === true && stampedWord === undefined) ? undefined : (stampedWord ?? marksWord);
|
|
34
35
|
const word = (() => {
|
|
35
36
|
if (ask?.requiresRealApproval === true)
|
|
36
37
|
return "mandated";
|
|
@@ -46,10 +47,11 @@ export function ruleClearanceOf(ask, marks) {
|
|
|
46
47
|
return "mandated";
|
|
47
48
|
return mandated ? "mandated" : undefined;
|
|
48
49
|
})();
|
|
49
|
-
return { ...(word !== undefined ? { word } : {}), mandated, ruleMayClear: !mandated && ask?.matchedAskRule === undefined };
|
|
50
|
+
return { ...(word !== undefined ? { word } : {}), mandated, ruleMayClear: !mandated && ask?.matchedAskRule === undefined, ...(mandate !== undefined ? { mandate } : {}) };
|
|
50
51
|
}
|
|
51
52
|
export function askMandateMember(ask) {
|
|
52
|
-
|
|
53
|
+
const { mandated, mandate } = ruleClearanceOf(ask, {});
|
|
54
|
+
return mandated ? { mandated: true, ...(mandate !== undefined ? { mandate } : {}) } : {};
|
|
53
55
|
}
|
|
54
56
|
export function ownedClearanceFacts(d) {
|
|
55
57
|
const { mandated, requiresRealApproval, decisionReason, matchedAskRule, probeMandated } = d;
|
|
@@ -114,6 +116,10 @@ export function classifierMayAnswer(origin) {
|
|
|
114
116
|
return CLASSIFIER_MAY_ANSWER[origin];
|
|
115
117
|
}
|
|
116
118
|
export const PERSISTED_RULE_MANDATES = ["operator_always", "tool_marks", "probe_mandate", "probe_unanswered", "write_protection", "write_protection_unresolved"];
|
|
119
|
+
const PERSISTED_RULE_MANDATE_SET = new Set(PERSISTED_RULE_MANDATES);
|
|
120
|
+
export function isPersistedRuleMandate(v) {
|
|
121
|
+
return PERSISTED_RULE_MANDATE_SET.has(v);
|
|
122
|
+
}
|
|
117
123
|
const MANDATE_LANE_ANSWERS = {
|
|
118
124
|
operator_always: { classifier: true, sandboxAdmission: true },
|
|
119
125
|
tool_marks: { classifier: true, sandboxAdmission: true },
|