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.
- package/README.md +34 -24
- package/install.js +2197 -200
- package/package.json +1 -1
- 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-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/index.html +2 -2
- package/src/dashboard/ui/dist/sw.js +1 -1
- 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
|
@@ -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
|
+
};
|