mover-os 4.7.8 → 4.7.9

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 (42) hide show
  1. package/README.md +34 -24
  2. package/install.js +2197 -200
  3. package/package.json +1 -1
  4. package/src/dashboard/build.js +41 -3
  5. package/src/dashboard/lib/active-context-parser.js +16 -4
  6. package/src/dashboard/lib/agent-session.js +70 -49
  7. package/src/dashboard/lib/approval-registry.js +170 -0
  8. package/src/dashboard/lib/daily-note-resolver.js +20 -3
  9. package/src/dashboard/lib/date-utils.js +9 -1
  10. package/src/dashboard/lib/distribution-parser.js +69 -10
  11. package/src/dashboard/lib/drift-history.js +6 -1
  12. package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
  13. package/src/dashboard/lib/engine-health.js +4 -1
  14. package/src/dashboard/lib/engine-writer.js +157 -11
  15. package/src/dashboard/lib/goal-forecast.js +154 -31
  16. package/src/dashboard/lib/library-indexer-v2.js +14 -0
  17. package/src/dashboard/lib/library-search.js +290 -0
  18. package/src/dashboard/lib/log-activation.sh +0 -0
  19. package/src/dashboard/lib/memory-index.js +61 -12
  20. package/src/dashboard/lib/pid-markers.js +80 -0
  21. package/src/dashboard/lib/regenerate-manifest.js +0 -0
  22. package/src/dashboard/lib/run-registry.js +75 -15
  23. package/src/dashboard/lib/state-core/backfill.js +298 -0
  24. package/src/dashboard/lib/state-core/dryrun.js +188 -0
  25. package/src/dashboard/lib/state-core/event-log.js +615 -0
  26. package/src/dashboard/lib/state-core/events.js +265 -0
  27. package/src/dashboard/lib/state-core/projections.js +376 -0
  28. package/src/dashboard/lib/state-core/start-close.js +162 -0
  29. package/src/dashboard/lib/state-core/trial.js +96 -0
  30. package/src/dashboard/lib/strategy-parser.js +3 -0
  31. package/src/dashboard/lib/suggested-now.js +2 -2
  32. package/src/dashboard/lib/transcript-parser.js +48 -8
  33. package/src/dashboard/server.js +422 -44
  34. package/src/dashboard/shortcut.js +0 -0
  35. package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
  36. package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
  37. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  38. package/src/dashboard/ui/dist/index.html +2 -2
  39. package/src/dashboard/ui/dist/sw.js +1 -1
  40. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
  41. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
  42. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +0 -34
