@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,148 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { relative, sep } from "node:path";
4
+ import { inlineUntrusted } from "../untrusted-text.js";
5
+ import { scanEntryFiles } from "./entry-scan.js";
6
+ import { entryFromFile, parseEntryFile } from "./frontmatter.js";
7
+ import { ControlPlaneCorruptError, canonicalize, lockedStrictUpdate, readStrictSidecar, scopeDirFor } from "./layout.js";
8
+ export const LAYER_OBSERVATIONS_FILE = "layer-observations.json";
9
+ const WHAT = "memory layer-observation table";
10
+ const str = (v) => typeof v === "string" && v.length > 0;
11
+ function coerce(raw) {
12
+ if (raw === undefined)
13
+ return { version: 1, rows: [] };
14
+ const rec = raw;
15
+ if (typeof rec !== "object" || rec === null || rec.version !== 1 || !Array.isArray(rec.rows))
16
+ throw new ControlPlaneCorruptError(`${WHAT} has the wrong shape or an unrecognized version — refusing (fail-closed)`);
17
+ const rows = rec.rows;
18
+ for (const r of rows) {
19
+ const row = r;
20
+ if (typeof row !== "object" || row === null || !str(row.scope) || !str(row.rel) || (row.entryId !== undefined && !str(row.entryId)) || !str(row.observedRev) || (row.rev !== undefined && (row.entryId === undefined || !str(row.rev))) || (row.sessionId !== undefined && !str(row.sessionId)) || typeof row.observedAt !== "number" || !Number.isFinite(row.observedAt) || (row.undecided !== undefined && (row.undecided !== true || row.entryId === undefined || row.rev !== undefined)) || (row.observationId !== undefined && (!str(row.observationId) || row.undecided !== true))) {
21
+ throw new ControlPlaneCorruptError(`${WHAT}: a row is malformed — refusing (fail-closed; an unevaluable row must not read as "no unexpected write")`);
22
+ }
23
+ }
24
+ return rec;
25
+ }
26
+ const keyOf = (o) => o.entryId === undefined
27
+ ? JSON.stringify([null, o.scope, o.rel, o.observedRev])
28
+ : o.undecided === true
29
+ ? JSON.stringify([o.entryId, { undecided: true }, o.observedRev, o.scope, o.rel, o.sessionId ?? null, o.observedAt, o.observationId ?? null])
30
+ : JSON.stringify([o.entryId, o.rev ?? null, o.observedRev]);
31
+ const contentKey = (entryId, observedRev) => JSON.stringify([entryId, observedRev]);
32
+ export function recordLayerObservations(controlDir, rows) {
33
+ if (rows.length === 0)
34
+ return;
35
+ lockedStrictUpdate(controlDir, LAYER_OBSERVATIONS_FILE, WHAT, coerce, (rec) => {
36
+ const byKey = new Map(rec.rows.map((r) => [keyOf(r), r]));
37
+ for (const r of rows)
38
+ if (!byKey.has(keyOf(r)))
39
+ byKey.set(keyOf(r), { ...r });
40
+ return { next: { version: 1, rows: [...byKey.values()] }, result: undefined };
41
+ });
42
+ }
43
+ export function layerObservationLookup(controlDir) {
44
+ const rows = coerce(readStrictSidecar(controlDir, LAYER_OBSERVATIONS_FILE, WHAT)).rows;
45
+ const ids = new Set(rows.flatMap((r) => (r.entryId !== undefined ? [r.entryId] : [])));
46
+ const byKey = new Map(rows.map((r) => [keyOf(r), r]));
47
+ const undecidedByContent = new Map(rows.flatMap((r) => (r.undecided === true && r.entryId !== undefined ? [[contentKey(r.entryId, r.observedRev), r]] : [])));
48
+ return {
49
+ covers: (entryId) => ids.has(entryId),
50
+ find: (entryId, rev, observedRev) => ids.has(entryId) ? (byKey.get(keyOf({ scope: "", rel: "", entryId, rev, observedRev })) ?? undecidedByContent.get(contentKey(entryId, observedRev))) : undefined,
51
+ findNote: (scope, rel, digest) => byKey.get(keyOf({ scope, rel, observedRev: digest })),
52
+ };
53
+ }
54
+ export function observedDivergenceNotice(rel, served, undecided = false) {
55
+ const file = JSON.stringify(inlineUntrusted(rel));
56
+ if (undecided)
57
+ return `change NOT adopted yet: ${file} changed while a session held its layer read-only, and whether a commit backs it could not be established — re-judged once a writing session can read the committed account`;
58
+ return served
59
+ ? `change NOT adopted: ${file} was written while a session held its layer read-only — memory serves the committed version, not these bytes`
60
+ : `file NOT adopted: ${file} appeared while a session held its layer read-only — it is not memory; left on disk for review`;
61
+ }
62
+ export function undecidedLayerObservations(controlDir) {
63
+ return coerce(readStrictSidecar(controlDir, LAYER_OBSERVATIONS_FILE, WHAT)).rows.filter((r) => r.undecided === true);
64
+ }
65
+ function ordinaryRow(r, rev) {
66
+ return {
67
+ scope: r.scope,
68
+ rel: r.rel,
69
+ ...(r.entryId !== undefined ? { entryId: r.entryId } : {}),
70
+ ...(rev !== undefined ? { rev } : {}),
71
+ observedRev: r.observedRev,
72
+ ...(r.sessionId !== undefined ? { sessionId: r.sessionId } : {}),
73
+ observedAt: r.observedAt,
74
+ };
75
+ }
76
+ export function settleUndecidedLayerObservations(controlDir, verdicts) {
77
+ if (verdicts.size === 0)
78
+ return;
79
+ const byKey = new Map([...verdicts].map(([row, v]) => [keyOf(row), v]));
80
+ lockedStrictUpdate(controlDir, LAYER_OBSERVATIONS_FILE, WHAT, coerce, (rec) => {
81
+ const next = new Map();
82
+ for (const r of rec.rows) {
83
+ const v = r.undecided === true ? byKey.get(keyOf(r)) : undefined;
84
+ if (v === "backed")
85
+ continue;
86
+ const settled = v === undefined ? r : ordinaryRow(r, v.rev);
87
+ if (!next.has(keyOf(settled)))
88
+ next.set(keyOf(settled), settled);
89
+ }
90
+ return { next: { version: 1, rows: [...next.values()] }, result: undefined };
91
+ });
92
+ }
93
+ export function layerDigest(text) {
94
+ return createHash("sha256").update(text, "utf8").digest("hex");
95
+ }
96
+ function readText(path) {
97
+ try {
98
+ return readFileSync(path, "utf8");
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ }
104
+ export function readOnlyLayerCensus(homes) {
105
+ const files = new Map();
106
+ for (const home of homes) {
107
+ for (const f of scanEntryFiles(home)) {
108
+ const path = canonicalize(f.path);
109
+ const text = readText(path);
110
+ if (text !== undefined)
111
+ files.set(path, layerDigest(text));
112
+ }
113
+ }
114
+ return files;
115
+ }
116
+ export function readOnlyLayerCandidates(memoryDir, controlDir, handle) {
117
+ const census = handle.layerBaseline;
118
+ if (census === undefined)
119
+ return [];
120
+ const out = [];
121
+ for (const scope of new Set(handle.scopes)) {
122
+ if (scope === handle.writeScope)
123
+ continue;
124
+ const home = canonicalize(scopeDirFor(memoryDir, controlDir, scope));
125
+ for (const f of scanEntryFiles(home)) {
126
+ const path = canonicalize(f.path);
127
+ const text = readText(path);
128
+ if (text === undefined || (path !== home && !path.startsWith(`${home}${sep}`)))
129
+ continue;
130
+ const digest = layerDigest(text);
131
+ if (census.get(path) === digest)
132
+ continue;
133
+ const id = parseEntryFile(text).id;
134
+ const rel = relative(home, path).split(sep).join("/");
135
+ out.push({ path, scope, rel, slug: f.slug, digest, ...(id !== undefined ? { id, observedRev: entryFromFile(text, id, f.slug, scope).rev } : { observedRev: digest }) });
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+ export function landedUndecided(batch) {
141
+ return batch?.landed === true ? batch.undecided : [];
142
+ }
143
+ export function sameObservation(a, b) {
144
+ return keyOf(a) === keyOf(b);
145
+ }
146
+ export function observationBacked(row, committed) {
147
+ return committed !== undefined && committed.scope === row.scope && committed.slug === row.rel.replace(/\.md$/, "") && committed.rev === row.observedRev;
148
+ }
@@ -261,6 +261,17 @@ export declare const canonicalize: typeof physicalPathOfExisting;
261
261
  export declare function isContainedIn(root: string, child: string): boolean;
262
262
  /** mkdir -p convenience used by the engine/backend (0o700 like the file stores). */
263
263
  export declare function ensureDirExists(dir: string): void;
264
+ /** The one remedy every surfaced permission refusal carries — `undefined` unless `err` is a write
265
+ * the file system refused on PERMISSIONS (`EACCES` / `EPERM`) at a path INSIDE `memoryDir` (a
266
+ * refusal elsewhere — a control plane kept outside the store — is not this store's to fix). The
267
+ * engine never changes permission bits on a store — a session's read-only layers are its policy,
268
+ * not file-system state — so bits that refuse a write were set by someone else, and nothing here
269
+ * clears them (an engine from 7.30.x or earlier left inherited layers read-only on disk). */
270
+ export declare function permissionRemedyOf(err: unknown, memoryDir: string): string | undefined;
271
+ /** Attach {@link permissionRemedyOf} to a permission refusal inside `memoryDir` — in place, so the
272
+ * error keeps its class and `code` (callers branch on both), and once (an inner seat that already
273
+ * named its store wins over an outer one). Any other value is returned untouched. */
274
+ export declare function withPermissionRemedy<T>(err: T, memoryDir: string): T;
264
275
  /** B2/L4 — the control-plane subdir holding gate-rejected originals. ONE name for all three writers
265
276
  * (engine harvest, engine derived-index rung, backend inbound gate): they write the same directory,
266
277
  * so its name and its permissions must not depend on which of them ran first. */
@@ -1,4 +1,4 @@
1
- import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync, writeSync } from "node:fs";
1
+ import { closeSync, constants as fsConstants, copyFileSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync, writeSync } from "node:fs";
2
2
  const { O_WRONLY, O_RDONLY, O_CREAT, O_TRUNC, O_NOFOLLOW, O_NONBLOCK, O_EXCL } = fsConstants;
3
3
  import { homedir } from "node:os";
4
4
  import { createHash } from "node:crypto";
@@ -687,10 +687,8 @@ function moveLooseContent(root, controlDir, rec, owner, label, renew, sink, resu
687
687
  const labels = new Set(Object.values(rec.scopes));
688
688
  const { names, probes } = looseRootContent(root, labels, controlDir);
689
689
  const home = join(root, label);
690
- if (names.length > 0) {
690
+ if (names.length > 0)
691
691
  ensureScopeHome(home);
692
- chmodSync(home, 0o755);
693
- }
694
692
  let moved = 0;
695
693
  for (const name of names) {
696
694
  renew();
@@ -730,6 +728,24 @@ export function ensureDirExists(dir) {
730
728
  if (!existsSync(dir))
731
729
  mkdirSync(dir, { recursive: true, mode: 0o700 });
732
730
  }
731
+ export function permissionRemedyOf(err, memoryDir) {
732
+ const e = typeof err === "object" && err !== null ? err : undefined;
733
+ if (e === undefined || (e.code !== "EACCES" && e.code !== "EPERM") || typeof e.path !== "string" || !isContainedIn(memoryDir, e.path))
734
+ return undefined;
735
+ return `file permissions refused this memory write (the engine never changes permission bits) — if an engine from 7.30.x or earlier left read-only bits on this store, run \`chmod -R u+w ${shellQuoted(memoryDir)}\` once no such engine still runs on it (an older engine sets the bits again whenever it mounts a layer read-only)`;
736
+ }
737
+ function shellQuoted(s) {
738
+ return `'${s.replace(/'/g, "'\\''")}'`;
739
+ }
740
+ const remedied = new WeakSet();
741
+ export function withPermissionRemedy(err, memoryDir) {
742
+ const remedy = permissionRemedyOf(err, memoryDir);
743
+ if (remedy === undefined || !(err instanceof Error) || remedied.has(err))
744
+ return err;
745
+ err.message = `${err.message} — ${remedy}`;
746
+ remedied.add(err);
747
+ return err;
748
+ }
733
749
  export const QUARANTINE_DIR = "quarantine";
734
750
  const DELETED_TOMBSTONE = "---\ndeleted: true\n---\n";
735
751
  export function writeFileNoFollow(path, content) {
@@ -391,7 +391,9 @@ export interface MaterializedFile {
391
391
  rev: string;
392
392
  /** True when this file is a budget stub (frontmatter+description only, body archived — §2.3). */
393
393
  stub: boolean;
394
- /** True when the file belongs to a read-only inherited layer (chmod-protected, never harvested as a write). */
394
+ /** True when the file belongs to a read-only inherited layer (read-only by this session's policy —
395
+ * its file mode is left as found; never harvested as a write, a change to it is a `readonly_layer`
396
+ * rejection and the committed copy is restored). */
395
397
  readonly: boolean;
396
398
  }
397
399
  /** The write face's home, for a call site that has established a write face exists (`writeScope !==
@@ -500,6 +502,28 @@ export interface MemorySessionHandle {
500
502
  files: Map<string, string>;
501
503
  index?: string;
502
504
  };
505
+ /**
506
+ * The read-only layers' census: every entry file of every layer this session holds read-only,
507
+ * canonical path → digest of its bytes, taken at materialize BEFORE the adopting read — what stood
508
+ * when this session's window opened. The harvest's observation half records, in the store's
509
+ * session-observation table, every id-bearing file of those layers whose bytes changed since and
510
+ * that no commit backs (the read side never adopts a recorded divergence); the detection half
511
+ * words a divergence that already stood as not this session's write. Absent ⇔ not a zero-copy
512
+ * mount (the observed plane is not the store the read side adopts from), or a handle minted
513
+ * before the field — nothing is recorded then.
514
+ */
515
+ layerBaseline?: Map<string, string>;
516
+ /**
517
+ * The session-account LEG this mount opened (minted at materialize, stored on the account row).
518
+ * The harvest closes the account, and a terminal harvest (or a failed prepare's release) ends the
519
+ * leg, only while the row still carries this leg — a resume that re-opened the row makes this
520
+ * mount's close / end stale, and they are not written over the later leg. Absent ⇔ this mount
521
+ * opened no account (no session id, provenance "off", an adoption-restricted mount, an open that
522
+ * failed and is known not to have landed) — nothing of an account is written for it then. An open
523
+ * that failed after it landed, or whose landing could not be read back, keeps its leg (every write
524
+ * made with it is leg-checked).
525
+ */
526
+ accountLeg?: string;
503
527
  }
504
528
  /** Stable rejection codes a harvest gate can produce (model-visible gate events — 镜头 I). */
505
529
  export type HarvestRejectionCode = "outside_root" | "symlink" | "secret" | "injection" | "filename" | "too_large" | "file_cap" | "readonly_layer" | "stub_modified" | "nested_too_deep" | "quarantine_failed" | "unreadable" | "deferred" | "polluted" | "restricted_divergence" | "invalid";
@@ -539,7 +563,7 @@ export interface MemoryAnnouncement {
539
563
  * #479's seat is that every face of one harvest names the same id, and a throat that minted its
540
564
  * own would hand the same occurrence two values). Present on the harvest-tail enqueues (the gate
541
565
  * items and the inbound-reject items); absent on every announcement with no report behind it
542
- * (backend adoption notices, mode-sweep notes, index-heal notes) and on a hand-built report that
566
+ * (backend adoption notices, layer-census notes, index-heal and index-write notes) and on a hand-built report that
543
567
  * carries no id — omitted, never fabricated.
544
568
  *
545
569
  * STRUCTURE-ONLY, deliberately: {@link import("./engine.js").renderAnnouncements} does not read
@@ -13,7 +13,7 @@ import type { Hooks } from "./hooks.js";
13
13
  import type { ToolExecuteContext } from "./tool-spec.js";
14
14
  import type { TaskSpec } from "./types.js";
15
15
  /** The fields of a spec the seat carries — one `Pick`, shared by the mint's input and the apply's target. */
16
- export type ParentSpecSeatFields = Pick<TaskSpec, "principal" | "oneShot" | "clientContext" | "onQuestion" | "hooks" | "additionalDirectories" | "additionalReadDirectories" | "envFacts" | "memoryPersistenceCapable" | "memory" | "compaction" | "skillListingBudgetFraction" | "skillListingMaxDescChars">;
16
+ export type ParentSpecSeatFields = Pick<TaskSpec, "principal" | "oneShot" | "clientContext" | "onQuestion" | "hooks" | "additionalDirectories" | "additionalReadDirectories" | "envFacts" | "memoryPersistenceCapable" | "memory" | "compaction" | "skillListingBudgetFraction" | "skillListingMaxDescChars" | "fileHistory">;
17
17
  /**
18
18
  * The seat a run hands its children. Every member is the parent's own value, frozen at mint (arrays copied and
19
19
  * frozen; the facts object copied). Members present iff the parent carries them — an absent member is "nothing to
@@ -43,6 +43,8 @@ export type ParentSpecSeatFields = Pick<TaskSpec, "principal" | "oneShot" | "cli
43
43
  * · `skillListingBudgetFraction` / `skillListingMaxDescChars` — the listing-budget knobs, the same shape for the
44
44
  * same reason: a fraction the parent tuned is a fraction OF EACH LEG'S OWN window, so it travels as a number
45
45
  * and every leg multiplies it by its own.
46
+ * · `fileHistory` — the request's file-history off switch (`"disabled"`, its only word), for the edits its delegates
47
+ * make on its behalf too. An ORDINARY member (fill law); with one word no child can author it back on.
46
48
  */
47
49
  export interface ParentSpecSeat {
48
50
  readonly principal?: string;
@@ -58,6 +60,7 @@ export interface ParentSpecSeat {
58
60
  readonly compaction?: TaskSpec["compaction"];
59
61
  readonly skillListingBudgetFraction?: number;
60
62
  readonly skillListingMaxDescChars?: number;
63
+ readonly fileHistory?: "disabled";
61
64
  }
62
65
  /** Mint the seat off the parent's REBOUND spec and its frozen question face (spec over deps — the caller resolves it once,
63
66
  * the same value the parent's own question routing reads). Absent fields stay absent; `oneShot` only when `true`. */
@@ -12,6 +12,7 @@ export function parentSpecSeatOf(spec, frozenOnQuestion) {
12
12
  ...(spec.compaction !== undefined ? { compaction: Object.freeze({ ...spec.compaction }) } : {}),
13
13
  ...(spec.skillListingBudgetFraction !== undefined ? { skillListingBudgetFraction: spec.skillListingBudgetFraction } : {}),
14
14
  ...(spec.skillListingMaxDescChars !== undefined ? { skillListingMaxDescChars: spec.skillListingMaxDescChars } : {}),
15
+ ...(spec.fileHistory !== undefined ? { fileHistory: spec.fileHistory } : {}),
15
16
  ...(spec.memory?.enabled === false ? { memoryOff: true } : {}),
16
17
  });
17
18
  }
@@ -31,6 +32,7 @@ export function applyParentSpecSeat(child, seat) {
31
32
  ...fill("compaction", (seat.compaction !== undefined ? { ...seat.compaction } : undefined)),
32
33
  ...fill("skillListingBudgetFraction", seat.skillListingBudgetFraction),
33
34
  ...fill("skillListingMaxDescChars", seat.skillListingMaxDescChars),
35
+ ...fill("fileHistory", seat.fileHistory),
34
36
  ...(seat.oneShot === true ? { oneShot: true } : {}),
35
37
  ...(seat.memoryPersistenceCapable === false ? { memoryPersistenceCapable: false } : fill("memoryPersistenceCapable", seat.memoryPersistenceCapable)),
36
38
  ...(seat.memoryOff === true ? { memory: memoryTurnedOff(child.memory) } : {}),
@@ -74,6 +74,7 @@ function syntheticCheckpoint(scope) {
74
74
  preview: { probe: true, note: "park wiring self-check display stub" },
75
75
  previewWithheld: "oversize",
76
76
  requiresRealApproval: true,
77
+ mandated: true,
77
78
  denialLimitFallback: { consecutive: 3, total: 3, limit: "consecutive", autoDenyAfterMs: 0 },
78
79
  origin: "denial_limit_fallback",
79
80
  batchToolCallIds: [`${scope}:call`],
@@ -1,4 +1,6 @@
1
1
  import type { AnnouncedListingSnapshot, AnnouncedListingSnapshotInput, AnnouncedMcpFailure } from "../../internal/harness.js";
2
+ import type { SessionTreeEntry } from "../../internal/harness-types.js";
3
+ import type { DateAnchorRead } from "./contracts.js";
2
4
  import type { McpDroppedTool, McpServerStatus } from "../mcp.js";
3
5
  import type { RunnerDeps } from "../types.js";
4
6
  /** Upper bound on the advisory-digest arm kept in the record (oldest dropped past it). A digest is
@@ -96,3 +98,27 @@ export declare function loadAnnounceOnceLedger(session: AnnounceOnceSession, che
96
98
  * present-only arms, copied; `undefined` when nothing was announced on any face (the resume seed
97
99
  * then falls to the lower rungs — unchanged contract). */
98
100
  export declare function serializeAnnouncedListings(ref: AnnouncedListingSnapshotInput): AnnouncedListingSnapshot | undefined;
101
+ /** The custom-entry kind that carries the session's date anchor (engine-owned; never model-visible). */
102
+ export declare const DATE_ANCHOR_CUSTOM_TYPE = "engine:date-anchor";
103
+ /**
104
+ * Read the session's date anchor off the active branch: the nearest anchor entry NEWER than the nearest
105
+ * compaction, honored only when it was written for that same segment. An anchor written by a leg whose
106
+ * prefix predates a compaction that landed in between therefore reads as absent — the one rule that makes a
107
+ * compaction re-anchor the date. An unreadable branch returns undefined (the leg renders today, writes nothing).
108
+ */
109
+ export declare function readDateAnchor(session: {
110
+ getBranch(): Promise<ReadonlyArray<SessionTreeEntry>>;
111
+ }): Promise<DateAnchorRead | undefined>;
112
+ /**
113
+ * The anchor write a leg owes when it settles, or undefined: owed when prepare found no anchor for the segment
114
+ * (the leg rendered today) or the model has since been told a newer date; never after an unreadable branch.
115
+ * Best-effort — a lost write costs one re-rendered prefix or one repeated reminder next leg, never a wrong date.
116
+ */
117
+ export declare function owedDateAnchor(session: {
118
+ appendCustomEntry(customType: string, data?: unknown): Promise<string>;
119
+ }, seat: {
120
+ legDate: string;
121
+ anchor: DateAnchorRead | undefined;
122
+ } | undefined, state: {
123
+ announcedDate: string;
124
+ } | undefined): (() => Promise<void>) | undefined;
@@ -112,3 +112,40 @@ export function serializeAnnouncedListings(ref) {
112
112
  };
113
113
  return Object.keys(out).length > 0 ? out : undefined;
114
114
  }
115
+ export const DATE_ANCHOR_CUSTOM_TYPE = "engine:date-anchor";
116
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
117
+ function normalizeDateAnchor(v) {
118
+ if (typeof v !== "object" || v === null)
119
+ return undefined;
120
+ const { prefixDate, announcedDate, segment } = v;
121
+ if (typeof prefixDate !== "string" || !ISO_DATE.test(prefixDate) || typeof announcedDate !== "string" || !ISO_DATE.test(announcedDate))
122
+ return undefined;
123
+ if (segment !== null && (typeof segment !== "string" || segment.length > 200))
124
+ return undefined;
125
+ return { prefixDate, announcedDate, segment };
126
+ }
127
+ export async function readDateAnchor(session) {
128
+ let branch;
129
+ try {
130
+ branch = await session.getBranch();
131
+ }
132
+ catch {
133
+ return undefined;
134
+ }
135
+ let found;
136
+ let i = branch.length - 1;
137
+ for (; i >= 0 && branch[i].type !== "compaction"; i--) {
138
+ const e = branch[i];
139
+ if (found === undefined && e.type === "custom" && e.customType === DATE_ANCHOR_CUSTOM_TYPE)
140
+ found = normalizeDateAnchor(e.data);
141
+ }
142
+ const segment = i >= 0 ? branch[i].id : null;
143
+ return { current: found !== undefined && found.segment === segment ? { prefixDate: found.prefixDate, announcedDate: found.announcedDate } : undefined, segment };
144
+ }
145
+ export function owedDateAnchor(session, seat, state) {
146
+ const read = seat?.anchor;
147
+ if (read === undefined || state === undefined || read.current?.announcedDate === state.announcedDate)
148
+ return undefined;
149
+ const data = { prefixDate: seat.legDate, announcedDate: state.announcedDate, segment: read.segment };
150
+ return () => session.appendCustomEntry(DATE_ANCHOR_CUSTOM_TYPE, data).then(() => undefined, () => undefined);
151
+ }
@@ -176,11 +176,22 @@ export interface CommittedPause {
176
176
  scope: string;
177
177
  gatedCallId?: string;
178
178
  }
179
+ /** The session's date anchor as prepare read it off the branch. The system-prefix date is frozen per
180
+ * SEGMENT — from session start, or from the latest landed compaction, to the next one. */
181
+ export interface DateAnchorRead {
182
+ /** The anchor written for the current segment; undefined ⇒ the leg renders today and anchors it. */
183
+ current: {
184
+ prefixDate: string;
185
+ announcedDate: string;
186
+ } | undefined;
187
+ /** The current segment: the nearest compaction entry's id, null before the first compaction. */
188
+ segment: string | null;
189
+ }
179
190
  /**
180
191
  * One engine frame whose commit waits for the receipt of the message carrying it.
181
192
  *
182
193
  * @contract frames.commit_on_receipt — an engine-minted frame that rides a user message (the git-status
183
- * frame, an `agent_listing` or `skills_listing` frame) is recorded as announced ONLY when the run loop's
194
+ * frame, an `agent_listing`, `skills_listing` or `date_change` frame) is recorded as announced ONLY when the run loop's
184
195
  * `message_end` walk sees a persisted user entry whose text equals the carrying text it was registered
185
196
  * with; every receipt with that text settles on that one entry. Until then nothing is recorded — a
186
197
  * message that never reaches the transcript (a pre-append failure, a suppressed or undrained steer, a
@@ -193,7 +204,7 @@ export interface FrameReceipt {
193
204
  text: string;
194
205
  /** Which frame this receipt settles. A boundary collector must not render a listing whose receipt is
195
206
  * still pending (the frame is queued, not yet in the transcript). */
196
- source: "git_status" | "agent_listing" | "skills_listing";
207
+ source: "git_status" | "agent_listing" | "skills_listing" | "date_change";
197
208
  /** Runs inside the `message_end` walk (synchronous, never awaited): advances the run-local record
198
209
  * and parks any session write for the next serialization point. */
199
210
  commit: (entryId: string) => void;
@@ -344,7 +355,7 @@ export interface Prepared {
344
355
  * than a snapshot, so the throw-path backstop terminal reports the same ledger the ordinary
345
356
  * assembly would. Always present on Prepared; independent of whether a fileHistoryStore is wired. */
346
357
  editedFilesSnapshot: () => TaskResult["editedFiles"];
347
- /** The run's turn-start boundary seat (present iff a fileHistoryStore is wired and
358
+ /** The run's turn-start boundary seat (present iff the run records file history into a wired store and
348
359
  * the run mounts a real fs env). runtask calls begin() at the first committed user entry and
349
360
  * awaits settle() at the lease close + the finish tail. */
350
361
  fileHistoryBoundary?: FileHistoryBoundarySeat;
@@ -623,6 +634,9 @@ export interface Prepared {
623
634
  /** Merged injection across all planes (single-root sessions: identical to engine.inject(handle)). */
624
635
  inject: () => import("../memory-engine/engine.js").MemoryInjection;
625
636
  harvest: (phase?: "checkpoint" | "terminal") => Promise<HarvestReport | undefined>;
637
+ /** End this mount's session leg WITHOUT a harvest: the run that mounted it will never reach its
638
+ * terminal harvest (a prepare that failed after the mount). Never throws. */
639
+ release: () => void;
626
640
  /**
627
641
  * The session's capture opt-out face (the `TaskStream.optOutMemoryCapture`
628
642
  * verb's target; host-API only — no tool face reads it, §2.6). `flip` re-adjudicates the
@@ -1027,10 +1041,13 @@ export interface Prepared {
1027
1041
  /** The date-flip detector seam: `legDate` = the date frozen
1028
1042
  * into this leg's system prefix; `today()` = the boundary-time LOCAL date (user-zone-bound closure).
1029
1043
  * The run loop feeds both to the `date_change` attachment producer; the prefix itself never
1030
- * re-renders mid-leg (cache preservation, CC parity). Present only when the prompt carries a date. */
1044
+ * re-renders mid-leg (cache preservation, CC parity). Present only when the prompt carries a date.
1045
+ * `anchor` = the session's date anchor as prepare read it (undefined ⇒ the branch was unreadable
1046
+ * and the leg writes none); the settle lane persists it when it is new or the announced date moved. */
1031
1047
  dateChange?: {
1032
1048
  legDate: string;
1033
1049
  today: () => string;
1050
+ anchor: DateAnchorRead | undefined;
1034
1051
  };
1035
1052
  /** [A2] C-4 (design-A §4) — the loadProjectMemory snapshot's declared instruction sources
1036
1053
  * (`ProjectMemoryLoad.instructionSources`): the probe input AND the lane's per-path baseline
@@ -2066,7 +2083,7 @@ export interface RunInternals {
2066
2083
  * re-exposes the SAME triple on its own ctx, so every same-tree descendant of a root session — at
2067
2084
  * any depth — lands in the root session's scope (the fixed point), while a descendant on another
2068
2085
  * tree (worktree isolation, explicit `cwd`, a fresh per-task sandbox) becomes the root of its own
2069
- * subtree's history. Absent on a top-level run, on a run with no live history store, and on a
2086
+ * subtree's history. Absent on a top-level run, on a run that records no file history, and on a
2070
2087
  * tier-3 revival (the reviver's lineage says nothing about the revived row's tree).
2071
2088
  */
2072
2089
  fileHistoryLineage?: {
@@ -60,8 +60,8 @@ export declare function attachRebuiltDenialTrackers<T extends {
60
60
  * in headless mode"). Once per run leg (the first such fallback owns the terminal); the deny that
61
61
  * triggered it stands as that call's result. Minted once so every mint site — the main gate's seat, the
62
62
  * inherited-lane arms, and the durable resume station — stops the SAME leg through the SAME seat.
63
- * `abort` is the run's own abort (controller + harness), handed in because the harness is built after
64
- * the seat is.
63
+ * `abort` is the run's own abort (controller + harness — the one a cancel fires; handed in because the harness
64
+ * is built after the seat): the durable stop fires it before the resume leg's prompt, whose door reads it.
65
65
  *
66
66
  * TWO LANES, ONE SENTENCE. The synchronous lane reached no approver at all; the durable lane parked the
67
67
  * fallback, and the decide that came back named a settlement word the ONE table
@@ -75,10 +75,6 @@ export interface PrepareAcquireReconcileResult {
75
75
  * SUCCEEDS (a conflict-losing attempt re-wakes and re-derives the whole set from the fresh
76
76
  * branch), and stays empty for a fresh session / a clean one with nothing to reconcile. */
77
77
  wakeRecovered: RecoveredOrphan[];
78
- /** owned — §E18 `"before"` mode: the validated target's parentId — the new conversation leaf, and
79
- * the E19 file-anchor walk's starting point (consumed by the rewind-restore block after env
80
- * setup). `null` outside `"before"` mode. */
81
- resumeAtBeforeParentId: string | null;
82
78
  }
83
79
  /** The B-3 phase body — the acquire+reconcile slice, verbatim (see the module header for the contract). */
84
80
  export declare function prepareAcquireReconcile(input: PrepareAcquireReconcileInput): Promise<PrepareAcquireReconcileResult>;
@@ -32,7 +32,6 @@ export async function prepareAcquireReconcile(input) {
32
32
  let session;
33
33
  let conflictRef;
34
34
  let wakeRecovered = [];
35
- let resumeAtBeforeParentId = null;
36
35
  for (let attempt = 0;; attempt++) {
37
36
  try {
38
37
  acquired = await sessions.acquire(spec.sessionId, spec.requireExistingSession ? { requireExisting: true } : input.placement !== undefined ? { placement: input.placement } : undefined);
@@ -105,7 +104,6 @@ export async function prepareAcquireReconcile(input) {
105
104
  e.code = "resume_at.before_root_unsupported";
106
105
  throw e;
107
106
  }
108
- resumeAtBeforeParentId = entry.parentId;
109
107
  }
110
108
  await session.getStorage().setLeafId(spec.resumeAtMode === "before" ? entry.parentId : spec.resumeAt);
111
109
  }
@@ -123,5 +121,5 @@ export async function prepareAcquireReconcile(input) {
123
121
  throw err;
124
122
  }
125
123
  }
126
- return { acquired, session, conflictRef, wakeRecovered, resumeAtBeforeParentId };
124
+ return { acquired, session, conflictRef, wakeRecovered };
127
125
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * PrepareTask's file-history phase, verbatim from the driver: the DV-14 first-touch
3
- * policy door, the `RunnerDeps.fileHistoryStore` bad-value door, the retired `rewindFiles` two-sense
3
+ * policy door, the `RunnerDeps.fileHistoryStore` / `fileHistoryCapture` and `TaskSpec.fileHistory` bad-value doors, the retired `rewindFiles` two-sense
4
4
  * split, the restoreFiles / resumeAt / rewindFilesTo combination gates, the run's history coordinates
5
5
  * (canonical root, recording scope, filesystem identity), the fork carry, the child-scope refusal, the
6
6
  * rewind RESTORE itself (anchor translation in "before" mode, the per-file ledger, partial-restore
@@ -23,12 +23,12 @@ import type { StoredSession } from "../../engine/session/session.js";
23
23
  import type { RunnerDeps, TaskResult, TaskSpec, TrackFileEditHook } from "../types.js";
24
24
  import type { FileHistoryBoundarySeat, RunInternals } from "./contracts.js";
25
25
  export interface PrepareFileHistoryInput {
26
- /** borrowed-readonly — the deployment seats this phase reads: the first-touch failure policy and the
27
- * history store (both validated HERE, once), the incident sink, and the retired-epoch diagnostic probe. */
28
- deps: Pick<RunnerDeps, "onTrackFailure" | "fileHistoryStore" | "onError">;
29
- /** borrowed-readonly — the REBOUND spec's rewind request fields (the retired `rewindFiles` spelling is
30
- * read off the same object as an unknown key) and the caller's cancel signal for the restore. */
31
- spec: Pick<TaskSpec, "resumeAt" | "resumeAtMode" | "restoreFiles" | "rewindFilesTo" | "acceptPartialRestore" | "signal">;
26
+ /** borrowed-readonly — the deployment seats this phase reads: the first-touch failure policy, the history
27
+ * store and its capture switch (all validated HERE, once), the incident sink, and the retired-epoch diagnostic probe. */
28
+ deps: Pick<RunnerDeps, "onTrackFailure" | "fileHistoryStore" | "fileHistoryCapture" | "onError">;
29
+ /** borrowed-readonly — the REBOUND spec's rewind request fields and off-switch word (the retired `rewindFiles`
30
+ * spelling is read off the same object as an unknown key) and the caller's cancel signal for the restore. */
31
+ spec: Pick<TaskSpec, "resumeAt" | "resumeAtMode" | "restoreFiles" | "rewindFilesTo" | "acceptPartialRestore" | "signal" | "fileHistory">;
32
32
  /** borrowed-readonly — the trusted lineage a delegating parent handed this run (absent for a root run
33
33
  * and for a host's own fork), read for the recording-scope decision only. */
34
34
  internals: Pick<RunInternals, "fileHistoryLineage"> | undefined;
@@ -41,19 +41,15 @@ export interface PrepareFileHistoryInput {
41
41
  handsEnabled: boolean;
42
42
  /** borrowed-readonly — the acquired session id: the run's OWN history scope and the restore's session key. */
43
43
  sessionId: string;
44
- /** borrowed-readonly — the acquired session, read for the fork lineage (`getMetadata`) and the "before"
45
- * anchor walk (`getEntry`); never written here. */
46
- session: Pick<StoredSession, "getEntry" | "getMetadata">;
47
- /** borrowed-readonly — §E18 "before" mode: the validated target's parentId (the acquire phase's output);
48
- * `null` outside "before" mode. */
49
- resumeAtBeforeParentId: string | null;
44
+ /** borrowed-readonly — the acquired session, read for the fork lineage (`getMetadata`); never written here. */
45
+ session: Pick<StoredSession, "getMetadata">;
50
46
  /** borrowed-readonly — the run's abort controller (driver-minted); only its `signal` is read, composed
51
47
  * with `spec.signal` for the restore. */
52
48
  abortController: AbortController;
53
49
  }
54
50
  export interface PrepareFileHistoryResult {
55
51
  /** borrowed-readonly — the hook the hands band calls before every mutation lane; `undefined` when the
56
- * machinery is not live (no store, or no real fs env). */
52
+ * run does not record (no store, capture turned off by any layer, or no real fs env). */
57
53
  trackFileEdit: TrackFileEditHook | undefined;
58
54
  /** borrowed-readonly — the turn-start boundary seat runtask drives; `undefined` when not live or when
59
55
  * this run records into its ROOT session's scope (a child mints no boundaries). */
@@ -64,8 +60,10 @@ export interface PrepareFileHistoryResult {
64
60
  /** borrowed-mutable — the non-fatal rewind disclosures (`TaskResult.rewindNotes`). Mutation owners after
65
61
  * this call: none — every push happens inside this phase; the driver only reads it into `Prepared`. */
66
62
  rewindNotes: NonNullable<TaskResult["rewindNotes"]>;
67
- /** borrowed-readonly — whether the file-history machinery is LIVE for this run (store wired AND hands). */
63
+ /** borrowed-readonly — whether this run RECORDS file history (store wired, no layer turned capture off, AND hands); restore reads the store seat. */
68
64
  fileHistoryEnabled: boolean;
65
+ /** borrowed-readonly — the manifest's word: `"on"` iff live, else `"disabled"` (whichever layer or missing half). */
66
+ fileHistoryFact: "on" | "disabled";
69
67
  /** borrowed-readonly — the scope this run's first-touch records land in (own session, or the root
70
68
  * session's for a same-tree delegated child). */
71
69
  historyScope: string;