@sema-agent/core 5.43.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,51 @@
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
+
3
49
  ## 5.43.0 — 2026-08-18
4
50
 
5
51
  ### Added
@@ -27,12 +73,15 @@
27
73
  - Grep's ripgrep legs always pass `-H` (#311): a single explicit file target keeps its path
28
74
  field, so a deny-guarded single-file grep keeps the rg fast path instead of abandoning rg for
29
75
  the JS scanner's explicit-file lane (an engine swap over that one file, losing rg's
30
- streaming/large-file handling). Observable flip, both arms: content-mode output for a single
31
- explicit file target now carries a path prefix in the TARGET's spelling as passed (the tool
32
- layer hands an absolute canonical path, so the prefix is ABSOLUTE); without a deny judge the
33
- line previously had no prefix, and WITH one it previously came from the JS lane with a
34
- root-relative prefix both spellings change. Multi-file and directory targets: byte-identical
35
- (measured). The two engines still disagree on path shape for the same call (jsGrep emits
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
36
85
  root-relative) — that alignment is tracked separately (#318), not silently changed here.
37
86
  - The deny-withholding note no longer fabricates on single-file scopes (5.43 rescan): `rg --files`
38
87
  lists an explicitly named file argument regardless of glob filters, so the existence probe
@@ -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 }));
@@ -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" &&
@@ -4683,6 +4683,34 @@ export interface EngineNotice {
4683
4683
  * `detail: { steer, taskId? }` / `{ followUp, taskId? }`. Per-run, at most once per family
4684
4684
  * (the terminal sweep is a single site).
4685
4685
  *
4686
+ * - `"memory.session_polluted"` (design/178 §3, #324a) — this session's memory crossed into the
4687
+ * one-way POLLUTED state (a tool classified as an external content source was invoked, directly
4688
+ * or through a delegated child): its memory writes are no longer eligible for the long-term
4689
+ * library. The notice states that VERDICT plus what a harvest does when it collects — it does
4690
+ * not promise quarantine, because a `writeScope`-null layering and a declared-unavailable
4691
+ * session both reach the zero-admission harvest arm (nothing collected ⇒ nothing captured), and
4692
+ * a mark landing after the pre-commit pollution read leaves already-committed rows to the
4693
+ * challenge sweep; what a harvest actually contained is the other code below. The challenge
4694
+ * clause is a policy statement, not a receipt — a sweep that cannot run announces
4695
+ * `"memory.challenge_sweep_failed"` on the engine's incident seat. Announced ONCE PER
4696
+ * SESSION, at the mark seat: a repeat mark inside the process and a resumed session whose
4697
+ * durable marker already exists stay quiet (the state is one-way, so a second line would carry
4698
+ * no new fact); a pollution state that cannot be read announces rather than assuming it was
4699
+ * already said. `detail: { reason, sessionId? }` — `reason` names the invoked tool and is
4700
+ * neutralized/length-bounded (the name comes from the host/protocol roster).
4701
+ * - `"memory.harvest_quarantined"` (design/178 §3, #324a) — a polluted session's harvest ran its
4702
+ * containment: `count` ENTRY files written or changed in the session were withheld from the
4703
+ * library, `moved` of them were physically moved into the control-plane quarantine directory,
4704
+ * and `escalated` of them carry a `HarvestReport.quarantineFailures` row (a failed capture, a
4705
+ * failed removal, or a file tombstoned in place — which counts as moved AND escalated, so the
4706
+ * two numbers are read off the report's rows, never subtracted from each other). One notice PER
4707
+ * HARVEST that withheld at least one entry file (a checkpoint harvest and the terminal harvest
4708
+ * are distinct facts), never minted for a clean session; `detail: { count, moved, escalated,
4709
+ * reason?, sessionId? }` (`reason` absent ⇔ the pollution marker could not be re-read at report
4710
+ * time — the withheld count stays true either way). Registered gap: the derived index (`MEMORY.md`) is
4711
+ * contained on a path that mints no rejection row, so an index-ONLY containment produces no
4712
+ * notice and is disclosed by the harvest report's warnings alone.
4713
+ *
4686
4714
  * Deliberately NOT a notice family: brain retry/reconnect liveness (a rate limit, a 5xx, a
4687
4715
  * transient network failure being retried). Those are per-attempt liveness frames with their own
4688
4716
  * frequency semantics and ride the wire `status` channel ({@link BrainStatus}), whose sink the
@@ -5,9 +5,10 @@ import type { ReadFace } from "./read-face.js";
5
5
  /**
6
6
  * The Grep card's details assembly, PURE over the engine text ({mode, offset} from the request) —
7
7
  * exported so the text→structured mapping is pinnable with synthetic texts (the byte-truncation and
8
- * fenced-partial shapes are impractical to construct through a live tool call). Follow-up #313 will
9
- * replace the text parsing with structured rows from runGrep; until then this seam is the honesty
10
- * boundary.
8
+ * fenced-partial shapes are impractical to construct through a live tool call). #313 moved PATH
9
+ * IDENTITY off the text and onto the engines' served rows (both legs supply them now); the counts
10
+ * and totals below still read the engine's own honesty markers out of the text, which is what keeps
11
+ * this seam the honesty boundary.
11
12
  */
12
13
  export declare function grepDetailFields(text: string, mode: "files_with_matches" | "content" | "count", offset?: number, structuredRows?: readonly import("./search.js").GrepRow[]): Record<string, unknown>;
13
14
  export declare function createGrepTool(env: ExecutionEnv, rootCanonical: string, additionalRoots?: readonly string[], readDeny?: ReadDenyMatcher, readFace?: ReadFace): AgentTool;
@@ -190,7 +190,27 @@ export interface ReadDenyJudge {
190
190
  /** JS-fallback grep: ignore-aware walk + per-line scan, honoring output_mode / context / head_limit.
191
191
  * design/199 件B: `deny` prunes the walk (note appended to every output shape); `denyOut` is the
192
192
  * structured twin — a sink because this function's dozen error returns predate the facts (the sink
193
- * is written the moment the walk lands, whatever the scan then returns). */
193
+ * is written the moment the walk lands, whatever the scan then returns).
194
+ *
195
+ * backlog #313 — the SERVED rows ride back next to the text. This scanner knows every result's
196
+ * identity natively (the walk hands it whole paths), so a row's `path` is that walked identity,
197
+ * never a cut of the rendered line — the mis-split family the text re-parse carries cannot exist
198
+ * here. The rows ARE the window `text` shows: post offset, post row cap, post byte ceiling, in the
199
+ * same order. The unit is the RECORD, which is the unit the cap has always counted: one record is
200
+ * one rendered line EXCEPT where the file's own name carries a newline, and then the one record
201
+ * spans two physical lines — the same relation {@link formatRgRecords} states for the rg leg, and
202
+ * precisely the case where a row's identity beats anything the text can be split into.
203
+ * Every row carries a path (this leg emits no group-separator row — the one pathless shape
204
+ * ripgrep's output can carry), and a no-match run's row set is `[]`, not absent. A typed
205
+ * `Error (grep): …` return carries no rows at all: a refusal is not a row set. */
206
+ export declare function jsGrepDetailed(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal, guards?: JsGrepGuards, deny?: ReadDenyJudge, denyOut?: {
207
+ withheld?: ReadDenyWithheld;
208
+ }): Promise<{
209
+ text: string;
210
+ rows?: readonly GrepRow[];
211
+ }>;
212
+ /** Text-only wrapper of {@link jsGrepDetailed} (the shape callers that never look at the served rows
213
+ * want — the same relation {@link runGrep} has to {@link runGrepDetailed}). */
194
214
  export declare function jsGrep(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal, guards?: JsGrepGuards, deny?: ReadDenyJudge, denyOut?: {
195
215
  withheld?: ReadDenyWithheld;
196
216
  }): Promise<string>;
@@ -210,18 +230,34 @@ export type GrepDegradation = {
210
230
  };
211
231
  /** Structured grep result: the model-facing text plus the degradation facts, so the tool layer can
212
232
  * ship them on the structured frame instead of leaving them prose-only. */
213
- /** #313 (first stage, rg legs) — one SERVED result row with its path read from ripgrep's own
214
- * field ({@link parseRgRecords}), exactly the window `text` shows (post cap/offset). The tool
215
- * layer prefers these over re-parsing `text` (whose `path:line:text` split mis-cuts a path that
216
- * itself contains `:digits:`); absent = a leg that has no structured rows yet (the JS scanner —
217
- * its rows stage is the ticket's remainder) and the text parse is the honest fallback. */
233
+ /** #313 — one SERVED result RECORD with its path taken from the engine's own knowledge, exactly the
234
+ * window `text` shows (post cap/offset): ripgrep's `--null` path field ({@link parseRgRecords}) on
235
+ * the rg legs, the walked file identity ({@link jsGrepDetailed}) on the JS-scanner legs. A record is
236
+ * one rendered line except where a path carries a newline (see {@link formatRgRecords}), which is
237
+ * also the case the text can no longer be split into records at all. The tool layer prefers these
238
+ * over re-parsing `text` (whose `path:line:text` split mis-cuts a path that
239
+ * itself contains `:digits:`).
240
+ *
241
+ * KNOWN EXCEPTION to "exactly the window `text` shows", stated because it is measurable: on the two
242
+ * ripgrep PARTIAL legs (timeout / error-exit with output) the delivered text is fenced by
243
+ * `delimitUntrusted`, which NEUTRALIZES fence markers inside the body, while the rows carry the
244
+ * record bytes as ripgrep emitted them. A row's `text` there is therefore the un-neutralized form —
245
+ * identity (`path`) is unaffected. Consume `text` for anything rendered; the rows' contract is
246
+ * identity. Reconciling the two (defuse per record, or withhold rows where the text is fenced) is a
247
+ * contract choice recorded for the backlog, not settled here.
248
+ *
249
+ * `path` is absent only where the record genuinely carries none —
250
+ * ripgrep's `--` group separator and the unaccountable shapes listed on {@link RgRecord} — and a
251
+ * row set carrying such a row sends the tool layer back to the text parse for identity. */
218
252
  export interface GrepRow {
219
253
  path?: string;
220
254
  text: string;
221
255
  }
222
256
  export interface GrepRunResult {
223
257
  text: string;
224
- /** Served rows (#313): present on the ripgrep legs, absent on the JS-scanner legs. */
258
+ /** Served rows (#313): present on BOTH engine legs ripgrep's and the JS scanner's, including the
259
+ * rescan legs. Absent only when the JS scanner refused the search outright (a typed
260
+ * `Error (grep): …` text): a refusal has no row set, truthfully, rather than an empty one. */
225
261
  rows?: readonly GrepRow[];
226
262
  degraded?: GrepDegradation;
227
263
  /** design/199 件B — deny-list withholding facts (see {@link ReadDenyWithheld}); absent = nothing
@@ -1,6 +1,6 @@
1
1
  import { delimitUntrusted } from "../../core/untrusted-text.js";
2
2
  import { MAX_EXEC_OUTPUT_BYTES } from "../../core/exec-output-tail.js";
3
- import { hasBinaryExtension, isAbsolutePathForm } from "./safety.js";
3
+ import { hasBinaryExtension, isAbsolutePathForm, isWinFormPath } from "./safety.js";
4
4
  const WALK_MAX_FILES = 5000;
5
5
  const WALK_MAX_DEPTH = 32;
6
6
  const GREP_DEFAULT_CAP = 250;
@@ -35,6 +35,25 @@ const TYPE_GLOBS = {
35
35
  css: "*.{css,scss,less}",
36
36
  };
37
37
  const basename = (p) => p.slice(Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")) + 1);
38
+ function collapseDotSegments(p) {
39
+ const win = isWinFormPath(p);
40
+ const lead = (win ? /^[\\/]*/ : /^\/*/).exec(p)?.[0] ?? "";
41
+ const sep = win ? "\\" : "/";
42
+ const parts = p.slice(lead.length).split(win ? /[\\/]+/ : /\/+/);
43
+ const floor = !win ? 0 : lead.length >= 2 ? 2 : /^[A-Za-z]:$/.test(parts[0] ?? "") ? 1 : 0;
44
+ const kept = [];
45
+ for (const part of parts) {
46
+ if (part === "" || part === ".")
47
+ continue;
48
+ if (part === "..") {
49
+ if (kept.length > floor)
50
+ kept.pop();
51
+ continue;
52
+ }
53
+ kept.push(part);
54
+ }
55
+ return kept.length === 0 ? "" : lead + kept.join(sep);
56
+ }
38
57
  const NO_MATCHES = "No matches.";
39
58
  const GREP_LINE_CLIP = 500;
40
59
  function clipLine(line) {
@@ -759,10 +778,10 @@ function multilineSpans(content, p, captureText = false, deadline = Infinity) {
759
778
  }
760
779
  return spans;
761
780
  }
762
- export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
781
+ export async function jsGrepDetailed(env, root, p, signal, guards, deny, denyOut) {
763
782
  const analysis = analyzeRedos(p.pattern, p.ignore_case === true);
764
783
  if (analysis.risk)
765
- return redosRejection(analysis.risk);
784
+ return { text: redosRejection(analysis.risk) };
766
785
  const budgetMs = guards?.budgetMs ?? GREP_FILE_BUDGET_MS;
767
786
  const longLineLimit = guards?.longLineLimit ?? GREP_GRAY_LINE_MAX;
768
787
  const grayPattern = analysis.quantifiedGroup || analysis.unboundedCount >= 2;
@@ -776,7 +795,7 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
776
795
  test = compilePattern(p);
777
796
  }
778
797
  catch (e) {
779
- return `Error (grep): invalid regex: ${e instanceof Error ? e.message : String(e)}`;
798
+ return { text: `Error (grep): invalid regex: ${e instanceof Error ? e.message : String(e)}` };
780
799
  }
781
800
  const ignore = await buildIgnore(env, root, signal);
782
801
  const rootPrefix = root.replace(/[\\/]+$/, "") + (root.includes("\\") ? "\\" : "/");
@@ -816,9 +835,20 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
816
835
  const cap = p.head_limit === 0 ? Infinity : Math.max(1, Math.floor(p.head_limit ?? GREP_DEFAULT_CAP));
817
836
  const mode = p.output_mode ?? "files_with_matches";
818
837
  const rel = (abs) => (abs.startsWith(rootPrefix) ? abs.slice(rootPrefix.length) : abs);
838
+ const startWin = isWinFormPath(start);
839
+ const startNorm = collapseDotSegments(start);
840
+ const targetSpell = p.path ? p.path.replace(startWin ? /[\\/]+$/ : /\/+$/, "") : "";
841
+ const isBoundary = (c) => c === "/" || (startWin && c === "\\");
819
842
  const relOut = (abs) => {
820
- const r = rel(abs);
821
- return p.path || r === abs ? r : `./${r}`;
843
+ if (!p.path) {
844
+ const r = rel(abs);
845
+ return r === abs ? r : `./${r}`;
846
+ }
847
+ if (abs === startNorm || abs === start)
848
+ return targetSpell;
849
+ if (abs.startsWith(startNorm) && isBoundary(abs[startNorm.length]))
850
+ return targetSpell + abs.slice(startNorm.length);
851
+ return rel(abs);
822
852
  };
823
853
  if (p.glob) {
824
854
  const globRes = splitGlobParam(p.glob).map((g) => globTokenToRegExp(normalizeGlobToken(g), globIsAnchored(g), true));
@@ -846,7 +876,7 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
846
876
  let outBytes = 0;
847
877
  let outputTruncated = false;
848
878
  let rowsBeforeWindow = 0;
849
- const pushRow = (row) => {
879
+ const pushRow = (row, path) => {
850
880
  if (outputTruncated)
851
881
  return;
852
882
  if (rowsBeforeWindow < off) {
@@ -861,7 +891,7 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
861
891
  return;
862
892
  }
863
893
  outBytes += size;
864
- out.push(row);
894
+ out.push({ path, text: row });
865
895
  };
866
896
  const fileMatches = [];
867
897
  const counts = [];
@@ -889,6 +919,7 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
889
919
  const lines = read.value.split("\n");
890
920
  if (lines.length > 1 && lines.at(-1) === "")
891
921
  lines.pop();
922
+ const shownPath = relOut(f);
892
923
  let fileCount = 0;
893
924
  const fileMatchStart = monotonicNow();
894
925
  if (p.multiline) {
@@ -902,8 +933,8 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
902
933
  }
903
934
  catch (e) {
904
935
  if (e instanceof GrepBudgetExceeded)
905
- return budgetError(relOut(f), monotonicNow() - fileMatchStart);
906
- return `Error (grep): invalid regex: ${e instanceof Error ? e.message : String(e)}`;
936
+ return { text: budgetError(shownPath, monotonicNow() - fileMatchStart) };
937
+ return { text: `Error (grep): invalid regex: ${e instanceof Error ? e.message : String(e)}` };
907
938
  }
908
939
  fileCount = spans.length;
909
940
  if (mode === "content") {
@@ -911,12 +942,12 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
911
942
  if (p.only_matching && text !== undefined) {
912
943
  const parts = text.split("\n");
913
944
  for (let k = 0; k < parts.length; k++) {
914
- pushRow(`${relOut(f)}:${s + k}:${clipLine(parts[k])}`);
945
+ pushRow(`${shownPath}:${s + k}:${clipLine(parts[k])}`, shownPath);
915
946
  }
916
947
  continue;
917
948
  }
918
949
  for (let j = Math.max(0, s - 1 - ctxB); j <= Math.min(lines.length - 1, eL - 1 + ctxA); j++) {
919
- pushRow(`${relOut(f)}:${j + 1}:${clipLine(lines[j])}`);
950
+ pushRow(`${shownPath}:${j + 1}:${clipLine(lines[j])}`, shownPath);
920
951
  }
921
952
  }
922
953
  }
@@ -926,7 +957,7 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
926
957
  if (grayPattern || (i > 0 && i % GREP_LINE_CHECK_EVERY === 0)) {
927
958
  const elapsed = monotonicNow() - fileMatchStart;
928
959
  if (elapsed >= budgetMs)
929
- return budgetError(relOut(f), elapsed);
960
+ return { text: budgetError(shownPath, elapsed) };
930
961
  }
931
962
  if (i > 0 && i % GREP_LINE_CHECK_EVERY === 0) {
932
963
  await yieldEventLoop();
@@ -945,16 +976,16 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
945
976
  if (mode === "content") {
946
977
  if (p.only_matching) {
947
978
  for (const part of matchedParts(lines[i], p)) {
948
- pushRow(`${relOut(f)}:${i + 1}:${clipLine(part)}`);
979
+ pushRow(`${shownPath}:${i + 1}:${clipLine(part)}`, shownPath);
949
980
  }
950
981
  }
951
982
  else if (ctx > 0) {
952
983
  for (let j = Math.max(0, i - ctxB); j <= Math.min(lines.length - 1, i + ctxA); j++) {
953
- pushRow(`${relOut(f)}:${j + 1}:${clipLine(lines[j])}`);
984
+ pushRow(`${shownPath}:${j + 1}:${clipLine(lines[j])}`, shownPath);
954
985
  }
955
986
  }
956
987
  else {
957
- pushRow(`${relOut(f)}:${i + 1}:${clipLine(lines[i])}`);
988
+ pushRow(`${shownPath}:${i + 1}:${clipLine(lines[i])}`, shownPath);
958
989
  }
959
990
  }
960
991
  }
@@ -968,7 +999,7 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
968
999
  if (mode === "files_with_matches")
969
1000
  fileMatches.push(f);
970
1001
  else if (mode === "count" && counts.length < collectCap)
971
- counts.push(`${relOut(f)}:${fileCount}`);
1002
+ counts.push({ path: shownPath, text: `${shownPath}:${fileCount}` });
972
1003
  }
973
1004
  }
974
1005
  if (scanAborted || skippedUnreadableFiles > 0 || skippedLongLines > 0 || multilineSkippedFiles > 0) {
@@ -988,7 +1019,7 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
988
1019
  }
989
1020
  }
990
1021
  const caveat = honestyCaveat + denyNote;
991
- const paged = (arr) => (off > 0 ? arr.slice(off, off + cap) : arr);
1022
+ const paged = (arr) => (off > 0 ? arr.slice(off, off + cap) : [...arr]);
992
1023
  const offNote = off > 0 ? `\n[offset ${off}]` : "";
993
1024
  if (mode === "files_with_matches") {
994
1025
  const haveMtimes = fileMatches.length > 0 && fileMatches.every((f) => walked.mtimes.has(f));
@@ -999,26 +1030,37 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
999
1030
  .map((x) => x.r)
1000
1031
  : fileMatches.map(relOut).sort();
1001
1032
  const body = sortedFileMatches.slice(off, off + cap);
1002
- return body.length === 0
1003
- ? NO_MATCHES + offNote + typeNote + caveat
1004
- : body.join("\n") + (totalFiles > off + cap ? `\n…[capped at ${cap} of ${totalFiles}]` : "") + offNote + typeNote + caveat;
1033
+ const rows = body.map((r) => ({ path: r, text: r }));
1034
+ return {
1035
+ text: body.length === 0
1036
+ ? NO_MATCHES + offNote + typeNote + caveat
1037
+ : body.join("\n") + (totalFiles > off + cap ? `\n…[capped at ${cap} of ${totalFiles}]` : "") + offNote + typeNote + caveat,
1038
+ rows,
1039
+ };
1005
1040
  }
1006
1041
  if (mode === "count") {
1007
1042
  const body = paged(counts);
1008
- return body.length === 0
1009
- ? NO_MATCHES + offNote + typeNote + caveat
1010
- : body.join("\n") + (totalFiles > off + cap ? `\n…[capped at ${cap} of ${totalFiles}]` : "") + offNote + typeNote + caveat;
1043
+ return {
1044
+ text: body.length === 0
1045
+ ? NO_MATCHES + offNote + typeNote + caveat
1046
+ : body.map((r) => r.text).join("\n") + (totalFiles > off + cap ? `\n…[capped at ${cap} of ${totalFiles}]` : "") + offNote + typeNote + caveat,
1047
+ rows: body,
1048
+ };
1011
1049
  }
1012
1050
  const body = out;
1013
1051
  const byteNote = outputTruncated ? `\n…[output truncated at ${outputMaxBytes} bytes — narrow the pattern or set a smaller head_limit]` : "";
1014
1052
  if (body.length === 0)
1015
- return NO_MATCHES + byteNote + offNote + typeNote + caveat;
1053
+ return { text: NO_MATCHES + byteNote + offNote + typeNote + caveat, rows: [] };
1054
+ const bodyText = body.map((r) => r.text).join("\n");
1016
1055
  if (out.length < cap)
1017
- return body.join("\n") + byteNote + offNote + typeNote + caveat;
1056
+ return { text: bodyText + byteNote + offNote + typeNote + caveat, rows: body };
1018
1057
  const marker = ctx > 0 || p.multiline || p.only_matching
1019
1058
  ? `\n…[capped at ${cap}; ${totalContent}+ match(es) found, more output omitted]`
1020
1059
  : `\n…[capped at ${cap} of ${totalContent}]`;
1021
- return body.join("\n") + marker + byteNote + offNote + typeNote + caveat;
1060
+ return { text: bodyText + marker + byteNote + offNote + typeNote + caveat, rows: body };
1061
+ }
1062
+ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
1063
+ return (await jsGrepDetailed(env, root, p, signal, guards, deny, denyOut)).text;
1022
1064
  }
1023
1065
  const rgCache = new WeakMap();
1024
1066
  export function detectRipgrep(env) {
@@ -1124,23 +1166,25 @@ export function rgOutputDenyTripwire(stdout, mode, judge, opts = {}) {
1124
1166
  }
1125
1167
  async function jsGrepDenyTripFallback(env, root, p, signal, trip, deny) {
1126
1168
  const denyOut = {};
1127
- const text = await jsGrep(env, root, p, signal, undefined, deny, denyOut);
1169
+ const { text, rows } = await jsGrepDetailed(env, root, p, signal, undefined, deny, denyOut);
1128
1170
  const why = trip.reason === "deny-hit" ? "its output involved sensitive-path deny-listed entries the exclusion globs cannot express" : "its output contained a record the line format cannot account for";
1129
1171
  return {
1130
1172
  text: text.startsWith("Error (grep)")
1131
1173
  ? text
1132
1174
  : `${text}\n[note: the ripgrep pass was abandoned (${why}); results are from the fallback scanner, which prunes with the authoritative deny judge]`,
1175
+ ...(rows !== undefined ? { rows } : {}),
1133
1176
  degraded: { fallback: "js-scan", reason: `ripgrep output tripped the deny tripwire (${trip.reason})` },
1134
1177
  ...(denyOut.withheld !== undefined ? { withheld: denyOut.withheld } : {}),
1135
1178
  };
1136
1179
  }
1137
1180
  async function jsGrepFallback(env, root, p, signal, reason, deny) {
1138
1181
  const denyOut = {};
1139
- const text = await jsGrep(env, root, p, signal, undefined, deny, denyOut);
1182
+ const { text, rows } = await jsGrepDetailed(env, root, p, signal, undefined, deny, denyOut);
1140
1183
  return {
1141
1184
  text: text.startsWith("Error (grep)")
1142
1185
  ? text
1143
1186
  : `${text}\n[note: ripgrep failed (${reason}); results are from a fallback scanner]`,
1187
+ ...(rows !== undefined ? { rows } : {}),
1144
1188
  degraded: { fallback: "js-scan", reason: `ripgrep failed (${reason})` },
1145
1189
  ...(denyOut.withheld !== undefined ? { withheld: denyOut.withheld } : {}),
1146
1190
  };
@@ -1288,8 +1332,8 @@ export async function runGrepDetailed(env, root, p, signal, deny) {
1288
1332
  if (await detectRipgrep(env))
1289
1333
  return rgGrepDetailed(env, root, p, signal, deny);
1290
1334
  const denyOut = {};
1291
- const text = await jsGrep(env, root, p, signal, undefined, deny, denyOut);
1292
- return { text, ...(denyOut.withheld !== undefined ? { withheld: denyOut.withheld } : {}) };
1335
+ const { text, rows } = await jsGrepDetailed(env, root, p, signal, undefined, deny, denyOut);
1336
+ return { text, ...(rows !== undefined ? { rows } : {}), ...(denyOut.withheld !== undefined ? { withheld: denyOut.withheld } : {}) };
1293
1337
  }
1294
1338
  export async function runGrep(env, root, p, signal) {
1295
1339
  return (await runGrepDetailed(env, root, p, signal)).text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.43.0",
3
+ "version": "5.44.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -65,7 +65,8 @@
65
65
  "gate:single-mint": "node scripts/verify-single-mint.mjs",
66
66
  "gate:nuia": "node scripts/verify-nuia-baseline.mjs",
67
67
  "gate:field-liveness": "node scripts/verify-field-liveness.mjs",
68
- "gate:error-surface": "node scripts/verify-error-surface.mjs"
68
+ "gate:error-surface": "node scripts/verify-error-surface.mjs",
69
+ "gate:criteria": "node scripts/criteria-lint.mjs"
69
70
  },
70
71
  "dependencies": {
71
72
  "@modelcontextprotocol/sdk": "1.30.0",