@@ -0,0 +1,298 @@
1
+ "use strict";
2
+
3
+ // backfill.js — CORE-LOOP PHASE 2 (SCHEMA-SPEC v0.2 section 4, migration).
4
+ // Turns a vault COPY's existing records into v0.2 events. Copy-first is the
5
+ // owner-approved default #5: nothing in this module is wired to any workflow,
6
+ // hook, or server route, and applyState (dryrun.js) refuses a non-pristine
7
+ // target. The live vault is never touched until the owner has inspected a
8
+ // dry-run manifest on a copy.
9
+ //
10
+ // WHAT v1 MIGRATES (mechanically faithful sources only):
11
+ // - ~/.mover/pattern-manifest.json override_history[] -> override.logged
12
+ // - Dailies "## Tasks" checkboxes -> plan.observed + plan.ticked
13
+ //
14
+ // WHAT v1 DOES NOT MIGRATE (spec amendment, recorded 2026-07-11): the v0.1
15
+ // spec table sourced receipt.filed from "Metrics_Log receipt lines", but the
16
+ // real Metrics_Log.md is a Fitbit vitality analysis with no receipts section
17
+ // — its dated lines are sleep scores. Fabricating receipts from them (or
18
+ // from session-log prose via keyword heuristics) would inflate the record
19
+ // this system exists to keep honest. Undercount over inflate: prose receipts
20
+ // are declared non-recoverable; receipt.filed/ship.published begin at
21
+ // Close-time capture going forward. Every skip is returned in `skipped`
22
+ // with a reason — absence is reported, never papered over.
23
+ //
24
+ // Every migrated event carries source {path, sourceHash, parserVersion,
25
+ // locator} per v0.2 amendment 2 — a locator for audit, never an identity.
26
+
27
+ const fs = require("fs");
28
+ const path = require("path");
29
+ const { sha256Hex } = require("./event-log");
30
+ const { overrideLogged, planTicked, planObserved } = require("./events");
31
+
32
+ // v2: literal NUL bytes dropped from identity + variable-length backtick
33
+ // fences parsed per CommonMark (terra round 3 findings 2 and 3).
34
+ const PARSER_VERSION = 2;
35
+
36
+ // ── time ────────────────────────────────────────────────────────────────────
37
+
38
+ // Day-precision source dates land at UTC noon: projections.dateKeyFromTs is
39
+ // UTC, so noon round-trips to the same YYYY-MM-DD for any writer zone within
40
+ // +/-11h, without pretending we know the time of day.
41
+ function tsFromDateKey(dateKey) {
42
+ const t = Date.parse(`${dateKey}T12:00:00Z`);
43
+ return Number.isFinite(t) ? t : null;
44
+ }
45
+
46
+ // Datetime strings ("2026-04-20T11:11") parse as local time by JS spec —
47
+ // deterministic per machine, which is all a backfill needs.
48
+ function tsFromLoose(value) {
49
+ if (typeof value !== "string" || !value.trim()) return null;
50
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) return tsFromDateKey(value.trim());
51
+ const t = Date.parse(value);
52
+ return Number.isFinite(t) ? t : null;
53
+ }
54
+
55
+ // ── shared ──────────────────────────────────────────────────────────────────
56
+
57
+ function sourceFor(relPath, fileText, locator) {
58
+ return {
59
+ path: relPath,
60
+ sourceHash: sha256Hex(fileText),
61
+ parserVersion: PARSER_VERSION,
62
+ locator,
63
+ };
64
+ }
65
+
66
+ // Code spans open with a run of N backticks and close at the next run of
67
+ // EXACTLY N (CommonMark). The old single-backtick regex corrupted
68
+ // ``a`b``-style spans (terra round 3 finding 3). An unclosed run stays
69
+ // literal text.
70
+ function extractCodeSpans(text, codeSpans) {
71
+ let out = "";
72
+ let i = 0;
73
+ while (i < text.length) {
74
+ if (text[i] !== "`") { out += text[i]; i += 1; continue; }
75
+ let n = 0;
76
+ while (text[i + n] === "`") n += 1;
77
+ let j = i + n;
78
+ let close = -1;
79
+ while (j < text.length) {
80
+ if (text[j] !== "`") { j += 1; continue; }
81
+ let m = 0;
82
+ while (text[j + m] === "`") m += 1;
83
+ if (m === n) { close = j; break; }
84
+ j += m;
85
+ }
86
+ if (close === -1) { out += text.slice(i, i + n); i += n; continue; }
87
+ codeSpans.push(text.slice(i + n, close));
88
+ out += "\u0000" + (codeSpans.length - 1) + "\u0000";
89
+ i = close + n;
90
+ }
91
+ return out;
92
+ }
93
+
94
+ function normalizeTask(text) {
95
+ // Markdown canonicalization (terra attack 7 + re-verdict #7): `Send
96
+ // **proposal**` and `Send proposal` are the same task; formatting must not
97
+ // fork identity. But a CODE SPAN is literal text - stripping its backticks
98
+ // AND its inner asterisks merged `Run \`a*b*\`` with `Run a*b` (distinct
99
+ // tasks). Code spans are held out verbatim (minus the backticks), emphasis
100
+ // stripping applies only OUTSIDE them; links tolerate one level of nested
101
+ // brackets in the label.
102
+ // Literal NUL bytes are dropped BEFORE the sentinel pass (terra round 3
103
+ // finding 2: task text containing a literal NUL-digit-NUL sequence
104
+ // resolved as a sentinel and collapsed to a colliding identity). NUL is
105
+ // not representable markdown content, so dropping it is canonicalization,
106
+ // not data loss.
107
+ const codeSpans = [];
108
+ let t = extractCodeSpans(text.replace(/\u0000/g, ""), codeSpans);
109
+ t = t
110
+ .replace(/\[((?:[^\[\]]|\[[^\]]*\])*)\]\([^)]*\)/g, "$1") // [text [nested]](url) -> text [nested]
111
+ .replace(/(\*\*|__|~~)/g, "")
112
+ .replace(/(^|\s)[*_](\S)/g, "$1$2") // leading emphasis marker on a word
113
+ .replace(/(\S)[*_](\s|$)/g, "$1$2"); // trailing emphasis marker on a word
114
+ t = t.replace(/\u0000(\d+)\u0000/g, (_, i) => codeSpans[Number(i)] || "");
115
+ return t.trim().replace(/\s+/g, " ");
116
+ }
117
+
118
+ // Stable across backfill runs AND across [x]/[ ] flips of the same task on
119
+ // the same note date — the taskId is (date, text), never checkbox state.
120
+ function taskIdFor(noteDate, text) {
121
+ return sha256Hex(`${noteDate}\n${normalizeTask(text)}`).slice(0, 16);
122
+ }
123
+
124
+ // ── override_history -> override.logged ─────────────────────────────────────
125
+
126
+ // Field fallbacks mirror lib/override-summary.js normalizeOverrides — the
127
+ // manifest has grown several field spellings over its life. A missing text
128
+ // field becomes the composed absence "not recorded" (the constructor requires
129
+ // non-empty strings; an honest placeholder beats dropping a real override).
130
+ // A missing/unparseable date is different: a fabricated timestamp would
131
+ // corrupt every date-keyed projection, so the entry is SKIPPED with a reason.
132
+ function backfillOverrides(manifestText, { manifestPath = "pattern-manifest.json" } = {}) {
133
+ const events = [];
134
+ const skipped = [];
135
+
136
+ let manifest;
137
+ try {
138
+ manifest = JSON.parse(manifestText);
139
+ } catch (e) {
140
+ return { events, skipped: [{ locator: manifestPath, reason: `manifest is not valid JSON: ${e.message}` }] };
141
+ }
142
+
143
+ const raw = (manifest && (manifest.override_history || manifest.overrideHistory)) || [];
144
+ if (!Array.isArray(raw)) {
145
+ return { events, skipped: [{ locator: `${manifestPath}#override_history`, reason: "override_history is not an array" }] };
146
+ }
147
+
148
+ raw.forEach((o, i) => {
149
+ const locator = `override_history[${i}]`;
150
+ if (o === null || typeof o !== "object") {
151
+ skipped.push({ locator, reason: "entry is not an object" });
152
+ return;
153
+ }
154
+ const ts = tsFromLoose(o.date || o.timestamp);
155
+ if (ts === null) {
156
+ skipped.push({ locator, reason: `no parseable date (got ${JSON.stringify(o.date || o.timestamp || null)})` });
157
+ return;
158
+ }
159
+ const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
160
+ events.push(
161
+ overrideLogged(
162
+ {
163
+ what: str(o.what) || str(o.action) || str(o.task) || "not recorded",
164
+ over: str(o.over) || str(o.plan_item) || str(o.conflicted_with) || "not recorded",
165
+ reason: str(o.reason) || str(o.user_override) || str(o.justification) || "not recorded",
166
+ level: typeof o.level === "number" ? o.level : str(o.level) || "UNKNOWN",
167
+ },
168
+ { ts, source: sourceFor(manifestPath, manifestText, locator) }
169
+ )
170
+ );
171
+ });
172
+
173
+ return { events, skipped };
174
+ }
175
+
176
+ // ── Daily Note "## Tasks" -> plan.observed + plan.ticked ────────────────────
177
+
178
+ const NOTE_NAME_RE = /^Daily - (\d{4}-\d{2}-\d{2})\.md$/;
179
+ const CHECKBOX_RE = /^\s*[-*]\s*\[([ xX~])\]\s+(.+?)\s*$/;
180
+
181
+ // One plan.observed (the honest denominator, v0.2 amendment 5) + one
182
+ // plan.ticked per [x] line. [ ] contributes to the denominator only.
183
+ // [~] means "written, not verified" in this house — it is NEVER migrated as
184
+ // done; only /morning or the user promotes it, and backfill does not get to
185
+ // promote what a human never verified.
186
+ function backfillDailyTasks(noteText, { noteDate, relPath }) {
187
+ const events = [];
188
+ const skipped = [];
189
+ const ts = tsFromDateKey(noteDate);
190
+ if (ts === null) {
191
+ return { events, skipped: [{ locator: relPath, reason: `invalid note date ${JSON.stringify(noteDate)}` }] };
192
+ }
193
+
194
+ const lines = noteText.split("\n");
195
+ const headIdx = lines.findIndex((l) => /^#{1,4}\s*Tasks\b/i.test(l));
196
+ if (headIdx === -1) {
197
+ return { events, skipped: [{ locator: `${relPath}#tasks`, reason: "no Tasks section" }] };
198
+ }
199
+
200
+ const taskIds = [];
201
+ const texts = [];
202
+ const ticks = [];
203
+ for (let i = headIdx + 1; i < lines.length; i++) {
204
+ if (/^#{1,4}\s/.test(lines[i])) break; // next heading ends the section
205
+ const m = lines[i].match(CHECKBOX_RE);
206
+ if (!m) continue;
207
+ const text = normalizeTask(m[2]);
208
+ const taskId = taskIdFor(noteDate, text);
209
+ if (taskIds.includes(taskId)) continue; // duplicate line, one denominator slot
210
+ taskIds.push(taskId);
211
+ texts.push(text);
212
+ if (m[1] === "x" || m[1] === "X") ticks.push({ taskId, task: text, line: i + 1 });
213
+ }
214
+
215
+ if (taskIds.length === 0) {
216
+ return { events, skipped: [{ locator: `${relPath}#tasks`, reason: "Tasks section has no checkboxes" }] };
217
+ }
218
+
219
+ events.push(
220
+ planObserved({ noteDate, taskIds, texts }, { ts, source: sourceFor(relPath, noteText, "tasks") })
221
+ );
222
+ for (const t of ticks) {
223
+ events.push(
224
+ planTicked(
225
+ { taskId: t.taskId, task: t.task, note_date: noteDate, done: true },
226
+ { ts, source: sourceFor(relPath, noteText, `tasks:line:${t.line}`) }
227
+ )
228
+ );
229
+ }
230
+ return { events, skipped };
231
+ }
232
+
233
+ // ── whole-vault sweep ────────────────────────────────────────────────────────
234
+
235
+ function collectDailyNotes(vaultRoot) {
236
+ const dir = path.join(vaultRoot, "02_Areas", "Engine", "Dailies");
237
+ const notes = [];
238
+ if (!fs.existsSync(dir)) return notes;
239
+ for (const month of fs.readdirSync(dir).sort()) {
240
+ const monthDir = path.join(dir, month);
241
+ let entries;
242
+ try {
243
+ entries = fs.readdirSync(monthDir);
244
+ } catch {
245
+ continue; // a stray file at the month level, not a folder
246
+ }
247
+ for (const name of entries.sort()) {
248
+ const m = name.match(NOTE_NAME_RE);
249
+ if (!m) continue;
250
+ notes.push({
251
+ noteDate: m[1],
252
+ absPath: path.join(monthDir, name),
253
+ relPath: path.join("02_Areas", "Engine", "Dailies", month, name),
254
+ });
255
+ }
256
+ }
257
+ return notes;
258
+ }
259
+
260
+ // The full copy-first backfill: manifest overrides + every Daily's tasks,
261
+ // returned in a deterministic order (ts ascending, locator as tiebreak) so
262
+ // two runs over the same copy produce the same append sequence and the same
263
+ // dedupeKey set (ids are fresh UUIDs by design — identity is opaque).
264
+ function backfillVault(vaultRoot, { manifestPath } = {}) {
265
+ const events = [];
266
+ const skipped = [];
267
+
268
+ if (manifestPath && fs.existsSync(manifestPath)) {
269
+ const text = fs.readFileSync(manifestPath, "utf8");
270
+ const r = backfillOverrides(text, { manifestPath: path.basename(manifestPath) });
271
+ events.push(...r.events);
272
+ skipped.push(...r.skipped);
273
+ } else if (manifestPath) {
274
+ skipped.push({ locator: manifestPath, reason: "manifest file not found" });
275
+ }
276
+
277
+ for (const note of collectDailyNotes(vaultRoot)) {
278
+ const text = fs.readFileSync(note.absPath, "utf8");
279
+ const r = backfillDailyTasks(text, { noteDate: note.noteDate, relPath: note.relPath });
280
+ events.push(...r.events);
281
+ skipped.push(...r.skipped);
282
+ }
283
+
284
+ events.sort((a, b) => a.ts - b.ts || String(a.source && a.source.locator).localeCompare(String(b.source && b.source.locator)) || a.type.localeCompare(b.type));
285
+ return { events, skipped };
286
+ }
287
+
288
+ module.exports = {
289
+ PARSER_VERSION,
290
+ tsFromDateKey,
291
+ tsFromLoose,
292
+ taskIdFor,
293
+ normalizeTask,
294
+ backfillOverrides,
295
+ backfillDailyTasks,
296
+ collectDailyNotes,
297
+ backfillVault,
298
+ };
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+
3
+ // dryrun.js — CORE-LOOP PHASE 2 (SCHEMA-SPEC v0.2 section 4, steps 2-3).
4
+ // The migration contract the owner inspects BEFORE anything touches a live
5
+ // vault: predictState computes the exact bytes a migration would write
6
+ // (events.jsonl + the four projection files) WITHOUT writing; applyState
7
+ // writes them to a pristine copy, re-reads every file from disk, and throws
8
+ // unless predicted and actual sha256 match byte-for-byte. The manifest is
9
+ // the lab's dry-run.js pattern promoted to the product path.
10
+ //
11
+ // Determinism: the caller captures ONE `now` and passes it to both predict
12
+ // and apply (rebuiltAt is operational metadata outside the hashed projection
13
+ // body, but it is inside the FILE bytes, so byte-identity needs the same
14
+ // clock reading). Event ids are fresh UUIDs per backfill run, so the
15
+ // manifest binds one run's events to one run's files — rerunning backfill
16
+ // produces the same dedupeKeys and facts, not the same ids.
17
+
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+ const { serializeEnvelope, sha256Hex, stableStringify, isValidEnvelope, acquireLock } = require("./event-log");
21
+ const { resolveEffectiveEvents, computeMetrics, computeStreak, computeBet, computeDrift, rebuildProjections, PROJECTION_VERSION } = require("./projections");
22
+
23
+ const STATE_DIR = path.join("02_Areas", "Engine", "State");
24
+ const LOG_NAME = "events.jsonl";
25
+ const PROJECTION_NAMES = {
26
+ metrics: "projection-metrics.json",
27
+ streak: "projection-streak.json",
28
+ bet: "projection-bet.json",
29
+ drift: "projection-drift.json",
30
+ };
31
+
32
+ // One projection file's content. The hashed body carries the deterministic
33
+ // watermark (v0.2 amendment 3: throughEventId/throughEventTs inside, never
34
+ // rebuiltAt); `hash` must reproduce across rebuilds of the same log.
35
+ function projectionFileBody(kind, data, watermark, eventCount) {
36
+ return {
37
+ v: PROJECTION_VERSION,
38
+ kind,
39
+ throughEventId: watermark.id,
40
+ throughEventTs: watermark.ts,
41
+ eventCount,
42
+ data,
43
+ };
44
+ }
45
+
46
+ function projectionFileText(kind, data, watermark, eventCount, rebuiltAt) {
47
+ const body = projectionFileBody(kind, data, watermark, eventCount);
48
+ const hash = sha256Hex(stableStringify(body));
49
+ // rebuiltAt and hash sit OUTSIDE the hashed body; stableStringify keeps
50
+ // the bytes reproducible for identical inputs.
51
+ return stableStringify({ rebuiltAt, hash, body }) + "\n";
52
+ }
53
+
54
+ // Predicts every byte a migration write would produce. Pure of the target
55
+ // filesystem: takes the events, returns { files: [{file, bytes, sha256}] }.
56
+ function predictState(events, { now } = {}) {
57
+ if (!Array.isArray(events) || !events.every((e) => isValidEnvelope(e))) {
58
+ throw new Error("dryrun.predictState: events must all be valid v0.2 envelopes");
59
+ }
60
+ if (typeof now !== "number") {
61
+ throw new Error("dryrun.predictState: opts.now (epoch ms) is required: predict and apply must share one clock reading for byte identity");
62
+ }
63
+
64
+ const logText = events.map((e) => serializeEnvelope(e) + "\n").join("");
65
+
66
+ const effective = resolveEffectiveEvents(events);
67
+ const last = events.length ? events[events.length - 1] : null;
68
+ const watermark = { id: last ? last.id : null, ts: last ? last.ts : null };
69
+ const eventCount = effective.length;
70
+
71
+ const data = {
72
+ metrics: computeMetrics(effective),
73
+ streak: computeStreak(effective),
74
+ bet: computeBet(effective),
75
+ drift: computeDrift(effective, {}),
76
+ };
77
+
78
+ const files = [{ file: path.join(STATE_DIR, LOG_NAME), text: logText }];
79
+ for (const [kind, name] of Object.entries(PROJECTION_NAMES)) {
80
+ files.push({ file: path.join(STATE_DIR, name), text: projectionFileText(kind, data[kind], watermark, eventCount, now) });
81
+ }
82
+
83
+ return {
84
+ files: files.map((f) => ({ file: f.file, bytes: Buffer.byteLength(f.text, "utf8"), sha256: sha256Hex(f.text), text: f.text })),
85
+ };
86
+ }
87
+
88
+ // Writes the predicted state onto a vault COPY and verifies byte identity by
89
+ // re-reading RAW BUFFERS from disk (a utf8-decode round trip can mask an
90
+ // invalid byte, terra attack 3). Refuses a non-pristine target; the whole
91
+ // check-write-verify sequence runs under the log's own advisory lock so two
92
+ // concurrent migrations cannot both pass the pristine check (terra attack 4),
93
+ // and an `.applying` marker makes a crash mid-write recoverable: a rerun
94
+ // that finds the marker knows the previous apply never completed and clears
95
+ // the partial State files instead of refusing forever.
96
+ //
97
+ // SCOPE (terra re-verdict #3, acknowledged not patched): verification is
98
+ // point-in-time. The lock binds every COOPERATIVE state-core writer; a
99
+ // hostile direct filesystem writer can always mutate a file between the last
100
+ // check and the caller's next read - no userspace check-then-return closes
101
+ // that. The durable integrity token is IN each file: every projection
102
+ // carries its own body hash, and the log's lines are self-validating
103
+ // envelopes, so a READER can re-verify at read time. verified:true means
104
+ // "the bytes I wrote were the bytes on disk when I looked", nothing more.
105
+ function applyState(vaultRoot, events, { now } = {}) {
106
+ const predicted = predictState(events, { now });
107
+ const logAbs = path.join(vaultRoot, STATE_DIR, LOG_NAME);
108
+ const applyingMarker = path.join(vaultRoot, STATE_DIR, ".applying");
109
+
110
+ fs.mkdirSync(path.dirname(logAbs), { recursive: true });
111
+ const lock = acquireLock(logAbs);
112
+ try {
113
+ if (fs.existsSync(applyingMarker)) {
114
+ // A previous apply crashed mid-write: everything under State/ from
115
+ // that run is unpublished garbage. Clear exactly the files we own.
116
+ for (const f of predicted.files) {
117
+ try { fs.rmSync(path.join(vaultRoot, f.file), { force: true }); } catch (_) {}
118
+ }
119
+ fs.rmSync(applyingMarker, { force: true });
120
+ }
121
+ if (fs.existsSync(logAbs)) {
122
+ throw new Error(`dryrun.applyState: ${logAbs} already exists. applyState only writes to a pristine copy, never over an existing log`);
123
+ }
124
+
125
+ fs.writeFileSync(applyingMarker, JSON.stringify({ pid: process.pid, startedAt: now }), "utf8");
126
+ for (const f of predicted.files) {
127
+ fs.writeFileSync(path.join(vaultRoot, f.file), f.text, "utf8");
128
+ }
129
+
130
+ const mismatches = [];
131
+ const manifest = predicted.files.map((f) => {
132
+ const actualBuf = fs.readFileSync(path.join(vaultRoot, f.file)); // raw Buffer, no decode
133
+ const actual = cryptoSha(actualBuf);
134
+ if (actual !== f.sha256 || actualBuf.length !== f.bytes) {
135
+ mismatches.push({ file: f.file, predicted: f.sha256, actual, predictedBytes: f.bytes, actualBytes: actualBuf.length });
136
+ }
137
+ return { file: f.file, bytes: f.bytes, sha256: f.sha256, actual };
138
+ });
139
+
140
+ if (mismatches.length) {
141
+ throw new Error(`dryrun.applyState: predicted vs actual bytes diverge for ${mismatches.map((m) => m.file).join(", ")}. Do not proceed to a live vault`);
142
+ }
143
+
144
+ // Cross-check the file assembly against the product reducer itself: the
145
+ // rebuilt projection HASH (the full reduced body, not just the watermark
146
+ // id — terra attack 3b showed a mid-window event mutation slipping past
147
+ // an id-only check) must be reproducible from the bytes on disk.
148
+ const rebuilt = rebuildProjections(logAbs, { now: () => now });
149
+ const last = events.length ? events[events.length - 1] : null;
150
+ if ((rebuilt.body.throughEventId || null) !== (last ? last.id : null)) {
151
+ throw new Error("dryrun.applyState: rebuildProjections watermark disagrees with the written log: dryrun assembly has drifted from the reducer");
152
+ }
153
+ const effective = resolveEffectiveEvents(events);
154
+ const expectedBody = {
155
+ v: PROJECTION_VERSION,
156
+ throughEventId: last ? last.id : null,
157
+ throughEventTs: last ? last.ts : null,
158
+ eventCount: effective.length,
159
+ metrics: computeMetrics(effective),
160
+ streak: computeStreak(effective),
161
+ bet: computeBet(effective),
162
+ drift: computeDrift(effective, {}),
163
+ };
164
+ if (rebuilt.hash !== sha256Hex(stableStringify(expectedBody))) {
165
+ throw new Error("dryrun.applyState: the reducer's rebuilt hash does not match the predicted reduction. Do not proceed to a live vault");
166
+ }
167
+
168
+ fs.rmSync(applyingMarker, { force: true });
169
+ return { manifest: manifest.map(({ file, bytes, sha256 }) => ({ file, bytes, sha256 })), verified: true, rebuiltHash: rebuilt.hash };
170
+ } finally {
171
+ lock.release();
172
+ }
173
+ }
174
+
175
+ // Raw-buffer sha256 (predictState hashes the utf8 TEXT, which encodes to the
176
+ // same bytes it writes; verification must hash what is actually on disk).
177
+ function cryptoSha(buf) {
178
+ return require("crypto").createHash("sha256").update(buf).digest("hex");
179
+ }
180
+
181
+ module.exports = {
182
+ STATE_DIR,
183
+ LOG_NAME,
184
+ PROJECTION_NAMES,
185
+ projectionFileText,
186
+ predictState,
187
+ applyState,
188
+ };