@sema-agent/core 5.25.0 → 5.27.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/dist/agents/agent-definition.js +5 -0
  3. package/dist/agents/agent-transcript-tool.d.ts +5 -2
  4. package/dist/agents/agent-transcript-tool.js +2 -1
  5. package/dist/agents/send-message-tool.d.ts +4 -1
  6. package/dist/agents/send-message-tool.js +1 -0
  7. package/dist/agents/subagent.d.ts +6 -2
  8. package/dist/agents/subagent.js +5 -0
  9. package/dist/core/checkpoint-store.d.ts +7 -2
  10. package/dist/core/hooks.d.ts +39 -4
  11. package/dist/core/hooks.js +18 -14
  12. package/dist/core/memory-engine/dual-root.js +3 -1
  13. package/dist/core/memory-engine/engine.d.ts +53 -6
  14. package/dist/core/memory-engine/engine.js +43 -11
  15. package/dist/core/memory-engine/file-backend.d.ts +81 -0
  16. package/dist/core/memory-engine/file-backend.js +250 -24
  17. package/dist/core/memory-engine/index.d.ts +1 -1
  18. package/dist/core/memory-engine/index.js +1 -1
  19. package/dist/core/memory-engine/types.d.ts +8 -1
  20. package/dist/core/memory-vector.d.ts +6 -1
  21. package/dist/core/memory-vector.js +14 -4
  22. package/dist/core/memory.js +1 -6
  23. package/dist/core/permission-rule-consent.js +8 -1
  24. package/dist/core/permission-rule-model.d.ts +70 -5
  25. package/dist/core/permission-rule-model.js +58 -0
  26. package/dist/core/runner/compaction-call-options.d.ts +4 -4
  27. package/dist/core/runner/compaction-call-options.js +3 -4
  28. package/dist/core/runner/prepare-memory.d.ts +34 -15
  29. package/dist/core/runner/prepare-memory.js +99 -26
  30. package/dist/core/runner/prepare-task.d.ts +9 -3
  31. package/dist/core/runner/prepare-task.js +60 -15
  32. package/dist/core/runner/runtask.d.ts +8 -1
  33. package/dist/core/store-contracts/tool-result-store-contract.d.ts +6 -0
  34. package/dist/core/store-contracts/tool-result-store-contract.js +24 -0
  35. package/dist/core/task-registry-agent.d.ts +4 -3
  36. package/dist/core/task-registry-agent.js +3 -3
  37. package/dist/core/task-registry-monitor.js +6 -5
  38. package/dist/core/task-registry.d.ts +6 -3
  39. package/dist/core/tool-policy.d.ts +9 -2
  40. package/dist/core/tool-result-budget.d.ts +1 -1
  41. package/dist/core/tool-result-budget.js +3 -3
  42. package/dist/core/tool-result-store.d.ts +164 -9
  43. package/dist/core/tool-result-store.js +82 -23
  44. package/dist/core/types.d.ts +103 -7
  45. package/dist/core/untrusted-text.d.ts +6 -2
  46. package/dist/core/untrusted-text.js +1 -1
  47. package/dist/engine/loop/types.d.ts +10 -3
  48. package/dist/engine/session/import-validate.js +2 -1
  49. package/dist/index.d.ts +3 -3
  50. package/dist/index.js +3 -3
  51. package/dist/orchestration/run-workflow-tool.d.ts +5 -3
  52. package/dist/orchestration/workflow.d.ts +9 -6
  53. package/dist/orchestration/workflow.js +2 -0
  54. package/dist/prompts/default.d.ts +11 -0
  55. package/dist/prompts/default.js +3 -0
  56. package/dist/stores/file/checkpoint-store.d.ts +2 -1
  57. package/dist/stores/file/fs-atomic.d.ts +1 -1
  58. package/dist/stores/file/index.d.ts +1 -1
  59. package/dist/stores/file/tool-result-store.d.ts +45 -9
  60. package/dist/stores/file/tool-result-store.js +76 -9
  61. package/dist/tools/fs/fs-shared.js +5 -4
  62. package/package.json +1 -1
@@ -1,8 +1,18 @@
1
+ const CJK_RUN = /[\u3040-\u30FF\u31F0-\u31FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7A3\uF900-\uFAFF]+/g;
1
2
  export function termSet(s) {
2
- return new Set(s
3
- .toLowerCase()
4
- .split(/[^a-z0-9]+/)
5
- .filter(Boolean));
3
+ const lower = s.toLowerCase();
4
+ const out = new Set();
5
+ for (const run of lower.split(/[^a-z0-9]+/))
6
+ if (run)
7
+ out.add(run);
8
+ for (const m of lower.matchAll(CJK_RUN)) {
9
+ const chars = [...m[0]];
10
+ for (const c of chars)
11
+ out.add(c);
12
+ for (let i = 0; i + 1 < chars.length; i++)
13
+ out.add(chars[i] + chars[i + 1]);
14
+ }
15
+ return out;
6
16
  }
