@sema-agent/core 7.30.0 → 7.31.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 (90) hide show
  1. package/CHANGELOG.md +274 -0
  2. package/dist/agents/cross-session-judge.d.ts +4 -12
  3. package/dist/agents/cross-session-judge.js +4 -11
  4. package/dist/agents/peer-session-drain.js +0 -5
  5. package/dist/agents/send-message-tool.js +1 -0
  6. package/dist/agents/subagent.d.ts +1 -0
  7. package/dist/agents/subagent.js +1 -0
  8. package/dist/core/ask-origin.d.ts +6 -0
  9. package/dist/core/ask-origin.js +10 -0
  10. package/dist/core/checkpoint-store.d.ts +14 -0
  11. package/dist/core/checkpoint-store.js +1 -0
  12. package/dist/core/engine-notice.d.ts +4 -0
  13. package/dist/core/governance-codes.d.ts +1 -1
  14. package/dist/core/governance-codes.js +3 -0
  15. package/dist/core/memory-engine/data-plane.d.ts +3 -1
  16. package/dist/core/memory-engine/data-plane.js +1 -1
  17. package/dist/core/memory-engine/delegation-settlement.d.ts +44 -3
  18. package/dist/core/memory-engine/delegation-settlement.js +38 -3
  19. package/dist/core/memory-engine/engine.d.ts +123 -26
  20. package/dist/core/memory-engine/engine.js +426 -149
  21. package/dist/core/memory-engine/entry-scan.d.ts +23 -0
  22. package/dist/core/memory-engine/entry-scan.js +55 -0
  23. package/dist/core/memory-engine/file-backend.d.ts +16 -23
  24. package/dist/core/memory-engine/file-backend.js +122 -99
  25. package/dist/core/memory-engine/layer-observations.d.ts +109 -0
  26. package/dist/core/memory-engine/layer-observations.js +148 -0
  27. package/dist/core/memory-engine/layout.d.ts +11 -0
  28. package/dist/core/memory-engine/layout.js +20 -4
  29. package/dist/core/memory-engine/types.d.ts +26 -2
  30. package/dist/core/parent-spec-seat.d.ts +4 -1
  31. package/dist/core/parent-spec-seat.js +2 -0
  32. package/dist/core/park-selfcheck.js +1 -0
  33. package/dist/core/runner/announce-once-ledger.d.ts +26 -0
  34. package/dist/core/runner/announce-once-ledger.js +37 -0
  35. package/dist/core/runner/contracts.d.ts +22 -5
  36. package/dist/core/runner/denial-limit-arms.d.ts +2 -2
  37. package/dist/core/runner/prepare-acquire-reconcile.d.ts +0 -4
  38. package/dist/core/runner/prepare-acquire-reconcile.js +1 -3
  39. package/dist/core/runner/prepare-file-history.d.ts +13 -15
  40. package/dist/core/runner/prepare-file-history.js +57 -43
  41. package/dist/core/runner/prepare-hands-readface.d.ts +2 -2
  42. package/dist/core/runner/prepare-listings.d.ts +3 -1
  43. package/dist/core/runner/prepare-listings.js +2 -2
  44. package/dist/core/runner/prepare-memory.js +16 -5
  45. package/dist/core/runner/prepare-park-ask.js +27 -16
  46. package/dist/core/runner/prepare-prompt-inputs.d.ts +8 -1
  47. package/dist/core/runner/prepare-prompt-inputs.js +5 -3
  48. package/dist/core/runner/prepare-task.js +7 -5
  49. package/dist/core/runner/prepare-wiring-manifest.d.ts +1 -0
  50. package/dist/core/runner/prepare-wiring-manifest.js +2 -1
  51. package/dist/core/runner/run-attachment-seats.js +1 -1
  52. package/dist/core/runner/run-leg.js +61 -52
  53. package/dist/core/runner/run-settle-and-teardown.js +4 -0
  54. package/dist/core/runner/run-turn-boundary.js +6 -6
  55. package/dist/core/runner/turn-attachments.d.ts +6 -6
  56. package/dist/core/runner-deps.d.ts +3 -1
  57. package/dist/core/secret-env.d.ts +11 -0
  58. package/dist/core/secret-env.js +22 -1
  59. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +2 -0
  60. package/dist/core/store-contracts/checkpoint-store-contract.js +22 -0
  61. package/dist/core/task-result.d.ts +1 -1
  62. package/dist/core/task-spec.d.ts +17 -8
  63. package/dist/core/tool-spec.d.ts +1 -1
  64. package/dist/core/wiring-manifest.d.ts +10 -0
  65. package/dist/core/wiring-manifest.js +7 -1
  66. package/dist/engine/execution-env/node-execution-env.d.ts +6 -0
  67. package/dist/engine/execution-env/node-execution-env.js +15 -17
  68. package/dist/engine/harness/agent-harness.js +7 -5
  69. package/dist/engine/lsp/node-lsp-manager.d.ts +7 -0
  70. package/dist/engine/lsp/node-lsp-manager.js +11 -3
  71. package/dist/index.d.ts +1 -1
  72. package/dist/index.js +1 -1
  73. package/dist/shell/lex.d.ts +8 -3
  74. package/dist/shell/lex.js +2 -2
  75. package/dist/tools/fs/bash-readonly-classifier.d.ts +7 -1
  76. package/dist/tools/fs/bash-readonly-classifier.js +7 -3
  77. package/dist/tools/fs/shell-read-establish.d.ts +1 -1
  78. package/dist/tools/fs/shell-read-establish.js +6 -34
  79. package/dist/tools/fs/shell-read-identity.d.ts +13 -0
  80. package/dist/tools/fs/shell-read-identity.js +39 -0
  81. package/dist/tools/fs/shell-read-judge.d.ts +8 -0
  82. package/dist/tools/fs/shell-read-judge.js +7 -0
  83. package/dist/tools/fs/shell-read-plan.d.ts +5 -1
  84. package/dist/tools/fs/shell-read-plan.js +13 -21
  85. package/dist/tools/fs/shell-read-word.d.ts +16 -2
  86. package/dist/tools/fs/shell-read-word.js +40 -5
  87. package/package.json +1 -1
  88. package/test/export-surface.snapshot.json +7 -1
  89. package/wire-changes/core@7.30.1.json +154 -0
  90. package/wire-changes/core@7.31.0.json +705 -0
