@sema-agent/core 5.25.0 → 5.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/agents/agent-definition.js +5 -0
  3. package/dist/agents/send-message-tool.js +1 -0
  4. package/dist/agents/subagent.d.ts +1 -0
  5. package/dist/agents/subagent.js +5 -0
  6. package/dist/core/hooks.js +3 -2
  7. package/dist/core/memory-engine/dual-root.js +3 -1
  8. package/dist/core/memory-engine/engine.d.ts +45 -1
  9. package/dist/core/memory-engine/engine.js +23 -5
  10. package/dist/core/memory-engine/index.d.ts +1 -1
  11. package/dist/core/memory-engine/index.js +1 -1
  12. package/dist/core/permission-rule-consent.js +8 -1
  13. package/dist/core/runner/compaction-call-options.d.ts +4 -4
  14. package/dist/core/runner/compaction-call-options.js +3 -4
  15. package/dist/core/runner/prepare-memory.d.ts +34 -15
  16. package/dist/core/runner/prepare-memory.js +85 -17
  17. package/dist/core/runner/prepare-task.js +31 -7
  18. package/dist/core/store-contracts/tool-result-store-contract.d.ts +6 -0
  19. package/dist/core/store-contracts/tool-result-store-contract.js +24 -0
  20. package/dist/core/task-registry-agent.js +3 -3
  21. package/dist/core/task-registry-monitor.js +6 -5
  22. package/dist/core/tool-result-budget.d.ts +1 -1
  23. package/dist/core/tool-result-budget.js +3 -3
  24. package/dist/core/tool-result-store.d.ts +164 -9
  25. package/dist/core/tool-result-store.js +82 -23
  26. package/dist/core/types.d.ts +68 -0
  27. package/dist/core/untrusted-text.d.ts +6 -2
  28. package/dist/core/untrusted-text.js +1 -1
  29. package/dist/engine/session/import-validate.js +2 -1
  30. package/dist/index.d.ts +3 -3
  31. package/dist/index.js +3 -3
  32. package/dist/orchestration/workflow.js +2 -0
  33. package/dist/prompts/default.d.ts +11 -0
  34. package/dist/prompts/default.js +3 -0
  35. package/dist/stores/file/fs-atomic.d.ts +1 -1
  36. package/dist/stores/file/tool-result-store.d.ts +45 -9
  37. package/dist/stores/file/tool-result-store.js +76 -9
  38. package/dist/tools/fs/fs-shared.js +5 -4
  39. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.26.0 — 2026-08-11