7
17
  export function jaccardDistance(query, text) {
8
18
  const t = termSet(text);
@@ -1,5 +1,6 @@
1
1
  import { uuidv7 } from "../internal/harness.js";
2
2
  import { parseScopeKey } from "./memory-engine/scope-contract.js";
3
+ import { termSet } from "./memory-vector.js";
3
4
  import { sanitizeUntrustedText } from "./untrusted-text.js";
4
5
  export function supportsConsolidation(store) {
5
6
  return (typeof store.searchScored === "function" &&
@@ -148,12 +149,6 @@ export function guardedMemoryStore(inner, utilityGate) {
148
149
  function renderBullet(e) {
149
150
  return `- (${e.ts} UTC) ${e.text}`;
150
151
  }
151
- function termSet(s) {
152
- return new Set(s
153
- .toLowerCase()
154
- .split(/[^a-z0-9]+/)
155
- .filter(Boolean));
156
- }
157
152
  export class InMemoryMemoryStore {
158
153
  byScope = new Map();
159
154
  cursorByScope = new Map();
@@ -281,8 +281,15 @@ export async function prepareCcImport(opts) {
281
281
  skipped.push({ rule: String(entry), reason: "settings entry is not a string" });
282
282
  continue;
283
283
  }
284
- if (!entry.startsWith("Bash("))
284
+ if (!entry.startsWith("Bash(")) {
285
+ const reason = /^[A-Za-z][A-Za-z0-9_]*\(.*\)$/.test(entry)
286
+ ? "unsupported.tool: only Bash(...) command rules import in v1 — this entry stays in the settings file, unimported"
287
+ : /^[A-Za-z][A-Za-z0-9_-]*$/.test(entry)
288
+ ? "unsupported.form: a bare tool-name entry is a name-set item, not a command rule — it stays in the settings file, unimported"
289
+ : "unsupported.form: not a Bash(...) command rule — it stays in the settings file, unimported";
290
+ skipped.push({ rule: entry, reason });
285
291
  continue;
292
+ }
286
293
  const parsed = parseAllowRuleText(entry);
287
294
  if ("reject" in parsed) {
288
295
  skipped.push({ rule: entry, reason: `${parsed.reject.code}: ${parsed.reject.message}` });
@@ -120,6 +120,53 @@ export declare const MAX_RULE_TEXT_CHARS = 512;
120
120
  * an EXACT rule naming a whole interpreter command line stays legal, since it authorizes one command.
121
121
  */
122
122
  export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
123
+ /**
124
+ * design/185 §1 — the reviewed command/subcommand grammar the PREFIX suggestion is generated from
125
+ * (exactly the "reviewed command/subcommand grammar" the generator's history note names as the one
126
+ * thing that would let it produce a prefix).
127
+ *
128
+ * A flat set of BODIES — word sequences, each at least two words. A prefix candidate exists for a
129
+ * command iff some body here is a word-boundary prefix of its folded form, and the LONGEST hit wins:
130
+ * the deeper body is the narrower rule, so listing (or not listing) a deeper body is how this table
131
+ * sets suggestion granularity per branch. No groups, no denylist, and no fallback arm: a head outside
132
+ * the table, an unreviewed subcommand, a runtime-defined name (a git alias, a `git-<x>`/`cargo-<x>`
133
+ * external subcommand, a gh extension, a kubectl plugin), a flag or operand in a body position and a
134
+ * quoted token all fail the same way — by not being listed. Closure comes from positive enumeration
135
+ * itself, never from an exclusion list racing names that only exist at runtime.
136
+ *
137
+ * Review criteria — every row must pass BOTH axes (the same principle as the interpreter refusal
138
+ * above: the rule text must not read narrower than what it grants):
139
+ * · "runs what it is told to": a body whose use is fetching or naming a program to execute
140
+ * (`npm exec`, `docker run`, `kubectl exec`, `gh extension`, `git submodule foreach`, the install
141
+ * family) is refused — one click cannot be read as having granted arbitrary execution. Running the
142
+ * WORKSPACE'S OWN pinned content (`npm run`, `npm ci`, `cargo run`, `cargo test`) is inside the
143
+ * boundary: the scripts and lockfiles those execute are checked into the repository being worked on.
144
+ * · "rewrites what others execute": a body whose main use is writing configuration that changes what
145
+ * OTHER commands later run (`git config` — hooksPath/pager/alias; `kubectl config` —
146
+ * exec-credential; `npm config`/`npm set` — script-shell; `go env` — persisted GOFLAGS/GOBIN) is
147
+ * refused — its readable width and its real width differ by a whole composition surface.
148
+ * Past both axes there is deliberately NO "dangerousness" axis: `git push:*` and `git rebase:*` are
149
+ * wide but readable, and a person nodding at that text is granting exactly that.
150
+ *
151
+ * Residual width, stated rather than hidden (a reviewed trade, not an oversight): a prefix rule
152
+ * admits ANY arguments after its body, and some listed bodies carry flags that name a program to
153
+ * execute (`go build`/`go test`/`go vet -toolexec`, `git fetch --upload-pack`, `git rebase -x`,
154
+ * `git grep -O`, `git push --receive-pack`). The axes judge a body's MAIN use, not every flag —
155
+ * per-flag grammar is the road this module's history rejected twice, and applied consistently it
156
+ * would empty the table. Three standing fences hold that residue: the org deny/ask layer runs ahead
157
+ * of the rule lane and cannot be silenced by it; a mandated ask (egress/irreversibility marks,
158
+ * shellGate:"always") is not rule-clearable either; and the narrower exact candidate — plus minting
159
+ * no rule at all — is always on the same card.
160
+ *
161
+ * Maintenance: adding a row is a reviewed change — keep the per-group reasoning beside it current.
162
+ * The integrity pins (every body ≥ 2 words, lowercase word shape, no interpreter heads, no
163
+ * duplicates) are enforced by this module's test suite. This table and the read-only classifier's
164
+ * allowlists are DIFFERENT instruments and must never be merged or cross-referenced: that one is a
165
+ * machine auto-allow face whose criterion is "provably read-only"; this one is a human suggestion
166
+ * face whose criterion is "width a person can read off the rule text". One guards against a machine
167
+ * loosening; the other against a person being misled.
168
+ */
169
+ export declare const SUGGESTION_LEXICON: readonly string[];
123
170
  /**
124
171
  * Parse one rule text into its canonical shape, or refuse it with a reason.
125
172
  *
@@ -184,11 +231,29 @@ export interface RuleSuggestion {
184
231
  /**
185
232
  * The 1-2 candidates offered on an approval card for `command`.
186
233
  *
187
- * The exact form (this whole command line) is always offered when the command is matchable at all. A
188
- * prefix form is offered only when it is a shape a person can read as narrow: the upstream multi-word
189
- * convention (`git status`, `npm run`) is preferred over a bare program name, and no prefix at all is
190
- * offered for an interpreter head that candidate would be refused by the validator anyway, and
191
- * offering a candidate the store will not accept is worse than offering one fewer.
234
+ * **Array order is a documented CONTRACT, not an implementation accident**: display order = array
235
+ * order = narrowest first. The EXACT form (this whole command line) is always index 0 whenever
236
+ * anything is offered at all; a broader reviewed PREFIX form at most one follows at index 1.
237
+ * Selection indices and redemption tickets are index-keyed against this order (a card's
238
+ * `selectedCandidate` and its `rt.<index>.` tickets), so consumers may rely on it.
239
+ *
240
+ * The prefix candidate comes ONLY from {@link SUGGESTION_LEXICON} — the longest reviewed body that is
241
+ * a word-boundary prefix of the folded command. History, and why there is no heuristic arm: two
242
+ * rounds of guessing produced two different wrong answers — a bare program name (`rm -f x` →
243
+ * `Bash(rm:*)`), then an operand mistaken for a subcommand (`rm harmless.txt` →
244
+ * `Bash(rm harmless.txt:*)`, which admits a second, unnamed target) — and both failed the same way:
245
+ * nothing in the command TEXT distinguishes a subcommand from an operand without a per-command
246
+ * grammar. The lexicon IS that grammar, per reviewed row; anything it does not list (a bare verb, an
247
+ * interpreter head, an unreviewed subcommand, a runtime-defined name) yields no prefix, with no
248
+ * fallback. Naive spacing note: `folded` keeps quoted whitespace, so splitting on single spaces can
249
+ * shear a quoted segment — harmless in this direction, because the sheared pieces carry quote
250
+ * characters and can never equal a bare lexicon word; every suspicious shape lands on "no prefix".
251
+ *
252
+ * Every produced candidate must survive the round trip — parse as a rule AND admit the very command
253
+ * it was minted from. True by construction (a word-boundary lexicon prefix of a folded simple command
254
+ * is exactly the matcher's two arms); enforced anyway, fail-closed: a candidate that would not
255
+ * round-trip is silently not offered, since offering an option redemption would refuse is worse than
256
+ * offering one fewer.
192
257
  *
193
258
  * Returns an empty array for anything the rule lane cannot speak for (compounds, redirections,
194
259
  * substitutions) — the card then simply carries no "don't ask again" option, which is the honest answer.
@@ -6,6 +6,54 @@ export const BARE_INTERPRETER_NAMES = new Set([
6
6
  "node", "deno", "bun", "python", "python2", "python3", "perl", "ruby", "php",
7
7
  "osascript", "env", "eval", "exec", "xargs", "nohup", "sudo", "doas", "su", "ssh",
8
8
  ]);
9
+ export const SUGGESTION_LEXICON = [
10
+ "git status", "git log", "git diff", "git show", "git branch", "git checkout", "git switch",
11
+ "git add", "git commit", "git push", "git pull", "git fetch", "git merge", "git rebase",
12
+ "git tag", "git blame", "git describe", "git cherry-pick", "git restore", "git reset",
13
+ "git rev-parse", "git ls-files", "git grep",
14
+ "git stash list", "git stash show", "git stash push", "git stash pop", "git stash drop",
15
+ "git remote show", "git worktree list", "git submodule update", "git submodule status",
16
+ "npm run", "npm test", "npm ci", "npm ls", "npm view", "npm outdated", "npm audit",
17
+ "npm pack", "npm publish", "npm version", "npm why",
18
+ "pnpm run", "pnpm test", "pnpm ls", "pnpm outdated", "pnpm audit", "pnpm why",
19
+ "yarn run", "yarn test", "yarn workspaces list",
20
+ "cargo build", "cargo test", "cargo run", "cargo check", "cargo clippy", "cargo fmt",
21
+ "cargo doc", "cargo tree", "cargo bench", "cargo update", "cargo metadata",
22
+ "go build", "go test", "go vet", "go fmt", "go doc",
23
+ "go mod tidy", "go mod download", "go mod verify", "go mod graph",
24
+ "docker ps", "docker images", "docker logs", "docker inspect", "docker build", "docker pull",
25
+ "docker push", "docker stop", "docker start", "docker restart", "docker rm", "docker rmi",
26
+ "docker compose up", "docker compose down", "docker compose ps", "docker compose logs",
27
+ "docker compose build", "docker compose pull",
28
+ "kubectl get", "kubectl describe", "kubectl logs", "kubectl apply", "kubectl delete",
29
+ "kubectl diff", "kubectl explain", "kubectl top",
30
+ "kubectl rollout status", "kubectl rollout restart", "kubectl rollout history",
31
+ "gh pr view", "gh pr list", "gh pr diff", "gh pr checks", "gh pr status", "gh pr create",
32
+ "gh pr merge", "gh issue view", "gh issue list", "gh issue create", "gh repo view",
33
+ "gh repo clone", "gh release list", "gh release view", "gh run list", "gh run view",
34
+ "gh run watch", "gh workflow list", "gh workflow view", "gh auth status",
35
+ "gh search repos", "gh search issues", "gh search prs", "gh search code",
36
+ ];
37
+ const LEXICON_BODIES = SUGGESTION_LEXICON.map((b) => b.split(" "));
38
+ function longestReviewedBody(tokens) {
39
+ let best;
40
+ for (const words of LEXICON_BODIES) {
41
+ if (words.length > tokens.length)
42
+ continue;
43
+ if (best !== undefined && words.length <= best.length)
44
+ continue;
45
+ let hit = true;
46
+ for (let i = 0; i < words.length; i++) {
47
+ if (words[i] !== tokens[i]) {
48
+ hit = false;
49
+ break;
50
+ }
51
+ }
52
+ if (hit)
53
+ best = words;
54
+ }
55
+ return best?.join(" ");
56
+ }
9
57
  function foldSpacing(s) {
10
58
  let out = "";
11
59
  let quote;
@@ -131,5 +179,15 @@ export function suggestRulesForCommand(command) {
131
179
  const exact = parseAllowRuleText(formatAllowRuleText(folded, "exact"));
132
180
  if ("rule" in exact)
133
181
  out.push({ rule: exact.rule.rule, match: "exact", command: exact.rule.command });
182
+ if (out.length === 1) {
183
+ const body = longestReviewedBody(folded.split(" "));
184
+ if (body !== undefined) {
185
+ const text = formatAllowRuleText(body, "prefix");
186
+ const parsed = parseAllowRuleText(text);
187
+ if ("rule" in parsed && parsed.rule.match === "prefix" && ruleAdmitsCommand(parsed.rule, command)) {
188
+ out.push({ rule: parsed.rule.rule, match: "prefix", command: parsed.rule.command });
189
+ }
190
+ }
191
+ }
134
192
  return out;
135
193
  }
@@ -95,10 +95,10 @@ export declare function buildStaleOffloadPointer(toolName: string, ref: string,
95
95
  * The session transcript is NEVER touched — this runs on the outgoing {@link Context} only.
96
96
  * Error results, image/document-bearing blocks' non-text parts, already-offloaded previews, and
97
97
  * replacements that would save < `minSavingsChars` are left verbatim. The full text is persisted
98
- * under a deterministic content-digested ref (design/30's mint helper over
99
- * `<toolCallId>_s<sha256-12 of the text>`write-once, so re-projection on every turn re-puts a
100
- * no-op; the digest exists because tool-call ids carry no cross-turn uniqueness contract),
101
- * readable back via `read_tool_result`.
98
+ * under a deterministic content-digested ref (`buildToolResultRef(sessionId, toolCallId,
99
+ * toolResultContentSegment(text))`a `~`-separated three-segment mint whose digest is its OWN
100
+ * segment; write-once, so re-projection on every turn re-puts a no-op; the digest exists because
101
+ * tool-call ids carry no cross-turn uniqueness contract), readable back via `read_tool_result`.
102
102
  */
103
103
  export declare function projectStaleToolResults(context: Context, cfg: ResolvedStaleToolResultOffload, store: ToolResultStore, sessionId: string,
104
104
  /** Run-scoped cache of refs already persisted by THIS run (独立复审 MED,已修): without it the
@@ -1,6 +1,5 @@
1
- import { createHash } from "node:crypto";
2
1
  import { emitTrace } from "../trace.js";
3
- import { buildToolResultRef, OFFLOAD_TOOL_NAME, PERSISTED_OUTPUT_PREFIX } from "../tool-result-store.js";
2
+ import { buildToolResultRef, OFFLOAD_TOOL_NAME, PERSISTED_OUTPUT_PREFIX, toolResultContentSegment, toolResultProvenanceOf, } from "../tool-result-store.js";
4
3
  export function buildWorkingFileAttachments(spec, prepared) {
5
4
  if (spec.compaction?.attachWorkingFiles === false || !prepared.readTaskFile)
6
5
  return undefined;
@@ -73,13 +72,13 @@ export async function projectStaleToolResults(context, cfg, store, sessionId, wr
73
72
  const text = toolResultText(msg);
74
73
  if (text.startsWith(PERSISTED_OUTPUT_PREFIX))
75
74
  continue;
76
- const ref = buildToolResultRef(sessionId, `${msg.toolCallId}_s${createHash("sha256").update(text, "utf8").digest("hex").slice(0, 32)}`);
75
+ const ref = buildToolResultRef(sessionId, msg.toolCallId, toolResultContentSegment(text));
77
76
  const pointer = buildStaleOffloadPointer(toolName, ref, text.length);
78
77
  if (text.length - pointer.length < cfg.minSavingsChars)
79
78
  continue;
80
79
  if (!writtenRefs.has(ref)) {
81
80
  try {
82
- await store.put(ref, text);
81
+ await store.put(ref, text, toolResultProvenanceOf(sessionId));
83
82
  writtenRefs.add(ref);
84
83
  }
85
84
  catch {
@@ -1,11 +1,3 @@
1
- /**
2
- * design/157 B15 一期 (P6 相位抽取) — prepareTask's long-term-memory phase, verbatim. The ONLY inputs
3
- * are the five fields below (measured, not assumed: spec.memory + deps.* + the three ambient values);
4
- * the ONLY outputs are the memory-engine session and the composed injection block. `deps.onError`
5
- * call order and payloads are part of the contract (event-sequence snapshot pin recorded across the
6
- * move). Throws pass through unchanged: a `config.memory_*`-coded violation is a DELIBERATE refusal
7
- * (design/142 S1 硬门) and must keep failing prepare loudly.
8
- */
9
1
  import type { BeforeWriteHook, RunnerDeps, TaskSpec, ToolSpec } from "../types.js";
10
2
  import type { Prepared } from "./prepare-task.js";
11
3
  export interface PrepareMemoryInput {
@@ -18,15 +10,42 @@ export interface PrepareMemoryInput {
18
10
  };
19
11
  /**
20
12
  * #181-F5 — whether a tool NAMED `Write` (the tool the CC `# Memory` instruction names) is on the
21
- * ASSEMBLED roster this run and not excluded: prepare-task's
22
- * `tools.some(t => t.name === "Write") && !exclude.includes("Write")`. Name occupancy IS the channel
23
- * declaration, so the hands band is only one of the arms that can satisfy it — a hands-less run that
24
- * mounts its own `Write` counts, and an `excludeTools: ["Write"]` run reads as unmounted whatever the
25
- * hands band did. Threaded into `engine.inject` so a run with a writable memory scope but no write
26
- * channel is not instructed to call a tool that is not on its roster; the RB-276 index seed follows
27
- * the same gate (it exists only to satisfy the Write tool's unread-overwrite guard).
13
+ * ASSEMBLED roster this run, not excluded, AND can actually REACH the host-side memory root:
14
+ * prepare-task's `tools.some(t => t.name === "Write") && !exclude.includes("Write")` plus the
15
+ * remote-env conjunct (a hand-band Write on a remote ExecutionEnv writes the sandbox filesystem,
16
+ * not the host memory root it does not count unless the deployment declares the mount shared
17
+ * via `memoryPersistenceCapable: true`). Name occupancy IS the channel declaration for the local
18
+ * arms a hands-less run that mounts its own `Write` counts, and an `excludeTools: ["Write"]`
19
+ * run reads as unmounted whatever the hands band did. Threaded into `engine.inject` so a run with
20
+ * a writable memory scope but no write channel is not instructed to call a tool that is not on
21
+ * its roster (or cannot reach the store); the RB-276 index seed follows the same gate.
28
22
  */
29
23
  writeToolsMounted: boolean;
24
+ /**
25
+ * The session-wide persistence verdict: `TaskSpec.memoryPersistenceCapable` (the deployment's own
26
+ * statement — the only honest channel for a custom memory writer persisting through its closure,
27
+ * which no inference can see) when set, else the KNOWN-store-path inference: a mounted,
28
+ * non-excluded file-write tool (Write/Edit/NotebookEdit) or a write-capable shell. A generic
29
+ * write-effect tool does NOT count — a mail sender's side effect is not a memory store, and
30
+ * counting it re-opened the silent-confabulation hole. `false` ⇒ this phase mounts
31
+ * {@link MEMORY_READONLY_NOTICE} where the engine is silent.
32
+ */
33
+ rosterCanPersist: boolean;
34
+ /**
35
+ * The raw `TaskSpec.memoryPersistenceCapable` DECLARATION, kept separate from the composed
36
+ * {@link rosterCanPersist} verdict because the two drive different arms:
37
+ * - `true` (declared) RETRACTS the engine's own read-only notice — the deployment vouches for a
38
+ * persistence channel the engine cannot see (a custom writer persisting through its closure),
39
+ * so "you cannot save" beside it would be a false claim. An INFERRED-true roster must NOT
40
+ * retract: over a writeScope-null layering the engine will refuse those very file writes
41
+ * (`read_only_layering`), so the roster's write tools prove nothing about THIS store and
42
+ * stripping the notice re-opens the silent-confabulation hole for exactly the state the
43
+ * notice was built for.
44
+ * - `false` (declared) also CLOSES the file-tool write channel into the writable memory root
45
+ * (the write gate refuses), so the mounted notice's "the engine will not accept writes into
46
+ * the memory store" stays a true statement instead of a disclosure the store then contradicts.
47
+ */
48
+ memoryPersistenceDeclared?: boolean;
30
49
  /**
31
50
  * design/178 ②-1 — whether the `memory_search`/`memory_get` pair PASSED its early mount conjuncts
32
51
  * (exclusion + name occupancy, decided in prepare-task BEFORE this phase). True ⇒ this phase builds
@@ -1,8 +1,9 @@
1
+ import { sep } from "node:path";
1
2
  import { admitMemoryScopes } from "../memory-admission.js";
2
- import { adoptLegacyRepoDirs, deriveRepoControlPlaneDir, deriveProjectControlDir, deriveProjectMemoryDir, deriveRepoMemoryDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, lookupProjectIdHint, recordProjectIdHint, resolveMemoryEngineRoot } from "../memory-engine/layout.js";
3
+ import { adoptLegacyRepoDirs, canonicalize, deriveRepoControlPlaneDir, deriveProjectControlDir, deriveProjectMemoryDir, deriveRepoMemoryDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, isContainedIn, lookupProjectIdHint, recordProjectIdHint, resolveMemoryEngineRoot } from "../memory-engine/layout.js";
3
4
  import { classifyScopePlanes, derivePersonalControlDir, derivePersonalMemoryDir, mergeHarvestReports, mergeInjections, needsDualRoots, parsedProjectPlane } from "../memory-engine/dual-root.js";
4
5
  import { normalizeMemorySpec } from "../memory.js";
5
- import { MEMORY_PREFERENCE_DISCIPLINE, MEMORY_RECALL_DISCIPLINE, MemoryEngine } from "../memory-engine/engine.js";
6
+ import { MEMORY_ANNOUNCEMENT_READONLY_CODA, MEMORY_PREFERENCE_DISCIPLINE, MEMORY_READONLY_NOTICE, MEMORY_RECALL_DISCIPLINE, MemoryEngine } from "../memory-engine/engine.js";
6
7
  import { createMemoryEngineTools } from "../memory-engine/tools.js";
7
8
  import { assertScopeContractPlacement, parseScopeKey, resolveProjectId } from "../memory-engine/scope-contract.js";
8
9
  import { FileMemoryEngineBackend } from "../memory-engine/file-backend.js";
@@ -113,17 +114,24 @@ export async function prepareMemory(input) {
113
114
  }
114
115
  const personalMemoryDir = derivePersonalMemoryDir(engineRoot);
115
116
  const personalControlDir = derivePersonalControlDir(engineRoot);
116
- const createPersonalEngine = () => {
117
- const personalBackend = typeof pinned === "string" && pinned
118
- ? new FileMemoryEngineBackend(personalMemoryDir, { controlDir: personalControlDir })
119
- : backend;
117
+ const effectiveControlDir = (b, fallback) => {
118
+ const pinnedCtl = b.controlPlaneRoot;
119
+ return typeof pinnedCtl === "string" && pinnedCtl ? pinnedCtl : fallback;
120
+ };
121
+ const choosePersonalBackend = () => typeof pinned === "string" && pinned ? new FileMemoryEngineBackend(personalMemoryDir, { controlDir: personalControlDir }) : backend;
122
+ const createPersonalEngine = (personalBackend) => {
120
123
  return {
121
124
  engine: new MemoryEngine({ backend: personalBackend, memoryDir: personalMemoryDir, controlDir: personalControlDir, onIncident: onEngineIncident }),
122
125
  backend: personalBackend,
123
126
  };
124
127
  };
125
128
  const onEngineIncident = (err) => deps.onError?.(err, { phase: "memory", sessionId });
126
- const retrievalBackend = (b) => b.retrievalView?.() ?? b;
129
+ const adoptionRestricted = input.memoryPersistenceDeclared === false;
130
+ const retrievalBackend = (b, planeRestricted) => planeRestricted
131
+ ? (b.restrictedAdoptionView?.({ audit: false }) ??
132
+ b.retrievalView?.() ??
133
+ b)
134
+ : (b.retrievalView?.() ?? b);
127
135
  const planeScopes = (scopes, write) => [...new Set([...scopes, ...(write !== null ? [write] : [])])];
128
136
  const pollutedOpts = (engine) => {
129
137
  const rec = engine.sessionPollution(sessionId);
@@ -131,34 +139,62 @@ export async function prepareMemory(input) {
131
139
  };
132
140
  let writeEngine;
133
141
  let writeHandle;
142
+ let readOnlyEngine;
143
+ let readOnlyHandle;
134
144
  let injectFn;
135
145
  let harvestBoth;
136
146
  let toolPlanes;
147
+ const admitNothingOpts = input.memoryPersistenceDeclared === false
148
+ ? { admitNothing: { reason: "harvest admitted nothing: memory persistence is declared unavailable for this session (memoryPersistenceCapable: false)" } }
149
+ : {};
137
150
  if (dual) {
151
+ const personalBackendChosen = choosePersonalBackend();
152
+ {
153
+ const projCtl = effectiveControlDir(backend, identityKey !== undefined ? deriveProjectControlDir(engineRoot, identityKey) : deriveRepoControlPlaneDir(engineRoot, repoRoot));
154
+ const persCtl = effectiveControlDir(personalBackendChosen, personalControlDir);
155
+ const overlap = (a, b) => isContainedIn(a, b) || isContainedIn(b, a);
156
+ const refuse = (what, a, b) => {
157
+ const e = new Error(`memory dual-root configuration error: ${what} overlap (${canonicalize(a)} vs ${canonicalize(b)}) — the planes' data roots and control planes must all be disjoint.`);
158
+ e.code = "config.memory_dual_root_overlap";
159
+ throw e;
160
+ };
161
+ if (overlap(memoryDir, personalMemoryDir))
162
+ refuse("the project and personal memory roots", memoryDir, personalMemoryDir);
163
+ if (overlap(projCtl, persCtl))
164
+ refuse("the project and personal control planes", projCtl, persCtl);
165
+ for (const [ctlName, ctl] of [["project control plane", projCtl], ["personal control plane", persCtl]]) {
166
+ for (const [rootName, dataRoot] of [["project memory root", memoryDir], ["personal memory mount", personalMemoryDir]]) {
167
+ if (overlap(ctl, dataRoot))
168
+ refuse(`the ${ctlName} and the ${rootName}`, ctl, dataRoot);
169
+ }
170
+ }
171
+ }
138
172
  const projectEngine = new MemoryEngine({
139
173
  backend,
140
174
  memoryDir,
141
175
  controlDir: identityKey !== undefined ? deriveProjectControlDir(engineRoot, identityKey) : deriveRepoControlPlaneDir(engineRoot, repoRoot),
142
176
  onIncident: onEngineIncident,
143
177
  });
144
- const personal = createPersonalEngine();
178
+ const personal = createPersonalEngine(personalBackendChosen);
145
179
  const personalEngine = personal.engine;
146
180
  const p = planes;
147
- const projectHandle = await projectEngine.materialize(p.project, p.writePlane === "project" ? memorySpec.writeScope : null);
148
- const personalHandle = await personalEngine.materialize(p.personal, p.writePlane === "personal" ? memorySpec.writeScope : null);
181
+ const projectHandle = await projectEngine.materialize(p.project, p.writePlane === "project" ? memorySpec.writeScope : null, { adoptionRestricted });
182
+ const personalHandle = await personalEngine.materialize(p.personal, p.writePlane === "personal" ? memorySpec.writeScope : null, { adoptionRestricted });
149
183
  const writeIsPersonal = p.writePlane === "personal";
150
184
  writeEngine = writeIsPersonal ? personalEngine : projectEngine;
151
185
  writeHandle = writeIsPersonal ? personalHandle : projectHandle;
186
+ readOnlyEngine = writeIsPersonal ? projectEngine : personalEngine;
187
+ readOnlyHandle = writeIsPersonal ? projectHandle : personalHandle;
152
188
  injectFn = () => mergeInjections(projectEngine.inject(projectHandle, { writeToolMounted: input.writeToolsMounted }), personalEngine.inject(personalHandle, { writeToolMounted: input.writeToolsMounted }));
153
189
  toolPlanes = [
154
190
  {
155
- backend: retrievalBackend(backend),
191
+ backend: retrievalBackend(backend, adoptionRestricted || p.writePlane !== "project"),
156
192
  scopes: planeScopes(p.project, p.writePlane === "project" ? memorySpec.writeScope : null),
157
193
  recordRetrieved: (ids) => projectEngine.recordRetrieved(ids),
158
194
  challengeExclusions: () => projectEngine.readChallengeExclusions(),
159
195
  },
160
196
  {
161
- backend: retrievalBackend(personal.backend),
197
+ backend: retrievalBackend(personal.backend, adoptionRestricted || p.writePlane !== "personal"),
162
198
  scopes: planeScopes(p.personal, p.writePlane === "personal" ? memorySpec.writeScope : null),
163
199
  recordRetrieved: (ids) => personalEngine.recordRetrieved(ids),
164
200
  challengeExclusions: () => personalEngine.readChallengeExclusions(),
@@ -167,7 +203,7 @@ export async function prepareMemory(input) {
167
203
  harvestBoth = async () => {
168
204
  const writeFirst = writeIsPersonal ? [personalEngine, personalHandle] : [projectEngine, projectHandle];
169
205
  const readOther = writeIsPersonal ? [projectEngine, projectHandle] : [personalEngine, personalHandle];
170
- const writeReport = await writeFirst[0].harvest(writeFirst[1], { ...pollutedOpts(writeFirst[0]), sessionId });
206
+ const writeReport = await writeFirst[0].harvest(writeFirst[1], { ...pollutedOpts(writeFirst[0]), sessionId, ...admitNothingOpts });
171
207
  let readReport;
172
208
  let readFailure;
173
209
  try {
@@ -183,16 +219,16 @@ export async function prepareMemory(input) {
183
219
  };
184
220
  }
185
221
  else if (personalOnly) {
186
- const personal = createPersonalEngine();
222
+ const personal = createPersonalEngine(choosePersonalBackend());
187
223
  const personalEngine = personal.engine;
188
- const handle = await personalEngine.materialize(memorySpec.scopes, memorySpec.writeScope);
224
+ const handle = await personalEngine.materialize(memorySpec.scopes, memorySpec.writeScope, { adoptionRestricted });
189
225
  writeEngine = personalEngine;
190
226
  writeHandle = handle;
191
227
  injectFn = () => personalEngine.inject(handle, { writeToolMounted: input.writeToolsMounted });
192
- harvestBoth = () => personalEngine.harvest(handle, { ...pollutedOpts(personalEngine), sessionId });
228
+ harvestBoth = () => personalEngine.harvest(handle, { ...pollutedOpts(personalEngine), sessionId, ...admitNothingOpts });
193
229
  toolPlanes = [
194
230
  {
195
- backend: retrievalBackend(personal.backend),
231
+ backend: retrievalBackend(personal.backend, adoptionRestricted || memorySpec.writeScope === null),
196
232
  scopes: planeScopes(memorySpec.scopes, memorySpec.writeScope),
197
233
  recordRetrieved: (ids) => personalEngine.recordRetrieved(ids),
198
234
  challengeExclusions: () => personalEngine.readChallengeExclusions(),
@@ -206,21 +242,39 @@ export async function prepareMemory(input) {
206
242
  controlDir: identityKey !== undefined ? deriveProjectControlDir(engineRoot, identityKey) : deriveRepoControlPlaneDir(engineRoot, repoRoot),
207
243
  onIncident: onEngineIncident,
208
244
  });
209
- const handle = await engine.materialize(memorySpec.scopes, memorySpec.writeScope);
245
+ const handle = await engine.materialize(memorySpec.scopes, memorySpec.writeScope, { adoptionRestricted });
210
246
  writeEngine = engine;
211
247
  writeHandle = handle;
212
248
  injectFn = () => engine.inject(handle, { writeToolMounted: input.writeToolsMounted });
213
- harvestBoth = () => engine.harvest(handle, { ...pollutedOpts(engine), sessionId });
249
+ harvestBoth = () => engine.harvest(handle, { ...pollutedOpts(engine), sessionId, ...admitNothingOpts });
214
250
  toolPlanes = [
215
251
  {
216
- backend: retrievalBackend(backend),
252
+ backend: retrievalBackend(backend, adoptionRestricted || memorySpec.writeScope === null),
217
253
  scopes: planeScopes(memorySpec.scopes, memorySpec.writeScope),
218
254
  recordRetrieved: (ids) => engine.recordRetrieved(ids),
219
255
  challengeExclusions: () => engine.readChallengeExclusions(),
220
256
  },
221
257
  ];
222
258
  }
223
- memoryWriteGateRef.current = (w) => writeEngine.gateWrite(writeHandle, w.key, w.content);
259
+ memoryWriteGateRef.current = (w) => {
260
+ if (readOnlyEngine !== undefined && readOnlyHandle !== undefined) {
261
+ const ro = readOnlyEngine.gateWrite(readOnlyHandle, w.key, w.content);
262
+ if (!ro.ok)
263
+ return ro;
264
+ }
265
+ if (input.memoryPersistenceDeclared === false) {
266
+ const root = writeHandle.writableRoot;
267
+ if (w.key === root || w.key.startsWith(`${root}${sep}`)) {
268
+ return {
269
+ ok: false,
270
+ code: "read_only_layering",
271
+ reason: `memory persistence is declared unavailable for this session (memoryPersistenceCapable: false) — writes into the memory store are refused. Nothing was written.`,
272
+ muted: false,
273
+ };
274
+ }
275
+ }
276
+ return writeEngine.gateWrite(writeHandle, w.key, w.content);
277
+ };
224
278
  const harvestSafe = async (phase = "terminal") => {
225
279
  try {
226
280
  const report = await harvestBoth();
@@ -261,7 +315,10 @@ export async function prepareMemory(input) {
261
315
  memoryTools = createMemoryEngineTools({ planes: toolPlanes });
262
316
  }
263
317
  catch (err) {
264
- if (typeof err.code === "string" && (err.code.startsWith("config.memory_scope") || err.code.startsWith("config.memory_project"))) {
318
+ if (typeof err.code === "string" &&
319
+ (err.code.startsWith("config.memory_scope") ||
320
+ err.code.startsWith("config.memory_project") ||
321
+ err.code === "config.memory_dual_root_overlap")) {
265
322
  throw err;
266
323
  }
267
324
  deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "memory", sessionId });
@@ -275,15 +332,31 @@ export async function prepareMemory(input) {
275
332
  }
276
333
  if (memoryEngineSession) {
277
334
  const injection = memoryEngineSession.inject();
278
- if (injection.block.trim())
279
- memoryBlock = injection.block;
335
+ let blockBody = injection.block;
336
+ if (!input.rosterCanPersist && injection.instruction === "" && injection.readOnlyNotice === undefined) {
337
+ blockBody = blockBody.trim() ? `${MEMORY_READONLY_NOTICE}\n\n${blockBody}` : MEMORY_READONLY_NOTICE;
338
+ }
339
+ else if (!input.rosterCanPersist && injection.instruction !== "") {
340
+ blockBody = [MEMORY_READONLY_NOTICE, injection.index, injection.announceBlock].filter((s) => Boolean(s && s.trim())).join("\n\n");
341
+ }
342
+ else if (input.memoryPersistenceDeclared === true && injection.readOnlyNotice !== undefined) {
343
+ blockBody = [injection.index, injection.announceBlock].filter((s) => Boolean(s && s.trim())).join("\n\n");
344
+ }
345
+ if (!input.rosterCanPersist &&
346
+ (injection.announceBlock?.trim() ?? "") !== "" &&
347
+ blockBody.includes(injection.announceBlock) &&
348
+ !blockBody.trimEnd().endsWith(MEMORY_ANNOUNCEMENT_READONLY_CODA)) {
349
+ blockBody = `${blockBody}\n\n${MEMORY_ANNOUNCEMENT_READONLY_CODA}`;
350
+ }
351
+ if (blockBody.trim())
352
+ memoryBlock = blockBody;
280
353
  if (memoryTools !== undefined) {
281
354
  memoryBlock = memoryBlock !== undefined ? `${memoryBlock}\n\n${MEMORY_RECALL_DISCIPLINE}` : MEMORY_RECALL_DISCIPLINE;
282
355
  }
283
- if (memoryEngineSession.handle.writeScope !== null && input.writeToolsMounted) {
356
+ if (memoryEngineSession.handle.writeScope !== null && input.writeToolsMounted && input.rosterCanPersist) {
284
357
  memoryBlock = memoryBlock !== undefined ? `${memoryBlock}\n\n${MEMORY_PREFERENCE_DISCIPLINE}` : MEMORY_PREFERENCE_DISCIPLINE;
285
358
  }
286
- if (memoryBlock !== undefined && injection.indexSeed !== undefined)
359
+ if (memoryBlock !== undefined && injection.indexSeed !== undefined && input.rosterCanPersist)
287
360
  seedFiles = [injection.indexSeed];
288
361
  }
289
362
  return { memoryEngineSession, memoryBlock, admittedOrgScopes, ownOrgVerdict, ...(memoryTools !== undefined ? { memoryTools } : {}), ...(seedFiles !== undefined ? { seedFiles } : {}) };
@@ -354,7 +354,10 @@ export interface Prepared {
354
354
  * deferred; a caller with no accessor has no deferred family to describe. */
355
355
  staticFaceFor?: (name: string) => boolean;
356
356
  /**
357
- * design/138 S1 — the MemoryEngine session (present iff `deps.memoryBackend` + `spec.memory.enabled`).
357
+ * design/138 S1 — the MemoryEngine session. Present when `deps.memoryBackend` + `spec.memory.enabled`
358
+ * hold AND the engine mount succeeded: a materialize failure without a `config.memory_*` code is
359
+ * fail-open (reported via `deps.onError`, the task runs memory-less), leaving this absent even though
360
+ * both flags hold.
358
361
  * `harvest` is the swallow-guarded boundary hook (task terminal in runtask + the checkpoint mint
359
362
  * point in commitSuspendSaga): it runs the FULL gate set (containment/secret/caps/deletion fuse),
360
363
  * commits entry patches to the backend, self-heals the derived index, and re-baselines (a second
@@ -1296,8 +1299,11 @@ export interface RunInternals {
1296
1299
  * design/99 (nested-subagent live tree) — an OPT-IN, DISPLAY-ONLY event sink a deployment sets on the TOP run to
1297
1300
  * receive a subagent's live `task_progress` ticks (which otherwise stay in the child's ISOLATED stream). Threaded
1298
1301
  * recursively down the delegation tree (via `ctx.forwardEvent`), so every nested subagent's ticks bubble to the
1299
- * SAME sink. The Runner forwards ONLY `task_progress` through it; the child stream is NEVER merged into the
1300
- * parent's MODEL context (this is purely a render channel). Absent unless the deployment opted in.
1302
+ * SAME sink. The Runner's ctx wrapper forwards `task_progress` always; when the run's spec sets
1303
+ * `forwardSubagentEvents: true` it ALSO forwards the child's content events (`text_delta` / `reasoning_delta` /
1304
+ * `tool_start` / `tool_end` — the subagent viewing pane, carrying the same UNTRUSTED-RAW/consumer-must-redact
1305
+ * contract as the main stream's tool events). Either way the child stream is NEVER merged into the parent's
1306
+ * MODEL context (this is purely a render channel). Absent unless the deployment opted in.
1301
1307
  */
1302
1308
  onForwardEvent?: (event: TaskEvent) => void;
1303
1309
  /**