@sema-agent/core 5.53.0 → 5.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/dist/agents/cumulative-stats.d.ts +26 -0
  3. package/dist/agents/cumulative-stats.js +56 -0
  4. package/dist/agents/observer.d.ts +11 -7
  5. package/dist/agents/observer.js +2 -4
  6. package/dist/agents/subagent.d.ts +4 -0
  7. package/dist/agents/subagent.js +1 -1
  8. package/dist/agents/verify.d.ts +27 -3
  9. package/dist/agents/verify.js +7 -2
  10. package/dist/core/governance-codes.js +14 -0
  11. package/dist/core/hooks.js +1 -1
  12. package/dist/core/lsp-diagnostics.d.ts +19 -17
  13. package/dist/core/lsp-diagnostics.js +11 -5
  14. package/dist/core/mcp.d.ts +214 -5
  15. package/dist/core/mcp.js +315 -30
  16. package/dist/core/memory-engine/consolidation.d.ts +378 -0
  17. package/dist/core/memory-engine/consolidation.js +342 -0
  18. package/dist/core/memory-engine/dual-root.js +3 -0
  19. package/dist/core/memory-engine/engine.d.ts +237 -4
  20. package/dist/core/memory-engine/engine.js +1111 -4
  21. package/dist/core/memory-engine/export-bundle.js +9 -0
  22. package/dist/core/memory-engine/file-backend.js +27 -1
  23. package/dist/core/memory-engine/frontmatter.d.ts +20 -1
  24. package/dist/core/memory-engine/frontmatter.js +111 -0
  25. package/dist/core/memory-engine/index.d.ts +4 -2
  26. package/dist/core/memory-engine/index.js +3 -1
  27. package/dist/core/memory-engine/memory-backend-contract.js +131 -0
  28. package/dist/core/memory-engine/sync-client.js +26 -0
  29. package/dist/core/memory-engine/tools.d.ts +9 -0
  30. package/dist/core/memory-engine/tools.js +57 -13
  31. package/dist/core/memory-engine/types.d.ts +99 -0
  32. package/dist/core/memory-recall.js +4 -3
  33. package/dist/core/memory.d.ts +33 -3
  34. package/dist/core/memory.js +6 -4
  35. package/dist/core/permission-rule-model.d.ts +140 -21
  36. package/dist/core/permission-rule-model.js +76 -17
  37. package/dist/core/permission-rule-org.d.ts +4 -3
  38. package/dist/core/permission-rule-org.js +12 -3
  39. package/dist/core/permission-rules.d.ts +22 -0
  40. package/dist/core/permission-rules.js +60 -6
  41. package/dist/core/reminder-disclosure.d.ts +29 -4
  42. package/dist/core/reminder-disclosure.js +60 -12
  43. package/dist/core/runner/prepare-memory.js +7 -2
  44. package/dist/core/runner/prepare-task.d.ts +31 -1
  45. package/dist/core/runner/prepare-task.js +31 -14
  46. package/dist/core/runner/runtask.d.ts +8 -1
  47. package/dist/core/runner/runtask.js +12 -10
  48. package/dist/core/runner/session-rule-policy.js +5 -3
  49. package/dist/core/runner/synthetic-tools.js +4 -2
  50. package/dist/core/runner/turn-attachments.d.ts +16 -6
  51. package/dist/core/runner/turn-attachments.js +34 -20
  52. package/dist/core/tool-policy.d.ts +18 -0
  53. package/dist/core/tool-policy.js +19 -8
  54. package/dist/core/types.d.ts +89 -6
  55. package/dist/core/untrusted-egress.js +12 -2
  56. package/dist/core/untrusted-text.d.ts +189 -3
  57. package/dist/core/untrusted-text.js +416 -6
  58. package/dist/engine/loop/types.d.ts +7 -0
  59. package/dist/index.d.ts +2 -2
  60. package/dist/index.js +2 -2
  61. package/dist/orchestration/workflow-types.d.ts +16 -6
  62. package/dist/orchestration/workflow-types.js +10 -4
  63. package/dist/orchestration/workflow.js +32 -6
  64. package/dist/prompts/default.d.ts +12 -2
  65. package/dist/stores/file/background-agent-store.js +1 -0
  66. package/dist/stores/file/checkpoint-store.d.ts +6 -2
  67. package/dist/stores/file/checkpoint-store.js +1 -0
  68. package/dist/stores/file/fs-atomic.d.ts +151 -10
  69. package/dist/stores/file/fs-atomic.js +208 -32
  70. package/dist/stores/file/index.d.ts +26 -3
  71. package/dist/stores/file/index.js +25 -2
  72. package/dist/stores/file/shared-ledger.d.ts +40 -5
  73. package/dist/stores/file/shared-ledger.js +24 -8
  74. package/dist/stores/file/workflow-run-store.d.ts +8 -1
  75. package/dist/stores/file/workflow-run-store.js +1 -0
  76. package/dist/tools/fs/bash-readonly-classifier.d.ts +71 -0
  77. package/dist/tools/fs/bash-readonly-classifier.js +58 -47
  78. package/dist/tools/fs/index.d.ts +3 -1
  79. package/package.json +1 -1
  80. package/test/export-surface.snapshot.json +30 -1
@@ -1,9 +1,10 @@
1
1
  import { Type } from "typebox";
2
2
  import { errorResult } from "../tools.js";
3
- import { defuseFenceMarkers, delimitUntrusted, inlineUntrusted, sanitizeUntrustedText } from "../untrusted-text.js";
3
+ import { defuseFenceMarkers, delimitUntrusted, FENCED_LANE_ENVELOPE_TAGS, inlineUntrusted, sanitizeUntrustedText } from "../untrusted-text.js";
4
4
  import { formatMemoryAge } from "../memory-recall.js";
5
5
  import { committedOriginOf } from "./frontmatter.js";
6
6
  import { MEMORY_EXPOSURE_BANNER, MEMORY_EXPOSURE_HANDLE_TAG, MEMORY_PROVENANCE_SEARCH_SENTENCE, MEMORY_SEARCH_BAND_ORDER_HEADER } from "./provenance-wording.js";
7
+ import { deriveSupersededSet, memorySupersededNote, MEMORY_SEARCH_SUPERSEDED_TAG } from "./consolidation.js";
7
8
  export const MEMORY_SEARCH_TOOL_NAME = "memory_search";
8
9
  export const MEMORY_GET_TOOL_NAME = "memory_get";
9
10
  export const MEMORY_ENGINE_TOOL_NAMES = [MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME];