4
+
5
+ No API-BREAKING changes (exports grow only; new members optional/additive). Several
6
+ behavior-surface narrowings — every one tighten-direction — called out below.
7
+
8
+ ### Narrowed (behavior, ruled)
9
+
10
+ - **A remote execution env no longer teaches the memory write path.** With `executionEnv` remote
11
+ and a memory store mounted, the `# Memory` write instruction is withheld (the sandboxed hands
12
+ cannot reach the host store; teaching the path walked the model into receipting saves that never
13
+ land). A deployment persisting through its own closure restores it by declaring
14
+ `TaskSpec.memoryPersistenceCapable: true`. **Consumer note**: probes pinning "remote run carries
15
+ the write instruction" go red; re-pin on the declaration.
16
+ - **`memoryPersistenceCapable: false` is enforced, not just disclosed.** Over a writable scope the
17
+ session mounts the read-only notice AND the write gate refuses file-tool writes into the memory
18
+ root (`read_only_layering`, loud), and the terminal/checkpoint harvest admits nothing — through
19
+ the engine's zero-admission arm, so the report half still runs: materialize-time inbound findings
20
+ and quarantine escalations are drained into a real `HarvestReport` (with a warning naming the
21
+ declaration) instead of a fabricated empty one.
22
+ - **The read-only notice retraction is DECLARED-only.** Only an explicit
23
+ `memoryPersistenceCapable: true` retracts the engine's writeScope-null notice; a write-capable
24
+ roster alone (inferred capability) no longer strips it — the gate refuses those very writes, so
25
+ inference proved nothing about the store.
26
+ - **Dual-root overlap is a prepare-time configuration error.** Two memory planes whose data roots
27
+ or control dirs overlap (either direction, either tier) are refused loudly at prepare instead of
28
+ running two engines over shared physical state.
29
+ - **A shell-doctrine mark is positive-only and monotonic.** A negative `irreversibility` tier is
30
+ not a provenance mark; `shellGate:"classify"` never downgrades an explicitly marked `"always"`.
31
+ Deployments with marked tools will see asks that classify used to swallow.
32
+ - **Tool-result refs are injective and conflict-typed (#119).** The mint hashes an injective
33
+ representation (four `~`-separated segments incl. a content digest); writing a different payload
34
+ under an existing ref is a typed `ref_conflict` error, never a silent overwrite; a damaged owner
35
+ record refuses whole.
36
+
37
+ ### Added
38
+
39
+ - **`TaskSpec.memoryPersistenceCapable`** (tri-state, additive): `true` = the deployment vouches
40
+ for a persistence channel the engine cannot see; `false` = mandatory floor (disclosure + write
41
+ gate + zero-admission harvest); absent = inferred from the final roster. Crosses the delegation
42
+ boundary tighten-only (a parent's `false` binds the subtree; a child definition cannot loosen
43
+ it). Non-boolean values are refused loudly at both doors (`config.memory_persistence_invalid` /
44
+ `config.agent.invalid`).
45
+ - **`MEMORY_READONLY_NOTICE` / `NO_PERSISTENT_MEMORY_NOTICE` exported**, and
46
+ **`MemoryInjection.readOnlyNotice`** (additive member): a session with no write channel is told
47
+ so instead of silently receipting saves; the standalone notice serves rosters the engine never
48
+ sees.
49
+ - **`MemoryEngine.harvest` accepts `admitNothing`** (additive option): a real harvest that commits
50
+ nothing while still draining and announcing inbound findings.
51
+
52
+ ### Fixed
53
+
54
+ - Delegation seats forward the capability floor (subagent arbitration, retained-resume folding,
55
+ workflow agent-type folding fill absence only).
56
+ - The offloaded-detail notice allowance derives from the ref-mint ceiling (a literal sized against
57
+ the retired two-segment shape under-bounded the four-segment notice).
58
+ - Ten stale-or-loose doc/comment spots from the merged-code rescan (classify doctrine conditionals
59
+ stated in public docs; JSDoc reattachments; TiDB sizing note states the four-segment mint).
60
+
61
+ ### Known residual (registered, not a regression)
62
+
63
+ - The zero-copy File backend's read-side inbound sync adopts disk divergence independent of session
64
+ intent: bytes a shell lands under the memory root during a read-only or declared-false session
65
+ can be adopted at a later materialize (checkpoint-resume included). Registered with three
66
+ candidate fixes pending a direction ruling; the disclosure, gate, and harvest boundaries above
67
+ all hold — this is the remaining channel, stated here so the boundary's edges are explicit.
68
+
3
69
  ## 5.25.0 — 2026-08-10
4
70
 
5
71
  No API-BREAKING changes (exports grow only; every new member is optional/additive). One
@@ -4,5 +4,10 @@ export function defineAgent(def) {
4
4
  e.code = "config.agent.invalid";
5
5
  throw e;
6
6
  }
7
+ if (def.memoryPersistenceCapable !== undefined && typeof def.memoryPersistenceCapable !== "boolean") {
8
+ const e = new Error(`defineAgent("${def.name}"): memoryPersistenceCapable must be a boolean when present — got ${JSON.stringify(def.memoryPersistenceCapable)} (a string "false" would silently read as capable).`);
9
+ e.code = "config.agent.invalid";
10
+ throw e;
11
+ }
7
12
  return Object.freeze({ ...def });
8
13
  }
@@ -682,6 +682,7 @@ export function createSendMessageTool(opts) {
682
682
  ...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {}),
683
683
  ...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
