mover-os 4.7.7 → 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.
- package/README.md +34 -24
- package/install.js +2868 -251
- package/package.json +15 -3
- package/src/dashboard/build.js +1541 -0
- package/src/dashboard/dashboard.js +276 -0
- package/src/dashboard/index.js +319 -0
- package/src/dashboard/lib/activation-log.js +297 -0
- package/src/dashboard/lib/active-context-parser.js +189 -0
- package/src/dashboard/lib/agent-command.js +93 -0
- package/src/dashboard/lib/agent-detect.js +255 -0
- package/src/dashboard/lib/agent-detector.js +92 -0
- package/src/dashboard/lib/agent-session.js +483 -0
- package/src/dashboard/lib/anti-identity-detector.js +116 -0
- package/src/dashboard/lib/approval-registry.js +170 -0
- package/src/dashboard/lib/auto-learnings-parser.js +183 -0
- package/src/dashboard/lib/cli-usage-parser.js +92 -0
- package/src/dashboard/lib/config-parser.js +109 -0
- package/src/dashboard/lib/connect-recommender.js +131 -0
- package/src/dashboard/lib/correlations-parser.js +231 -0
- package/src/dashboard/lib/daily-note-resolver.js +228 -0
- package/src/dashboard/lib/date-utils.js +43 -0
- package/src/dashboard/lib/distribution-parser.js +137 -0
- package/src/dashboard/lib/dossier-parser.js +64 -0
- package/src/dashboard/lib/drift-history.js +88 -0
- package/src/dashboard/lib/drift-score.js +119 -0
- package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
- package/src/dashboard/lib/engine-health.js +173 -0
- package/src/dashboard/lib/engine-writer.js +1831 -0
- package/src/dashboard/lib/execution-plan.js +125 -0
- package/src/dashboard/lib/experiments-parser.js +429 -0
- package/src/dashboard/lib/feed-parser.js +294 -0
- package/src/dashboard/lib/forked-future.js +60 -0
- package/src/dashboard/lib/goal-forecast.js +427 -0
- package/src/dashboard/lib/goals-parser.js +67 -0
- package/src/dashboard/lib/health-protocols-parser.js +133 -0
- package/src/dashboard/lib/hook-activity.js +48 -0
- package/src/dashboard/lib/hook-indexer.js +169 -0
- package/src/dashboard/lib/hourly-activity-parser.js +143 -0
- package/src/dashboard/lib/identity-parser.js +85 -0
- package/src/dashboard/lib/ingestion.js +418 -0
- package/src/dashboard/lib/library-indexer-v2.js +226 -0
- package/src/dashboard/lib/library-indexer.js +105 -0
- package/src/dashboard/lib/library-search.js +290 -0
- package/src/dashboard/lib/log-activation.sh +61 -0
- package/src/dashboard/lib/memory-curator.js +97 -0
- package/src/dashboard/lib/memory-gardener.js +177 -0
- package/src/dashboard/lib/memory-gepa.js +102 -0
- package/src/dashboard/lib/memory-index.js +519 -0
- package/src/dashboard/lib/memory-rerank.js +72 -0
- package/src/dashboard/lib/memory-text.js +136 -0
- package/src/dashboard/lib/metrics-log-parser.js +76 -0
- package/src/dashboard/lib/moves-usage-parser.js +184 -0
- package/src/dashboard/lib/onboarding-forge.js +70 -0
- package/src/dashboard/lib/override-outcome-parser.js +68 -0
- package/src/dashboard/lib/override-summary.js +73 -0
- package/src/dashboard/lib/paths.js +192 -0
- package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
- package/src/dashboard/lib/phantom-strategy.js +129 -0
- package/src/dashboard/lib/pid-markers.js +80 -0
- package/src/dashboard/lib/project-scanner.js +121 -0
- package/src/dashboard/lib/promise-wall.js +88 -0
- package/src/dashboard/lib/record-score.js +173 -0
- package/src/dashboard/lib/redaction.js +140 -0
- package/src/dashboard/lib/refusal-parser.js +44 -0
- package/src/dashboard/lib/regenerate-manifest.js +206 -0
- package/src/dashboard/lib/rewind-snapshots.js +689 -0
- package/src/dashboard/lib/roast-wall-parser.js +200 -0
- package/src/dashboard/lib/run-registry.js +286 -0
- package/src/dashboard/lib/safe-write.js +63 -0
- package/src/dashboard/lib/session-log-parser.js +145 -0
- package/src/dashboard/lib/session-time-parser.js +158 -0
- package/src/dashboard/lib/skill-index.js +171 -0
- package/src/dashboard/lib/skill-indexer.js +118 -0
- package/src/dashboard/lib/skill-recommender.js +689 -0
- package/src/dashboard/lib/state-core/backfill.js +298 -0
- package/src/dashboard/lib/state-core/dryrun.js +188 -0
- package/src/dashboard/lib/state-core/event-log.js +615 -0
- package/src/dashboard/lib/state-core/events.js +265 -0
- package/src/dashboard/lib/state-core/projections.js +376 -0
- package/src/dashboard/lib/state-core/start-close.js +162 -0
- package/src/dashboard/lib/state-core/trial.js +96 -0
- package/src/dashboard/lib/strategy-parser.js +248 -0
- package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
- package/src/dashboard/lib/streak-parser.js +95 -0
- package/src/dashboard/lib/suggested-now.js +254 -0
- package/src/dashboard/lib/tool-awareness.js +125 -0
- package/src/dashboard/lib/transcript-parser.js +371 -0
- package/src/dashboard/lib/vault-graph-parser.js +205 -0
- package/src/dashboard/lib/view-generator.js +163 -0
- package/src/dashboard/lib/voice-dna-parser.js +57 -0
- package/src/dashboard/lib/walkthrough-script.js +140 -0
- package/src/dashboard/lib/workflow-graph-parser.js +170 -0
- package/src/dashboard/lib/workflow-library-parser.js +146 -0
- package/src/dashboard/server.js +2402 -0
- package/src/dashboard/shortcut.js +284 -0
- package/src/dashboard/static/setup-poc.html +306 -0
- package/src/dashboard/static/walkthrough-poc.html +580 -0
- package/src/dashboard/styles.css +1201 -0
- package/src/dashboard/templates/index.html +278 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hero.png +0 -0
- package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
- package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
- package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
- package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
- package/src/dashboard/ui/dist/icon.svg +4 -0
- package/src/dashboard/ui/dist/index.html +18 -0
- package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
- package/src/dashboard/ui/dist/registerSW.js +1 -0
- package/src/dashboard/ui/dist/sw.js +1 -0
- package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
|
@@ -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
|
+
};
|