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,265 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * events.js — CORE-LOOP PHASE 1: the v0.2 fact vocabulary as validated
5
+ * constructors (SCHEMA-SPEC.md v0.2 amendments 5-8, sol-rescore-2.md
6
+ * blocking objections 5-8, answered). Same scope fence as event-log.js: not
7
+ * wired to any workflow, route, or hook in this phase.
8
+ *
9
+ * Each constructor returns a full v0.2 envelope (via event-log.makeEvent)
10
+ * ready to hand to event-log.appendEvent. They validate required fields and
11
+ * throw loudly on a malformed payload — a caller building a fact wrong
12
+ * should fail at construction time, not silently corrupt the log.
13
+ */
14
+
15
+ const { makeEvent, computeDedupeKey } = require("./event-log");
16
+
17
+ // TIME-SERIES facts (plan.ticked, receipt.filed, override.logged) fold their
18
+ // ts into the dedupe basis. Payload-only dedupe silently DROPPED a re-tick
19
+ // after an untick (identical payload -> same key -> no-op -> the projection
20
+ // said done:false forever) and would drop a legitimately repeated receipt or
21
+ // override on a later day. Identity facts keep payload-only dedupe: session.*
22
+ // carry a UUID sessionId, ship.published carries receiptId (single-sourcing
23
+ // is intentional), plan.observed is latest-wins. Retry discipline still
24
+ // holds: a retry REUSES the built envelope (same ts, same key), and backfill
25
+ // derives ts deterministically from the source, so re-runs still dedupe.
26
+ // (Self-caught live repro 2026-07-11, spec amendment v0.2.2.)
27
+ function timeSeriesDedupe(type, body, opts) {
28
+ if (opts.dedupeKey != null) return opts;
29
+ const ts = opts.ts != null ? opts.ts : Date.now();
30
+ return { ...opts, ts, dedupeKey: computeDedupeKey(type, { ...body, tsDiscriminator: ts }) };
31
+ }
32
+
33
+ function requireString(payload, field, typeName) {
34
+ const v = payload && payload[field];
35
+ if (typeof v !== "string" || v.length === 0) {
36
+ throw new Error(`events.${typeName}: payload.${field} must be a non-empty string`);
37
+ }
38
+ return v;
39
+ }
40
+
41
+ function requireBool(payload, field, typeName) {
42
+ const v = payload && payload[field];
43
+ if (typeof v !== "boolean") {
44
+ throw new Error(`events.${typeName}: payload.${field} must be a boolean`);
45
+ }
46
+ return v;
47
+ }
48
+
49
+ function optionalString(payload, field, typeName) {
50
+ const v = payload && payload[field];
51
+ if (v === undefined || v === null) return undefined;
52
+ if (typeof v !== "string") throw new Error(`events.${typeName}: payload.${field}, if present, must be a string`);
53
+ return v;
54
+ }
55
+
56
+ function optionalNumber(payload, field, typeName) {
57
+ const v = payload && payload[field];
58
+ if (v === undefined || v === null) return undefined;
59
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`events.${typeName}: payload.${field}, if present, must be a finite number`);
60
+ return v;
61
+ }
62
+
63
+ // ── session.started / session.closed (SCHEMA-SPEC v0.2 amendment 6) ────────
64
+ //
65
+ // Share a stable `sessionId` (UUID). A crash before Close is later closed by
66
+ // a `session.closed { reason:"reaped" }` written from the NEXT session's
67
+ // start-time sweep (see findOrphanSessions / reapSession below) — this is
68
+ // what makes session correlation survive a crash rather than leaving a
69
+ // session open forever (objection 6).
70
+
71
+ function sessionStarted(payload = {}, opts = {}) {
72
+ const sessionId = payload.sessionId != null ? requireString(payload, "sessionId", "sessionStarted") : (opts.sessionId || require("crypto").randomUUID());
73
+ const agent = requireString(payload, "agent", "sessionStarted");
74
+ const project = requireString(payload, "project", "sessionStarted");
75
+ const focus = optionalString(payload, "focus", "sessionStarted");
76
+ const body = { sessionId, agent, project };
77
+ if (focus !== undefined) body.focus = focus;
78
+ return makeEvent("session.started", body, opts);
79
+ }
80
+
81
+ function sessionClosed(payload = {}, opts = {}) {
82
+ const sessionId = requireString(payload, "sessionId", "sessionClosed");
83
+ const agent = requireString(payload, "agent", "sessionClosed");
84
+ const project = requireString(payload, "project", "sessionClosed");
85
+ const reason = optionalString(payload, "reason", "sessionClosed") || "closed";
86
+ if (reason !== "closed" && reason !== "reaped") {
87
+ throw new Error(`events.sessionClosed: payload.reason must be "closed" or "reaped" (got ${JSON.stringify(reason)})`);
88
+ }
89
+ const mins = optionalNumber(payload, "mins", "sessionClosed");
90
+ // Vault-zone policy (SCHEMA-SPEC v0.2.7, the wiring precondition from
91
+ // clause 17): the WRITER stamps the product-local day (the Daily Note date
92
+ // rule, pre-05:00 belongs to yesterday) into dateKey; ts stays the
93
+ // zone-free epoch truth. Reducers prefer dateKey over a UTC derivation of
94
+ // ts, so a rebuild on a machine in any zone reproduces the same
95
+ // projections. Optional: migrated/legacy closes fall back to ts.
96
+ const dateKey = optionalString(payload, "dateKey", "sessionClosed");
97
+ if (dateKey !== undefined) {
98
+ // Shape AND round-trip (terra wiring finding 9): Date.UTC silently
99
+ // normalizes impossible dates, so components must compare back.
100
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateKey);
101
+ const rt = m && (() => {
102
+ const dt = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
103
+ return dt.getUTCFullYear() === +m[1] && dt.getUTCMonth() === +m[2] - 1 && dt.getUTCDate() === +m[3];
104
+ })();
105
+ if (!rt) throw new Error(`events.sessionClosed: payload.dateKey must be a real YYYY-MM-DD date (got ${JSON.stringify(dateKey)})`);
106
+ }
107
+ // A normal close needs a real summary; a reaped close is machine-written
108
+ // and gets an honest default rather than forcing a caller to fabricate one.
109
+ const summary = reason === "reaped" ? optionalString(payload, "summary", "sessionClosed") || "session reaped: no session.closed found before the next session.started" : requireString(payload, "summary", "sessionClosed");
110
+ const body = { sessionId, agent, project, reason, summary };
111
+ if (mins !== undefined) body.mins = mins;
112
+ if (dateKey !== undefined) body.dateKey = dateKey;
113
+ return makeEvent("session.closed", body, opts);
114
+ }
115
+
116
+ // Pure scan: given the full raw event list (log order), returns the
117
+ // session.started events whose sessionId has no matching session.closed —
118
+ // crashed/orphaned sessions a new Start's sweep should reap. Pure function
119
+ // of `events`, no fs/clock access, so it composes cleanly with either a
120
+ // fresh readEvents() result or a projections.js reduction.
121
+ function findOrphanSessions(events) {
122
+ const started = new Map(); // sessionId -> first session.started event seen
123
+ const closed = new Set();
124
+ for (const e of events) {
125
+ if (e.type === "session.started" && e.payload && e.payload.sessionId) {
126
+ if (!started.has(e.payload.sessionId)) started.set(e.payload.sessionId, e);
127
+ } else if (e.type === "session.closed" && e.payload && e.payload.sessionId) {
128
+ closed.add(e.payload.sessionId);
129
+ }
130
+ }
131
+ const orphans = [];
132
+ for (const [sessionId, startEvt] of started) {
133
+ if (!closed.has(sessionId)) orphans.push(startEvt);
134
+ }
135
+ return orphans;
136
+ }
137
+
138
+ // Builds the reaping session.closed event for one orphaned session.started.
139
+ function reapSession(startEvent, opts = {}) {
140
+ if (!startEvent || startEvent.type !== "session.started") {
141
+ throw new Error("events.reapSession: startEvent must be a session.started event");
142
+ }
143
+ return sessionClosed(
144
+ {
145
+ sessionId: startEvent.payload.sessionId,
146
+ agent: startEvent.payload.agent,
147
+ project: startEvent.payload.project,
148
+ reason: "reaped",
149
+ },
150
+ opts
151
+ );
152
+ }
153
+
154
+ // ── receipt.filed ────────────────────────────────────────────────────────
155
+
156
+ function receiptFiled(payload = {}, opts = {}) {
157
+ const text = requireString(payload, "text", "receiptFiled");
158
+ const cat = requireString(payload, "cat", "receiptFiled");
159
+ const project = optionalString(payload, "project", "receiptFiled");
160
+ const published = requireBool(payload, "published", "receiptFiled");
161
+ const body = { text, cat, published };
162
+ if (project !== undefined) body.project = project;
163
+ return makeEvent("receipt.filed", body, timeSeriesDedupe("receipt.filed", body, opts));
164
+ }
165
+
166
+ // ── override.logged ─────────────────────────────────────────────────────
167
+
168
+ function overrideLogged(payload = {}, opts = {}) {
169
+ const what = requireString(payload, "what", "overrideLogged");
170
+ const over = requireString(payload, "over", "overrideLogged");
171
+ const reason = requireString(payload, "reason", "overrideLogged");
172
+ const level = payload && payload.level;
173
+ if (typeof level !== "string" && typeof level !== "number") {
174
+ throw new Error("events.overrideLogged: payload.level must be a string or number");
175
+ }
176
+ const body = { what, over, reason, level };
177
+ return makeEvent("override.logged", body, timeSeriesDedupe("override.logged", body, opts));
178
+ }
179
+
180
+ // ── plan.ticked / plan.observed (SCHEMA-SPEC v0.2 amendment 5) ─────────────
181
+ //
182
+ // plan.observed is the denominator inventory: written at Start (and on plan
183
+ // edits) so `done/total` can be derived honestly even for tasks that were
184
+ // NEVER ticked (objection 5 — a toggle-only log can't see untouched tasks).
185
+ // plan.ticked gains a stable `taskId`; untick emits a new event with
186
+ // `done:false` — a state transition, not a correction (sol's answer #2).
187
+
188
+ function planTicked(payload = {}, opts = {}) {
189
+ const taskId = requireString(payload, "taskId", "planTicked");
190
+ const task = requireString(payload, "task", "planTicked");
191
+ const noteDate = requireString(payload, "note_date", "planTicked");
192
+ const done = requireBool(payload, "done", "planTicked");
193
+ const body = { taskId, task, note_date: noteDate, done };
194
+ return makeEvent("plan.ticked", body, timeSeriesDedupe("plan.ticked", body, opts));
195
+ }
196
+
197
+ function planObserved(payload = {}, opts = {}) {
198
+ const noteDate = requireString(payload, "noteDate", "planObserved");
199
+ const taskIds = payload && payload.taskIds;
200
+ const texts = payload && payload.texts;
201
+ if (!Array.isArray(taskIds) || !taskIds.every((t) => typeof t === "string" && t.length > 0)) {
202
+ throw new Error("events.planObserved: payload.taskIds must be an array of non-empty strings");
203
+ }
204
+ if (!Array.isArray(texts) || texts.length !== taskIds.length || !texts.every((t) => typeof t === "string")) {
205
+ throw new Error("events.planObserved: payload.texts must be an array of strings the same length as taskIds");
206
+ }
207
+ // plan.observed is ALSO a time-series fact (sol re-score #3 refutation:
208
+ // re-observing inventory A after an edit to B deduped the second A against
209
+ // the first, leaving B as the latest plan forever). "Latest-wins" only
210
+ // tolerates payload-dedupe when the duplicate IS the latest; an A-B-A
211
+ // sequence proves it is not.
212
+ const body = { noteDate, taskIds: taskIds.slice(), texts: texts.slice() };
213
+ return makeEvent("plan.observed", body, timeSeriesDedupe("plan.observed", body, opts));
214
+ }
215
+
216
+ // ── ship.published (SCHEMA-SPEC v0.2 amendment 7) ──────────────────────────
217
+ //
218
+ // Carries `receiptId` so a published receipt and its ship.published event
219
+ // are single-sourced — projections.js dedupes ships by receiptId, never
220
+ // double-counting a receipt that both set published:true AND has a
221
+ // ship.published (objection 7).
222
+
223
+ function shipPublished(payload = {}, opts = {}) {
224
+ const what = requireString(payload, "what", "shipPublished");
225
+ const channel = requireString(payload, "channel", "shipPublished");
226
+ const receiptId = requireString(payload, "receiptId", "shipPublished");
227
+ const url = optionalString(payload, "url", "shipPublished");
228
+ const body = { what, channel, receiptId };
229
+ if (url !== undefined) body.url = url;
230
+ return makeEvent("ship.published", body, opts);
231
+ }
232
+
233
+ // ── event.corrected (SCHEMA-SPEC v0.2 amendment 8) ─────────────────────────
234
+ //
235
+ // One universal correction event. Reducers REPLACE the referenced event's
236
+ // payload wholesale (never merge/overlay); correction-of-correction follows
237
+ // the latest `refId` chain tip — implemented in projections.js
238
+ // (resolveEffectiveEvents), tested for determinism there.
239
+
240
+ function eventCorrected(payload = {}, opts = {}) {
241
+ const refId = requireString(payload, "refId", "eventCorrected");
242
+ const replacement = payload && payload.replacement;
243
+ if (replacement === null || typeof replacement !== "object" || Array.isArray(replacement)) {
244
+ throw new Error("events.eventCorrected: payload.replacement must be an object (the replacement payload)");
245
+ }
246
+ return makeEvent("event.corrected", { refId, replacement }, opts);
247
+ }
248
+
249
+ const FACT_TYPES = ["session.started", "session.closed", "receipt.filed", "override.logged", "plan.ticked", "plan.observed", "ship.published"];
250
+ const CORRECTION_TYPE = "event.corrected";
251
+
252
+ module.exports = {
253
+ FACT_TYPES,
254
+ CORRECTION_TYPE,
255
+ sessionStarted,
256
+ sessionClosed,
257
+ findOrphanSessions,
258
+ reapSession,
259
+ receiptFiled,
260
+ overrideLogged,
261
+ planTicked,
262
+ planObserved,
263
+ shipPublished,
264
+ eventCorrected,
265
+ };
@@ -0,0 +1,376 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * projections.js — CORE-LOOP PHASE 1: the four v0.2 projections (metrics,
5
+ * streak, bet, drift) as pure reducers over the event log, plus correction
6
+ * resolution (SCHEMA-SPEC.md v0.2 amendment 8 / sol-rescore-2.md objection
7
+ * 8). Same scope fence as event-log.js / events.js: not wired to any route
8
+ * or dashboard render in this phase.
9
+ *
10
+ * Determinism (SCHEMA-SPEC.md v0.2 amendment 3 / objection 2, answered): the
11
+ * canonical HASHED body carries `throughEventId` + `throughEventTs` (a pure
12
+ * function of the events replayed); `rebuiltAt` is wall-clock operational
13
+ * metadata and lives OUTSIDE the hashed body, as a sibling field on the
14
+ * returned envelope. Three calls to rebuildProjections() over an unchanged
15
+ * log must produce the same hash regardless of when each call happened.
16
+ */
17
+
18
+ const fs = require("fs");
19
+ const crypto = require("crypto");
20
+ const { readEvents, parseLog, stableStringify, sha256Hex } = require("./event-log");
21
+
22
+ const PROJECTION_VERSION = 1;
23
+ const DEFAULT_DRIFT_LAST_N = 5;
24
+
25
+ // ── correction resolution (objection 8) ─────────────────────────────────
26
+ //
27
+ // `event.corrected { refId, replacement }` REPLACES the referenced event's
28
+ // payload wholesale. A correction can itself be corrected — refId then
29
+ // points at the earlier correction's own id, not the original fact — so
30
+ // resolution walks the chain to its tip. Multiple corrections landing on the
31
+ // same target (direct re-corrections, not a chain) resolve to whichever one
32
+ // is LATEST in log order.
33
+
34
+ function indexCorrections(events) {
35
+ // targetId -> [event.corrected events in log order] that named it directly
36
+ const targets = new Map();
37
+ for (const e of events) {
38
+ if (e.type !== "event.corrected") continue;
39
+ const refId = e.payload && e.payload.refId;
40
+ if (!refId) continue;
41
+ if (!targets.has(refId)) targets.set(refId, []);
42
+ targets.get(refId).push(e);
43
+ }
44
+ return { targets };
45
+ }
46
+
47
+ function effectivePayload(eventId, originalPayload, index) {
48
+ let current = eventId;
49
+ let payload = originalPayload;
50
+ const visited = new Set([current]);
51
+ while (index.targets.has(current)) {
52
+ const corrections = index.targets.get(current);
53
+ const winner = corrections[corrections.length - 1]; // latest in log order wins at this hop
54
+ payload = winner.payload.replacement;
55
+ current = winner.id;
56
+ if (visited.has(current)) break; // cycle guard; should never happen with real UUIDs
57
+ visited.add(current);
58
+ }
59
+ return payload;
60
+ }
61
+
62
+ // Returns a new array: `event.corrected` entries removed (they are
63
+ // operators, not facts to fold on their own), every corrected fact's payload
64
+ // swapped for its resolved effective payload, everything else untouched.
65
+ // Pure function of `events` (log order in, same order out minus corrections).
66
+ function resolveEffectiveEvents(events) {
67
+ const index = indexCorrections(events);
68
+ const out = [];
69
+ for (const e of events) {
70
+ if (e.type === "event.corrected") continue;
71
+ const payload = effectivePayload(e.id, e.payload, index);
72
+ out.push(payload === e.payload ? e : Object.assign({}, e, { payload }));
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // ── date helpers (pure functions of ts; UTC, never wall-clock "now") ──────
78
+
79
+ function dateKeyFromTs(ts) {
80
+ return new Date(ts).toISOString().slice(0, 10); // YYYY-MM-DD, UTC
81
+ }
82
+
83
+ // A dateKey is valid only if it ROUND-TRIPS: right shape AND a calendar date
84
+ // that exists (Date.UTC normalizes "2031-13-40" to 2032-02-09, so components
85
+ // must be compared back). Terra wiring finding 9.
86
+ function validDateKey(dk) {
87
+ if (typeof dk !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(dk)) return false;
88
+ const [y, m, d] = dk.split("-").map(Number);
89
+ const dt = new Date(Date.UTC(y, m - 1, d));
90
+ return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
91
+ }
92
+
93
+ function daysBetween(aDateKey, bDateKey) {
94
+ const [ay, am, ad] = aDateKey.split("-").map(Number);
95
+ const [by, bm, bd] = bDateKey.split("-").map(Number);
96
+ const a = Date.UTC(ay, am - 1, ad);
97
+ const b = Date.UTC(by, bm - 1, bd);
98
+ return Math.round((b - a) / 86400000);
99
+ }
100
+
101
+ // ── metrics projection (objections 5 and 7) ────────────────────────────
102
+
103
+ function computeMetrics(events) {
104
+ const byDate = {}; // dateKey(ts) -> { receipts, ships, overrides }
105
+ const shipReceiptSeenByDate = {}; // dateKey -> Set(receiptId) for the ship dedupe
106
+ const observedByNoteDate = {}; // noteDate -> { taskIds, atTs } — latest plan.observed wins
107
+ const tickedState = {}; // `${note_date}::${taskId}` -> last-write-wins done boolean
108
+
109
+ function ensureDate(d) {
110
+ if (!byDate[d]) byDate[d] = { receipts: 0, ships: 0, overrides: 0 };
111
+ return byDate[d];
112
+ }
113
+
114
+ for (const e of events) {
115
+ if (e.type === "receipt.filed") {
116
+ ensureDate(dateKeyFromTs(e.ts)).receipts += 1;
117
+ } else if (e.type === "ship.published") {
118
+ const d = dateKeyFromTs(e.ts);
119
+ const receiptId = e.payload && e.payload.receiptId;
120
+ if (!shipReceiptSeenByDate[d]) shipReceiptSeenByDate[d] = new Set();
121
+ // A published receipt and its ship.published event are single-sourced
122
+ // by receiptId (objection 7): the same receiptId shipped twice on the
123
+ // same date counts once, never inflating the ships tally.
124
+ if (!shipReceiptSeenByDate[d].has(receiptId)) {
125
+ shipReceiptSeenByDate[d].add(receiptId);
126
+ ensureDate(d).ships += 1;
127
+ }
128
+ } else if (e.type === "override.logged") {
129
+ ensureDate(dateKeyFromTs(e.ts)).overrides += 1;
130
+ } else if (e.type === "plan.observed") {
131
+ const noteDate = e.payload.noteDate;
132
+ const prev = observedByNoteDate[noteDate];
133
+ // Latest plan.observed per noteDate wins (a plan edit re-inventories
134
+ // the day); ties broken by log position (later in `events` wins).
135
+ if (!prev || e.ts >= prev.atTs) {
136
+ observedByNoteDate[noteDate] = { taskIds: e.payload.taskIds.slice(), atTs: e.ts };
137
+ }
138
+ } else if (e.type === "plan.ticked") {
139
+ const key = `${e.payload.note_date}::${e.payload.taskId}`;
140
+ tickedState[key] = e.payload.done; // last write in log order wins — a toggle, not a merge
141
+ }
142
+ }
143
+
144
+ // done/total derives from plan.observed (denominator) + plan.ticked
145
+ // (numerator), including tasks that were NEVER ticked (objection 5) —
146
+ // those default to not-done rather than being invisible.
147
+ const ticksByNoteDate = {};
148
+ for (const noteDate of Object.keys(observedByNoteDate)) {
149
+ const { taskIds } = observedByNoteDate[noteDate];
150
+ let done = 0;
151
+ for (const taskId of taskIds) {
152
+ if (tickedState[`${noteDate}::${taskId}`] === true) done += 1;
153
+ }
154
+ ticksByNoteDate[noteDate] = { done, total: taskIds.length };
155
+ }
156
+
157
+ return { byDate, ticksByNoteDate };
158
+ }
159
+
160
+ // ── streak projection ────────────────────────────────────────────────────
161
+ //
162
+ // "Logging" activity = session.closed (the /log workflow's fact). A
163
+ // projection must be a PURE function of the events it folds — it cannot
164
+ // reference wall-clock "today" without breaking rebuild determinism across
165
+ // calendar days — so `current` is the run ending at the MOST RECENT active
166
+ // date found IN THE LOG, not real-world today. `asOfDate` makes that
167
+ // reference point explicit to any caller who needs to reconcile it against
168
+ // wall-clock "today" themselves.
169
+
170
+ function computeStreak(events) {
171
+ // ONE close per session: a grace-window false reap followed by the real
172
+ // Close produced two session.closed events for one session and inflated
173
+ // the streak by a day (terra re-verdict #5). The LAST close per sessionId
174
+ // in log order wins - the real close supersedes an earlier reap; an event
175
+ // without a sessionId (foreign/legacy) still counts individually.
176
+ const lastCloseBySession = new Map();
177
+ const anonymous = [];
178
+ for (const e of events) {
179
+ if (e.type !== "session.closed") continue;
180
+ const sid = e.payload && e.payload.sessionId;
181
+ if (sid) lastCloseBySession.set(sid, e);
182
+ else anonymous.push(e);
183
+ }
184
+ const dateSet = new Set();
185
+ for (const e of [...lastCloseBySession.values(), ...anonymous]) {
186
+ // Vault-zone policy (SCHEMA-SPEC v0.2.7): a live-wired close carries the
187
+ // product-local day in payload.dateKey (writer-stamped, zone-stable
188
+ // across rebuild machines). Migrated/legacy closes fall back to the UTC
189
+ // derivation of ts, which is zone-safe for their noon-anchored stamps.
190
+ // Round-trip validated, not just shape (terra wiring finding 9: a
191
+ // hand-written "2031-13-40" passed the regex and Date.UTC silently
192
+ // normalized it into a phantom streak day). An impossible date falls
193
+ // back to ts like any other malformed payload.
194
+ const dk = e.payload && validDateKey(e.payload.dateKey) ? e.payload.dateKey : dateKeyFromTs(e.ts);
195
+ dateSet.add(dk);
196
+ }
197
+ const sorted = [...dateSet].sort(); // ISO YYYY-MM-DD sorts lexicographically == chronologically
198
+ if (sorted.length === 0) {
199
+ return { current: 0, best: 0, coverage: 0, activeDays: 0, spanDays: 0, asOfDate: null };
200
+ }
201
+
202
+ let best = 1;
203
+ let run = 1;
204
+ for (let i = 1; i < sorted.length; i++) {
205
+ run = daysBetween(sorted[i - 1], sorted[i]) === 1 ? run + 1 : 1;
206
+ if (run > best) best = run;
207
+ }
208
+
209
+ let current = 1;
210
+ for (let i = sorted.length - 1; i > 0; i--) {
211
+ if (daysBetween(sorted[i - 1], sorted[i]) === 1) current += 1;
212
+ else break;
213
+ }
214
+
215
+ const spanDays = daysBetween(sorted[0], sorted[sorted.length - 1]) + 1;
216
+ const coverage = spanDays > 0 ? sorted.length / spanDays : 0;
217
+
218
+ return { current, best, coverage, activeDays: sorted.length, spanDays, asOfDate: sorted[sorted.length - 1] };
219
+ }
220
+
221
+ // ── bet projection ──────────────────────────────────────────────────────
222
+ //
223
+ // Honesty contract (SCHEMA-SPEC.md section 2): activated-install / referral
224
+ // counts stay MANUAL/owner-fed — nothing in the six fact types asserts them,
225
+ // so this projection aggregates only what events actually assert (shipped
226
+ // artifacts, filed receipts) and says so explicitly rather than fabricating
227
+ // a bet-progress number the log cannot support.
228
+
229
+ function computeBet(events) {
230
+ const shipReceiptIds = new Set();
231
+ let receiptsCount = 0;
232
+ for (const e of events) {
233
+ if (e.type === "ship.published" && e.payload && e.payload.receiptId != null) {
234
+ shipReceiptIds.add(e.payload.receiptId); // deduped, matches computeMetrics' ships tally
235
+ } else if (e.type === "receipt.filed") {
236
+ receiptsCount += 1;
237
+ }
238
+ }
239
+ return {
240
+ shipsCount: shipReceiptIds.size,
241
+ receiptsCount,
242
+ note: "activated-install / referral counts are owner-fed manually, not derived from events (SCHEMA-SPEC.md section 2 honesty contract)",
243
+ };
244
+ }
245
+
246
+ // ── drift projection ────────────────────────────────────────────────────
247
+
248
+ function computeDrift(events, opts = {}) {
249
+ const lastN = opts.lastN != null ? opts.lastN : DEFAULT_DRIFT_LAST_N;
250
+ const overrides = events.filter((e) => e.type === "override.logged");
251
+ const byLevel = {};
252
+ for (const e of overrides) {
253
+ const level = String(e.payload.level);
254
+ byLevel[level] = (byLevel[level] || 0) + 1;
255
+ }
256
+ const lastNEntries = overrides.slice(-lastN).map((e) => ({
257
+ id: e.id,
258
+ ts: e.ts,
259
+ what: e.payload.what,
260
+ over: e.payload.over,
261
+ reason: e.payload.reason,
262
+ level: e.payload.level,
263
+ }));
264
+ return { total: overrides.length, byLevel, lastN: lastNEntries };
265
+ }
266
+
267
+ // ── rebuild ─────────────────────────────────────────────────────────────
268
+
269
+ // Reads the log fresh from disk, resolves corrections, folds all four
270
+ // projections, and returns { rebuiltAt, hash, body }. `rebuiltAt` is outside
271
+ // the hashed body (operational metadata, objection 2); `body.throughEventId`
272
+ // / `body.throughEventTs` are the deterministic watermark INSIDE the hashed
273
+ // body, taken from the last RAW event in the log (including any trailing
274
+ // event.corrected — the watermark tracks log position, not fact count).
275
+ // Pure reduction from an already-parsed event set to the projection body +
276
+ // hash. Factored out of rebuildProjections so readVerifiedState can reduce
277
+ // from a CAPTURED buffer (not a fresh read), which is what closes the
278
+ // A->B->A window sol named: the reduction must be of the exact bytes we
279
+ // verified, never a separate read that could have caught an interleaved B.
280
+ function reduceToBody(events, opts = {}) {
281
+ const effective = resolveEffectiveEvents(events);
282
+ const last = events.length ? events[events.length - 1] : null;
283
+ const body = {
284
+ v: PROJECTION_VERSION,
285
+ throughEventId: last ? last.id : null,
286
+ throughEventTs: last ? last.ts : null,
287
+ eventCount: effective.length,
288
+ metrics: computeMetrics(effective),
289
+ streak: computeStreak(effective),
290
+ bet: computeBet(effective),
291
+ drift: computeDrift(effective, opts.driftLastN != null ? { lastN: opts.driftLastN } : {}),
292
+ };
293
+ return { body, hash: sha256Hex(stableStringify(body)) };
294
+ }
295
+
296
+ function rebuildProjections(logPath, opts = {}) {
297
+ const { events, quarantined } = readEvents(logPath, opts);
298
+ const { body, hash } = reduceToBody(events, opts);
299
+ const rebuiltAt = typeof opts.now === "function" ? opts.now() : Date.now();
300
+
301
+ return { rebuiltAt, hash, body, quarantinedCount: quarantined.length };
302
+ }
303
+
304
+ // readVerifiedState — the check-then-use read path terra round 3 required
305
+ // (SCHEMA-SPEC v0.2.5 clause 29). readEvents/rebuildProjections validate
306
+ // envelope SHAPE and reduce, but a consumer that will ACT on the state needs
307
+ // stronger assurance that what it reduced is what is on disk at the moment
308
+ // of use. This does three things the plain rebuild does not:
309
+ //
310
+ // 1. Reads the raw log as a Buffer and reduces THAT captured buffer (event
311
+ // + reducer integrity). Reducing the captured bytes, not a fresh read,
312
+ // is what closes the A->B->A window sol #5 named: a separate middle read
313
+ // could reduce an interleaved B while the two outer reads both saw A.
314
+ // 2. Reads the raw buffer a SECOND time and compares the two as BYTES
315
+ // (Buffer.equals, not a utf8-decoded string). A utf8 compare let an
316
+ // invalid-byte change decode identically (the same lossy-decode class
317
+ // terra caught in the apply path); byte comparison catches it. Any write
318
+ // landing between the reads makes verified:false with reason
319
+ // "log-mutated-during-read", and the caller retries rather than acting on
320
+ // a torn/stale snapshot. Honest bound: it cannot stop a writer who
321
+ // mutates strictly AFTER the second read (terra confirmed that residual
322
+ // is not cheaply solvable in userspace).
323
+ // 3. When the caller supplies expectHash (e.g. the hash stored in a
324
+ // persisted projection file), cross-checks the fresh reduction against
325
+ // it so a stale or tampered stored projection is caught before use.
326
+ //
327
+ // Returns { verified, reason, hash, body, rawSha, quarantinedCount,
328
+ // eventCount }. verified:false is a signal to retry or refuse, never to
329
+ // proceed on the returned body.
330
+ function readVerifiedState(logPath, opts = {}) {
331
+ const fsImpl = opts.fs || fs;
332
+ const bufOf = (p) => {
333
+ try {
334
+ const b = fsImpl.readFileSync(p); // no encoding => Buffer
335
+ return Buffer.isBuffer(b) ? b : Buffer.from(String(b), "utf8");
336
+ } catch (e) {
337
+ if (e.code === "ENOENT") return Buffer.alloc(0);
338
+ throw e;
339
+ }
340
+ };
341
+
342
+ const buf1 = bufOf(logPath);
343
+ // Reduce the CAPTURED buffer, never a fresh read (A->B->A close).
344
+ const { events, quarantined } = parseLog(buf1.toString("utf8"));
345
+ const { body, hash } = reduceToBody(events, opts);
346
+ const buf2 = bufOf(logPath);
347
+
348
+ const rawSha = crypto.createHash("sha256").update(buf1).digest("hex");
349
+ const result = (verified, reason) => ({
350
+ verified,
351
+ reason,
352
+ hash,
353
+ body,
354
+ rawSha,
355
+ quarantinedCount: quarantined.length,
356
+ eventCount: body.eventCount,
357
+ });
358
+
359
+ // Byte-exact comparison: catches invalid-byte edits a utf8 compare masks.
360
+ if (!buf1.equals(buf2)) return result(false, "log-mutated-during-read");
361
+ if (opts.expectHash != null && opts.expectHash !== hash) return result(false, "projection-hash-mismatch");
362
+ return result(true, null);
363
+ }
364
+
365
+ module.exports = {
366
+ PROJECTION_VERSION,
367
+ resolveEffectiveEvents,
368
+ dateKeyFromTs,
369
+ daysBetween,
370
+ computeMetrics,
371
+ computeStreak,
372
+ computeBet,
373
+ computeDrift,
374
+ rebuildProjections,
375
+ readVerifiedState,
376
+ };