@@ -122,6 +123,13 @@ export function createMemoryEngineTools(opts) {
122
123
  const scopeSet = new Set(plane.scopes);
123
124
  return (await plane.backend.getByIds(ids)).filter((e) => scopeSet.has(e.scope) && e.frontmatter.deleted !== true);
124
125
  };
126
+ const supersededOf = async (plane, exclusions) => {
127
+ if (plane.scopes.length === 0)
128
+ return new Map();
129
+ const face = plane.backend.retrievalView?.() ?? plane.backend;
130
+ const headers = await face.listHeaders(plane.scopes);
131
+ return deriveSupersededSet(headers, { excluded: (id) => exclusions?.has(id) === true });
132
+ };
125
133
  const searchTool = {
126
134
  name: MEMORY_SEARCH_TOOL_NAME,
127
135
  description: carry ? `${SEARCH_DESCRIPTION}\n\n${MEMORY_PROVENANCE_SEARCH_SENTENCE}` : SEARCH_DESCRIPTION,
@@ -129,13 +137,16 @@ export function createMemoryEngineTools(opts) {
129
137
  defer: true,
130
138
  offload: false,
131
139
  contentOrigin: "local",
132
- contract: { contractId: "core.memory_search@1", implementationRevision: "4" },
140
+ contract: { contractId: "core.memory_search@1", implementationRevision: "5" },
133
141
  parameters: Type.Object({
134
142
  query: Type.String({ description: "Keywords to look for (lexical match against entry names, descriptions and bodies)." }),
135
143
  limit: Type.Optional(Type.Number({ description: `Maximum hits to return (default ${MEMORY_SEARCH_DEFAULT_LIMIT}, max ${MEMORY_SEARCH_MAX_LIMIT}).` })),
144
+ includeSuperseded: Type.Optional(Type.Boolean({
145
+ description: "Also return entries a newer consolidated entry has superseded (kept as evidence; hidden from results by default). Superseded hits are tagged, with the superseding entry's id.",
146
+ })),
136
147
  }, { additionalProperties: false }),
137
148
  execute: async (args, ctx) => {
138
- const { query, limit: rawLimit } = args;
149
+ const { query, limit: rawLimit, includeSuperseded } = args;
139
150
  const signal = ctx.signal;
140
151
  if (query.trim() === "") {
141
152
  return refusedSearch("empty_query", "query must be non-empty — pass the concrete keywords a past memory entry would contain.");
@@ -150,17 +161,32 @@ export function createMemoryEngineTools(opts) {
150
161
  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");
151
162
  }
152
163
  }
164
+ const supersededMaps = [];
165
+ try {
166
+ for (let i = 0; i < planes.length; i++) {
167
+ const plane = planes[i];
168
+ supersededMaps.push(plane === undefined ? new Map() : await supersededOf(plane, exclusions[i]));
169
+ }
170
+ }
171
+ catch (err) {
172
+ if (signal?.aborted === true)
173
+ throw err;
174
+ return refusedSearch("error", "Memory search is unavailable: the header listing behind the superseded-entry filter could not be read. Try again, and report the failure if it persists.", "failed");
175
+ }
153
176
  const merged = [];
154
177
  try {
155
178
  for (let i = 0; i < planes.length; i++) {
156
179
  const plane = planes[i];
157
180
  if (plane.scopes.length === 0)
158
181
  continue;
159
- const askFor = carry ? limit + (exclusions[i]?.size ?? 0) : limit;
182
+ const supersededSlots = includeSuperseded === true ? 0 : supersededMaps[i]?.size ?? 0;
183
+ const askFor = (carry ? limit + (exclusions[i]?.size ?? 0) : limit) + supersededSlots;
160
184
  const hits = await plane.backend.search(query, plane.scopes, { limit: askFor, ...(carry ? { exposureBands: true } : {}) });
161
185
  for (const h of hits) {
162
186
  if (exclusions[i]?.has(h.id))
163
187
  continue;
188
+ if (includeSuperseded !== true && supersededMaps[i]?.has(h.id))
189
+ continue;
164
190
  merged.push({ ...h, planeIndex: i });
165
191
  }
166
192
  }
@@ -219,10 +245,12 @@ export function createMemoryEngineTools(opts) {
219
245
  for (let i = 0; i < live.length; i++) {
220
246
  const h = live[i];
221
247
  const exposed = exposedFlags[i];
248
+ const supersededBy = supersededMaps[h.planeIndex]?.get(h.id);
249
+ const supersededSuffix = supersededBy !== undefined ? ` ${MEMORY_SEARCH_SUPERSEDED_TAG}` : "";
222
250
  if (exposed) {
223
- hits.push({ exposure: "external", id: h.id, scope: h.scope, score: h.score, mtimeMs: h.mtimeMs, sizeBytes: h.sizeBytes });
251
+ hits.push({ exposure: "external", id: h.id, scope: h.scope, score: h.score, mtimeMs: h.mtimeMs, sizeBytes: h.sizeBytes, ...(supersededBy !== undefined ? { supersededBy } : {}) });
224
252
  lines.push("");
225
- lines.push(`${i + 1}. [mem:${h.id}] ${MEMORY_EXPOSURE_HANDLE_TAG} (scope ${inlineUntrusted(h.scope, 80)}, score ${h.score.toFixed(3)}, ${ageOf(now, h.mtimeMs)}) — read it with ${MEMORY_GET_TOOL_NAME} id ${h.id}`);
253
+ lines.push(`${i + 1}. [mem:${h.id}] ${MEMORY_EXPOSURE_HANDLE_TAG}${supersededSuffix} (scope ${inlineUntrusted(h.scope, 80)}, score ${h.score.toFixed(3)}, ${ageOf(now, h.mtimeMs)}) — read it with ${MEMORY_GET_TOOL_NAME} id ${h.id}`);
226
254
  continue;
227
255
  }
228
256
  hits.push({
@@ -234,10 +262,11 @@ export function createMemoryEngineTools(opts) {
234
262
  score: h.score,
235
263
  mtimeMs: h.mtimeMs,
236
264
  sizeBytes: h.sizeBytes,
265
+ ...(supersededBy !== undefined ? { supersededBy } : {}),
237
266
  });
238
267
  const hook = h.description ? ` — ${inlineUntrusted(h.description, 200)}` : "";
239
268
  lines.push("");
240
- lines.push(`${i + 1}. ${entryPath(h.scope, h.slug)}${hook} (id ${h.id}, score ${h.score.toFixed(3)}, ${ageOf(now, h.mtimeMs)})`);
269
+ lines.push(`${i + 1}. ${entryPath(h.scope, h.slug)}${supersededSuffix}${hook} (id ${h.id}, score ${h.score.toFixed(3)}, ${ageOf(now, h.mtimeMs)})`);
241
270
  const body = (entryById.get(h.id)?.body ?? "").trim();
242
271
  if (body !== "")
243
272
  lines.push(delimitUntrusted(`memory entry ${h.slug}`, body, MEMORY_SEARCH_SNIPPET_CAP));
@@ -253,7 +282,7 @@ export function createMemoryEngineTools(opts) {
253
282
  defer: true,
254
283
  offload: false,
255
284
  contentOrigin: "local",
256
- contract: { contractId: "core.memory_get@1", implementationRevision: "4" },
285
+ contract: { contractId: "core.memory_get@1", implementationRevision: "5" },
257
286
  parameters: Type.Object({
258
287
  id: Type.Optional(Type.String({ description: "Entry id (exact lookup). Pass either id or slug, not both." })),
259
288
  slug: Type.Optional(Type.String({ description: "Entry slug (its file path without .md). Ambiguous across scopes unless scope is also passed." })),
@@ -352,6 +381,15 @@ export function createMemoryEngineTools(opts) {
352
381
  });
353
382
  }
354
383
  const exposed = carry && exposedEntry(entry);
384
+ let supersededBy;
385
+ if (entryPlane !== undefined) {
386
+ try {
387
+ supersededBy = (await supersededOf(entryPlane, entryPlane.challengeExclusions?.())).get(entry.id);
388
+ }
389
+ catch {
390
+ supersededBy = undefined;
391
+ }
392
+ }
355
393
  try {
356
394
  entryPlane?.recordRetrieved([entry.id]);
357
395
  }
@@ -365,6 +403,7 @@ export function createMemoryEngineTools(opts) {
365
403
  const head = [
366
404
  `Memory entry ${entryPath(entry.scope, entry.slug)} (id ${entry.id}${mtimeMs !== undefined ? `, ${ageOf(now, mtimeMs)}` : ""})`,
367
405
  ...(exposed ? [MEMORY_EXPOSURE_BANNER] : []),
406
+ ...(supersededBy !== undefined ? [memorySupersededNote(supersededBy)] : []),
368
407
  ...(fm.name !== undefined ? [`name: ${inlineUntrusted(fm.name, 120)}`] : []),
369
408
  ...(fm.description !== undefined ? [`description: ${inlineUntrusted(fm.description, 200)}`] : []),
370
409
  ...(fm.type !== undefined ? [`type: ${inlineUntrusted(fm.type, 40)}`] : []),
@@ -380,8 +419,11 @@ export function createMemoryEngineTools(opts) {
380
419
  return refusedGet("taint_mark_failed", `Memory entry ${entry.id.slice(0, 64)} was not delivered: its external-origin take-up could not be recorded for this session (fail-closed). Report this to the operator and retry.`, "failed", { id: entry.id });
381
420
  }
382
421
  }
422
+ const neutralLines = defuseFenceMarkers(sanitizeUntrustedText(entry.body, [...FENCED_LANE_ENVELOPE_TAGS])).split("\n");
423
+ if (neutralLines.length > 0 && neutralLines[neutralLines.length - 1] === "")
424
+ neutralLines.pop();
383
425
  if (lineCursor > 0 && totalLines > 0) {
384
- const line = defuseFenceMarkers(sanitizeUntrustedText(allLines[offset]));
426
+ const line = neutralLines[offset];
385
427
  const tail = skipBytes(line, lineCursor);
386
428
  const cut = cutToBytes(tail.text, MEMORY_GET_PAGE_CAP_BYTES);
387
429
  const shown = Buffer.byteLength(cut.text, "utf8");
@@ -405,23 +447,24 @@ export function createMemoryEngineTools(opts) {
405
447
  lineCursor: tail.skippedBytes,
406
448
  ...(nextLineCursor !== undefined ? { nextLineCursor } : {}),
407
449
  ...(exposed ? { exposure: "external" } : {}),
450
+ ...(supersededBy !== undefined ? { supersededBy } : {}),
408
451
  };
409
452
  return { content: head.join("\n"), details };
410
453
  }
411
454
  const page = [];
412
455
  let bytes = 0;
413
- for (let i = offset; i < Math.min(totalLines, offset + limit); i++) {
414
- const lineBytes = Buffer.byteLength(allLines[i], "utf8") + 1;
456
+ for (const neutralizedLine of neutralLines.slice(offset, offset + limit)) {
457
+ const lineBytes = Buffer.byteLength(neutralizedLine, "utf8") + 1;
415
458
  if (page.length > 0 && bytes + lineBytes > MEMORY_GET_PAGE_CAP_BYTES)
416
459
  break;
417
- page.push(allLines[i]);
460
+ page.push(neutralizedLine);
418
461
  bytes += lineBytes;
419
462
  if (bytes > MEMORY_GET_PAGE_CAP_BYTES)
420
463
  break;
421
464
  }
422
465
  const end = offset + page.length;
423
466
  head.push(`body lines ${totalLines === 0 ? 0 : offset + 1}-${end} of ${totalLines}:`);
424
- const neutralized = defuseFenceMarkers(sanitizeUntrustedText(page.join("\n")));
467
+ const neutralized = defuseFenceMarkers(sanitizeUntrustedText(page.join("\n"), [...FENCED_LANE_ENVELOPE_TAGS]));
425
468
  const cut = cutToBytes(neutralized, MEMORY_GET_PAGE_CAP_BYTES);
426
469
  head.push(delimitUntrusted(`memory entry ${entry.slug}`, cut.text));
427
470
  let nextLineCursor;
@@ -446,6 +489,7 @@ export function createMemoryEngineTools(opts) {
446
489
  totalLines,
447
490
  ...(nextLineCursor !== undefined ? { nextLineCursor } : {}),
448
491
  ...(exposed ? { exposure: "external" } : {}),
492
+ ...(supersededBy !== undefined ? { supersededBy } : {}),
449
493
  };
450
494
  return { content: head.join("\n"), details };
451
495
  },
@@ -27,6 +27,55 @@ export interface MemoryEntryOrigin {
27
27
  cause?: MemoryOriginCause;
28
28
  at: number;
29
29
  }
30
+ /**
31
+ * design/339 §1.4 — one input-lineage row of a distilled (consolidation-produced) entry.
32
+ * `superseded: true` rows are the SUPERSESSION EDGES (this product replaces that input in the
33
+ * default read face — rev-anchored, see {@link MemoryEntryDistilled}); rows without the flag are
34
+ * evidence-only inputs (episodic sources, net-new distillation material) that are never suppressed.
35
+ */
36
+ export interface MemoryEntryDistilledInput {
37
+ /** The input entry's immutable id. */
38
+ id: string;
39
+ /** The input's committed rev AT PLAN FREEZE — the edge's rev anchor (design/339 D-2a): the edge
40
+ * is valid only while the target's committed rev still equals this, so a later legitimate edit
41
+ * of the superseded entry REVIVES it (new information must never stay suppressed). */
42
+ rev: string;
43
+ /** Present ⇔ this row is a supersession edge (the product replaces this input in the active set). */
44
+ superseded?: true;
45
+ }
46
+ /**
47
+ * design/339 §1.4 — the ENGINE-MINTED supersession/lineage carrier riding ON the distilled product
48
+ * entry itself (typed frontmatter member, the `origin` carriage family). The block is authored
49
+ * exclusively by the consolidation protocol's plan freeze — a harvest never adopts one off the
50
+ * model-writable plane (stripped with a report warning, same law as `origin`), and once committed
51
+ * it is immutable: every later op must carry it forward DEEP-EQUAL (strip or member rewrite =
52
+ * malformed-refusal; the one legal exit is the committed tombstone). Riding on the product makes
53
+ * the edge publish ATOMIC for free (per-entry atomicity = edge atomicity; a partial apply can never
54
+ * suppress a target whose replacement is missing — the edge IS the replacement's bytes), and makes
55
+ * the lineage TRAVEL with the entry across backend/sync/bundle seams (retroactive challenge
56
+ * propagation stays mechanical on a deployment that never saw the plan file).
57
+ */
58
+ export interface MemoryEntryDistilled {
59
+ /** Minting plan (audit anchor; opaque on a deployment the entry traveled to). */
60
+ planId: string;
61
+ /** Mint time, ms epoch — fixed at plan freeze (a replay re-adds the exact same bytes). */
62
+ at: number;
63
+ /**
64
+ * The CARRIER-side rev anchor (design/339 r3-2): the product's own committed rev at plan freeze.
65
+ * NON-CIRCULAR BY CONSTRUCTION: this member is excluded from the rev's canonical tuple
66
+ * ({@link import("./frontmatter.js").computeEntryRev}), so at freeze it simply EQUALS the entry's
67
+ * committed rev. An edge is valid only while the carrier's committed rev still equals this —
68
+ * a legitimate edit of the product (which may rewrite or retract what it integrated) SUSPENDS
69
+ * its edges and revives the inputs (the only accurate copy must never hide outside the default
70
+ * read face). Idempotent re-adds / deep-carrying updates don't move the rev, so untouched
71
+ * carriers keep their edges. Tamper is closed by the immutability law: a rewrite of this member
72
+ * is a malformed-refusal regardless of the rev arithmetic.
73
+ */
74
+ carrierRev: string;
75
+ /** The product's COMPLETE input lineage (id + freeze-time rev; `superseded` rows are the edges).
76
+ * Bounded at freeze by `maxInputsPerProduct` (§6.1). */
77
+ inputs: MemoryEntryDistilledInput[];
78
+ }
30
79
  /**
31
80
  * Structured header fields of one memory entry (the CC frontmatter shape: `name`/`description`/
32
81
  * `metadata.type`). `extra` preserves any additional frontmatter lines VERBATIM (round-trip safety:
@@ -93,6 +142,19 @@ export interface MemoryEntryFrontmatter {
93
142
  * deliberately no `"clean"` spelling to upgrade to (the `trust: "untrusted"` single-value law).
94
143
  */
95
144
  origin?: MemoryEntryOrigin;
145
+ /**
146
+ * design/339 §1.4 — the consolidation lineage/supersession carrier (see
147
+ * {@link MemoryEntryDistilled}). Engine-minted at plan freeze ONLY; model-written spellings
148
+ * (typed or suspect `extra` form) are stripped at harvest with a report warning, and a committed
149
+ * block is immutable under every touching op (deep-equal carry-forward, the `origin` I-2 seat's
150
+ * third member). Threat direction is the INVERSE of `origin`'s (design/339 §1.4.4): the
151
+ * dangerous act is ADDING an edge (a forged edge suppresses good memory — the delete-attack
152
+ * surface add-only exists to close), so only the pristine TYPED seat ever produces edges —
153
+ * suspect `extra`-form bytes are preserved verbatim but INERT (they mint no edge; fewer edges
154
+ * is the safe direction), while the immutability law still protects the committed typed block
155
+ * (stripping it would whitewash the product's lineage out of retroactive challenge reach).
156
+ */
157
+ distilled?: MemoryEntryDistilled;
96
158
  /** Unknown frontmatter lines preserved verbatim (order kept), re-serialized on projection. */
97
159
  extra?: string[];
98
160
  }
@@ -134,6 +196,23 @@ export interface MemoryEntryHeader {
134
196
  * question. Absent ⇔ no marker (the entry was never judged exposed — not "proven clean").
135
197
  */
136
198
  exposure?: "external";
199
+ /**
200
+ * design/339 §4.1-3 — the committed DISTILLED fact, carried on BOTH header faces (`listHeaders`
201
+ * and `search`, one projection): present ⇔ the committed entry carries a typed `distilled` block.
202
+ * `supersedes` is the block's edge PROJECTION (the `superseded: true` input rows — the minimal
203
+ * set the engine's active-set derivation needs; the full lineage stays off the header, payload
204
+ * bounded), `carrierRev` the block's carrier-side rev anchor (edge validity needs it beside the
205
+ * header's own `rev`). A DATA fact, not a mode — the backend reports what is stored; active-set
206
+ * derivation and every read-face treatment live in the engine/tool layer (same law as
207
+ * {@link exposure}).
208
+ */
209
+ distilled?: {
210
+ carrierRev: string;
211
+ supersedes: Array<{
212
+ id: string;
213
+ rev: string;
214
+ }>;
215
+ };
137
216
  }
138
217
  /** A scored search hit (FileBackend = lexical floor; PgBackend = design/81 vector rungs, S3). */
139
218
  export interface ScoredMemoryEntry extends MemoryEntryHeader {
@@ -254,10 +333,19 @@ export interface MemoryBackend {
254
333
  * earlier delete in the same batch does not blank the baseline). The one legal exit is the
255
334
  * COMMITTED tombstone: after a delete commits, the marker's life ends with the id (a fresh id —
256
335
  * or the same id in a LATER batch — starts an unmarked life; the engine re-judges its session);
336
+ * - design/339 §4.1-1 `distilled` 不可变律 (the I-2 seat's third member): when the id's PRE-BATCH
337
+ * committed state carries a typed `frontmatter.distilled` block, ANY op spelling that touches
338
+ * the id (`update`, plain re-`add`, `guard: "absent"` add, same-batch delete-then-re-add) must
339
+ * carry it forward DEEP-EQUAL (planId + at + carrierRev + every input row, order included); a
340
+ * strip or member rewrite is refused with reason matching /malformed patch refused/. Judged
341
+ * against the PRE-BATCH committed state; the one legal exit is the COMMITTED tombstone (the
342
+ * block's life ends with the id). Suspect `extra`-form distilled bytes are NOT this law's
343
+ * subject (they are inert verbatim leftovers — they mint no edge and carry like any `extra`);
257
344
  * - PRECEDENCE (FAM-1 #5, every whitewash spelling): the malformed judgment answers BEFORE
258
345
  * guard/CAS arithmetic — a strip riding a stale `baseRev` (or a guard conflict) still answers
259
346
  * /malformed patch refused/, never the ordinary rev-mismatch conflict (a whitewash is illegal
260
347
  * at ANY rev, and the weaker conflict would tell the caller's ladder to rebase and retry it);
348
+ * design/339 c37: the same precedence covers the `distilled` law;
261
349
  * - design/336 §4 hold protocol: the backend is hold-UNAWARE — instruction holds live on the
262
350
  * ENGINE's control plane, so a held id keeps serving reads and ordinary add/update patches for
263
351
  * it apply plainly (no queueing, no refusal). The settlement-period write interaction resolves
@@ -460,6 +548,17 @@ export interface HarvestReport {
460
548
  * (`memory.hold_opened` / `memory.hold_released` / `memory.hold_disposed`) is derived from the
461
549
  * three hold seats.
462
550
  */
551
+ /**
552
+ * design/339 §2.2 — the consolidation RECOMMENDATION edge (advisory, additive): this harvest's
553
+ * engine-minted session count crossed the configured thresholds (time gate open ∧ enough
554
+ * distinct sessions ∧ the once-per-crossing edge armed). Present at most once per crossing;
555
+ * absent always when consolidation is OFF (the default — zero gate state exists then). The
556
+ * recommendation NEVER executes anything: the host owns the verbs.
557
+ */
558
+ consolidationRecommended?: {
559
+ scope: string;
560
+ sessionsSince: number;
561
+ };
463
562
  containment?: {
464
563
  /** The derived index (MEMORY.md) was restored to its materialize-time baseline this harvest
465
564
  * (the session's prose additions were captured and dropped). */
@@ -1,3 +1,4 @@
1
+ import { MEMORY_INJECTION_TAGS } from "./memory.js";
1
2
  import { sanitizeUntrustedText } from "./untrusted-text.js";
2
3
  export const DEFAULT_MAX_SELECTED = 5;
3
4
  export const DEFAULT_MAX_LINKED = 3;
@@ -107,8 +108,8 @@ export function composeSelectiveBody(manifestText, selected, nowMs, linked = [],
107
108
  const ageMs = nowMs - r.mtimeMs;
108
109
  const verify = r.timestampMissing || ageMs > ONE_DAY_MS ? " — verify it's still current" : "";
109
110
  const stale = r.timestampMissing ? ` (write time unknown${verify})` : ` (written ${formatMemoryAge(ageMs)}${verify})`;
110
- const prefix = label ? `${sanitizeUntrustedText(label, ["user_memory"])} ` : "";
111
- return `- ${prefix}${sanitizeUntrustedText(r.text, ["user_memory"])}${stale}`;
111
+ const prefix = label ? `${sanitizeUntrustedText(label, [...MEMORY_INJECTION_TAGS])} ` : "";
112
+ return `- ${prefix}${sanitizeUntrustedText(r.text, [...MEMORY_INJECTION_TAGS])}${stale}`;
112
113
  };
113
114
  let body = selected.map((r) => renderNote(r)).join("\n");
114
115
  if (linked.length > 0) {
@@ -126,7 +127,7 @@ export function composeSelectiveBody(manifestText, selected, nowMs, linked = [],
126
127
  const selectedSection = selected.length > 0 ? `\n\nRelevant notes:\n${body}` : "\n\n(no notes selected as relevant to this task)";
127
128
  const recallCall = recallToolName !== undefined ? `call ${recallToolName}` : `search the memory-recall tool bound to this scope`;
128
129
  const manifestSection = recallable
129
- ? `Memory index — to load a note in full, ${recallCall} with keywords from its description:\n${sanitizeUntrustedText(manifestText, ["user_memory"])}`
130
+ ? `Memory index — to load a note in full, ${recallCall} with keywords from its description:\n${sanitizeUntrustedText(manifestText, [...MEMORY_INJECTION_TAGS])}`
130
131
  : "";
131
132
  return `${manifestSection}${selectedSection}`;
132
133
  }
@@ -541,12 +541,42 @@ export declare function normalizeMemorySpec(input: MemorySpecInput | undefined):
541
541
  * Exported so the inject-all de-dup seeding (prepare-task.ts) can detect truncation and NOT seed dropped
542
542
  * (truncated-out) note ids as "already surfaced" — see the MINOR-1 audit fix there. */
543
543
  export declare const MAX_MEMORY_BYTES: number;
544
- /** The wrapper-tag family the memory injection fences neutralize (`<system-reminder>` is ALWAYS
544
+ /** The wrapper-tag family the memory ADMISSION gate rejects on (`<system-reminder>` is ALWAYS
545
545
  * included by {@link sanitizeUntrustedText} itself). Single source (design/138 S2-C, C-F10/O-F11):
546
546
  * the memory write-time scan (`memory-engine/scan.ts`) detects break-out markup by diffing
547
- * `sanitizeUntrustedText(text, MEMORY_WRAPPER_TAGS)` against the raw text, so the scan rules and
548
- * the ACTUAL injection boundary (this file's compose* fences) can never drift apart. */
547
+ * `sanitizeUntrustedText(text, MEMORY_WRAPPER_TAGS)` against the raw text.
548
+ *
549
+ * ADMISSION half — deliberately UNCHANGED at the single outer wrapper. See
550
+ * {@link MEMORY_INJECTION_TAGS} for the (wider) containment half and for why the two are allowed to
551
+ * differ in exactly one direction. */
549
552
  export declare const MEMORY_WRAPPER_TAGS: readonly string[];
553
+ /**
554
+ * The tag family the memory injection fences actually NEUTRALIZE — the whole authority-envelope
555
+ * census (untrusted-text.ts `ENGINE_ENVELOPES`), a deliberate SUPERSET of {@link MEMORY_WRAPPER_TAGS}.
556
+ *
557
+ * Two reproduced findings drove the widening:
558
+ * ① `<scope>` — {@link composeLayeredMemoryBlock} renders a `<scope name="…">` SUB-ENVELOPE per
559
+ * layer, but the neutralized list named only the outer wrapper, so a layer payload could close its
560
+ * own `</scope>` and open a fresh `<scope name="managed">`: a forged higher-authority layer landing
561
+ * at the END of the block, where this function's own contract puts the highest-priority layer.
562
+ * Project-scope content is repository-controlled by our own stated posture
563
+ * (PROJECT_CONTEXT_FRAMING), i.e. attacker-reachable.
564
+ * ② the SIBLING envelopes — nesting a forged `<task-notification>` or `<new-diagnostics>` inside
565
+ * `<user_memory>` does not make it inert: the TAG is what a model reads as harness speech, and
566
+ * this payload lands in the SYSTEM PROMPT. Widening is safe precisely here because the sanitizer
567
+ * runs on the PAYLOAD, before this file assembles the engine's own wrappers around it — it can
568
+ * never defuse our own fences.
569
+ *
570
+ * WHY THE ADMISSION LIST DID NOT WIDEN WITH IT (the asymmetry is the point). The write scan
571
+ * (`memory-engine/scan.ts`) diffs against {@link MEMORY_WRAPPER_TAGS} and REFUSES on a difference,
572
+ * and it runs on re-serialization / sync / import paths as well as on fresh writes. Widening it would
573
+ * retroactively refuse ALREADY-STORED entries — and `<scope>test</scope>` is a Maven POM element, so
574
+ * the false-rejection is not hypothetical. The drift therefore runs in the one safe direction:
575
+ * containment ⊇ admission. A note the gate accepts is neutralized on the way in to the prompt; the
576
+ * dangerous drift would be the reverse (admission accepting what containment does not defuse), and
577
+ * that one is structurally impossible while this constant is a superset.
578
+ */
579
+ export declare const MEMORY_INJECTION_TAGS: readonly string[];
550
580
  /**
551
581
  * Wrap memory content in a `<user_memory>` block. This is the **variable tail** of the system prompt
552
582
  * (per-user, scoped, timestamped) — the default prompt provider places it LAST so the stable base
@@ -1,7 +1,8 @@
1
1
  import { uuidv7 } from "../internal/harness.js";
2
2
  import { parseScopeKey } from "./memory-engine/scope-contract.js";
3
3
  import { termSet } from "./memory-vector.js";
4
- import { sanitizeUntrustedText } from "./untrusted-text.js";
4
+ import { attrEscape } from "./task-notification.js";
5
+ import { sanitizeUntrustedText, ENGINE_AUTHORITY_ENVELOPE_TAGS } from "./untrusted-text.js";
5
6
  export function supportsConsolidation(store) {
6
7
  return (typeof store.searchScored === "function" &&
7
8
  typeof store.update === "function" &&
@@ -402,6 +403,7 @@ export function normalizeMemorySpec(input) {
402
403
  }
403
404
  export const MAX_MEMORY_BYTES = 100 * 1024;
404
405
  export const MEMORY_WRAPPER_TAGS = ["user_memory"];
406
+ export const MEMORY_INJECTION_TAGS = [...ENGINE_AUTHORITY_ENVELOPE_TAGS];
405
407
  export function composeMemoryBlock(content, scope) {
406
408
  const trimmed = (content ?? "").trim();
407
409
  if (!trimmed) {
@@ -418,7 +420,7 @@ export function composeMemoryBlock(content, scope) {
418
420
  const head = buf.subarray(0, cut).toString("utf8");
419
421
  payload = `${head}\n<truncated bytes=${bytes - cut}>`;
420
422
  }
421
- return `<user_memory scope="${scope}">\n${sanitizeUntrustedText(payload, [...MEMORY_WRAPPER_TAGS])}\n</user_memory>`;
423
+ return `<user_memory scope="${attrEscape(scope)}">\n${sanitizeUntrustedText(payload, [...MEMORY_INJECTION_TAGS])}\n</user_memory>`;
422
424
  }
423
425
  export function composeLayeredMemoryBlock(layers) {
424
426
  const sections = [];
@@ -435,8 +437,8 @@ export function composeLayeredMemoryBlock(layers) {
435
437
  cut--;
436
438
  payload = `${buf.subarray(0, cut).toString("utf8")}\n<truncated bytes=${bytes - cut}>`;
437
439
  }
438
- sections.push(`<scope name="${sanitizeUntrustedText(layer.scope, [...MEMORY_WRAPPER_TAGS])}">\n` +
439
- `${sanitizeUntrustedText(payload, [...MEMORY_WRAPPER_TAGS])}\n` +
440
+ sections.push(`<scope name="${attrEscape(layer.scope)}">\n` +
441
+ `${sanitizeUntrustedText(payload, [...MEMORY_INJECTION_TAGS])}\n` +
440
442
  `</scope>`);
441
443
  }
442
444
  if (sections.length === 0)
@@ -7,19 +7,36 @@
7
7
  * semantics; who may mint one, where it is stored, and where in the gate it is consumed live in
8
8
  * `permission-rule-store.ts`, `permission-rule-consent.ts` and `hooks.ts` respectively.
9
9
  *
10
- * ## The floor: only a single simple command can ever be matched
10
+ * ## The floor: what a rule may name, and what a rule may match
11
11
  *
12
- * Everything a shell can use to run a SECOND program behind an admitted first oneoperators,
13
- * substitution, subshells, backgrounding, and **redirection** — puts the command outside this lane
14
- * entirely: it falls back to the pre-existing chain and asks. That includes redirection specifically,
15
- * which is a deliberate registered divergence from upstream (upstream strips redirections before
16
- * matching; with this repo's write gate not covering the shell tool, stripping would turn a rule as
17
- * innocuous as `Bash(ls)` into a licence for `ls > ~/.ssh/authorized_keys`).
12
+ * Everything a shell can use to run a program the text does not NAMEsubstitution, subshells,
13
+ * backgrounding, escapes, line breaks, and **redirection** — puts the command outside this lane
14
+ * entirely: it falls back to the pre-existing chain and asks. Redirection specifically is a deliberate
15
+ * registered divergence from upstream (upstream strips redirections before matching; with this repo's
16
+ * write gate not covering the shell tool, stripping would turn a rule as innocuous as `Bash(ls)` into a
17
+ * licence for `ls > ~/.ssh/authorized_keys`).
18
18
  *
19
- * The floor is `parseLeadingCommandName` the one simple-command parser already shared by the
20
- * read-only classifier, the reversibility probe, the coarse command-name policy and the skill tool
21
- * specifier. A second tokenizer would drift, and drift on a loosening face shows up as a circumvention rather
22
- * than as a test failure.
19
+ * CONNECTORS are the one construct the lane does speak for, and only in the EXACT form. `./gradlew
20
+ * build && ./gradlew test` is one thing a person reads and approves in one glance, and the whole of it
21
+ * is written into the rule; nothing is admitted that the rule text does not spell out end to end. The
22
+ * PREFIX form stays single-command on both sides — as a rule BODY (`Bash(a && b:*)` is refused) and as
23
+ * a MATCH (`Bash(npm:*)` does not admit `npm test && curl evil.example`, which is the whole reason the
24
+ * two forms are separated here rather than sharing one matcher arm). Upstream reaches the same
25
+ * placement through a per-segment evaluation of its full decision chain, where a segment DENY is
26
+ * returned strictly before any whole-string allow; this lane's equivalent is that the deny/ask layer
27
+ * (`permission-rule-org.ts`) judges every segment and runs ahead of the allow lane at the gate.
28
+ *
29
+ * The floor is `parseLeadingCommandName` + `splitShellCompoundSegments` — the one simple-command parser
30
+ * and the one segmentation, both already shared with the read-only classifier, the reversibility probe,
31
+ * the coarse command-name policy and the skill tool specifier. A second tokenizer would drift, and drift
32
+ * on a loosening face shows up as a circumvention rather than as a test failure.
33
+ *
34
+ * argv[0] may carry a PATH prefix here (`./gradlew`, `/usr/bin/git`) — opt-in at the shared parser and
35
+ * used by this lane alone. The bare-name requirement belongs to the argv[0]-NAME filters, which compare
36
+ * one token against a name set; this lane compares the whole command line, so a path prefix is not a way
37
+ * past anything — it IS the text that was approved. No basename folding follows from it: `./gradlew`
38
+ * and `gradlew` stay two different commands to the matcher. The single place a basename is taken is the
39
+ * interpreter refusal below, which must read `/usr/bin/node` as `node`.
23
40
  *
24
41
  * ## Normalization order is load-bearing
25
42
  *
@@ -121,11 +138,64 @@ export declare const MAX_RULE_TEXT_CHARS = 512;
121
138
  * any program you like" — that is not a shape a single click can be understood to have authorized.
122
139
  *
123
140
  * The check is on argv[0] of the prefix, not on the prefix being exactly one word: `node -e:*` is the
124
- * same licence spelled longer. Deliberately strict-side it costs the ability to persist a rule like
141
+ * same licence spelled longer. It is on argv[0]'s BASENAME, not its spelling: `/usr/bin/node:*` and
142
+ * `./node:*` grant precisely what `node:*` grants, and a table consulted with the full path would be a
143
+ * table any path prefix walks around (upstream takes the same basename before consulting its own
144
+ * interpreter set). Deliberately strict-side — it costs the ability to persist a rule like
125
145
  * `python manage.py migrate:*` (which an import reports as skipped rather than dropping silently), and
126
146
  * an EXACT rule naming a whole interpreter command line stays legal, since it authorizes one command.
147
+ *
148
+ * TWO groups, and the distinction matters when the table is next edited:
149
+ * · LANGUAGE interpreters (`node`, `python`, `ruby`, …) — the argument IS a program. This half is
150
+ * wider than upstream's own set, deliberately, and is the argument the paragraph above makes.
151
+ * · LAUNCHERS — a program whose argument is another program to run: the shells, the environment and
152
+ * privilege wrappers, the schedulers and resource wrappers, the tracers, and the shell BUILTINS
153
+ * that dispatch (`command`, `builtin`). This half is upstream's set, adopted verbatim rather than
154
+ * reasoned out row by row: it is the same question upstream answers with the same mechanism (its
155
+ * set is consulted on argv[0]'s basename too), and every row passes this table's own first axis —
156
+ * a body whose use is naming a program to execute is refused. They were missing, and each was one
157
+ * token in front of the licence the language half already refuses: `command node:*`, `timeout 9
158
+ * node:*` and `nice node:*` all grant exactly what `node:*` grants.
159
+ *
160
+ * KNOWN RESIDUAL, stated rather than hidden, and it is a property of the instrument rather than of this
161
+ * particular list: a positive list of NAMES cannot be made complete, and two independent review rounds
162
+ * enumerated escapes faster than rows could be added. Three shapes, none of which a longer list fixes:
163
+ * · a version- or distribution-suffixed spelling of a listed interpreter (`python3.12`, `nodejs`);
164
+ * · a launcher nobody listed — the set of programs that run another program has no boundary, and each
165
+ * round produced more of them;
166
+ * · a spelling that defeats the NAME question entirely: `/proc/self/exe` (basename `exe`, the running
167
+ * shell), a symlink or a renamed binary, a BusyBox applet, a dynamic loader invoked directly.
168
+ * Matching a family would need a pattern rather than a set, with its own false-positive surface
169
+ * (`node-gyp`, `timeout-monitor`), and no pattern addresses the third shape at all. What carries the
170
+ * residue is therefore NOT this table: it is the three standing fences — the org deny/ask layer runs
171
+ * ahead of this lane and cannot be silenced by it, a mandated ask is not rule-clearable, and the
172
+ * narrower exact candidate (plus minting nothing) is always on the same card. This table's job is to
173
+ * keep the OBVIOUS one-token licence off a card a person clicks once, not to be a boundary.
127
174
  */
128
175
  export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
176
+ /**
177
+ * Shell KEYWORDS, refused as a segment's `argv[0]` everywhere this lane reads a command.
178
+ *
179
+ * The floor this lane stands on extracts the first TOKEN of a segment and calls it the command name.
180
+ * That identification is what every comparison here rests on — the interpreter refusal, the deny/ask
181
+ * layer's per-segment judgement, and a person's reading of the rule text. For a control structure it is
182
+ * simply false. `for x in once; do curl evil.example; done` splits into three segments whose first
183
+ * tokens are `for`, `do` and `done`; all three are ordinary bare words, so the floor accepts each,
184
+ * `curl` is named by nothing, and a published `deny Bash(curl:*)` matches none of them while bash runs
185
+ * curl. A whole loop body — any number of programs — hides behind three tokens that name no program.
186
+ *
187
+ * Refusing the keyword puts the whole command outside the lane, which is the honest answer: no rule can
188
+ * be minted for it and no rule matches it, so it asks. Recovering these shapes properly needs a real
189
+ * shell grammar (upstream has one — it parses to a syntax tree and reads the commands out of the
190
+ * structure, so a keyword is never mistaken for a program); a token-level lane cannot, and pretending
191
+ * otherwise is the loosening direction.
192
+ *
193
+ * `!` is here for the same reason with a sharper edge: it is a keyword that PREFIXES a real command, so
194
+ * `! node -e …` is the `node` licence the interpreter table exists to refuse, wearing one extra token.
195
+ * `{`/`}`/`[[`/`]]` are already refused by the floor's argv[0] metacharacter rule; listed anyway,
196
+ * because that rule belongs to another module and is not this lane's to depend on.
197
+ */
198
+ export declare const SHELL_RESERVED_WORDS: ReadonlySet<string>;
129
199
  /**
130
200
  * design/185 §1 — the reviewed command/subcommand grammar the PREFIX suggestion is generated from
131
201
  * (exactly the "reviewed command/subcommand grammar" the generator's history note names as the one
@@ -215,10 +285,46 @@ export declare function formatAllowRuleText(command: string, match: PersistedRul
215
285
  * Does this rule's command pattern admit `command`?
216
286
  *
217
287
  * `command` is the raw tool argument: the floor and the folding happen HERE, in that order, so no
218
- * caller can accidentally match a folded compound. Returns false for anything that is not a single
219
- * simple command including every redirection form.
288
+ * caller can match a command this lane has not read. Returns false for every redirection, substitution,
289
+ * subshell, backgrounded or escaped form.
290
+ *
291
+ * **A PREFIX rule never admits a compound.** This is the single load-bearing line of the connector
292
+ * widening, and it is checked on the MATCH side rather than left to the mint side: `Bash(npm:*)` is an
293
+ * ordinary, legitimately mintable rule, and if the "does this command start with `npm `" arm were
294
+ * allowed to see a compound at all, that rule would admit `npm test && curl evil.example` — one stored
295
+ * yes to a build command turned into a standing yes to whatever is chained behind it. An EXACT rule has
296
+ * no such reach by construction: it admits one string, the one it spells.
220
297
  */
221
298
  export declare function ruleAdmitsCommand(rule: Pick<PersistedAllowRule, "match" | "command">, command: string): boolean;
299
+ /**
300
+ * The same match, asked the DENY/ASK layer's question — "does this rule speak about this program run?".
301
+ *
302
+ * Split from {@link ruleAdmitsCommand} because the two differ on one axis that decides real cases: a
303
+ * quoted operator. `curl "https://x/?a=1&b=2"` is ONE command bash runs, and the `&` in a query string
304
+ * is not a connector; the matching side refuses it anyway (a rule text is a spelling with no operator
305
+ * characters in it at all — the historical rule-face contract), and the deny side inheriting that
306
+ * refusal made `deny Bash(curl:*)` silent on the commonest spelling of the very program it names
307
+ * (adversarial round 3).
308
+ *
309
+ * The asymmetry only ever runs one way: this predicate reads MORE commands than the matching one, never
310
+ * fewer. A shape only the ALLOW side could read would be a standing approval no published policy could
311
+ * see — the inversion this whole ticket exists to prevent.
312
+ */
313
+ export declare function ruleAdmitsProgramRun(rule: Pick<PersistedAllowRule, "match" | "command">, command: string): boolean;
314
+ /**
315
+ * The segments of `command` as the deny/ask layer must judge them, or `undefined` for a command this
316
+ * lane cannot read.
317
+ *
318
+ * A tightening rule speaks about a PROGRAM RUN, and a compound runs several. `Bash(curl:*)` published
319
+ * as a deny means "this machine does not make that call", and reading `npm test && curl evil.example`
320
+ * as one unmatched blob answered that with silence — the shape the widening above would otherwise make
321
+ * permanently approvable. Exported (rather than folded into a matcher here) because the layer that
322
+ * needs it holds the rules: this module owns what a command IS, `permission-rule-org.ts` owns what the
323
+ * organization says about each part of it.
324
+ *
325
+ * A single simple command yields a one-element list, so a deny that matched before matches identically.
326
+ */
327
+ export declare function ruleLaneSegmentsOf(command: string): readonly string[] | undefined;
222
328
  /**
223
329
  * Is `path` inside (or equal to) `root`? Word-boundary containment on the path separator, so `/a` does
224
330
  * not contain `/ab`. Both sides are expected to be canonical already.
@@ -271,13 +377,26 @@ export interface RuleSuggestion {
271
377
  * shear a quoted segment — harmless in this direction, because the sheared pieces carry quote
272
378
  * characters and can never equal a bare lexicon word; every suspicious shape lands on "no prefix".
273
379
  *
274
- * Every produced candidate must survive the round trip — parse as a rule AND admit the very command
275
- * it was minted from. True by construction (a word-boundary lexicon prefix of a folded simple command
276
- * is exactly the matcher's two arms); enforced anyway, fail-closed: a candidate that would not
277
- * round-trip is silently not offered, since offering an option redemption would refuse is worse than
278
- * offering one fewer.
380
+ * Every produced candidate must survive the round trip — parse as a rule, come back as the match form
381
+ * this seat is offering, AND admit the very command it was minted from. Enforced on BOTH seats,
382
+ * fail-closed: a candidate that would not round-trip is silently not offered, since offering an option
383
+ * redemption would refuse is worse than offering one fewer.
384
+ *
385
+ * The FORM half of that check is not decoration. A command may end in the rule grammar's own prefix
386
+ * marker — `rm :*` is a legal thing to type — and wrapping it as an exact rule produces the text
387
+ * `Bash(rm :*)`, which the validator reads back as the PREFIX rule `Bash(rm:*)` over the command `rm`.
388
+ * Trusting the exact seat's own intent and labelling that result `match: "exact"` put a blanket
389
+ * every-`rm` rule on the card under the narrowest option's name, one click from being persisted (and,
390
+ * for a lexicon command like `git status :*`, emitted twice — once mislabelled, once as the real prefix
391
+ * candidate). The seat therefore believes the PARSER about what it got back, never its own request.
392
+ *
393
+ * A COMPOUND (`./gradlew build && ./gradlew test`) fills the exact seat and only that one: the offered
394
+ * rule spells the whole chain and admits exactly it. That is the shape this seat was missing — the
395
+ * ordinary build invocation is a connector chain, and a card that could offer nothing for it made every
396
+ * such command a fresh question forever, with no way for an answer to accumulate.
279
397
  *
280
- * Returns an empty array for anything the rule lane cannot speak for (compounds, redirections,
281
- * substitutions) — the card then simply carries no "don't ask again" option, which is the honest answer.
398
+ * Returns an empty array for anything the rule lane cannot speak for (redirections, substitutions,
399
+ * subshells, backgrounding) — the card then simply carries no "don't ask again" option, which is the
400
+ * honest answer.
282
401
  */
283
402
  export declare function suggestRulesForCommand(command: string): RuleSuggestion[];