mover-os 4.7.8 → 4.7.10
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 +2229 -204
- package/package.json +3 -2
- package/src/dashboard/build.js +41 -3
- package/src/dashboard/lib/active-context-parser.js +16 -4
- package/src/dashboard/lib/agent-session.js +70 -49
- package/src/dashboard/lib/approval-registry.js +170 -0
- package/src/dashboard/lib/daily-note-resolver.js +20 -3
- package/src/dashboard/lib/date-utils.js +9 -1
- package/src/dashboard/lib/distribution-parser.js +69 -10
- package/src/dashboard/lib/drift-history.js +6 -1
- package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
- package/src/dashboard/lib/engine-health.js +4 -1
- package/src/dashboard/lib/engine-writer.js +157 -11
- package/src/dashboard/lib/goal-forecast.js +154 -31
- package/src/dashboard/lib/library-indexer-v2.js +14 -0
- package/src/dashboard/lib/library-search.js +290 -0
- package/src/dashboard/lib/log-activation.sh +0 -0
- package/src/dashboard/lib/memory-index.js +61 -12
- package/src/dashboard/lib/pid-markers.js +80 -0
- package/src/dashboard/lib/regenerate-manifest.js +0 -0
- package/src/dashboard/lib/run-registry.js +75 -15
- 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 +3 -0
- package/src/dashboard/lib/suggested-now.js +2 -2
- package/src/dashboard/lib/transcript-parser.js +48 -8
- package/src/dashboard/server.js +422 -44
- package/src/dashboard/shortcut.js +0 -0
- package/src/dashboard/ui/dist/assets/index-BoxTW_kj.js +161 -0
- package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
- package/src/dashboard/ui/dist/assets/index-wnamvqxp.js +34 -0
- package/src/dashboard/ui/dist/index.html +2 -2
- package/src/dashboard/ui/dist/sw.js +1 -1
- package/src/system/counts.json +7 -0
- package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
- package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
- package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +0 -34
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const { dailiesDir } = require("./paths");
|
|
6
|
-
const { localDateKey,
|
|
6
|
+
const { localDateKey, sessionMidnight } = require("./date-utils");
|
|
7
7
|
|
|
8
8
|
// distribution-parser.js — F30. Heuristic scan of Daily Notes session logs
|
|
9
9
|
// for distribution-action keywords. Buckets by day. Returns 30-day rolling
|
|
@@ -27,39 +27,98 @@ const KEYWORDS = [
|
|
|
27
27
|
/\bDM'?(?:d|ed)\b/i,
|
|
28
28
|
/\bsent (?:\d+ )?(?:DMs?|emails?|outreach|messages?|cold)\b/i,
|
|
29
29
|
/\breached out\b/i,
|
|
30
|
-
|
|
30
|
+
// reply seeding: PERFORMED forms only. The bare "reply-seeding" noun matched
|
|
31
|
+
// every strategy sentence that discussed the channel ("reply-seeding should
|
|
32
|
+
// be primary", "70% X reply-seeding") and made an all-planning week read as
|
|
33
|
+
// 8 receipts (terra Evidence walk, 2026-07-12).
|
|
34
|
+
/\breply[- ]seeded\b/i,
|
|
35
|
+
/\b(?:sent|posted|did|dropped)\b[^.\n]{0,40}\breply[- ]seeds?\b/i,
|
|
36
|
+
/\breply[- ]seeds?\b[^.\n]{0,30}\b(?:sent|posted|done)\b/i,
|
|
31
37
|
// replies count only when aimed at a person/audience surface, not code review
|
|
32
38
|
/\breplied?\b[^.\n]{0,50}\b(?:lead|prospect|customer|buyer|DM|thread|tweet|comment)\b/i,
|
|
33
39
|
/\btweeted\b/i,
|
|
34
40
|
];
|
|
35
41
|
|
|
42
|
+
// A clause that matches a KEYWORD is still NOT a receipt when it is talk
|
|
43
|
+
// ABOUT distribution rather than a report of doing it. Three failure classes
|
|
44
|
+
// from the 2026-07-12 terra Evidence walk, where ALL 8 of the week's counted
|
|
45
|
+
// "receipts" were prose (two literally said "the reel never shipped"):
|
|
46
|
+
// negation — "the reel never shipped", "reply seed not posted"
|
|
47
|
+
// plan/modal — "reply-seeding should be primary", "will post", "70% X"
|
|
48
|
+
// meta-discussion — strategy docs, reviews, corpus analysis mentioning reels
|
|
49
|
+
// Undercount over inflate: a rejected real receipt costs a pip; a counted
|
|
50
|
+
// fake receipt makes the product lie on its own honesty surface.
|
|
51
|
+
const NOT_A_RECEIPT = [
|
|
52
|
+
// bare "zero" over-blocked ("the false-zero bug"); only "zero <artifact>"
|
|
53
|
+
// is a negation ("sent zero DMs").
|
|
54
|
+
/\b(?:never|not|didn'?t|hasn'?t|wasn'?t|won'?t|isn'?t|couldn'?t|unshipped|unfilmed|unsent|unposted)\b/i,
|
|
55
|
+
/\bzero (?:DMs?|reels?|posts?|replies|tweets?|threads?|outreach)\b/i,
|
|
56
|
+
/\b(?:should|would|will|must|needs? to|plan(?:s|ned|ning)?|intend|gonna|tomorrow|next (?:week|step)|queued|owed|blocked|pending|drafted|commitment|deadline)\b/i,
|
|
57
|
+
/\b(?:strategy|doc|playbook|corpus|harvest|review|audit|analys\w*|research|parser?|keyword|counted|counter|allocation|weight|copy|evidence|rework|redesign|mandate)\b/i,
|
|
58
|
+
// "No reel shipped" / "no thread posted" — capital-No negation the \bnot\b
|
|
59
|
+
// class misses.
|
|
60
|
+
/\bno (?:reels?|tweets?|threads?|posts?|DMs?|articles?|outreach)\b/i,
|
|
61
|
+
/%/,
|
|
62
|
+
// A question is never a receipt ("| Twitter thread published? |" template
|
|
63
|
+
// checklist rows counted for weeks in March).
|
|
64
|
+
/\?/,
|
|
65
|
+
];
|
|
66
|
+
function isReceiptClause(frag) {
|
|
67
|
+
if (!KEYWORDS.some((re) => re.test(frag))) return false;
|
|
68
|
+
return !NOT_A_RECEIPT.some((re) => re.test(frag));
|
|
69
|
+
}
|
|
70
|
+
|
|
36
71
|
function compute({ vault, daysLookback = 30 } = {}) {
|
|
37
72
|
if (!vault) return { available: false, days: [], todayCount: null, total7d: null, total30d: null, delta7d: null };
|
|
38
73
|
const dDir = dailiesDir(vault);
|
|
39
74
|
if (!fs.existsSync(dDir)) return { available: false, days: [], todayCount: null, total7d: null, total30d: null, delta7d: null };
|
|
40
75
|
|
|
41
|
-
|
|
76
|
+
// Session-day anchor (T245): before 05:00 "today" is still yesterday's
|
|
77
|
+
// session, same convention daily-note-resolver uses — otherwise the 2am
|
|
78
|
+
// outreach a user is actively logging shows todayCount 0 (R5-A).
|
|
79
|
+
const today = sessionMidnight();
|
|
42
80
|
const days = [];
|
|
43
81
|
for (let i = 0; i < daysLookback; i++) {
|
|
44
|
-
|
|
82
|
+
// setDate (not ms arithmetic): a fixed 24h step skips one calendar day
|
|
83
|
+
// across a DST spring-forward and shifts the rest of the window (R5-A).
|
|
84
|
+
const d = new Date(today.getTime());
|
|
85
|
+
d.setDate(d.getDate() - i);
|
|
45
86
|
const key = localDateKey(d);
|
|
46
87
|
const monthDir = key.slice(0, 7);
|
|
47
88
|
const fpath = path.join(dDir, monthDir, `Daily - ${key}.md`);
|
|
48
89
|
let count = 0;
|
|
90
|
+
// The actual matched clauses, so Evidence can show REAL receipt lines
|
|
91
|
+
// instead of the fabricated "Distribution shipped" template rows terra
|
|
92
|
+
// confirmed (v4b syntheticReceipts, 2026-07-12).
|
|
93
|
+
const matches = [];
|
|
49
94
|
// R3-E1: a day with no note is NO-RECORD, not "zero receipts" — the UI
|
|
50
95
|
// needs the difference to clamp all-time views to the first real note.
|
|
51
96
|
let exists = false;
|
|
52
97
|
try {
|
|
53
98
|
const text = fs.readFileSync(fpath, "utf8");
|
|
54
99
|
exists = true;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
100
|
+
// Only a recognizable session-log section counts. The old full-file
|
|
101
|
+
// fallback (header renamed → scan EVERYTHING) counted journal prose
|
|
102
|
+
// about last month's article as today's outreach — the 29-vs-2 scar's
|
|
103
|
+
// second face. No section = count 0 (undercount over inflate).
|
|
104
|
+
// H2 OR H3, suffix allowed: /log itself writes "### Session Log
|
|
105
|
+
// [ATTENDED] — 10:00 [Claude Code]" (Codex R5-C catch).
|
|
106
|
+
const slm = text.match(/^#{2,3}\s*(?:session\s*log|log)\b/im);
|
|
107
|
+
const section = slm ? text.slice(slm.index) : "";
|
|
108
|
+
// Count at most ONE action per clause: "sent DMs and reached out to 5
|
|
109
|
+
// leads" is one outreach session described two ways, not two — but
|
|
110
|
+
// comma-separated real actions ("posted a thread, sent 5 DMs") each
|
|
111
|
+
// count (Codex R5-C: sentence-level was undercounting those).
|
|
112
|
+
if (section) {
|
|
113
|
+
for (const frag of section.split(/[.\n,;]+/)) {
|
|
114
|
+
if (isReceiptClause(frag)) {
|
|
115
|
+
count += 1;
|
|
116
|
+
if (matches.length < 6) matches.push(frag.trim().slice(0, 140));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
60
119
|
}
|
|
61
120
|
} catch (_) { /* missing daily — count stays 0 */ }
|
|
62
|
-
days.push({ date: key, count, exists });
|
|
121
|
+
days.push({ date: key, count, exists, matches });
|
|
63
122
|
}
|
|
64
123
|
const total7d = days.slice(0, 7).reduce((s, d) => s + d.count, 0);
|
|
65
124
|
const total30d = days.reduce((s, d) => s + d.count, 0);
|
|
@@ -9,12 +9,17 @@
|
|
|
9
9
|
const fs = require("fs");
|
|
10
10
|
const path = require("path");
|
|
11
11
|
const os = require("os");
|
|
12
|
+
const { localDateKey } = require("./date-utils");
|
|
12
13
|
|
|
13
14
|
const HISTORY_PATH = path.join(os.homedir(), ".mover", "drift-history.json");
|
|
14
15
|
const MAX_DAYS = 90;
|
|
15
16
|
|
|
17
|
+
// LOCAL calendar day, not UTC. The bare toISOString().slice(0,10) bucketed
|
|
18
|
+
// by UTC while every other parser (streak, distribution, session-time) keys
|
|
19
|
+
// by local time via date-utils, so near midnight in any non-UTC zone the
|
|
20
|
+
// drift sparkline's day disagreed with the rest of the dashboard by one day.
|
|
16
21
|
function todayKey() {
|
|
17
|
-
return
|
|
22
|
+
return localDateKey();
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
function ensureDir(filePath) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Known-good hashes of shipped Engine DEFAULTS across versions (CRLF-normalized+trimmed, sha256). engine-writer allows overwriting a non-sentinel file ONLY if its hash matches one of these (a fresh/older install default); a hash NOT here means the user modified it = real data, refuse. Regenerate: node scripts/gen-engine-fingerprints.mjs.",
|
|
3
|
+
"generated": "git-history",
|
|
4
|
+
"files": {
|
|
5
|
+
"Goals.md": [
|
|
6
|
+
"27abf1d26d51525baa5a3ae9c8cc6f879a723f929334852b7ef9549a5fa11c57",
|
|
7
|
+
"30f3e24ad7e45bbb2053a0863164357b3e6cf718146540e685462495e38c996c"
|
|
8
|
+
],
|
|
9
|
+
"Mover_Dossier.md": [
|
|
10
|
+
"c0d6817e390153f834d6a52e8d0e686cb3eb056e6457afecafd5e3abc0c47c49",
|
|
11
|
+
"21b1ecb65d7c7dbe7be03efa965fefd54f727663e3f0088be66dd453fd244677",
|
|
12
|
+
"72ca850dfd15972045918fcf0170315da72290a7ca7d034710362ca77e7cd1fa",
|
|
13
|
+
"e10ace7b0a72d17f342c70d03f015525e7029b13e43eae2c43e8c6ecd14483ee",
|
|
14
|
+
"bc568ebd12e25377274cf906d0569218c46d1c06e5dc9e857b98e3fdc128b667"
|
|
15
|
+
],
|
|
16
|
+
"Active_Context.md": [
|
|
17
|
+
"433ef66c852de9a5c37bb65cc40c9d20e577fbcb46ecf91963c8f2d7b23f8a70",
|
|
18
|
+
"5dec535b04d4c7b11029222812e782a02dcbaea03faaa8979e0adb45bf9698b9",
|
|
19
|
+
"22b4855fc5f8337b2d1d2f077d12cf709333ea2cc3aa96c7fd1e380ea55ab7fc",
|
|
20
|
+
"de5b8133f2fd35ad70920bc1c08f2ab75c26edcf58f6b88fccf245a0e729c5aa",
|
|
21
|
+
"fc6f35e45f7b3934d0ab705b6e259ce195c637019e49381885dab7c963f3ccca",
|
|
22
|
+
"8eec472e3f451a1d8a121b3a3bd76065137921aa95824666fbb466fec6fb6443",
|
|
23
|
+
"f0bf0c38418e6c42be46b1546acd676e55aeb64cd1ba31c164dc7047b5ee6807",
|
|
24
|
+
"25f525f55fb86c638cd67792ef05f46de688e048586a3ccb2d980b8d1a78c7db",
|
|
25
|
+
"63e7db7db109bdfa78edf5df5d69e0286d614e84aa0165ae372576f6a816602c",
|
|
26
|
+
"52a8d57d45cafd662e3d106c2acb6fc07116572ca82bc04ff96f7e913ce47395",
|
|
27
|
+
"d79582e10cb0410554b9a1a669127cc42150805a1207f867edf90ce60f9effa2",
|
|
28
|
+
"829a755f53600266f60a8daaa9450e8e3516a7b03538eabeed9ff94ef5ec2b1b",
|
|
29
|
+
"aaa0e5bdac55f53db3bd36db526f841c5e6f62fb7cd8abf7626ff95b68818e14",
|
|
30
|
+
"4e2b064fe6d2391704a490020be147c507809a1c420da67eac34add5c85b3ebf",
|
|
31
|
+
"ae47ece41ab9c4dfa12df6e0c9b895de2e44ad1b788bcbc84728f43764a0a90a",
|
|
32
|
+
"52332d01a067494dc2cd2a5a642d741aa16cf847e49b5e73b744586e167a7faa"
|
|
33
|
+
],
|
|
34
|
+
"Auto_Learnings.md": [
|
|
35
|
+
"2f02d8724b3283101be1dce7b14e69f478e841d36453ee990ede636cb0daaced",
|
|
36
|
+
"54fe4b57dd90bed94ef7c5d6af50ef290347afef55081d5c3ec51b4bc3181b60",
|
|
37
|
+
"a04fe481a5e79eaf0888c0fd71100fbbc709c1d2c8a1145b7d6fa6482d32a144",
|
|
38
|
+
"0d5bb1ed881384f1559b9fd2702199d28f7e151bd5ec9f9a559b9ece038e88e5",
|
|
39
|
+
"e1f5361fbd42cbfb655dbe4fd3a8129ec27e5d31c5bbc397615d0eb61c507438",
|
|
40
|
+
"10d443002d1056d6a939dea23231acaa0a5f01f1b3c6a86b808bb383efd34621",
|
|
41
|
+
"2ee52d9282a830ef34c52de5d38e78f19710769b1433391dc787fdfed8cf6093"
|
|
42
|
+
],
|
|
43
|
+
"Metrics_Log.md": [
|
|
44
|
+
"3e3ab4011b65ddf8b435898ee31d136d31fa346cf408a741b6752f6bd9178537",
|
|
45
|
+
"4f3e7ba572b46cb7cf594640737badb5c2a742e8e2942a53d554ac0e4cddcb69",
|
|
46
|
+
"a840982311c4ad8300813316926b50a6bc6050b1c45a058ad146b6b695299dbf"
|
|
47
|
+
],
|
|
48
|
+
"Someday_Maybe.md": [
|
|
49
|
+
"d9d7889a0923059c182d197aa3579a6d4ee78e19d980a7b144652bae7d7715a5",
|
|
50
|
+
"115ff4539c651ea87184b124a40ea75c874b5122c26d1ecfce3b5657c3d618da"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -153,7 +153,10 @@ function scanForExpiredGoals(text) {
|
|
|
153
153
|
// Drop the bare `Date:` arm. Require explicit "Target Date" semantics.
|
|
154
154
|
// Also: require ISO YYYY-MM-DD shape (was `[\d\-]{8,10}` which accepts
|
|
155
155
|
// `2-3-2026`, `0-0-0`, etc).
|
|
156
|
-
|
|
156
|
+
// Both bold conventions accepted: `**Target Date:** X` AND `**Target
|
|
157
|
+
// Date**: X` — the colon-outside-bold style made a 6-month-overdue goal
|
|
158
|
+
// silently invisible to the RED alert (R5-A false-negative twin of B#17).
|
|
159
|
+
const re = /(?:\*\*Target Date:?\*\*:?|^\s*Target Date:)\s*(\d{4}-\d{2}-\d{2})\b/gim;
|
|
157
160
|
let m;
|
|
158
161
|
const now = Date.now();
|
|
159
162
|
while ((m = re.exec(text)) !== null) {
|
|
@@ -49,7 +49,24 @@ const os = require("os");
|
|
|
49
49
|
const ENGINE_DIR_REL = path.join("02_Areas", "Engine");
|
|
50
50
|
|
|
51
51
|
function assertSafeTarget(targetDir, allowEngineWrite = false) {
|
|
52
|
-
const
|
|
52
|
+
const lexical = path.resolve(targetDir);
|
|
53
|
+
// terra F2 (2026-07-11): path.resolve is purely LEXICAL — if 02_Areas/Engine (or
|
|
54
|
+
// a parent) is a SYMLINK, the endsWith check still matched "02_Areas/Engine" while
|
|
55
|
+
// the real writes escaped through the link to an external dir. Resolve the REAL
|
|
56
|
+
// path so the checks below see where writes actually land. realpath fails for a
|
|
57
|
+
// not-yet-created target (fresh vault) — that has no symlink to follow, so the
|
|
58
|
+
// lexical path is authoritative there.
|
|
59
|
+
let resolved = lexical;
|
|
60
|
+
try { resolved = fs.realpathSync(lexical); }
|
|
61
|
+
catch {
|
|
62
|
+
// Target doesn't exist yet; resolve the deepest existing ancestor so a
|
|
63
|
+
// symlinked PARENT is still caught, then re-append the remainder.
|
|
64
|
+
try {
|
|
65
|
+
let cur = lexical, tail = [];
|
|
66
|
+
while (cur && cur !== path.dirname(cur) && !fs.existsSync(cur)) { tail.unshift(path.basename(cur)); cur = path.dirname(cur); }
|
|
67
|
+
resolved = path.join(fs.realpathSync(cur), ...tail);
|
|
68
|
+
} catch { resolved = lexical; }
|
|
69
|
+
}
|
|
53
70
|
// Compare case-INSENSITIVELY (Bug6): macOS/APFS is case-insensitive, so a lowercase
|
|
54
71
|
// ".../02_areas/engine" points at the real Engine but a case-sensitive endsWith would miss it and
|
|
55
72
|
// let the write through. Keep `resolved` (original case) for the error messages.
|
|
@@ -250,7 +267,19 @@ function parseProjectLine(line) {
|
|
|
250
267
|
}
|
|
251
268
|
const name = cleanProjectName(parts.shift() || "");
|
|
252
269
|
const rest = sanitizeValue(parts.join(": "), 300);
|
|
253
|
-
|
|
270
|
+
// People answer in prose ("6 bookings, P1, next action portfolio site, blocker underpricing"),
|
|
271
|
+
// so lift the fields out of the text instead of leaving the brief full of [TBD].
|
|
272
|
+
const prio = rest.match(/\b(P[0-9])\b/i);
|
|
273
|
+
const blocker = rest.match(/\bblockers?\s*[:\-]?\s+([^,.;]+)/i);
|
|
274
|
+
const next = rest.match(/\bnext(?:\s+(?:action|step|move))?\s*[:\-]?\s+([^,.;]+)/i);
|
|
275
|
+
return {
|
|
276
|
+
name,
|
|
277
|
+
outcome: rest,
|
|
278
|
+
state: "",
|
|
279
|
+
priority: prio ? prio[1].toUpperCase() : "",
|
|
280
|
+
blocker: blocker ? blocker[1].trim() : "",
|
|
281
|
+
nextAction: next ? next[1].trim() : "",
|
|
282
|
+
};
|
|
254
283
|
}
|
|
255
284
|
|
|
256
285
|
function cleanProjectSources(raw) {
|
|
@@ -297,9 +326,17 @@ function cleanProjects(raw) {
|
|
|
297
326
|
if (Array.isArray(raw)) {
|
|
298
327
|
raw.forEach(push);
|
|
299
328
|
} else if (typeof raw === "string") {
|
|
329
|
+
// Prose answers separate projects with sentences, not semicolons:
|
|
330
|
+
// "…blocker underpricing. Fitness comeback: gym 3x a week, P2." Split on a
|
|
331
|
+
// period only when the next sentence opens like a new project head
|
|
332
|
+
// ("Name: details"), so decimals and ordinary sentences stay intact.
|
|
333
|
+
// Field-label words never open a new project: "…root cause found. Next:
|
|
334
|
+
// ship the fix." is a continuation parseProjectLine LIFTS as nextAction —
|
|
335
|
+
// splitting there minted junk "Next"/"Priority" projects (R5-A).
|
|
300
336
|
raw
|
|
301
337
|
.split(/\n+|;+/)
|
|
302
|
-
.
|
|
338
|
+
.flatMap((chunk) => chunk.split(/(?<=\.)\s+(?=(?!(?:Next(?:\s+(?:[Aa]ction|[Ss]tep|[Mm]ove))?|Blockers?|Priority|Action|Step|Status|State|Metric|Goal)\s*:)[A-Z][^.:;]{1,60}:\s)/))
|
|
339
|
+
.map((x) => x.trim().replace(/\.$/, ""))
|
|
303
340
|
.filter(Boolean)
|
|
304
341
|
.forEach(push);
|
|
305
342
|
}
|
|
@@ -1416,14 +1453,82 @@ function writeEngine({ transcript, digest = "", targetDir, dryRun = true, force
|
|
|
1416
1453
|
if (e.code !== "ENOENT") throw e; // ENOENT = fresh; anything else re-throws
|
|
1417
1454
|
}
|
|
1418
1455
|
}
|
|
1456
|
+
// terra F1 (2026-07-11): the sentinel check misses a PARTIAL Engine whose user
|
|
1457
|
+
// data lives only in non-sentinel files (a legacy or interrupted setup). Also
|
|
1458
|
+
// refuse when the Engine already holds USAGE artifacts that are never shipped
|
|
1459
|
+
// as install defaults — real Daily notes or Weekly/Monthly/Quarterly Reviews.
|
|
1460
|
+
// The installer ships those folders EMPTY (.gitkeep only), so this can never
|
|
1461
|
+
// false-refuse a fresh install (which is exactly why the sentinel-only guard
|
|
1462
|
+
// existed). Voice_DNA.md is NOT used here — it IS shipped as a default.
|
|
1463
|
+
const usageDirs = ["Dailies", "Weekly Reviews", "Monthly Reviews", "Quarterly Reviews"];
|
|
1464
|
+
const hasRealNote = (root) => {
|
|
1465
|
+
let found = false;
|
|
1466
|
+
const walk = (d) => {
|
|
1467
|
+
let entries;
|
|
1468
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
1469
|
+
for (const e of entries) {
|
|
1470
|
+
if (found) return;
|
|
1471
|
+
const full = path.join(d, e.name);
|
|
1472
|
+
if (e.isDirectory()) walk(full);
|
|
1473
|
+
else if (e.name.toLowerCase().endsWith(".md")) found = true;
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1476
|
+
walk(path.join(targetDir, root));
|
|
1477
|
+
return found;
|
|
1478
|
+
};
|
|
1479
|
+
const usage = usageDirs.filter(hasRealNote);
|
|
1480
|
+
if (usage.length > 0) {
|
|
1481
|
+
throw new Error(
|
|
1482
|
+
`[engine-writer] SAFETY BLOCK: the Engine already contains usage history (${usage.join(", ")}). ` +
|
|
1483
|
+
`Refusing to overwrite a real prior Engine (a fresh install ships these folders empty).`
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
// sol flaw #3 COMPLETION (2026-07-11): a partial Engine whose user data lives
|
|
1487
|
+
// ONLY in a non-sentinel file (Goals / Dossier / Active_Context /
|
|
1488
|
+
// Auto_Learnings / Metrics_Log) with no dailies. Fingerprint each existing
|
|
1489
|
+
// target against the KNOWN shipped defaults across versions
|
|
1490
|
+
// (engine-default-fingerprints.json, generated from git history). A file that
|
|
1491
|
+
// matches ANY known default is a fresh OR older-version install default ->
|
|
1492
|
+
// allow (this is what fixed the cross-version false-refuse that made the
|
|
1493
|
+
// current-only fingerprint unsafe). A non-empty file matching NONE was
|
|
1494
|
+
// modified by the user = real data -> refuse. If the manifest is missing, the
|
|
1495
|
+
// sentinel + usage-artifact checks above still stand.
|
|
1496
|
+
let fingerprints = null;
|
|
1497
|
+
try { fingerprints = require("./engine-default-fingerprints.json").files; } catch {}
|
|
1498
|
+
if (fingerprints) {
|
|
1499
|
+
const crypto = require("crypto");
|
|
1500
|
+
const dnHash = (s) => crypto.createHash("sha256").update(String(s).replace(/\r\n/g, "\n").trim()).digest("hex");
|
|
1501
|
+
for (const { path: filePath } of files) {
|
|
1502
|
+
const base = path.basename(filePath);
|
|
1503
|
+
if (SENTINEL_FILES.has(base)) continue;
|
|
1504
|
+
const known = fingerprints[base];
|
|
1505
|
+
if (!Array.isArray(known) || known.length === 0) continue; // no default tracked (e.g. a Daily)
|
|
1506
|
+
let onDisk;
|
|
1507
|
+
try { onDisk = fs.readFileSync(filePath, "utf8"); } catch { continue; } // absent = fresh
|
|
1508
|
+
if (String(onDisk).replace(/\r\n/g, "\n").trim().length === 0) continue; // empty = nothing to lose
|
|
1509
|
+
if (!known.includes(dnHash(onDisk))) {
|
|
1510
|
+
throw new Error(
|
|
1511
|
+
`[engine-writer] SAFETY BLOCK: ${base} differs from every shipped default and holds real user data. ` +
|
|
1512
|
+
`Refusing to overwrite an existing Engine.`
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1419
1517
|
}
|
|
1420
1518
|
|
|
1421
|
-
// ── Atomic-ish multi-file commit (Bug1+2)
|
|
1422
|
-
// Write EVERY file to its tmp first; only once all
|
|
1423
|
-
// the write phase (disk full, ENOTDIR, perms) unlinks
|
|
1424
|
-
// retry is never locked out by a
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
1519
|
+
// ── Atomic-ish multi-file commit (Bug1+2, hardened R5-A) ────────────────────
|
|
1520
|
+
// Write EVERY file to its tmp first; only once all succeed do we rename them
|
|
1521
|
+
// in. A failure during the write phase (disk full, ENOTDIR, perms) unlinks
|
|
1522
|
+
// every staged tmp and commits NOTHING — so a retry is never locked out by a
|
|
1523
|
+
// half-seeded sentinel (Identity_Prime/Strategy), and no orphaned
|
|
1524
|
+
// .engine-writer.tmp is left behind.
|
|
1525
|
+
//
|
|
1526
|
+
// The rename phase is NOT assumed infallible: the target is an Obsidian
|
|
1527
|
+
// vault, and synced vaults (iCloud/OneDrive/Dropbox) intermittently lock
|
|
1528
|
+
// files mid-rename. Two defenses: (1) the SENTINEL files rename LAST, so a
|
|
1529
|
+
// failure on any other file can never seed the guard that locks retries out;
|
|
1530
|
+
// (2) on a rename failure every already-renamed file is rolled back to its
|
|
1531
|
+
// tmp, restoring the pre-write state exactly, before rethrowing.
|
|
1427
1532
|
const staged = [];
|
|
1428
1533
|
try {
|
|
1429
1534
|
for (const { path: filePath, content } of files) {
|
|
@@ -1436,9 +1541,50 @@ function writeEngine({ transcript, digest = "", targetDir, dryRun = true, force
|
|
|
1436
1541
|
for (const { tmp } of staged) { try { fs.unlinkSync(tmp); } catch (_) {} }
|
|
1437
1542
|
throw e; // nothing renamed → no partial Engine, no force=false lockout, no dangling tmp
|
|
1438
1543
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1544
|
+
const SENTINELS = new Set(["Identity_Prime.md", "Strategy.md"]);
|
|
1545
|
+
const renameOrder = [
|
|
1546
|
+
...staged.filter((s) => !SENTINELS.has(path.basename(s.filePath))),
|
|
1547
|
+
...staged.filter((s) => SENTINELS.has(path.basename(s.filePath))),
|
|
1548
|
+
];
|
|
1549
|
+
const renamed = [];
|
|
1550
|
+
try {
|
|
1551
|
+
for (const entry of renameOrder) {
|
|
1552
|
+
// A forward rename REPLACES any existing file (the structure installer
|
|
1553
|
+
// ships non-empty defaults for the non-sentinel targets) — park the
|
|
1554
|
+
// original first so a rollback can restore it, not just delete the new
|
|
1555
|
+
// bytes (Codex R5-C catch: the first rollback lost the pre-existing
|
|
1556
|
+
// Dossier default on a mid-commit rename failure).
|
|
1557
|
+
entry.bak = null;
|
|
1558
|
+
if (fs.existsSync(entry.filePath)) {
|
|
1559
|
+
// sol flaw #4 (2026-07-11): the old code SWALLOWED a park failure and then
|
|
1560
|
+
// overwrote the original anyway — so if the .bak rename failed, the
|
|
1561
|
+
// original bytes were replaced with no backup, and a later rollback had
|
|
1562
|
+
// nothing to restore (reproduced: Goals.md vanished after a later
|
|
1563
|
+
// Metrics_Log rename failure). Fail CLOSED instead: if we cannot back up
|
|
1564
|
+
// an existing file, do NOT overwrite it. The throw lands in the rollback
|
|
1565
|
+
// below with this file's original still in place, untouched.
|
|
1566
|
+
entry.bak = entry.filePath + ".engine-writer.bak";
|
|
1567
|
+
fs.renameSync(entry.filePath, entry.bak);
|
|
1568
|
+
}
|
|
1569
|
+
fs.renameSync(entry.tmp, entry.filePath); // atomic: readers never see a partial file
|
|
1570
|
+
renamed.push(entry);
|
|
1571
|
+
}
|
|
1572
|
+
} catch (e) {
|
|
1573
|
+
// Roll back. Order matters: move the new bytes out of the way, then
|
|
1574
|
+
// every parked original goes BACK to its path — including the entry
|
|
1575
|
+
// whose rename failed AFTER parking (its original sits at .bak with
|
|
1576
|
+
// nothing at filePath; deleting that bak would lose it).
|
|
1577
|
+
for (const { tmp, filePath } of renamed) {
|
|
1578
|
+
try { fs.renameSync(filePath, tmp); } catch (_) {}
|
|
1579
|
+
}
|
|
1580
|
+
for (const { filePath, bak } of staged) {
|
|
1581
|
+
if (bak && fs.existsSync(bak)) { try { fs.renameSync(bak, filePath); } catch (_) {} }
|
|
1582
|
+
}
|
|
1583
|
+
for (const { tmp } of staged) { try { fs.unlinkSync(tmp); } catch (_) {} }
|
|
1584
|
+
throw e;
|
|
1441
1585
|
}
|
|
1586
|
+
// Success: clear the parked originals.
|
|
1587
|
+
for (const { bak } of renamed) { if (bak) { try { fs.unlinkSync(bak); } catch (_) {} } }
|
|
1442
1588
|
|
|
1443
1589
|
return { files, written: true };
|
|
1444
1590
|
}
|