@@ -0,0 +1,23 @@
1
+ /** Files/dirs the entry scan never treats as entries: the derived index, dotfiles (`.hydrate`,
2
+ * transaction staging files), and anything not `.md`. */
3
+ export declare const MEMORY_INDEX_FILENAME = "MEMORY.md";
4
+ /** Default nesting depth cap under a scope dir (CC memory is flat; a small allowance for topic dirs). */
5
+ export declare const DEFAULT_MAX_ENTRY_DEPTH = 3;
6
+ /** One on-disk entry file the scan found. */
7
+ export interface ScannedEntryFile {
8
+ /** Absolute path. */
9
+ path: string;
10
+ /** Slug = path relative to the scope dir, without `.md` (may contain `/` for nested topics). */
11
+ slug: string;
12
+ }
13
+ /**
14
+ * Enumerate a scope dir's entry files (depth-capped, symlinks skipped, dotfiles/index/non-`.md`
15
+ * ignored). Shared by the backend and the engine's harvest scan so the two can never disagree on
16
+ * what counts as an entry file. A scope dir is that scope's home and nothing else — no other
17
+ * scope's directory can lie under it, so there is nothing to exclude.
18
+ */
19
+ export declare function scanEntryFiles(dir: string, opts?: {
20
+ maxDepth?: number;
21
+ /** Harvest gate hook: called for every path the scan SKIPS, with why (backend scans ignore it). */
22
+ onSkip?: (path: string, kind: "symlink" | "depth" | "nonmd" | "dotfile" | "unreadable") => void;
23
+ }): ScannedEntryFile[];
@@ -0,0 +1,55 @@
1
+ import { lstatSync, readdirSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ export const MEMORY_INDEX_FILENAME = "MEMORY.md";
4
+ export const DEFAULT_MAX_ENTRY_DEPTH = 3;
5
+ export function scanEntryFiles(dir, opts = {}) {
6
+ const maxDepth = opts.maxDepth ?? DEFAULT_MAX_ENTRY_DEPTH;
7
+ const out = [];
8
+ const walk = (d, depth) => {
9
+ let names;
10
+ try {
11
+ names = readdirSync(d);
12
+ }
13
+ catch {
14
+ if (d !== dir)
15
+ opts.onSkip?.(d, "unreadable");
16
+ return;
17
+ }
18
+ for (const name of names.sort()) {
19
+ if (name.startsWith(".") || name === MEMORY_INDEX_FILENAME) {
20
+ if (name.startsWith("."))
21
+ opts.onSkip?.(join(d, name), "dotfile");
22
+ continue;
23
+ }
24
+ const p = join(d, name);
25
+ let st;
26
+ try {
27
+ st = lstatSync(p);
28
+ }
29
+ catch {
30
+ opts.onSkip?.(p, "unreadable");
31
+ continue;
32
+ }
33
+ if (st.isSymbolicLink()) {
34
+ opts.onSkip?.(p, "symlink");
35
+ continue;
36
+ }
37
+ if (st.isDirectory()) {
38
+ if (depth + 1 <= maxDepth)
39
+ walk(p, depth + 1);
40
+ else
41
+ opts.onSkip?.(p, "depth");
42
+ continue;
43
+ }
44
+ if (!st.isFile())
45
+ continue;
46
+ if (!name.endsWith(".md")) {
47
+ opts.onSkip?.(p, "nonmd");
48
+ continue;
49
+ }
50
+ out.push({ path: p, slug: relative(dir, p).replace(/\.md$/, "") });
51
+ }
52
+ };
53
+ walk(dir, 0);
54
+ return out;
55
+ }
@@ -1,29 +1,7 @@
1
1
  import { scopeDirName, type ChallengeEvent, type MemoryPartitionIncidentSink } from "./layout.js";
2
2
  import type { MemoryBundleImportPlan, MemoryImportReport } from "./export-bundle.js";
3
3
  import type { HarvestRejection, MemoryBackend, MemoryEntry, MemoryEntryHeader, MemoryScopeEnumeration, NotePatch, PatchReport, ScoredMemoryEntry } from "./types.js";
4
- /** Files/dirs the entry scan never treats as entries: the derived index, dotfiles (`.hydrate`,
5
- * transaction staging files), and anything not `.md`. */
6
- export declare const MEMORY_INDEX_FILENAME = "MEMORY.md";
7
- /** Default nesting depth cap under a scope dir (CC memory is flat; a small allowance for topic dirs). */
8
- export declare const DEFAULT_MAX_ENTRY_DEPTH = 3;
9
- /** One on-disk entry file the scan found. */
10
- export interface ScannedEntryFile {
11
- /** Absolute path. */
12
- path: string;
13
- /** Slug = path relative to the scope dir, without `.md` (may contain `/` for nested topics). */
14
- slug: string;
15
- }
16
- /**
17
- * Enumerate a scope dir's entry files (depth-capped, symlinks skipped, dotfiles/index/non-`.md`
18
- * ignored). Shared by the backend and the engine's harvest scan so the two can never disagree on
19
- * what counts as an entry file. A scope dir is that scope's home and nothing else — no other
20
- * scope's directory can lie under it, so there is nothing to exclude.
21
- */
22
- export declare function scanEntryFiles(dir: string, opts?: {
23
- maxDepth?: number;
24
- /** Harvest gate hook: called for every path the scan SKIPS, with why (backend scans ignore it). */
25
- onSkip?: (path: string, kind: "symlink" | "depth" | "nonmd" | "dotfile" | "unreadable") => void;
26
- }): ScannedEntryFile[];
4
+ export { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles, type ScannedEntryFile } from "./entry-scan.js";
27
5
  /** design/178 v2-b §3.3(b) — the explicit erasure selector (each form resolves to an explicit id
28
6
  * list before anything is deleted; there is no whole-store selector). */
29
7
  export type ErasureSelect = {
@@ -737,6 +715,14 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
737
715
  * the probe classifies those inline and keeps going — a store with a lingering copy never
738
716
  * contends for the writer lock on every read. */
739
717
  private readScope;
718
+ /** #1076b — the observation-table door of the adopting read: bytes a session recorded appearing
719
+ * while it held this layer read-only are never adopted. Gate-failing bytes are not taken here —
720
+ * the inbound gate's containment keeps its door. A hit is announced, the committed copy stands in
721
+ * when this path is the entry's bound projection, and the disk is left as found (the account it
722
+ * is judged against is current — the scan sees to that). The notice is deduplicated against what
723
+ * is still queued, so every session that meets the file is told once. Returns whether the file
724
+ * was settled (the caller moves on). */
725
+ private settledAsObserved;
740
726
  /**
741
727
  * design-A RB-5 ([A3]) — the read-side whitewash gate, shared by the bound-here divergence lane
742
728
  * and the move+edit lane so the two can never drift: an out-of-session disk change (git pull /
@@ -846,6 +832,13 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
846
832
  * WRITES ARE REFUSED, loudly — a view handed to a write path is a defect, not a fallback.
847
833
  */
848
834
  retrievalView(): MemoryBackend;
835
+ /** #1076b — the lock-free retrieval face serves no recorded divergence either (side-effect-free, so
836
+ * no notice): the committed copy stands in at the entry's bound projection, anything else is
837
+ * withheld. The table is read per call; the account only when it names one of these ids. */
838
+ private withoutObserved;
839
+ /** The committed rows read afresh from disk (no cache install), or undefined when the account
840
+ * cannot be read — a fault, never folded into "no rows". */
841
+ private committedRowsOrFault;
849
842
  /** Dedup keys for divergence findings the restricted view already recorded: one materialize reads
850
843
  * a scope several times (listHeaders, then getByIds per scope), and a checkpoint-cadence harvest
851
844
  * re-audits the same still-pending state — without the key, one untouched divergence would mint
@@ -6,63 +6,13 @@ import { MAX_MEMORY_BYTES } from "../memory.js";
6
6
  import { inlineUntrusted } from "../untrusted-text.js";
7
7
  import { ambiguousOriginRepresentation, committedDistilledOf, committedOriginOf, computeEntryRev, distilledEquals, entryFromFile, isValidEntryId, originEquals, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
8
8
  import { convertV1SidecarKeys } from "./delegation-settlement.js";
9
- import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, CHALLENGES_FILE, IMPORT_RECEIPTS_DIR, LINEAGE_FILE, SESSION_POLLUTION_DIR, appendChallengeEvents, atomicWriteFileSync, quarantineAndTombstone, captureAndClearLineageForEntries, adoptRootScopeLayout, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, importLatchTxnId, importLineageCommitted, importSessionPollution, isContainedIn, lineageContributionsOfSession, listSessionPollution, readChallengeEvents, readLineageRecord, registerScope, registeredScopes, releaseImportLatch, layoutNeedsAdoption, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, scopeRegistryShape, stageImportLatch, writeAllSync, } from "./layout.js";
9
+ import { layerObservationLookup, observedDivergenceNotice } from "./layer-observations.js";
10
+ import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles } from "./entry-scan.js";
11
+ import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, CHALLENGES_FILE, IMPORT_RECEIPTS_DIR, LINEAGE_FILE, SESSION_POLLUTION_DIR, appendChallengeEvents, atomicWriteFileSync, quarantineAndTombstone, captureAndClearLineageForEntries, adoptRootScopeLayout, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, importLatchTxnId, importLineageCommitted, importSessionPollution, isContainedIn, permissionRemedyOf, withPermissionRemedy, lineageContributionsOfSession, listSessionPollution, readChallengeEvents, readLineageRecord, registerScope, registeredScopes, releaseImportLatch, layoutNeedsAdoption, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, scopeRegistryShape, stageImportLatch, writeAllSync, } from "./layout.js";
10
12
  import { screenInboundEntries } from "./data-plane.js";
11
13
  import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
12
14
  import { thrownText } from "../thrown-value.js";
13
- export const MEMORY_INDEX_FILENAME = "MEMORY.md";
14
- export const DEFAULT_MAX_ENTRY_DEPTH = 3;
15
- export function scanEntryFiles(dir, opts = {}) {
16
- const maxDepth = opts.maxDepth ?? DEFAULT_MAX_ENTRY_DEPTH;
17
- const out = [];
18
- const walk = (d, depth) => {
19
- let names;
20
- try {
21
- names = readdirSync(d);
22
- }
23
- catch {
24
- if (d !== dir)
25
- opts.onSkip?.(d, "unreadable");
26
- return;
27
- }
28
- for (const name of names.sort()) {
29
- if (name.startsWith(".") || name === MEMORY_INDEX_FILENAME) {
30
- if (name.startsWith("."))
31
- opts.onSkip?.(join(d, name), "dotfile");
32
- continue;
33
- }
34
- const p = join(d, name);
35
- let st;
36
- try {
37
- st = lstatSync(p);
38
- }
39
- catch {
40
- opts.onSkip?.(p, "unreadable");
41
- continue;
42
- }
43
- if (st.isSymbolicLink()) {
44
- opts.onSkip?.(p, "symlink");
45
- continue;
46
- }
47
- if (st.isDirectory()) {
48
- if (depth + 1 <= maxDepth)
49
- walk(p, depth + 1);
50
- else
51
- opts.onSkip?.(p, "depth");
52
- continue;
53
- }
54
- if (!st.isFile())
55
- continue;
56
- if (!name.endsWith(".md")) {
57
- opts.onSkip?.(p, "nonmd");
58
- continue;
59
- }
60
- out.push({ path: p, slug: relative(dir, p).replace(/\.md$/, "") });
61
- }
62
- };
63
- walk(dir, 0);
64
- return out;
65
- }
15
+ export { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles } from "./entry-scan.js";
66
16
  export function canonicalJsonStringify(value) {
67
17
  if (Array.isArray(value))
68
18
  return `[${value.map((v) => (v === undefined || typeof v === "function" || typeof v === "symbol" ? "null" : canonicalJsonStringify(v))).join(",")}]`;
@@ -257,6 +207,45 @@ function validateLedgerRowsV2(rowsRaw, path) {
257
207
  }
258
208
  return out;
259
209
  }
210
+ function parseJournal(raw, jp) {
211
+ try {
212
+ const parsed = JSON.parse(raw);
213
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ops))
214
+ throw new Error("wrong shape");
215
+ return parsed;
216
+ }
217
+ catch {
218
+ throw new ControlPlaneCorruptError(`transaction journal is unparseable: ${jp}`);
219
+ }
220
+ }
221
+ function journalLedgerSnapshot(journal, jp) {
222
+ const hasV2 = journal.ledger2 !== undefined;
223
+ const hasV1 = journal.ledger !== undefined;
224
+ if (hasV2 === hasV1) {
225
+ throw new ControlPlaneCorruptError(`transaction journal has ${hasV2 ? "both ledger snapshots" : "no ledger snapshot"} (wrong shape): ${jp}`);
226
+ }
227
+ if (hasV2) {
228
+ const env = journal.ledger2;
229
+ if (!env || typeof env !== "object" || Array.isArray(env) || env.v !== LEDGER_SCHEMA_VERSION) {
230
+ throw new ControlPlaneCorruptError(`transaction journal ledger2 snapshot has the wrong shape: ${jp}`);
231
+ }
232
+ const extras = {};
233
+ for (const [k, val] of Object.entries(env))
234
+ if (k !== "v" && k !== "rows")
235
+ extras[k] = val;
236
+ return { form: "v2", rows: validateLedgerRowsV2(env.rows, jp), extras };
237
+ }
238
+ const map = journal.ledger;
239
+ if (!map || typeof map !== "object" || Array.isArray(map))
240
+ throw new ControlPlaneCorruptError(`transaction journal ledger snapshot has the wrong shape: ${jp}`);
241
+ const rows = Object.create(null);
242
+ for (const [id, rev] of Object.entries(map)) {
243
+ if (typeof rev !== "string")
244
+ throw new ControlPlaneCorruptError(`transaction journal v1 ledger snapshot entry ${JSON.stringify(id)} is not a string: ${jp}`);
245
+ rows[id] = { rev };
246
+ }
247
+ return { form: "v1", rows };
248
+ }
260
249
  function parseLedgerText(raw, path) {
261
250
  let parsed;
262
251
  try {
@@ -1240,44 +1229,9 @@ export class FileMemoryEngineBackend {
1240
1229
  const jp = join(this.controlPlaneRoot, JOURNAL_FILE);
1241
1230
  const raw = readControlFileOrAbsent(jp, "transaction journal");
1242
1231
  if (raw !== undefined) {
1243
- let journal;
1244
- try {
1245
- const parsed = JSON.parse(raw);
1246
- if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ops))
1247
- throw new Error("wrong shape");
1248
- journal = parsed;
1249
- }
1250
- catch {
1251
- throw new ControlPlaneCorruptError(`transaction journal is unparseable: ${jp}`);
1252
- }
1253
- const hasV2 = journal.ledger2 !== undefined;
1254
- const hasV1 = journal.ledger !== undefined;
1255
- if (hasV2 === hasV1) {
1256
- throw new ControlPlaneCorruptError(`transaction journal has ${hasV2 ? "both ledger snapshots" : "no ledger snapshot"} (wrong shape): ${jp}`);
1257
- }
1258
- let snapshotRows;
1259
- let snapshotExtras;
1260
- if (hasV2) {
1261
- const env = journal.ledger2;
1262
- if (!env || typeof env !== "object" || Array.isArray(env) || env.v !== LEDGER_SCHEMA_VERSION) {
1263
- throw new ControlPlaneCorruptError(`transaction journal ledger2 snapshot has the wrong shape: ${jp}`);
1264
- }
1265
- snapshotRows = validateLedgerRowsV2(env.rows, jp);
1266
- snapshotExtras = {};
1267
- for (const [k, val] of Object.entries(env))
1268
- if (k !== "v" && k !== "rows")
1269
- snapshotExtras[k] = val;
1270
- }
1271
- else {
1272
- const map = journal.ledger;
1273
- if (!map || typeof map !== "object" || Array.isArray(map))
1274
- throw new ControlPlaneCorruptError(`transaction journal ledger snapshot has the wrong shape: ${jp}`);
1275
- snapshotRows = Object.create(null);
1276
- for (const [id, rev] of Object.entries(map)) {
1277
- if (typeof rev !== "string")
1278
- throw new ControlPlaneCorruptError(`transaction journal v1 ledger snapshot entry ${JSON.stringify(id)} is not a string: ${jp}`);
1279
- snapshotRows[id] = { rev };
1280
- }
1232
+ const journal = parseJournal(raw, jp);
1233
+ const { rows: snapshotRows, extras: snapshotExtras, form: snapshotForm } = journalLedgerSnapshot(journal, jp);
1234
+ if (snapshotForm === "v1") {
1281
1235
  try {
1282
1236
  const diskRaw = readControlFileOrAbsent(join(this.controlPlaneRoot, LEDGER_FILE), "committed-rev ledger");
1283
1237
  const diskParsed = diskRaw !== undefined ? JSON.parse(diskRaw) : undefined;
@@ -1314,7 +1268,7 @@ export class FileMemoryEngineBackend {
1314
1268
  }
1315
1269
  journalEvs.set(e.ev, canonical);
1316
1270
  }
1317
- if (hasV1)
1271
+ if (snapshotForm === "v1")
1318
1272
  throw new ControlPlaneCorruptError(`transaction journal carries transfers with a v1 snapshot (impossible form): ${jp}`);
1319
1273
  if (journalEvs.size > 0) {
1320
1274
  for (const row of this.readTransferChain(fenceToken)) {
@@ -1464,12 +1418,12 @@ export class FileMemoryEngineBackend {
1464
1418
  }
1465
1419
  catch (err) {
1466
1420
  const code = err.code;
1467
- throw new ControlPlaneCorruptError(`transaction journal replay failed on op kind=${op.kind} target=${JSON.stringify(op.target)} (${code ?? (thrownText(err))}) — the journal is kept at ${jp}; an I/O failure retries at the next construction, a persistent fault needs the explicit control-plane rebuild path`);
1421
+ throw new ControlPlaneCorruptError(`transaction journal replay failed on op kind=${op.kind} target=${JSON.stringify(op.target)} (${code ?? (thrownText(err))}) — the journal is kept at ${jp}; ${permissionRemedyOf(err, this.directoryRoot) ?? "an I/O failure retries at the next construction, a persistent fault needs the explicit control-plane rebuild path"}`);
1468
1422
  }
1469
1423
  }
1470
1424
  this.recoveryCommitProbe?.();
1471
1425
  this.assertTxnLockOwnership(fenceToken);
1472
- if (hasV2) {
1426
+ if (snapshotForm === "v2") {
1473
1427
  this.ledger = snapshotRows;
1474
1428
  this.envelopeExtras = snapshotExtras;
1475
1429
  this.persistedSchemaVersion = "v2";
@@ -1589,6 +1543,9 @@ export class FileMemoryEngineBackend {
1589
1543
  const moveEvents = [];
1590
1544
  const moveAnnouncements = [];
1591
1545
  const skippedUnreadable = [];
1546
+ let table;
1547
+ const observed = () => (table ??= layerObservationLookup(this.controlPlaneRoot));
1548
+ let freshAccount;
1592
1549
  for (const f of files) {
1593
1550
  let text;
1594
1551
  try {
@@ -1608,6 +1565,12 @@ export class FileMemoryEngineBackend {
1608
1565
  const committed = rowsRev(rows, entry.id);
1609
1566
  const binding = rowsBinding(rows, entry.id);
1610
1567
  const relPath = relative(this.directoryRoot, f.path);
1568
+ if (probeOnly === true && observed().covers(entry.id)) {
1569
+ const fresh = (freshAccount ??= this.committedRowsOrFault() ?? null);
1570
+ const freshBinding = fresh !== null ? rowsBinding(fresh, entry.id) : undefined;
1571
+ if (fresh === null || rowsRev(fresh, entry.id) !== committed || freshBinding?.scope !== binding?.scope || freshBinding?.slug !== binding?.slug)
1572
+ return undefined;
1573
+ }
1611
1574
  if (committed !== undefined && binding !== undefined && !(binding.scope === scope && binding.slug === f.slug)) {
1612
1575
  const probe = probeProjection(this.bindingAbsPath(binding), entry.id);
1613
1576
  if (probe === "present") {
@@ -1618,6 +1581,8 @@ export class FileMemoryEngineBackend {
1618
1581
  this.announceAdoptionNotice(`bindprobe|${relPath}`, `committed entry ${entry.id} appears at ${JSON.stringify(inlineUntrusted(relPath))} but is bound to ${JSON.stringify(inlineUntrusted(this.bindingRelPath(binding)))}, and the bound projection could not be probed conclusively (a read failure, or a foreign file at that address — not ENOENT) — nothing is served or adopted this pass`);
1619
1582
  continue;
1620
1583
  }
1584
+ if (this.settledAsObserved(observed(), entries, entry, relPath, f.slug, scope, committed, binding, text, "quarantined-only"))
1585
+ continue;
1621
1586
  if (probeOnly)
1622
1587
  return undefined;
1623
1588
  if (lockToken !== undefined)
@@ -1630,6 +1595,8 @@ export class FileMemoryEngineBackend {
1630
1595
  continue;
1631
1596
  }
1632
1597
  if (committed !== entry.rev) {
1598
+ if (committed !== undefined && this.settledAsObserved(observed(), entries, entry, relPath, f.slug, scope, committed, binding, text, "restored"))
1599
+ continue;
1633
1600
  if (probeOnly)
1634
1601
  return undefined;
1635
1602
  if (lockToken !== undefined)
@@ -1664,6 +1631,8 @@ export class FileMemoryEngineBackend {
1664
1631
  entry = entryFromFile(shadowText, entry.id, f.slug, scope);
1665
1632
  }
1666
1633
  else {
1634
+ if (committed === undefined && this.settledAsObserved(observed(), entries, entry, relPath, f.slug, scope, committed, binding, text, "restored"))
1635
+ continue;
1667
1636
  if (committed !== undefined)
1668
1637
  adoptedExternal.push(relPath);
1669
1638
  bindRowTracked(rows, entry.id, { rev: entry.rev, scope, slug: f.slug, at: this.now() }, moveEvents, "adopted-move");
@@ -1740,6 +1709,25 @@ export class FileMemoryEngineBackend {
1740
1709
  this.batchScan.set(scope, entries);
1741
1710
  return entries;
1742
1711
  }
1712
+ settledAsObserved(table, out, entry, relPath, slug, scope, committed, binding, text, containment) {
1713
+ if (!table.covers(entry.id) || this.inboundGate(relPath, text) !== undefined)
1714
+ return false;
1715
+ if (committed !== undefined && committed !== entry.rev && this.whitewashInboundFinding(relPath, entry, containment) !== undefined)
1716
+ return false;
1717
+ const hit = table.find(entry.id, committed, entry.rev);
1718
+ if (hit === undefined)
1719
+ return false;
1720
+ const standIn = committed !== undefined && binding !== undefined && binding.scope === scope && binding.slug === slug ? this.readCommittedShadow(entry.id) : undefined;
1721
+ const notice = observedDivergenceNotice(relPath, standIn !== undefined, hit.undecided === true);
1722
+ try {
1723
+ enqueueMemoryAnnouncement(this.controlPlaneRoot, { kind: "external", at: this.now(), items: [notice] }, { supersedesPrefix: notice });
1724
+ }
1725
+ catch {
1726
+ }
1727
+ if (standIn !== undefined)
1728
+ out.push(entryFromFile(standIn, entry.id, slug, scope));
1729
+ return true;
1730
+ }
1743
1731
  whitewashInboundFinding(relPath, entry, containment) {
1744
1732
  const committedShadow = this.readCommittedShadow(entry.id);
1745
1733
  const committedFm = committedShadow !== undefined ? parseEntryFile(committedShadow).frontmatter : undefined;
@@ -1862,14 +1850,46 @@ export class FileMemoryEngineBackend {
1862
1850
  throw new Error(`memory retrieval view is read-only — ${op} must go through the backend itself`);
1863
1851
  };
1864
1852
  return {
1865
- listHeaders: async (scopes) => (this.ensureLayout(), this.listHeadersFrom(scopes, (s) => this.readScope(s, false))),
1866
- getByIds: async (ids) => (this.ensureLayout(), this.getByIdsFrom(ids, (s) => this.readScope(s, false))),
1867
- search: async (query, scopes, opts) => (this.ensureLayout(), this.searchFrom(query, scopes, opts, (s) => this.readScope(s, false))),
1853
+ listHeaders: async (scopes) => (this.ensureLayout(), this.listHeadersFrom(scopes, (s) => this.withoutObserved(this.readScope(s, false), s))),
1854
+ getByIds: async (ids) => (this.ensureLayout(), this.getByIdsFrom(ids, (s) => this.withoutObserved(this.readScope(s, false), s))),
1855
+ search: async (query, scopes, opts) => (this.ensureLayout(), this.searchFrom(query, scopes, opts, (s) => this.withoutObserved(this.readScope(s, false), s))),
1868
1856
  applyPatches: async () => refuse("applyPatches"),
1869
1857
  getConsolidationCursor: async (scope) => this.getConsolidationCursor(scope),
1870
1858
  setConsolidationCursor: async () => refuse("setConsolidationCursor"),
1871
1859
  };
1872
1860
  }
1861
+ withoutObserved(entries, scope) {
1862
+ const table = layerObservationLookup(this.controlPlaneRoot);
1863
+ if (!entries.some((e) => table.covers(e.id)))
1864
+ return entries;
1865
+ const account = this.committedRowsOrFault();
1866
+ if (account === undefined)
1867
+ return entries.filter((e) => !table.covers(e.id));
1868
+ const out = [];
1869
+ for (const e of entries) {
1870
+ const committed = rowsRev(account, e.id);
1871
+ const binding = rowsBinding(account, e.id);
1872
+ const boundHere = binding !== undefined && binding.scope === scope && binding.slug === e.slug;
1873
+ if ((committed === e.rev && boundHere) || table.find(e.id, committed, e.rev) === undefined) {
1874
+ out.push(e);
1875
+ continue;
1876
+ }
1877
+ const standIn = committed !== undefined && boundHere ? this.readCommittedShadow(e.id) : undefined;
1878
+ if (standIn !== undefined)
1879
+ out.push(entryFromFile(standIn, e.id, e.slug, scope));
1880
+ }
1881
+ return out;
1882
+ }
1883
+ committedRowsOrFault() {
1884
+ const path = join(this.controlPlaneRoot, LEDGER_FILE);
1885
+ try {
1886
+ const raw = readControlFileOrAbsent(path, "committed-rev ledger");
1887
+ return raw === undefined ? Object.create(null) : parseLedgerText(raw, path).rows;
1888
+ }
1889
+ catch {
1890
+ return undefined;
1891
+ }
1892
+ }
1873
1893
  restrictedFindingKeys = new Set();
1874
1894
  recordRestrictedFinding(key, finding) {
1875
1895
  if (this.restrictedFindingKeys.has(key))
@@ -2187,6 +2207,9 @@ export class FileMemoryEngineBackend {
2187
2207
  try {
2188
2208
  return await this.applyPatchesLocked(patches, lock.token);
2189
2209
  }
2210
+ catch (err) {
2211
+ throw withPermissionRemedy(err, this.directoryRoot);
2212
+ }
2190
2213
  finally {
2191
2214
  lock.release();
2192
2215
  }
@@ -2865,7 +2888,7 @@ export class FileMemoryEngineBackend {
2865
2888
  }
2866
2889
  catch (err) {
2867
2890
  this.ledger = undefined;
2868
- throw err;
2891
+ throw withPermissionRemedy(err, this.directoryRoot);
2869
2892
  }
2870
2893
  finally {
2871
2894
  lock.release();
@@ -0,0 +1,109 @@
1
+ import type { HarvestRejection, MemorySessionHandle } from "./types.js";
2
+ export declare const LAYER_OBSERVATIONS_FILE = "layer-observations.json";
3
+ /** One recorded unexpected write into a read-only layer. */
4
+ export interface LayerObservation {
5
+ /** The layer (scope) the bytes were observed in. */
6
+ scope: string;
7
+ /** Scope-home-relative, `/`-separated path the bytes were observed at (disclosure only). */
8
+ rel: string;
9
+ /** The entry id the observed file carries; absent ⇔ an id-less note. */
10
+ entryId?: string;
11
+ /** The entry's committed rev when observed; absent ⇔ the id had no committed row. */
12
+ rev?: string;
13
+ /** The observed content's identity: its entry rev (what the read side computes from the file), or
14
+ * for an id-less note the sha256 of its bytes. */
15
+ observedRev: string;
16
+ /** The observing session, when the harvest was given one. */
17
+ sessionId?: string;
18
+ observedAt: number;
19
+ /** Whether a commit backs these bytes could not be established when they were observed (a
20
+ * transaction journal was pending — the committed account could not be read whole). The row — one
21
+ * per observation — then stands against ANY committed rev (never adopted) until the first session
22
+ * that may write settles it, once its preflight has recovered the journal
23
+ * ({@link settleUndecidedLayerObservations}): the committed account backs its bytes at its seat ⇒
24
+ * this observation is removed (the bytes are committed content); it does not ⇒ pinned to the
25
+ * committed rev read, an ordinary row from then on, retired when that rev moves (superseded, as
26
+ * every ordinary row is); the account still cannot be read ⇒ it stays undecided. Only id-bearing
27
+ * rows (a note has no commit question) and never with `rev`. */
28
+ undecided?: true;
29
+ /** An undecided row's own identity (random, minted with the row): two observations alike in every
30
+ * other field — the same session in the same millisecond — stay two rows. Undecided rows only. */
31
+ observationId?: string;
32
+ }
33
+ /** Record observations (locked, journaled; an existing key keeps its first row). Nothing is pruned
34
+ * here: the rev a harvest read before this lock need not be current any more, so it cannot decide
35
+ * which other rows are stale — and a row against a rev the entry has left can never match anyway. */
36
+ export declare function recordLayerObservations(controlDir: string, rows: readonly LayerObservation[]): void;
37
+ /** The table as a read-side lookup. */
38
+ export interface LayerObservationLookup {
39
+ /** Whether any row names `entryId` (a cheap pre-test before the committed rev is fetched). */
40
+ covers(entryId: string): boolean;
41
+ /** The row naming exactly this content (`observedRev`) of `entryId` against its committed rev
42
+ * `rev` (absent ⇔ the id has no committed row) — or an undecided row for that content, which stands
43
+ * against any rev until it is settled — else undefined. */
44
+ find(entryId: string, rev: string | undefined, observedRev: string): LayerObservation | undefined;
45
+ /** The row naming exactly these bytes (`digest`) of an id-less note at (`scope`, `rel`), or undefined. */
46
+ findNote(scope: string, rel: string, digest: string): LayerObservation | undefined;
47
+ }
48
+ /** Read the table (throws the strict family's refusal on a corrupt table). */
49
+ export declare function layerObservationLookup(controlDir: string): LayerObservationLookup;
50
+ /** The disclosure line for a recorded divergence met on the adopting read (one spelling, every
51
+ * door). The verdict leads: the render boundary caps an item at a label's length. */
52
+ export declare function observedDivergenceNotice(rel: string, served: boolean, undecided?: boolean): string;
53
+ /** The undecided rows ({@link LayerObservation.undecided}) awaiting settlement. */
54
+ export declare function undecidedLayerObservations(controlDir: string): LayerObservation[];
55
+ /** Settle undecided rows (locked, journaled), each by its own key (one observation — never another
56
+ * observation of the same entry): `"backed"` removes the row (the committed account backs its bytes
57
+ * at its seat: those bytes are committed content, so no unexpected write is lost), `{ rev }` pins
58
+ * it to the committed rev the settlement read (`rev` absent ⇔ no committed row) — an ordinary row,
59
+ * retired like any other when that rev moves. A row without a verdict stays undecided. */
60
+ export declare function settleUndecidedLayerObservations(controlDir: string, verdicts: ReadonlyMap<LayerObservation, "backed" | {
61
+ rev?: string;
62
+ }>): void;
63
+ /** The census digest: whole-file bytes (sha256), so "the bytes that stood when the window opened"
64
+ * means the bytes. */
65
+ export declare function layerDigest(text: string): string;
66
+ /** The read-only layers' census (materialize, zero-copy): canonical path → {@link layerDigest} of
67
+ * every entry file under `homes`, scanned at the read side's depth (what the adopting read sees). */
68
+ export declare function readOnlyLayerCensus(homes: readonly string[]): Map<string, string>;
69
+ /** A file of a read-only layer whose bytes changed since the materialize-time census. */
70
+ export interface ReadOnlyObservationCandidate {
71
+ path: string;
72
+ scope: string;
73
+ rel: string;
74
+ slug: string;
75
+ digest: string;
76
+ id?: string;
77
+ /** The entry rev of the bytes, or their digest for an id-less note. */
78
+ observedRev: string;
79
+ }
80
+ /** The observation half's candidates: files of the layers `handle` holds read-only whose bytes differ
81
+ * from its materialize-time census (none without a census). Throws when the scope registry cannot
82
+ * be read (the homes are resolved through it). */
83
+ export declare function readOnlyLayerCandidates(memoryDir: string, controlDir: string, handle: Pick<MemorySessionHandle, "layerBaseline" | "scopes" | "writeScope">): ReadOnlyObservationCandidate[];
84
+ /** What a harvest's observation half judged at detection: the rows (written AS JUDGED — a retry writes
85
+ * the same rows, never a re-judgment against a later account), the rejections and undecided
86
+ * candidates they carry, whether they landed, and the failure of the last attempt that did not. */
87
+ export interface ObservationBatch {
88
+ rows: LayerObservation[];
89
+ rejections: HarvestRejection[];
90
+ /** Each undecided candidate with the row it was recorded as (its observation identity). */
91
+ undecided: Array<{
92
+ candidate: ReadOnlyObservationCandidate;
93
+ row: LayerObservation;
94
+ }>;
95
+ landed: boolean;
96
+ failure?: string;
97
+ }
98
+ /** The undecided candidates a LANDED batch recorded (an unlanded one claims none). */
99
+ export declare function landedUndecided(batch: ObservationBatch | undefined): ObservationBatch["undecided"];
100
+ /** Whether two rows are the same observation (the table's key — for an undecided row, one
101
+ * observation: session and time included). */
102
+ export declare function sameObservation(a: LayerObservation, b: LayerObservation): boolean;
103
+ /** Whether the committed row backs an observation's bytes at its seat (same rev, bound there) — the
104
+ * observation was a commit, not an unexpected write. */
105
+ export declare function observationBacked(row: Pick<LayerObservation, "scope" | "rel" | "observedRev">, committed: {
106
+ rev: string;
107
+ scope?: string;
108
+ slug?: string;
109
+ } | undefined): boolean;