684
684
  ...(ctx.oneShot === true ? { oneShot: true } : {}),
685
+ ...(ctx.memoryPersistenceCapable === false ? { memoryPersistenceCapable: false } : {}),
685
686
  },
686
687
  ...(ctx.autoModeReview !== undefined ? { currentAutoModeReview: ctx.autoModeReview } : {}),
687
688
  });
@@ -400,6 +400,7 @@ export declare function createSubagentResume(deps: {
400
400
  handsReadOnly?: true;
401
401
  interactiveTools?: false;
402
402
  oneShot?: true;
403
+ memoryPersistenceCapable?: false;
403
404
  };
404
405
  /** The RESUMING caller's own handback-review seat (its trusted `ToolExecuteContext.autoModeReview`),
405
406
  * same turn-bound reasoning as `currentOnQuestion` above: a resumed cycle is a completion like any
@@ -522,6 +522,9 @@ export function createSubagentResume(deps) {
522
522
  ...(entry.specSnapshot.handsReadOnly === true || deps.currentClamps?.handsReadOnly === true ? { handsReadOnly: true } : {}),
523
523
  ...(entry.specSnapshot.interactiveTools === false || deps.currentClamps?.interactiveTools === false ? { interactiveTools: false } : {}),
524
524
  ...(entry.specSnapshot.oneShot === true || deps.currentClamps?.oneShot === true ? { oneShot: true } : {}),
525
+ ...(entry.specSnapshot.memoryPersistenceCapable === false || deps.currentClamps?.memoryPersistenceCapable === false
526
+ ? { memoryPersistenceCapable: false }
527
+ : {}),
525
528
  signal: abort.signal,
526
529
  };
527
530
  if (deps.registry !== undefined && deps.taskId !== undefined && deps.taskAccess !== undefined) {
@@ -1668,6 +1671,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1668
1671
  };
1669
1672
  const childThinking = def?.thinking ?? ctx.thinkingLevel;
1670
1673
  const provenanceAgentName = agentName ?? def?.name;
1674
+ const childMemoryPersistenceCapable = ctx.memoryPersistenceCapable === false ? false : (def?.memoryPersistenceCapable ?? ctx.memoryPersistenceCapable);
1671
1675
  const childOnAsk = ctx.onAsk !== undefined
1672
1676
  ? withDelegationProvenance(ctx.onAsk, {
1673
1677
  parentToolCallId: ctx.toolCallId,
@@ -1689,6 +1693,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1689
1693
  ...(ctx.onQuestion !== undefined ? { onQuestion: ctx.onQuestion } : {}),
1690
1694
  ...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
1691
1695
  ...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {}),
1696
+ ...(childMemoryPersistenceCapable !== undefined ? { memoryPersistenceCapable: childMemoryPersistenceCapable } : {}),
1692
1697
  ...(ctx.oneShot === true ? { oneShot: true } : {}),
1693
1698
  ...(ctx.clientContext !== undefined ? { clientContext: ctx.clientContext } : {}),
1694
1699
  ...(ctx.excludeTools !== undefined ? { excludeTools: [...ctx.excludeTools] } : {}),
@@ -342,11 +342,12 @@ export async function runToolGate(input) {
342
342
  await notifier.notifyAsync(() => input.persistedRules?.onResolved?.({ toolName: req.toolName, toolCallId, rule: hit }), "toolGate.persistedRuleResolved");
343
343
  }
344
344
  else if (hit !== undefined) {
345
+ const shownRule = inlineUntrusted(hit, 200);
345
346
  const mandateNoun = persistedRuleMandate === "operator_always" ? "this deployment mandates per-call confirmation for shell commands (shellGate: always)" : "this tool carries egress/irreversibility marks (a mandated confirmation a rule cannot clear)";
346
347
  decision = {
347
348
  ...decision,
348
- persistedRuleShadowed: hit,
349
- message: `${decision.message !== undefined ? `${decision.message} ` : ""}(a persisted allow rule (${hit}) matches this call but does not clear the ask — ${mandateNoun})`,
349
+ persistedRuleShadowed: shownRule,
350
+ message: `${decision.message !== undefined ? `${decision.message} ` : ""}(a persisted allow rule (${shownRule}) matches this call but does not clear the ask — ${mandateNoun})`,
350
351
  };
351
352
  }
352
353
  }
@@ -31,13 +31,15 @@ export function mergeInjections(project, personal) {
31
31
  if (!personal)
32
32
  return project;
33
33
  const instruction = project.instruction || personal.instruction;
34
+ const readOnlyNotice = instruction === "" && project.readOnlyNotice !== undefined && personal.readOnlyNotice !== undefined ? project.readOnlyNotice : undefined;
34
35
  const indexParts = [project.index, personal.index].filter((s) => Boolean(s && s.trim()));
35
36
  const announcements = [...(project.announcements ?? []), ...(personal.announcements ?? [])];
36
37
  const announceParts = [project.announceBlock, personal.announceBlock].filter((s) => Boolean(s && s.trim()));
37
- const blockParts = [instruction, ...indexParts, ...announceParts];
38
+ const blockParts = [instruction || readOnlyNotice, ...indexParts, ...announceParts];
38
39
  const indexSeed = project.indexSeed ?? personal.indexSeed;
39
40
  return {
40
41
  instruction,
42
+ ...(readOnlyNotice !== undefined ? { readOnlyNotice } : {}),
41
43
  ...(indexParts.length > 0 ? { index: indexParts.join("\n\n") } : {}),
42
44
  ...(announcements.length > 0 ? { announcements } : {}),
43
45
  ...(announceParts.length > 0 ? { announceBlock: announceParts.join("\n\n") } : {}),
@@ -40,6 +40,37 @@ export declare const MEMORY_RECALL_DISCIPLINE = "Before answering questions abou
40
40
  * would forge the very account the three-tier discipline forbids forging).
41
41
  */
42
42
  export declare const MEMORY_PREFERENCE_DISCIPLINE = "When the user confirms a stored preference or fact still holds, refresh that entry's `last-confirmed: <YYYY-MM-DD>` frontmatter line (add it when absent). When you save a preference, add an `applies-when: <context>` frontmatter line naming when it applies. Both are plain frontmatter lines \u2014 write them yourself; nothing fills them in for you.";
43
+ /**
44
+ * Corrections for announcement segments that carry store-mutation guidance ("record a fresh entry …
45
+ * tombstone the old one", minted in an earlier writable session) a session cannot act on. The queue
46
+ * items are opaque strings (rewriting them would be text surgery over minted-at-enqueue wording),
47
+ * so the correction is a trailing coda, not a rewrite — in two scopes, because the two mounting
48
+ * seats speak about different things (an unqualified "the memory store is not writable" beside
49
+ * another, WRITABLE plane's instruction would negate that plane's guidance):
50
+ * - PLANE scope, attached by `inject()` right after a read-only layering's own announcement block:
51
+ * explicitly local to the immediately preceding notices, so a mixed dual-root merge keeps the
52
+ * writable plane's instruction and announcements fully actionable.
53
+ * - SESSION scope, attached by the runner at the block tail when the whole session cannot persist:
54
+ * there is no writable instruction left standing there (it is replaced or absent), so the global
55
+ * wording is accurate. Name-free by construction (#181 class).
56
+ */
57
+ export declare const MEMORY_ANNOUNCEMENT_READONLY_PLANE_CODA = "The notices immediately above concern a READ-ONLY memory store: any guidance in them to record, update, or tombstone an entry cannot be applied to that store this session \u2014 surface it to the user instead of claiming it done.";
58
+ export declare const MEMORY_ANNOUNCEMENT_READONLY_CODA = "The memory store itself is not writable this session, so any guidance above to record, update, or tombstone a memory entry cannot be applied here \u2014 surface it to the user instead of claiming it done.";
59
+ /**
60
+ * The read-only counterpart of the `# Memory` write instruction. A run with memory mounted but no
61
+ * way to write it used to get an EMPTY instruction — correct in what it doesn't teach, but silent
62
+ * about the state itself, and a model asked "remember X" under that silence answers with a success
63
+ * receipt for a save that never happens (the confabulated-receipt shape). This section states the
64
+ * state instead. Mounted only where "you cannot save" is provably TRUE: by `inject()` for a
65
+ * read-only layering (writeScope null ⇒ chmod'd tree), and by the runner for handsReadOnly (the
66
+ * shell rides the read-only band), for a declared-unavailable session (`memoryPersistenceCapable:
67
+ * false` — where the runner's write gate also refuses the file channel, keeping the engine-refusal
68
+ * sentence true), and for a write-less roster the persistence inference cannot vouch for. A merely
69
+ * Write-less roster does NOT qualify — other tools can still write the root. Name-free by
70
+ * construction (#181 class — it names no tool), and NOT part of the CC-verbatim capture: CC has no
71
+ * read-only memory layering, so there is nothing to capture; the section is sema-authored.
72
+ */
73
+ export declare const MEMORY_READONLY_NOTICE = "# Memory\n\nYou have READ-ONLY access to persistent memory in this session: stored notes are available below, but this session has no memory write channel \u2014 the engine will not accept writes into the memory store. If the user asks you to remember something for later, say plainly that you cannot save it in this session \u2014 never claim to have noted or remembered it.";
43
74
  /** CC index-injection parameters: MEMORY.md's first 200 lines / 25KB enter the prompt. */
44
75
  export declare const MEMORY_INDEX_MAX_LINES = 200;
45
76
  export declare const MEMORY_INDEX_MAX_BYTES: number;
@@ -105,6 +136,16 @@ export interface MemoryInjection {
105
136
  /** CC `# Memory` section (system-authority instruction — §0.3 逐字 surface). Empty for a read-only
106
137
  * layering (no write channel to instruct). */
107
138
  instruction: string;
139
+ /** Present exactly when this is a read-only LAYERING (writeScope null — the tree is chmod'd
140
+ * read-only, so no tool writes into it whatever the roster): the {@link MEMORY_READONLY_NOTICE}
141
+ * section stating that memory cannot be saved to, so the model declines "remember X" instead of
142
+ * issuing a confabulated success receipt. NOT set for `writeToolMounted:false` over a writable
143
+ * scope — there only the instruction-named tool is absent, other roster tools can still write the
144
+ * root, and the claim would be false (the runner mounts the notice for its own provably
145
+ * write-less shape, handsReadOnly). Kept as its OWN member (not folded into `instruction`) so the
146
+ * dual-root merge's "the write plane's instruction wins" falsy-OR keeps working: a read-only
147
+ * plane must never outrank a write plane's instruction. */
148
+ readOnlyNotice?: string;
108
149
  /** The derived MEMORY.md index, truncated (200 lines / 25KB) and FENCED untrusted. Undefined when empty. */
109
150
  index?: string;
110
151
  /** design/138 S2-B (时机①) — the announcements DRAINED by this inject (queued by the previous
@@ -301,7 +342,7 @@ export declare class MemoryEngine {
301
342
  ok: true;
302
343
  } | {
303
344
  ok: false;
304
- code: ScanFinding["code"];
345
+ code: ScanFinding["code"] | "read_only_layering";
305
346
  reason: string;
306
347
  muted: boolean;
307
348
  };
@@ -315,6 +356,9 @@ export declare class MemoryEngine {
315
356
  reason: string;
316
357
  };
317
358
  sessionId?: string;
359
+ admitNothing?: {
360
+ reason: string;
361
+ };
318
362
  }): Promise<HarvestReport>;
319
363
  private harvestCore;
320
364
  /**
@@ -34,6 +34,11 @@ export function buildMemoryInstruction(memoryDir, instructionFileName) {
34
34
  }
35
35
  export const MEMORY_RECALL_DISCIPLINE = "Before answering questions about earlier work, decisions, dates, people, or the user's preferences, look them up: `memory_search` finds entries by keyword and `memory_get` reads a full entry — the injected memory index only lists what exists. When a lookup comes up empty, say that you checked memory and found nothing instead of guessing.";
36
36
  export const MEMORY_PREFERENCE_DISCIPLINE = "When the user confirms a stored preference or fact still holds, refresh that entry's `last-confirmed: <YYYY-MM-DD>` frontmatter line (add it when absent). When you save a preference, add an `applies-when: <context>` frontmatter line naming when it applies. Both are plain frontmatter lines — write them yourself; nothing fills them in for you.";
37
+ export const MEMORY_ANNOUNCEMENT_READONLY_PLANE_CODA = "The notices immediately above concern a READ-ONLY memory store: any guidance in them to record, update, or tombstone an entry cannot be applied to that store this session — surface it to the user instead of claiming it done.";
38
+ export const MEMORY_ANNOUNCEMENT_READONLY_CODA = "The memory store itself is not writable this session, so any guidance above to record, update, or tombstone a memory entry cannot be applied here — surface it to the user instead of claiming it done.";
39
+ export const MEMORY_READONLY_NOTICE = `# Memory
40
+
41
+ You have READ-ONLY access to persistent memory in this session: stored notes are available below, but this session has no memory write channel — the engine will not accept writes into the memory store. If the user asks you to remember something for later, say plainly that you cannot save it in this session — never claim to have noted or remembered it.`;
37
42
  export const MEMORY_INDEX_MAX_LINES = 200;
38
43
  export const MEMORY_INDEX_MAX_BYTES = 25 * 1024;
39
44
  export const STUB_ARCHIVED_LINE = "[body archived — request hydration by listing the slug in memory/.hydrate]";
@@ -41,6 +46,7 @@ export const DEFAULT_MAX_MEMORY_FILES = 500;
41
46
  export const DEFAULT_HARVEST_DEADLINE_MS = 5_000;
42
47
  export const DEFAULT_HARVEST_FILE_BUDGET = 2_000;
43
48
  export const MASS_DELETION_FUSE_RATIO = 0.5;
49
+ let indexCaptureSeq = 0;
44
50
  export class MemoryEngine {
45
51
  backend;
46
52
  memoryDir;
@@ -425,6 +431,7 @@ export class MemoryEngine {
425
431
  inject(handle, opts) {
426
432
  const writeChannel = handle.writeScope !== null && opts?.writeToolMounted !== false;
427
433
  const instruction = writeChannel ? buildMemoryInstruction(handle.writableRoot) : "";
434
+ const readOnlyNotice = handle.writeScope === null ? MEMORY_READONLY_NOTICE : undefined;
428
435
  const indexPath = join(handle.writableRoot, MEMORY_INDEX_FILENAME);
429
436
  const onDisk = handle.indexOnDiskUntrusted === true ? undefined : readSafe(indexPath);
430
437
  const indexText = onDisk !== undefined && onDisk.trim() !== "" ? onDisk : handle.indexText;
@@ -440,14 +447,17 @@ export class MemoryEngine {
440
447
  if (drained.queue.length > 0 || drained.folded > 0) {
441
448
  announcements = drained.queue;
442
449
  announceBlock = renderAnnouncements(drained.queue, drained.folded);
450
+ if (handle.writeScope === null)
451
+ announceBlock = `${announceBlock}\n\n${MEMORY_ANNOUNCEMENT_READONLY_PLANE_CODA}`;
443
452
  }
444
453
  }
445
454
  catch (err) {
446
455
  this.discloseAnnounceFailure("inject drain", err);
447
456
  }
448
- const block = [instruction, index, announceBlock].filter((s) => Boolean(s && s.trim())).join("\n\n");
457
+ const block = [instruction || readOnlyNotice, index, announceBlock].filter((s) => Boolean(s && s.trim())).join("\n\n");
449
458
  return {
450
459
  instruction,
460
+ ...(readOnlyNotice !== undefined ? { readOnlyNotice } : {}),
451
461
  ...(index !== undefined ? { index } : {}),
452
462
  ...(announcements !== undefined ? { announcements } : {}),
453
463
  ...(announceBlock !== undefined ? { announceBlock } : {}),
@@ -459,8 +469,14 @@ export class MemoryEngine {
459
469
  const root = handle.writableRoot;
460
470
  if (canonicalPath !== root && !canonicalPath.startsWith(`${root}${sep}`))
461
471
  return { ok: true };
462
- if (handle.writeScope === null)
463
- return { ok: true };
472
+ if (handle.writeScope === null) {
473
+ return {
474
+ ok: false,
475
+ code: "read_only_layering",
476
+ reason: "this session's memory is read-only (no write scope) — the engine does not accept writes into the memory domain. Nothing was written.",
477
+ muted: false,
478
+ };
479
+ }
464
480
  const findings = [];
465
481
  const nameFinding = scanMemoryFileName(relative(root, canonicalPath));
466
482
  if (nameFinding !== undefined)
@@ -512,10 +528,12 @@ export class MemoryEngine {
512
528
  warnings: [],
513
529
  };
514
530
  const writeScope = handle.writeScope;
515
- if (writeScope === null) {
531
+ if (writeScope === null || opts?.admitNothing !== undefined) {
516
532
  const roFindings = this.backend.drainInboundFindings?.();
517
533
  if (roFindings !== undefined && roFindings.length > 0)
518
534
  report.inboundFindings = roFindings;
535
+ if (opts?.admitNothing !== undefined)
536
+ report.warnings.push(opts.admitNothing.reason);
519
537
  return report;
520
538
  }
521
539
  try {
@@ -1240,7 +1258,7 @@ export class MemoryEngine {
1240
1258
  }
1241
1259
  }
1242
1260
  if (!landed)
1243
- writeFileSync(`${dest}.${process.pid}`, text, { encoding: "utf8", flag: "wx" });
1261
+ writeFileSync(`${dest}.${process.pid}.${indexCaptureSeq++}`, text, { encoding: "utf8", flag: "wx" });
1244
1262
  captured = true;
1245
1263
  }
1246
1264
  catch (err) {
@@ -1,4 +1,4 @@
1
- export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_RECALL_DISCIPLINE, MEMORY_PREFERENCE_DISCIPLINE, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, renderAnnouncements, type MemoryEngineOptions, type MemoryInjection, } from "./engine.js";
1
+ export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_RECALL_DISCIPLINE, MEMORY_PREFERENCE_DISCIPLINE, MEMORY_READONLY_NOTICE, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, renderAnnouncements, type MemoryEngineOptions, type MemoryInjection, } from "./engine.js";
2
2
  export { MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME, MEMORY_ENGINE_TOOL_NAMES, type MemorySearchDetails, type MemorySearchHit, type MemoryGetDetails, } from "./tools.js";
3
3
  export { scanMemoryWrite, scanMemoryFileName, scanRemediation, MEMORY_FILENAME_SEGMENT_RE } from "./scan.js";
4
4
  export { FileMemoryEngineBackend, scanEntryFiles, MEMORY_INDEX_FILENAME, DEFAULT_MAX_ENTRY_DEPTH, type ScannedEntryFile } from "./file-backend.js";
@@ -1,4 +1,4 @@
1
- export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_RECALL_DISCIPLINE, MEMORY_PREFERENCE_DISCIPLINE, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, renderAnnouncements, } from "./engine.js";
1
+ export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_RECALL_DISCIPLINE, MEMORY_PREFERENCE_DISCIPLINE, MEMORY_READONLY_NOTICE, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, renderAnnouncements, } from "./engine.js";
2
2
  export { MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME, MEMORY_ENGINE_TOOL_NAMES, } from "./tools.js";
3
3
  export { scanMemoryWrite, scanMemoryFileName, scanRemediation, MEMORY_FILENAME_SEGMENT_RE } from "./scan.js";
4
4
  export { FileMemoryEngineBackend, scanEntryFiles, MEMORY_INDEX_FILENAME, DEFAULT_MAX_ENTRY_DEPTH } from "./file-backend.js";
@@ -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}` });
@@ -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