@sema-agent/core 5.42.0 → 5.44.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 CHANGED
@@ -1,5 +1,102 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.44.0 — 2026-08-19
4
+
5
+ ### Added
6
+ - Memory pollution is LOUD (#324a): two EngineNotice codes — `memory.session_polluted` (once per
7
+ session, minted on the wx-atomic first transition; states the VERDICT: content written after the
8
+ mark is not admitted to the long-term library at harvest) and `memory.harvest_quarantined`
9
+ (at most once per harvest when a polluted session's harvest withheld ≥1 entry file;
10
+ `detail: {count, moved, escalated, reason?, sessionId?}` — deduplicated-path counts, `moved` =
11
+ files that actually left the model-visible plane, `escalated` = quarantine failures). Both ride
12
+ `deliverEngineNotice` (seat / absent-warn / malformed-seat fallback, unchanged). The notices
13
+ state facts, not promises: writeScope-null and persistence-incapable arms take the zero-adoption
14
+ return and are NOT described as quarantined.
15
+ - `MemoryEngine.markSessionPolluted` now returns `SessionPollutionMarkOutcome` (was `void`) —
16
+ additive; the wx-atomic marker distinguishes `"created" | "existed" | "unpersisted"`.
17
+ - Release-criteria gate hardening (5.44 rescan disposal, 8 findings): the EngineNotice MINT
18
+ judgment is checker-based (the `code` value inside an object literal whose non-nullable
19
+ contextual type carries `code`+`message` on EVERY union arm, in files the shipped build
20
+ compiles) — a literal type, the `err.code=` refusal dialect, a comment, an examples-only mint
21
+ and a bare `{code,message}` const no longer count; a public CLASS is a legal citation root
22
+ (instance + declared statics, minus private/write-only/language-supplied); a disclosed release
23
+ (CHANGELOG section present) with no criteria document reds the gate.
24
+ - design/278 recipe pins: the coordinator fresh-spawn recipe core arms (spawn-fresh imperative,
25
+ verbatim user quote, literal command, execute-only, relayed-consent line) are pinned six-ways —
26
+ the recipe the G6 classifier exception depends on can no longer drift silently. (The #278
27
+ product fix itself shipped in 5.38.0; the ledger row was stale and is now closed.)
28
+
29
+ ### Changed
30
+ - Grep target-spelling flip completion (#318): `jsGrepDetailed` answers — including the rescan
31
+ legs an rg-present host falls back to — carry the single explicit FILE target's own spelling
32
+ (family-aware dot-segment collapse, one family judgment for the whole anchor; `..` cannot climb
33
+ past a namespace root). The two engines no longer disagree on path shape for the same call.
34
+ - Grep structured rows completion (#313 remainder): the JS legs hand served rows too; the served-row
35
+ contract states its one measured exception.
36
+ - `memory_get` not-found no longer blindly suggests `memory_search`; when the missing slug was
37
+ written THIS session the text states the mount-snapshot fact (new writes become retrievable
38
+ next session) and, in a polluted session, states that the write will be withheld at harvest.
39
+ `memory_search` zero-hit output appends the pollution disclosure line in polluted sessions only
40
+ (clean sessions byte-identical). Tool contract revisions move: search 1→2, get 2→3.
41
+ - The 5.43.0 `-H` entry erratum (recorded in that section): the flip covers count mode too, and
42
+ the "without a deny judge" arm is not a configuration any public route produces.
43
+
44
+ ### Fixed
45
+ - `docs/release-criteria/5.43.0.json` four unexecutable/false arms repaired (deny-less arm no
46
+ public route can produce; the deny-note flip F5 declared and claimed by G3; the per-process
47
+ console latch construction made blackbox-executable; the rg-leg note wording quoted as minted).
48
+
49
+ ## 5.43.0 — 2026-08-18
50
+
51
+ ### Added
52
+ - `TaskResult.checkpointId` — the non-secret checkpoint identity now rides the suspended /
53
+ needs_review terminals beside `checkpointToken` (full chain: mint → publishCommittedSuspend →
54
+ suspend/review ref → runTask ref copy → assemble-result; the ref copy is an explicit-key
55
+ whitelist and the new key crosses it). **Erratum for the 5.42.0 entry**: that release minted
56
+ `checkpointId` on `Checkpoint`/`CheckpointSummary` only — it did NOT yet reach `TaskResult`;
57
+ the entry omitted that boundary. This release completes the face.
58
+ - `NodeExecutionEnv` constructor seat `onSecretEnvScrub` (#316): the Bash exec site's secret-env
59
+ scrub decision is observable. Findings name the denylist-matched keys withheld from the child
60
+ env; a caller-overridden key (constructor `shellEnv` or per-exec `env`) is NOT reported withheld —
61
+ the child has it by the caller's explicit choice; an async seat's rejection is consumed (an announcement must
62
+ never become an exec failure); unwired builds print a once-per-process console summary.
63
+ Companion doc fix (#315, doc half): the scrub matches key NAMES against the denylist — a secret
64
+ VALUE under an innocent name is not detected; the doc now says so where the denylist lives.
65
+ - Grep structured rows (#313, first stage): the ripgrep legs hand their served rows
66
+ (`{path?, text}`, post cap/offset) to the tool layer, and `grepDetailFields` prefers row
67
+ identity when every served row carries it — `details.filenames` on rg legs no longer re-parses
68
+ paths out of formatted text (a path containing `:digits:` can no longer split wrong). Text
69
+ parsing remains the honest fallback (JS fallback leg unchanged; its rows stage is a later
70
+ window).
71
+
72
+ ### Changed
73
+ - Grep's ripgrep legs always pass `-H` (#311): a single explicit file target keeps its path
74
+ field, so a deny-guarded single-file grep keeps the rg fast path instead of abandoning rg for
75
+ the JS scanner's explicit-file lane (an engine swap over that one file, losing rg's
76
+ streaming/large-file handling). Observable flip: content-mode AND count-mode output for a
77
+ single explicit file target now carries a path prefix in the TARGET's spelling as passed (the
78
+ tool layer hands an absolute canonical path, so the prefix is ABSOLUTE; count mode reads
79
+ `<path>:<n>`); `files_with_matches` did not move. **Erratum (5.44 rescan)**: this entry
80
+ originally scoped the flip to content mode and described a "without a deny judge" arm — no
81
+ public route produces that arm (every route to the tool compiles a read-deny judge), and the
82
+ previous release answered the call from the JS lane with a ROOT-RELATIVE prefix in every deny
83
+ configuration a caller can produce; count mode flipped the same way. Multi-file and directory
84
+ targets: byte-identical (measured). The two engines still disagree on path shape for the same call (jsGrep emits
85
+ root-relative) — that alignment is tracked separately (#318), not silently changed here.
86
+ - The deny-withholding note no longer fabricates on single-file scopes (5.43 rescan): `rg --files`
87
+ lists an explicitly named file argument regardless of glob filters, so the existence probe
88
+ behind the note would have claimed "entries were excluded" on every deny-wired single-file grep
89
+ the `-H` change made reachable. A target the stat probe proves is a FILE is judged clean without
90
+ the probe — the tool layer already judged that one target against the same deny table before
91
+ the engine ran, and a scope of exactly one served file has nothing else to withhold. Directory
92
+ scopes keep the probe unchanged.
93
+ - A clean no-match ripgrep run now carries `rows: []` (5.43 rescan): the one rg terminal that
94
+ omitted the served-rows key, breaking the "rows present ⟺ ripgrep leg" reading.
95
+ - `onSecretEnvScrub` judges its seat by FUNCTION-ness (5.43 rescan): a present non-function seat
96
+ (null, an untyped host's JSON wiring) no longer silences both channels at once — the findings
97
+ keep the seatless once-per-process console summary and the seat defect itself is announced once
98
+ per process (the #170 shape; loud-bad-value law).
99
+
3
100
  ## 5.42.0 — 2026-08-18
4
101
 
5
102
  ### Added
@@ -14,6 +14,8 @@ export const NON_GOVERNANCE_MEMORY_CODES = new Set([
14
14
  "memory.partition_split",
15
15
  "memory.tail",
16
16
  "memory.pollution_mark_failed",
17
+ "memory.session_polluted",
18
+ "memory.harvest_quarantined",
17
19
  "memory.challenge_sweep_failed",
18
20
  "memory.lineage_settle_failed",
19
21
  "memory.challenge_ledger_corrupt",
@@ -1,6 +1,7 @@
1
+ import type { EngineNotice } from "../types.js";
1
2
  import { type CommittedBinding, type EraseMemoryEntriesInput, type MemoryErasureAttestation, type TransferEvidence } from "./file-backend.js";
2
3
  import { type MemoryExportBundle, type MemoryImportReport } from "./export-bundle.js";
3
- import { type ChallengeAssignment, type ChallengeEvent, type ControlPlaneRebuildReceipt, type StrictControlPlaneLedger, type ChallengedHistoryRow, type LineagePendingTxn, type LineagePromotion, type MemoryPartitionIncidentSink, type RetrievedAccountRow, type SessionPollutionRecord } from "./layout.js";
4
+ import { type ChallengeAssignment, type ChallengeEvent, type ControlPlaneRebuildReceipt, type StrictControlPlaneLedger, type ChallengedHistoryRow, type LineagePendingTxn, type LineagePromotion, type MemoryPartitionIncidentSink, type RetrievedAccountRow, type SessionPollutionMarkOutcome, type SessionPollutionRecord } from "./layout.js";
4
5
  import type { HarvestReport, MemoryAnnouncement, MemoryBackend, MemorySessionHandle, ScanFinding } from "./types.js";
5
6
  /**
6
7
  * The CC `# Memory` instruction section — VERBATIM from the live capture
@@ -255,6 +256,91 @@ export interface EntryProvenanceAccount {
255
256
  reason?: string;
256
257
  };
257
258
  }
259
+ /**
260
+ * The pollution mark's loud half ({@link EngineNotice}). The engine's own `onIncident` seat carries the
261
+ * FAILURES of this machinery (`memory.pollution_mark_failed`, `memory.challenge_sweep_failed`); this is
262
+ * the other fact — the machinery worked, and its verdict silently changed what every memory write in
263
+ * this session will do. Before this code existed the transition had no face at all: the session went on
264
+ * writing memory files, the harvest quarantined every one of them, and nothing said so while a host
265
+ * could still act on it.
266
+ *
267
+ * SCOPE OF THE CLAIM (adversarial-review round 1, P1 — the first cut PROMISED quarantine on every mark).
268
+ * This notice states the VERDICT, not the mechanism's outcome: "no longer eligible", plus what a harvest
269
+ * does WHEN IT COLLECTS. That distinction is load-bearing, because several real paths reach a polluted
270
+ * harvest that quarantines nothing — a `writeScope`-null layering and a declared-unavailable session
271
+ * both take `harvestCore`'s zero-admission return (nothing is collected, so nothing is captured), and a
272
+ * mark landing after the pre-commit pollution read leaves already-committed rows to the CHALLENGE sweep
273
+ * rather than to containment. What a given harvest actually contained is the other code's job
274
+ * (`memory.harvest_quarantined`), and the message says so instead of guessing here. The baseline clause
275
+ * is likewise scoped to a collecting harvest: containment judges a changed file against the
276
+ * MATERIALIZE-time baseline, not against the mark's timestamp, so pre-mark writes are covered too.
277
+ *
278
+ * The challenge clause is likewise a POLICY statement with its failure channel named (adversarial-review
279
+ * round 3): the mark-side sweep is best-effort — an unreadable or unwritable lineage ledger makes
280
+ * `markSessionPolluted` announce `memory.challenge_sweep_failed` and return normally — so this notice
281
+ * must not be read as a receipt that the sweep landed.
282
+ *
283
+ * Minted by the runner's mark seat (the one call site of the mark face), so an engine-direct host keeps
284
+ * the incident seat and gains no new obligation.
285
+ *
286
+ * `reason` names a TOOL, and tool names come from the host/protocol roster: neutralized and bounded
287
+ * here — the same treatment the report warnings give it — before it rides either the message or the
288
+ * detail (a host renders both).
289
+ */
290
+ export declare function memorySessionPollutedNotice(input: {
291
+ reason: string;
292
+ sessionId?: string;
293
+ }): EngineNotice;
294
+ /**
295
+ * The polluted harvest's loud half ({@link EngineNotice}): what the containment actually did, minted
296
+ * once per harvest that withheld at least one ENTRY file. `count` is the withheld set (the
297
+ * `polluted`-coded rejections — entry files that diverged from the materialize baseline), `moved` the
298
+ * subset that also LEFT the model-visible plane, and `escalated` the subset carrying a
299
+ * `quarantineFailures` row.
300
+ *
301
+ * `escalated` is its own input rather than `count - moved` (adversarial-review round 3): the two are
302
+ * NOT the same set. A file whose delete fails but whose tombstone write succeeds counts as removed —
303
+ * it rides `movedToQuarantine` AND an escalation row saying it was tombstoned in place — so the
304
+ * arithmetic would report a clean containment while a known failure sat in the report. Every
305
+ * not-moved file does have an escalation row, so `escalated` is the superset and the honest clause.
306
+ *
307
+ * KNOWN GAP (adversarial-review round 1, P2 — registered, not closed here): the derived index is
308
+ * contained by a different path (`restorePollutedIndex` captures and restores `MEMORY.md`) that mints
309
+ * no rejection row, so a polluted session whose ONLY memory change was an index line produces no
310
+ * `count` and no notice — its containment is disclosed by the harvest report's warnings alone. Closing
311
+ * it needs a structured containment signal on `HarvestReport`, a public type this fix does not touch.
312
+ * The wording therefore says "entry file(s)" rather than implying the whole containment.
313
+ *
314
+ * `reason` is optional because the mark record is read separately from the report: a record that cannot
315
+ * be read must not turn a true "N files were withheld" into a lie about why.
316
+ */
317
+ /**
318
+ * The three numbers {@link memoryHarvestQuarantinedNotice} reports, read off ONE harvest report. Pure
319
+ * and exported so the derivation is provable on its own (the tombstone case — moved AND escalated — is
320
+ * not constructible through a real filesystem without root-dependent permission tricks).
321
+ *
322
+ * Every number is scoped to the WITHHELD paths: a dual-root harvest merges the read plane's report in,
323
+ * and that plane's ordinary write gate quarantines on its own grounds — counting its rows would credit
324
+ * this session's pollution with containment it never performed.
325
+ *
326
+ * FILES, NOT ROWS (adversarial-review round 4). One path can appear twice in the same report: when the
327
+ * pre-commit pollution recheck flips a clean harvest to containment, a file the ordinary secret/filename
328
+ * gate already quarantined is contained again, and both passes append. Counting rows would then publish
329
+ * `moved`/`escalated` greater than `count` — an impossible statement about a set of files — so every
330
+ * number here is a count of DISTINCT paths.
331
+ */
332
+ export declare function pollutionContainmentCounts(report: HarvestReport): {
333
+ count: number;
334
+ moved: number;
335
+ escalated: number;
336
+ };
337
+ export declare function memoryHarvestQuarantinedNotice(input: {
338
+ count: number;
339
+ moved: number;
340
+ escalated: number;
341
+ reason?: string;
342
+ sessionId?: string;
343
+ }): EngineNotice;
258
344
  export declare class MemoryEngine {
259
345
  private readonly backend;
260
346
  private readonly memoryDir;
@@ -301,8 +387,14 @@ export declare class MemoryEngine {
301
387
  * record, and the durable marker survives restarts (control plane — never model-writable
302
388
  * frontmatter). A failed durable write is disclosed via `onIncident` (`memory.pollution_mark_failed`)
303
389
  * while the in-memory mark keeps THIS process fail-closed.
390
+ *
391
+ * RETURNS the durable marker's {@link SessionPollutionMarkOutcome} — `"created"` ⇔ this call made
392
+ * the transition. That is the ONE atomic answer to "was this the clean → polluted crossing", and the
393
+ * runner's once-per-session disclosure is keyed on it: a pre-read cannot decide it (two concurrent
394
+ * markers of one session would both read clean and both announce). Callers that do not disclose
395
+ * ignore it exactly as before.
304
396
  */
305
- markSessionPolluted(sessionId: string, reason: string): void;
397
+ markSessionPolluted(sessionId: string, reason: string): SessionPollutionMarkOutcome;
306
398
  /** The session's pollution record (in-process mark first, then the durable marker) — undefined =
307
399
  * clean. Reads are side-effect-free. */
308
400
  sessionPollution(sessionId: string): SessionPollutionRecord | undefined;
@@ -48,6 +48,47 @@ export const DEFAULT_HARVEST_DEADLINE_MS = 5_000;
48
48
  export const DEFAULT_HARVEST_FILE_BUDGET = 2_000;
49
49
  export const MASS_DELETION_FUSE_RATIO = 0.5;
50
50
  let indexCaptureSeq = 0;
51
+ export function memorySessionPollutedNotice(input) {
52
+ const reason = inlineUntrusted(input.reason, 200);
53
+ return {
54
+ code: "memory.session_polluted",
55
+ message: `Memory for this session is marked polluted: ${reason}. Its memory writes are no longer eligible for the ` +
56
+ `long-term library — a harvest withholds what it collects (quarantined for host review, judged against the ` +
57
+ `session's opening baseline, so changes made before this point are withheld too), and contributions this ` +
58
+ `session already committed are challenged (a sweep that cannot run announces memory.challenge_sweep_failed). ` +
59
+ `What a given harvest actually contained is announced as memory.harvest_quarantined.`,
60
+ detail: { reason, ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}) },
61
+ };
62
+ }
63
+ export function pollutionContainmentCounts(report) {
64
+ const withheld = new Set(report.rejections.filter((r) => r.code === "polluted").map((r) => r.path));
65
+ const distinct = (paths) => new Set(paths.filter((p) => withheld.has(p))).size;
66
+ return {
67
+ count: withheld.size,
68
+ moved: distinct(report.movedToQuarantine),
69
+ escalated: distinct((report.quarantineFailures ?? []).map((q) => q.path)),
70
+ };
71
+ }
72
+ export function memoryHarvestQuarantinedNotice(input) {
73
+ const reason = input.reason !== undefined ? inlineUntrusted(input.reason, 200) : undefined;
74
+ return {
75
+ code: "memory.harvest_quarantined",
76
+ message: `Memory harvest committed nothing for this polluted session${reason !== undefined ? ` (${reason})` : ""}: ` +
77
+ `${input.count} memory entry file(s) written or changed in it were withheld, ${input.moved} of them moved into ` +
78
+ `the control-plane quarantine directory for host review` +
79
+ (input.escalated > 0
80
+ ? `; ${input.escalated} carr${input.escalated === 1 ? "ies" : "y"} a containment escalation (a file that did not leave the ` +
81
+ `model-visible plane, or one tombstoned in place rather than deleted) — the harvest report's quarantineFailures names them.`
82
+ : "."),
83
+ detail: {
84
+ count: input.count,
85
+ moved: input.moved,
86
+ escalated: input.escalated,
87
+ ...(reason !== undefined ? { reason } : {}),
88
+ ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}),
89
+ },
90
+ };
91
+ }
51
92
  export class MemoryEngine {
52
93
  backend;
53
94
  memoryDir;
@@ -115,14 +156,14 @@ export class MemoryEngine {
115
156
  markSessionPolluted(sessionId, reason) {
116
157
  if (!this.pollutedSessions.has(sessionId))
117
158
  this.pollutedSessions.set(sessionId, { at: this.now(), reason });
118
- let durable = false;
159
+ let outcome;
119
160
  try {
120
- durable = markSessionPolluted(this.controlDir, sessionId, reason, this.now);
161
+ outcome = markSessionPolluted(this.controlDir, sessionId, reason, this.now);
121
162
  }
122
163
  catch {
123
- durable = false;
164
+ outcome = "unpersisted";
124
165
  }
125
- if (!durable) {
166
+ if (outcome === "unpersisted") {
126
167
  const sink = this.onIncident;
127
168
  if (sink !== undefined) {
128
169
  try {
@@ -156,6 +197,7 @@ export class MemoryEngine {
156
197
  }
157
198
  }
158
199
  }
200
+ return outcome;
159
201
  }
160
202
  sessionPollution(sessionId) {
161
203
  const inProcess = this.pollutedSessions.get(sessionId);
@@ -288,11 +288,18 @@ export interface SessionPollutionRecord {
288
288
  }
289
289
  /**
290
290
  * Mark `sessionId` polluted (idempotent; the FIRST record wins — `wx` create refuses overwrite).
291
- * Returns true when a durable marker exists after the call (created now or already present);
292
- * false the marker could not be persisted the caller must keep its own in-memory mark and
293
- * disclose (the session stays polluted for this process either way).
291
+ *
292
+ * The outcome distinguishes the two ways a durable marker can exist after the call, because the
293
+ * `wx` create is the only ATOMIC answer to "who made this transition" available anywhere in the
294
+ * stack (#324a, adversarial-review round 5): a check-then-act pre-read lets two concurrent markers
295
+ * of one session both believe they were first, and a once-per-session disclosure keyed on that would
296
+ * announce twice. `"created"` ⇔ THIS call minted the marker; `"existed"` ⇔ it was already there (a
297
+ * repeat mark, a resumed session, or a racing peer that won the create); `"unpersisted"` ⇔ the marker
298
+ * could not be written at all — the caller must keep its own in-memory mark and disclose (the session
299
+ * stays polluted for this process either way).
294
300
  */
295
- export declare function markSessionPolluted(controlDir: string, sessionId: string, reason: string, now: () => number): boolean;
301
+ export type SessionPollutionMarkOutcome = "created" | "existed" | "unpersisted";
302
+ export declare function markSessionPolluted(controlDir: string, sessionId: string, reason: string, now: () => number): SessionPollutionMarkOutcome;
296
303
  /** Read the durable pollution record for `sessionId` (undefined = no marker).
297
304
  * A marker that EXISTS still reads as polluted whether or not its bytes can be read or parsed
298
305
  * (fail-closed: neither corruption nor an unreadable node may launder the state) — a synthesized
@@ -740,12 +740,12 @@ export function markSessionPolluted(controlDir, sessionId, reason, now) {
740
740
  ensureDirExists(dirname(path));
741
741
  const record = { at: now(), reason };
742
742
  writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
743
- return true;
743
+ return "created";
744
744
  }
745
745
  catch (err) {
746
746
  if (err instanceof Error && "code" in err && err.code === "EEXIST")
747
- return true;
748
- return false;
747
+ return "existed";
748
+ return "unpersisted";
749
749
  }
750
750
  }
751
751
  export function readSessionPollution(controlDir, sessionId) {
@@ -66,6 +66,18 @@ export interface MemoryEngineToolsOptions {
66
66
  planes: ReadonlyArray<MemoryEnginePlane>;
67
67
  /** Clock (tests); default Date.now. Drives the natural-language age rendering only. */
68
68
  now?: () => number;
69
+ /**
70
+ * design/178 §3 (#324b) — the session's pollution READ face: the record when this session is marked
71
+ * polluted, `undefined` when it is clean. Read-only and side-effect-free (the mark is one-way and
72
+ * lives at the runner's mark seat); consulted ONLY on the empty outcomes, where a session whose
73
+ * writes are being withheld deserves to hear it instead of being told to search again.
74
+ *
75
+ * Absent ⇒ no pollution face is wired (engine-direct hosts, tests): the empty-outcome wording is
76
+ * then exactly the clean-session wording — nothing is inferred from the seat's absence.
77
+ */
78
+ sessionPollution?: () => {
79
+ reason: string;
80
+ } | undefined;
69
81
  }
70
82
  export interface MemorySearchHit {
71
83
  id: string;
@@ -91,6 +91,26 @@ export function skipBytes(text, startBytes) {
91
91
  export function createMemoryEngineTools(opts) {
92
92
  const { planes } = opts;
93
93
  const now = opts.now ?? Date.now;
94
+ const pollutionSentence = () => {
95
+ let reason;
96
+ try {
97
+ reason = opts.sessionPollution?.()?.reason;
98
+ }
99
+ catch {
100
+ return "";
101
+ }
102
+ if (reason === undefined)
103
+ return "";
104
+ return ` This session is marked polluted (${inlineUntrusted(reason, 200)}): the memory it writes is not admitted to the library at harvest, so writing this down now would not make it retrievable later either.`;
105
+ };
106
+ const noMatch = (query) => {
107
+ const details = { outcome: "ok", hits: [] };
108
+ return {
109
+ content: `No memory entries matched ${JSON.stringify(inlineUntrusted(query, 120))}. If the user expected you to know this, say that you checked memory and found nothing.` +
110
+ pollutionSentence(),
111
+ details,
112
+ };
113
+ };
94
114
  const getWithinScopes = async (plane, ids) => {
95
115
  const scopeSet = new Set(plane.scopes);
96
116
  return (await plane.backend.getByIds(ids)).filter((e) => scopeSet.has(e.scope) && e.frontmatter.deleted !== true);
@@ -102,7 +122,7 @@ export function createMemoryEngineTools(opts) {
102
122
  defer: true,
103
123
  offload: false,
104
124
  contentOrigin: "local",
105
- contract: { contractId: "core.memory_search@1", implementationRevision: "1" },
125
+ contract: { contractId: "core.memory_search@1", implementationRevision: "2" },
106
126
  parameters: Type.Object({
107
127
  query: Type.String({ description: "Keywords to look for (lexical match against entry names, descriptions and bodies)." }),
108
128
  limit: Type.Optional(Type.Number({ description: `Maximum hits to return (default ${MEMORY_SEARCH_DEFAULT_LIMIT}, max ${MEMORY_SEARCH_MAX_LIMIT}).` })),
@@ -144,10 +164,8 @@ export function createMemoryEngineTools(opts) {
144
164
  }
145
165
  merged.sort(contractOrder);
146
166
  const top = merged.slice(0, limit);
147
- if (top.length === 0) {
148
- const details = { outcome: "ok", hits: [] };
149
- return { content: `No memory entries matched ${JSON.stringify(inlineUntrusted(query, 120))}. If the user expected you to know this, say that you checked memory and found nothing.`, details };
150
- }
167
+ if (top.length === 0)
168
+ return noMatch(query);
151
169
  const bodyById = new Map();
152
170
  try {
153
171
  for (let i = 0; i < planes.length; i++) {
@@ -171,10 +189,8 @@ export function createMemoryEngineTools(opts) {
171
189
  return refusedSearch("challenge_ledger_unavailable", "Memory search is unavailable: the challenge ledger for a mounted memory plane cannot be read (fail-closed). Report this to the operator.", "failed");
172
190
  }
173
191
  const live = top.filter((h) => bodyById.has(h.id) && terminalExclusions[h.planeIndex]?.has(h.id) !== true);
174
- if (live.length === 0) {
175
- const details = { outcome: "ok", hits: [] };
176
- return { content: `No memory entries matched ${JSON.stringify(inlineUntrusted(query, 120))}. If the user expected you to know this, say that you checked memory and found nothing.`, details };
177
- }
192
+ if (live.length === 0)
193
+ return noMatch(query);
178
194
  for (let i = 0; i < planes.length; i++) {
179
195
  const ids = live.filter((h) => h.planeIndex === i).map((h) => h.id);
180
196
  if (ids.length === 0)
@@ -219,7 +235,7 @@ export function createMemoryEngineTools(opts) {
219
235
  defer: true,
220
236
  offload: false,
221
237
  contentOrigin: "local",
222
- contract: { contractId: "core.memory_get@1", implementationRevision: "2" },
238
+ contract: { contractId: "core.memory_get@1", implementationRevision: "3" },
223
239
  parameters: Type.Object({
224
240
  id: Type.Optional(Type.String({ description: "Entry id (exact lookup). Pass either id or slug, not both." })),
225
241
  slug: Type.Optional(Type.String({ description: "Entry slug (its file path without .md). Ambiguous across scopes unless scope is also passed." })),
@@ -275,7 +291,10 @@ export function createMemoryEngineTools(opts) {
275
291
  }
276
292
  if (matches.length === 0) {
277
293
  const where = scope !== undefined ? ` in scope ${JSON.stringify(inlineUntrusted(scope, 80))}` : "";
278
- return refusedGet("not_found", `No memory entry with slug ${JSON.stringify(inlineUntrusted(slug, 160))}${where} is mounted in this session. Try ${MEMORY_SEARCH_TOOL_NAME}.`, "not_found", { slug: slug, ...(scope !== undefined ? { scope } : {}) });
294
+ return refusedGet("not_found", `No memory entry with slug ${JSON.stringify(inlineUntrusted(slug, 160))}${where} is mounted in this session. ` +
295
+ `Only entries carrying a committed entry id are served here, and a memory file written into the memory root during ` +
296
+ `this session gets its id when the session-boundary harvest admits it — a file written in this session is not served here before then.` +
297
+ pollutionSentence(), "not_found", { slug: slug, ...(scope !== undefined ? { scope } : {}) });
279
298
  }
280
299
  if (matches.length > 1) {
281
300
  const candidates = matches.map((m) => ({ scope: m.header.scope, slug: m.header.slug, id: m.header.id }));
@@ -170,6 +170,8 @@ export interface ResultFlags {
170
170
  * but does not set `threw`, so it never reaches the `flags.threw` branch. */
171
171
  suspendRef?: {
172
172
  token: import("../checkpoint-store.js").CheckpointToken;
173
+ /** The non-secret identity twin ({@link import("../checkpoint-store.js").Checkpoint.checkpointId}). */
174
+ checkpointId?: string;
173
175
  gate: import("../checkpoint-store.js").CheckpointGate;
174
176
  /** RB-439-b: how the paused workspace comes back (`"park_only"` = a non-suspendable target that was
175
177
  * never actually paused). Echoed on `TaskResult.workspaceRestoreMode`; absent for a process-local
@@ -190,6 +192,7 @@ export interface ResultFlags {
190
192
  * so it never hits the failure branches above. */
191
193
  reviewRef?: {
192
194
  token: import("../checkpoint-store.js").CheckpointToken;
195
+ checkpointId?: string;
193
196
  gate: import("../checkpoint-store.js").CheckpointGate;
194
197
  /** RB-439-b: the review pause pauses the same workspace the approval pause does — same discriminant. */
195
198
  restoreMode?: "snapshot" | "park_only";
@@ -60,6 +60,7 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
60
60
  let blockedReason;
61
61
  let salvagedOutput;
62
62
  let checkpointToken;
63
+ let checkpointId;
63
64
  let checkpointGate;
64
65
  let workspaceRestoreMode;
65
66
  const isDegenerate = final?.stopReason === "error" && isDegenerateCutMessage(final);
@@ -114,6 +115,7 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
114
115
  else if (flags.suspendRef) {
115
116
  status = "suspended";
116
117
  checkpointToken = flags.suspendRef.token;
118
+ checkpointId = flags.suspendRef.checkpointId;
117
119
  checkpointGate = flags.suspendRef.gate;
118
120
  workspaceRestoreMode = flags.suspendRef.restoreMode;
119
121
  }
@@ -121,6 +123,7 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
121
123
  status = "needs_review";
122
124
  errorCode = "review.pending";
123
125
  checkpointToken = flags.reviewRef.token;
126
+ checkpointId = flags.reviewRef.checkpointId;
124
127
  checkpointGate = flags.reviewRef.gate;
125
128
  workspaceRestoreMode = flags.reviewRef.restoreMode;
126
129
  }
@@ -162,5 +165,5 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
162
165
  void _internalCompaction;
163
166
  if (flags.unpricedSpend)
164
167
  delete publicStats.costMicroUsd;
165
- return { taskId, sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: flags.remoteEnvFailures } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), stats: publicStats };
168
+ return { taskId, sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, ...(checkpointId !== undefined ? { checkpointId } : {}), checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: flags.remoteEnvFailures } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), stats: publicStats };
166
169
  }
@@ -1,9 +1,10 @@
1
1
  import { sep } from "node:path";
2
+ import { deliverEngineNotice } from "../types.js";
2
3
  import { admitMemoryScopes } from "../memory-admission.js";
3
4
  import { adoptLegacyRepoDirs, canonicalize, deriveRepoControlPlaneDir, deriveProjectControlDir, deriveProjectMemoryDir, deriveRepoMemoryDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, isContainedIn, lookupProjectIdHint, recordProjectIdHint, resolveMemoryEngineRoot } from "../memory-engine/layout.js";
4
5
  import { classifyScopePlanes, derivePersonalControlDir, derivePersonalMemoryDir, mergeHarvestReports, mergeInjections, needsDualRoots, parsedProjectPlane } from "../memory-engine/dual-root.js";
5
6
  import { normalizeMemorySpec } from "../memory.js";
6
- import { MEMORY_ANNOUNCEMENT_READONLY_CODA, MEMORY_PREFERENCE_DISCIPLINE, MEMORY_READONLY_NOTICE, MEMORY_RECALL_DISCIPLINE, MemoryEngine } from "../memory-engine/engine.js";
7
+ import { MEMORY_ANNOUNCEMENT_READONLY_CODA, MEMORY_PREFERENCE_DISCIPLINE, MEMORY_READONLY_NOTICE, MEMORY_RECALL_DISCIPLINE, MemoryEngine, memoryHarvestQuarantinedNotice, memorySessionPollutedNotice, pollutionContainmentCounts, } from "../memory-engine/engine.js";
7
8
  import { createMemoryEngineTools } from "../memory-engine/tools.js";
8
9
  import { assertScopeContractPlacement, parseScopeKey, resolveProjectId } from "../memory-engine/scope-contract.js";
9
10
  import { FileMemoryEngineBackend } from "../memory-engine/file-backend.js";
@@ -298,12 +299,26 @@ export async function prepareMemory(input) {
298
299
  }
299
300
  return writeEngine.gateWrite(writeHandle, w.key, w.content);
300
301
  };
302
+ let pollutionAnnounced = false;
303
+ const announceHarvestContainment = (report) => {
304
+ const { count, moved, escalated } = pollutionContainmentCounts(report);
305
+ if (count === 0)
306
+ return;
307
+ let reason;
308
+ try {
309
+ reason = writeEngine.sessionPollution(sessionId)?.reason;
310
+ }
311
+ catch {
312
+ }
313
+ deliverEngineNotice(deps.onNotice, memoryHarvestQuarantinedNotice({ count, moved, escalated, ...(reason !== undefined ? { reason } : {}), sessionId }));
314
+ };
301
315
  const harvestSafe = async (phase = "terminal") => {
302
316
  try {
303
317
  const report = await harvestBoth();
304
318
  if (!report.ok && report.incident) {
305
319
  deps.onError?.(new Error(`memory harvest refused (${report.incident.kind}): ${report.incident.detail}`), { phase: "memory", sessionId });
306
320
  }
321
+ announceHarvestContainment(report);
307
322
  try {
308
323
  deps.onMemoryHarvestReport?.(report, { sessionId, phase });
309
324
  }
@@ -327,7 +342,13 @@ export async function prepareMemory(input) {
327
342
  harvest: harvestSafe,
328
343
  pollution: {
329
344
  polluted: () => writeEngine.sessionPollution(sessionId),
330
- markPolluted: (reason) => writeEngine.markSessionPolluted(sessionId, reason),
345
+ markPolluted: (reason) => {
346
+ const outcome = writeEngine.markSessionPolluted(sessionId, reason);
347
+ if (outcome === "existed" || (outcome === "unpersisted" && pollutionAnnounced))
348
+ return;
349
+ pollutionAnnounced = true;
350
+ deliverEngineNotice(deps.onNotice, memorySessionPollutedNotice({ reason, sessionId }));
351
+ },
331
352
  },
332
353
  contentSafety: {
333
354
  trustedTools: new Set(memorySpec.trustedTools ?? []),
@@ -341,7 +362,7 @@ export async function prepareMemory(input) {
341
362
  writeScope: memorySpec.writeScope,
342
363
  };
343
364
  if (input.memorySearchToolsPlanned)
344
- memoryTools = createMemoryEngineTools({ planes: toolPlanes });
365
+ memoryTools = createMemoryEngineTools({ planes: toolPlanes, sessionPollution: () => writeEngine.sessionPollution(sessionId) });
345
366
  }
346
367
  catch (err) {
347
368
  if (typeof err.code === "string" &&
@@ -433,6 +433,7 @@ export interface Prepared {
433
433
  * fired this run. */
434
434
  suspendRef: {
435
435
  token?: CheckpointToken;
436
+ checkpointId?: string;
436
437
  gate?: CheckpointGate;
437
438
  scope?: string;
438
439
  restoreMode?: "snapshot" | "park_only";
@@ -453,6 +454,7 @@ export interface Prepared {
453
454
  * `status:"needs_review"`. Empty unless a review pause fired this run. */
454
455
  reviewRef: {
455
456
  token?: CheckpointToken;
457
+ checkpointId?: string;
456
458
  gate?: CheckpointGate;
457
459
  scope?: string;
458
460
  restoreMode?: "snapshot" | "park_only";
@@ -3537,9 +3537,11 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3537
3537
  return { ok: false, reason };
3538
3538
  }
3539
3539
  };
3540
- const publishCommittedSuspend = (token, gate, scope, remoteHandle) => {
3540
+ const publishCommittedSuspend = (token, gate, scope, remoteHandle, checkpointId) => {
3541
3541
  const ref = gate.kind === "needs_review" || gate.kind === "plan_review" ? reviewRef : suspendRef;
3542
3542
  ref.token = token;
3543
+ if (checkpointId !== undefined)
3544
+ ref.checkpointId = checkpointId;
3543
3545
  ref.gate = gate;
3544
3546
  if (remoteHandle !== undefined)
3545
3547
  ref.restoreMode = remoteHandle.restoreMode === "park_only" ? "park_only" : "snapshot";
@@ -3625,7 +3627,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3625
3627
  };
3626
3628
  if (!(await commitSuspendSaga(token, cp, suspendableEnv, remoteHandle)).ok)
3627
3629
  return false;
3628
- publishCommittedSuspend(token, gate, scope, remoteHandle);
3630
+ publishCommittedSuspend(token, gate, scope, remoteHandle, cp.checkpointId);
3629
3631
  try {
3630
3632
  await sessions.pin?.(sessionId);
3631
3633
  }
@@ -3711,7 +3713,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3711
3713
  };
3712
3714
  if (!(await commitSuspendSaga(token, cp, suspendableEnv, remoteHandle)).ok)
3713
3715
  return false;
3714
- publishCommittedSuspend(token, gate, scope, remoteHandle);
3716
+ publishCommittedSuspend(token, gate, scope, remoteHandle, cp.checkpointId);
3715
3717
  try {
3716
3718
  await sessions.pin?.(sessionId);
3717
3719
  }
@@ -4006,7 +4008,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4006
4008
  if (!committed.ok) {
4007
4009
  return committed.reason !== undefined ? { parkFailed: committed.reason } : undefined;
4008
4010
  }
4009
- publishCommittedSuspend(token, gate, cp.scope, remoteHandle);
4011
+ publishCommittedSuspend(token, gate, cp.scope, remoteHandle, cp.checkpointId);
4010
4012
  try {
4011
4013
  await sessions.pin?.(sessionId);
4012
4014
  }
@@ -3472,6 +3472,7 @@ export class Runner {
3472
3472
  suspendRef: prepared.suspendRef.token !== undefined && prepared.suspendRef.gate !== undefined
3473
3473
  ? {
3474
3474
  token: prepared.suspendRef.token,
3475
+ ...(prepared.suspendRef.checkpointId !== undefined ? { checkpointId: prepared.suspendRef.checkpointId } : {}),
3475
3476
  gate: prepared.suspendRef.gate,
3476
3477
  ...(prepared.suspendRef.restoreMode !== undefined ? { restoreMode: prepared.suspendRef.restoreMode } : {}),
3477
3478
  }
@@ -3479,6 +3480,7 @@ export class Runner {
3479
3480
  reviewRef: prepared.reviewRef.token !== undefined && prepared.reviewRef.gate !== undefined
3480
3481
  ? {
3481
3482
  token: prepared.reviewRef.token,
3483
+ ...(prepared.reviewRef.checkpointId !== undefined ? { checkpointId: prepared.reviewRef.checkpointId } : {}),
3482
3484
  gate: prepared.reviewRef.gate,
3483
3485
  ...(prepared.reviewRef.restoreMode !== undefined ? { restoreMode: prepared.reviewRef.restoreMode } : {}),
3484
3486
  }