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.
Files changed (43) hide show
  1. package/README.md +34 -24
  2. package/install.js +2229 -204
  3. package/package.json +3 -2
  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-BoxTW_kj.js +161 -0
  36. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  37. package/src/dashboard/ui/dist/assets/index-wnamvqxp.js +34 -0
  38. package/src/dashboard/ui/dist/index.html +2 -2
  39. package/src/dashboard/ui/dist/sw.js +1 -1
  40. package/src/system/counts.json +7 -0
  41. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
  42. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
  43. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +0 -34
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mover-os",
3
- "version": "4.7.8",
3
+ "version": "4.7.10",
4
4
  "description": "Your AI co-founder. Remembers your goals, pushes back when you drift. Works with 15 AI agents.",
5
5
  "bin": {
6
6
  "moveros": "install.js"
@@ -16,7 +16,8 @@
16
16
  "src/dashboard/templates/**",
17
17
  "src/dashboard/static/**",
18
18
  "src/dashboard/lib/**",
19
- "src/dashboard/ui/dist/**"
19
+ "src/dashboard/ui/dist/**",
20
+ "src/system/counts.json"
20
21
  ],
21
22
  "scripts": {
22
23
  "test": "node test/runner.mjs",
@@ -449,10 +449,18 @@ async function build({ vault, version }) {
449
449
  // What to call the operator: config userName (user-editable in Configure)
450
450
  // wins, then the Identity_Prime H1 name written by onboarding. Never a
451
451
  // folder or account name — an honest null greets namelessly instead.
452
+ // Plausibility gate: pre-4.7.9 onboarding could store a whole identity
453
+ // sentence as the name ("I'm a 24-year-old freelance photographer...").
454
+ // A greeting name reads as a callsign; anything sentence-shaped greets
455
+ // namelessly instead (wrong name is worse than no name).
456
+ const plausibleName = (s) =>
457
+ typeof s === "string" && s.trim() && s.trim().length <= 32 &&
458
+ !/[.!?\n:;]/.test(s.trim()) && s.trim().split(/\s+/).length <= 3
459
+ ? s.trim() : null;
452
460
  const cfgForName = tryParse("config", () => require("./lib/config-parser").read(), {});
453
461
  const profileName =
454
- (cfgForName && typeof cfgForName.userName === "string" && cfgForName.userName.trim()) ||
455
- (identity && identity.name) || null;
462
+ plausibleName(cfgForName && cfgForName.userName) ||
463
+ plausibleName(identity && identity.name) || null;
456
464
 
457
465
  const stateJson = {
458
466
  builtAt: builtAt.toISOString(),
@@ -662,7 +670,10 @@ async function build({ vault, version }) {
662
670
  };
663
671
  })(),
664
672
  projects: (() => {
665
- const out = tryParse("projects", () => projectScanner.scanProjects(vault, { limit: 12 }), { available: false, projects: [] });
673
+ // limit 1000 = effectively all. The old 12 silently hid 20 of the
674
+ // owner's 32 projects from the Library shelf; he ruled: show all,
675
+ // split active/dormant (2026-07-12). Consumers that want fewer slice.
676
+ const out = tryParse("projects", () => projectScanner.scanProjects(vault, { limit: 1000 }), { available: false, projects: [] });
666
677
  if (!out || !out.available) return { available: false, projects: [], totalCount: 0 };
667
678
  return {
668
679
  available: true,
@@ -767,6 +778,17 @@ async function build({ vault, version }) {
767
778
  } catch (_) { return { available: false, outcomes: [] }; }
768
779
  })(),
769
780
  overrideSummary,
781
+ // Core-loop TRIAL MODE (SCHEMA-SPEC v0.2 section 2, wired 2026-07-12):
782
+ // compares the legacy override count against override.logged events and
783
+ // logs movement to State/trial-deltas.jsonl. The UI keeps rendering the
784
+ // LEGACY values everywhere; this field only makes the gap observable.
785
+ // Best-effort: unmigrated vaults report available:false and nothing runs.
786
+ stateCoreTrial: (() => {
787
+ try {
788
+ const { runTrial } = require("./lib/state-core/trial");
789
+ return runTrial({ vault, manifestOverrideCount: overrideEntries.length });
790
+ } catch (_) { return { available: false, reason: "trial module unavailable" }; }
791
+ })(),
770
792
  refusals: (() => {
771
793
  // False-zero discipline (T261): full RefusalsData shape on parse failure
772
794
  // so count/count7d/recent are never undefined under available:false.
@@ -1028,6 +1050,22 @@ function activeContextForState(ac, manifest) {
1028
1050
  activeSessions: ac.activeSessions || [],
1029
1051
  backlog: ac.backlog || [],
1030
1052
  commitments: ac.commitments || [],
1053
+ // Workflow State bus timestamps (log_last_run etc). Evidence needs these:
1054
+ // activation-log only sees DASHBOARD-launched runs, so it claimed "0
1055
+ // workflow runs this week" while /log had run three times that day in
1056
+ // the terminal (owner-caught, 2026-07-10). These timestamps are written
1057
+ // by the workflows themselves, so they cover both paths. Whitelisted to
1058
+ // *_last_run keys: the section parser also slurps stray session-log
1059
+ // fields (a "latest_delta_..." key carried a whole session summary blob).
1060
+ workflowState: (() => {
1061
+ const ws = ac.workflowState;
1062
+ if (!ws || typeof ws !== "object") return null;
1063
+ const out = {};
1064
+ for (const [k, v] of Object.entries(ws)) {
1065
+ if (/_last_run$/.test(k) && typeof v === "string" && v.length <= 32) out[k] = v;
1066
+ }
1067
+ return Object.keys(out).length ? out : null;
1068
+ })(),
1031
1069
  // v6 SURFACE 5 — buried ideas, for the Say-it graveyard echo. Exposed
1032
1070
  // raw; the client extracts name/reason/date/cost (strict gate: no stanza
1033
1071
  // unless all parse).
@@ -84,13 +84,17 @@ function parseActiveContext(vault) {
84
84
  };
85
85
 
86
86
  result.workflowState = (() => {
87
+ // A "## Workflow State" section whose body didn't parse into fields is
88
+ // {} — truthy, so the old `if (ws && ws.fields)` made the top-of-file
89
+ // fallback dead code and the dashboard claimed "no /log run today" while
90
+ // log_last_run sat in the preface (R5-A). Merge: section fields win,
91
+ // preface fields fill whatever the section is missing.
87
92
  const ws = result.findSection("workflow state");
88
- if (ws && ws.fields) return ws.fields;
89
- // Fallback: top-of-file fields
90
93
  const out = {};
91
94
  for (const k of ["log_last_run", "analyse_day_last_run", "weekly_review_last_run", "pivot_strategy_last_run", "monthly_review_last_run", "review_week_day", "friction_escalation"]) {
92
95
  if (result.fields[k]) out[k] = result.fields[k];
93
96
  }
97
+ if (ws && ws.fields) Object.assign(out, ws.fields);
94
98
  return out;
95
99
  })();
96
100
 
@@ -123,8 +127,15 @@ function parseBulletList(section, kind) {
123
127
  const lines = section.body.split("\n");
124
128
  const items = [];
125
129
  for (const line of lines) {
126
- const m = line.match(/^[-*]\s*\[(.)\]\s*(.+)$/) || line.match(/^[-*]\s+(.+)$/);
130
+ // Leading whitespace allowed: nested bullets (a normal Obsidian outliner
131
+ // habit) were silently dropped from BACKLOG/WAITING FOR/COMMITMENTS (R5-A).
132
+ // They still parse as items, but carry nested:true so surfaces that list
133
+ // top-level obligations (Home's Loose Ends) can skip them — a sub-bullet
134
+ // is an annotation on its parent (e.g. an "[UPDATE]" note), not its own
135
+ // waiting-on-someone row (owner screenshot, 2026-07-12).
136
+ const m = line.match(/^\s*[-*]\s*\[(.)\]\s*(.+)$/) || line.match(/^\s*[-*]\s+(.+)$/);
127
137
  if (m) {
138
+ const nested = /^\s/.test(line);
128
139
  if (m.length === 3) {
129
140
  const checked = m[1] === "x" || m[1] === "X";
130
141
  const text = m[2].trim();
@@ -140,12 +151,13 @@ function parseBulletList(section, kind) {
140
151
  items.push({
141
152
  checked,
142
153
  done: checked,
154
+ nested,
143
155
  text,
144
156
  raw: line.trim(),
145
157
  ...meta,
146
158
  });
147
159
  } else {
148
- items.push({ checked: null, done: null, text: m[1].trim(), raw: line.trim() });
160
+ items.push({ checked: null, done: null, nested, text: m[1].trim(), raw: line.trim() });
149
161
  }
150
162
  }
151
163
  }
@@ -31,53 +31,16 @@
31
31
  */
32
32
 
33
33
  const { spawn } = require("child_process");
34
- const os = require("os");
35
- const path = require("path");
36
- const fs = require("fs");
34
+ const crypto = require("crypto");
35
+ const { StringDecoder } = require("string_decoder");
37
36
 
38
37
  // ─── crash-orphan markers (MF2) ──────────────────────────────────────────────
39
38
  // Each live session's pid (== its process-group id, since detached) is recorded
40
39
  // with the owning dashboard's pid. On startup a fresh manager reaps any group
41
40
  // whose OWNER process is gone — i.e. children escaped by a crashed dashboard.
42
- // One file per session pid (Codex r2-MF2): eliminates the shared-file
43
- // read-modify-write race across concurrent dashboards, and a corrupt write only
44
- // loses its own marker. Writes are atomic (tmp + rename).
45
- const MARKERS_DIR = path.join(os.homedir(), ".mover", "dashboard", "agent-pids");
46
-
47
- function markerPath(pid) { return path.join(MARKERS_DIR, String(pid) + ".json"); }
48
- function writeMarker(pid, data) {
49
- try {
50
- fs.mkdirSync(MARKERS_DIR, { recursive: true });
51
- const f = markerPath(pid), tmp = f + ".tmp";
52
- fs.writeFileSync(tmp, JSON.stringify(data));
53
- fs.renameSync(tmp, f); // atomic: a reader never sees a half-written marker
54
- } catch (_) {}
55
- }
56
- function removeMarker(pid) { try { fs.unlinkSync(markerPath(pid)); } catch (_) {} }
57
- function readMarkers() {
58
- let files; try { files = fs.readdirSync(MARKERS_DIR); } catch (_) { return []; }
59
- const out = [];
60
- for (const fn of files) {
61
- if (!fn.endsWith(".json")) continue;
62
- try { out.push(JSON.parse(fs.readFileSync(path.join(MARKERS_DIR, fn), "utf8"))); } catch (_) {}
63
- }
64
- return out;
65
- }
66
- function isAlive(pid) {
67
- if (!pid) return false;
68
- try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === "EPERM"; }
69
- }
70
- // True if ANY member of the process group still exists (not just the leader) —
71
- // process.kill(-pid, 0) throws ESRCH only when the whole group is gone.
72
- function groupAlive(pid) {
73
- if (!pid) return false;
74
- try { process.kill(-pid, 0); return true; } catch (e) { return e && e.code === "EPERM"; }
75
- }
76
- function killGroup(pid, sig) {
77
- if (!pid) return false;
78
- try { process.kill(-pid, sig); return true; }
79
- catch (_) { try { process.kill(pid, sig); return true; } catch (__) { return false; } }
80
- }
41
+ // Shared with RunRegistry (R5-A) via lib/pid-markers.js so Run-tab children
42
+ // get the identical guarantee; the marker dir is common on purpose.
43
+ const { writeMarker, removeMarker, readMarkers, isAlive, groupAlive, killGroup } = require("./pid-markers");
81
44
 
82
45
  // ─── setup framing + tool lockout ────────────────────────────────────────────
83
46
  // The interview is the single source the Engine is written from, so depth matters.
@@ -265,8 +228,24 @@ class AgentSessionManager {
265
228
  const adapter = ADAPTERS[agent];
266
229
  if (!adapter) throw new Error(`Unknown agent: ${agent}`);
267
230
  if (adapter.unsupported) throw new Error(adapter.unsupported);
231
+ // Concurrency cap (R5-A): each session is a live CLI child; a retry loop
232
+ // must not stack them. 6 covers Chat + onboarding + walkthrough headroom.
233
+ let liveCount = 0;
234
+ for (const s of this.sessions.values()) if (!s.closed) liveCount++;
235
+ if (liveCount >= 6) {
236
+ const err = new Error("6 agent sessions are already open. Close one first.");
237
+ err.code = "SESSION_LIMIT";
238
+ throw err;
239
+ }
268
240
 
269
- const id = `sess_${process.pid}_${++this._seq}`;
241
+ // terra session F1 (2026-07-11): the id was `sess_<pid>_<seq>` — deterministic,
242
+ // so a second same-origin tab could DERIVE another tab's id (pid shared, seq
243
+ // sequential) and read its SSE transcript / send / approve into it (the token
244
+ // is process-global, not per-session). Use an unguessable random id: the id
245
+ // itself is the per-session capability. `++this._seq` kept for internal
246
+ // ordering/telemetry only, never for addressing.
247
+ ++this._seq;
248
+ const id = `sess_${crypto.randomBytes(16).toString("hex")}`;
270
249
  // MOVER_STUDIO_RUN=1 marks every dashboard-spawned agent (onboarding
271
250
  // interview, walkthrough, and any future workflow run routed here) so the
272
251
  // global lifecycle hooks early-exit: an interview that ends must not fire
@@ -291,6 +270,9 @@ class AgentSessionManager {
291
270
  transcript: [],
292
271
  pending: new Map(),
293
272
  stdoutBuf: "",
273
+ // terra session F3: a stateful UTF-8 decoder so a multibyte char split
274
+ // across stdout chunks is not corrupted (each chunk was decoded alone).
275
+ decoder: new StringDecoder("utf8"),
294
276
  lastActivity: Date.now(),
295
277
  closed: false,
296
278
  lastStderr: "",
@@ -309,9 +291,25 @@ class AgentSessionManager {
309
291
  return { sessionId: id };
310
292
  }
311
293
 
294
+ // Transcript byte cap (R5-A): an active long chat (or repeated file
295
+ // ingests) grew memory without bound — the idle sweep only reclaims
296
+ // INACTIVE sessions. Cap total retained chars, dropping the oldest turns;
297
+ // the agent CLI holds its own context, so trimming here only affects
298
+ // replay-on-reattach depth, never the conversation itself.
299
+ _pushTranscript(sess, entry) {
300
+ sess.transcript.push(entry);
301
+ sess.transcriptBytes = (sess.transcriptBytes || 0) + entry.text.length;
302
+ const CAP = 4 * 1024 * 1024;
303
+ while (sess.transcriptBytes > CAP && sess.transcript.length > 1) {
304
+ const dropped = sess.transcript.shift();
305
+ sess.transcriptBytes -= dropped.text.length;
306
+ sess.transcriptTruncated = true;
307
+ }
308
+ }
309
+
312
310
  send(sessionId, text) {
313
311
  const sess = this._require(sessionId);
314
- sess.transcript.push({ role: "user", text: String(text) });
312
+ this._pushTranscript(sess, { role: "user", text: String(text) });
315
313
  sess.lastActivity = Date.now();
316
314
  try { sess.child.stdin.write(sess.adapter.encodeTurn(text)); }
317
315
  catch (e) { throw new Error(`send failed: ${e.message}`); }
@@ -366,7 +364,17 @@ class AgentSessionManager {
366
364
 
367
365
  _onStdout(sess, chunk) {
368
366
  sess.lastActivity = Date.now();
369
- sess.stdoutBuf += chunk.toString("utf8");
367
+ // F3: decode through the per-session StringDecoder (holds a partial trailing
368
+ // multibyte char until its continuation bytes arrive).
369
+ sess.stdoutBuf += sess.decoder.write(chunk);
370
+ // terra session F4: a newline-free line must not grow the buffer without
371
+ // bound (a malformed/stalled provider stream). Cap the PENDING line; over the
372
+ // cap, drop the un-parseable partial and surface an error instead of leaking
373
+ // memory. A real stream-json line is far under 8 MiB.
374
+ if (sess.stdoutBuf.length > 8 * 1024 * 1024) {
375
+ sess.stdoutBuf = "";
376
+ this._emit(sess, { kind: "error", message: "[mover-studio] agent stream exceeded the line limit; dropping malformed output." });
377
+ }
370
378
  let nl;
371
379
  while ((nl = sess.stdoutBuf.indexOf("\n")) >= 0) {
372
380
  const line = sess.stdoutBuf.slice(0, nl).trim();
@@ -377,7 +385,7 @@ class AgentSessionManager {
377
385
  const ev = sess.adapter.normalize(obj);
378
386
  if (!ev) continue;
379
387
  if (ev.kind === "_session") { sess.agentSessionId = ev.sessionId; continue; }
380
- if (ev.kind === "assistant") sess.transcript.push({ role: "assistant", text: ev.text });
388
+ if (ev.kind === "assistant") this._pushTranscript(sess, { role: "assistant", text: ev.text });
381
389
  if (ev.kind === "permission" && ev.requestId) sess.pending.set(ev.requestId, ev.request);
382
390
  this._emit(sess, ev);
383
391
  }
@@ -394,11 +402,24 @@ class AgentSessionManager {
394
402
  sess.closed = true;
395
403
  const pid = sess.child && sess.child.pid;
396
404
  try { sess.child.stdin.end(); } catch (_) {}
397
- if (pid) this._removeMarker(pid);
398
405
  this.sessions.delete(sess.id);
399
406
  killGroup(pid, "SIGTERM");
400
- const t = setTimeout(() => { if (groupAlive(pid)) killGroup(pid, "SIGKILL"); }, 2500);
401
- if (t.unref) t.unref();
407
+ // terra run-path F1: do NOT drop the orphan-recovery marker up-front. The old
408
+ // code removed it immediately, then SIGKILL'd after 2.5s — so a TERM-ignoring
409
+ // tool child that outlived that window was left with NO marker, and if the
410
+ // dashboard exited meanwhile (the session already deleted, so shutdownAll
411
+ // skips it) the child was permanently orphaned. Keep the marker until the
412
+ // whole GROUP is actually dead, escalating to SIGKILL — mirrors
413
+ // _cleanupAsync. Non-blocking (unref'd poll), so end()/idle stay synchronous.
414
+ const start = Date.now();
415
+ let killed = false;
416
+ const tick = () => {
417
+ if (!pid || !groupAlive(pid)) { if (pid) this._removeMarker(pid); return; }
418
+ if (!killed && Date.now() - start >= 2500) { killGroup(pid, "SIGKILL"); killed = true; }
419
+ if (killed && Date.now() - start >= 3200) { if (!groupAlive(pid)) this._removeMarker(pid); return; }
420
+ const t = setTimeout(tick, 150); if (t.unref) t.unref();
421
+ };
422
+ tick();
402
423
  }
403
424
 
404
425
  // Awaitable cleanup (shutdown). SIGTERM, resolve on child 'close', escalate to
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * approval-registry.js — packet #2 rider: dashboard-owned Engine-write
5
+ * approval (OWNER-DECISIONS.md, "Packet #2 VERIFICATION VERDICT").
6
+ *
7
+ * WHY: Codex/Gemini PreToolUse "ask" on a core Engine file currently converts
8
+ * to a hard deny in mover-hook-adapter.js (isPermissionDecision branch),
9
+ * because neither agent supports an interactive terminal prompt. The owner's
10
+ * rider: for a DASHBOARD-launched run, Mover Studio owns the UI, so the
11
+ * daemon can hold a real pending approval and let the user answer it from
12
+ * the browser instead of always denying.
13
+ *
14
+ * This registry is the server-held half of that flow: create a pending
15
+ * approval (tokened POST from the hook adapter), poll its status (tokened
16
+ * GET, the hook's poll loop), list pending ones (GET, dashboard UI), and
17
+ * resolve one (tokened POST, dashboard UI click). In-memory only, mirrors
18
+ * RunRegistry / AgentSessionManager: a daemon restart clears everything,
19
+ * which is correct — a pending approval only makes sense against a live run.
20
+ *
21
+ * TTL expiry resolves as DENY (fail closed), same contract as the hook
22
+ * adapter's existing unconditional deny it is meant to soften, never worsen.
23
+ *
24
+ * NOTE (2026-07-11): nothing currently calls request() in production. The
25
+ * hook-adapter wiring that would create these (mover-hook-adapter.js's
26
+ * ask->deny conversion) was evaluated and NOT changed — the Codex/Gemini
27
+ * host hook timeout (5s, ~/.codex/hooks.json PreToolUse Edit|Write|apply_patch
28
+ * / install.js generateGeminiHooks BeforeTool write_file|replace) leaves no
29
+ * safety-margin room for a human-reviewable wait; see dev/plan.md
30
+ * T-PACKET2-RIDER-1 for the full evidence trail. This module is real,
31
+ * tested, standalone infrastructure ready for that follow-up.
32
+ */
33
+
34
+ const crypto = require("crypto");
35
+
36
+ const DEFAULTS = {
37
+ ttlMs: 60 * 1000, // ask (owner rider): TTL expiry resolves as deny
38
+ maxPending: 50, // bound memory; evict oldest non-pending first
39
+ sweepMs: 30 * 1000, // periodic cleanup cadence
40
+ graceMs: 5 * 60 * 1000, // keep a decided/expired record this long so a
41
+ // late poll still reads the real outcome
42
+ };
43
+
44
+ const MAX_LEN = { agent: 32, file: 1024, tool: 64, excerpt: 4000 };
45
+
46
+ function capString(v, max, field) {
47
+ const s = String(v == null ? "" : v);
48
+ if (!s.trim()) throw new Error(`${field} is required`);
49
+ return s.length > max ? s.slice(0, max) : s;
50
+ }
51
+
52
+ class ApprovalRegistry {
53
+ constructor(opts = {}) {
54
+ this.opt = { ...DEFAULTS, ...opts };
55
+ this.pending = new Map(); // id -> record
56
+ this._sweep = setInterval(() => this._evict(), this.opt.sweepMs);
57
+ if (this._sweep.unref) this._sweep.unref();
58
+ }
59
+
60
+ /**
61
+ * Create a pending approval. spec: { agent, file, tool, excerpt }.
62
+ * Returns the record (id, expiresAt, status:"pending").
63
+ */
64
+ request(spec) {
65
+ const s = spec || {};
66
+ const agent = capString(s.agent, MAX_LEN.agent, "agent");
67
+ const file = capString(s.file, MAX_LEN.file, "file");
68
+ const tool = String(s.tool == null ? "" : s.tool).slice(0, MAX_LEN.tool);
69
+ const excerpt = String(s.excerpt == null ? "" : s.excerpt).slice(0, MAX_LEN.excerpt);
70
+
71
+ this._trimCount();
72
+
73
+ const id = crypto.randomBytes(16).toString("hex");
74
+ const createdAt = Date.now();
75
+ const rec = {
76
+ id,
77
+ agent,
78
+ file,
79
+ tool,
80
+ excerpt,
81
+ createdAt,
82
+ expiresAt: createdAt + this.opt.ttlMs,
83
+ status: "pending",
84
+ decidedAt: null,
85
+ decidedReason: null,
86
+ };
87
+ this.pending.set(id, rec);
88
+ return rec;
89
+ }
90
+
91
+ /**
92
+ * Look up a record and resolve TTL expiry lazily (a poll after the TTL
93
+ * elapsed sees "denied", even if no sweep has run yet). Returns null if
94
+ * the id was never issued or has aged out of the grace window.
95
+ */
96
+ get(id) {
97
+ const rec = this.pending.get(String(id || ""));
98
+ if (!rec) return null;
99
+ if (rec.status === "pending" && Date.now() > rec.expiresAt) {
100
+ rec.status = "denied";
101
+ rec.decidedAt = Date.now();
102
+ rec.decidedReason = "timeout";
103
+ }
104
+ return rec;
105
+ }
106
+
107
+ /** All currently PENDING (not yet decided, not yet expired) approvals. */
108
+ listPending() {
109
+ const out = [];
110
+ for (const id of this.pending.keys()) {
111
+ const rec = this.get(id); // applies TTL-expiry lazily
112
+ if (rec && rec.status === "pending") out.push({ ...rec });
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * Resolve a pending approval. Returns { ok, code, status, error }.
119
+ * - unknown/aged-out id -> { ok:false, code:404 }
120
+ * - already decided (approved/denied/timeout) -> { ok:false, code:409 }
121
+ * - success -> { ok:true, status: "approved"|"denied" }
122
+ */
123
+ respond(id, approve) {
124
+ const rec = this.get(id);
125
+ if (!rec) return { ok: false, code: 404, error: "no such approval" };
126
+ if (rec.status !== "pending") {
127
+ return { ok: false, code: 409, error: `already ${rec.status}` };
128
+ }
129
+ rec.status = approve === true ? "approved" : "denied";
130
+ rec.decidedAt = Date.now();
131
+ rec.decidedReason = "response";
132
+ return { ok: true, status: rec.status };
133
+ }
134
+
135
+ /** Cap the number of retained records: drop the oldest DECIDED ones first,
136
+ * never a live pending one, so a burst of writes can't push out a request
137
+ * a human hasn't answered yet. */
138
+ _trimCount() {
139
+ if (this.pending.size < this.opt.maxPending) return;
140
+ const decided = [...this.pending.values()]
141
+ .filter((r) => r.status !== "pending")
142
+ .sort((a, b) => a.createdAt - b.createdAt);
143
+ while (this.pending.size >= this.opt.maxPending && decided.length) {
144
+ this.pending.delete(decided.shift().id);
145
+ }
146
+ // Degenerate case: still at cap and every record is genuinely pending
147
+ // (a flood). Drop the oldest pending rather than grow unbounded; it
148
+ // will read as a denial to whichever caller is waiting on it, which is
149
+ // the fail-closed outcome anyway.
150
+ if (this.pending.size >= this.opt.maxPending) {
151
+ const oldest = [...this.pending.values()].sort((a, b) => a.createdAt - b.createdAt)[0];
152
+ if (oldest) this.pending.delete(oldest.id);
153
+ }
154
+ }
155
+
156
+ _evict() {
157
+ const now = Date.now();
158
+ for (const rec of [...this.pending.values()]) {
159
+ const decidedAt = rec.status === "pending" ? null : (rec.decidedAt || rec.expiresAt);
160
+ if (decidedAt && now - decidedAt > this.opt.graceMs) this.pending.delete(rec.id);
161
+ }
162
+ }
163
+
164
+ /** Test/shutdown hygiene: stop the sweep timer. */
165
+ stop() {
166
+ if (this._sweep) { clearInterval(this._sweep); this._sweep = null; }
167
+ }
168
+ }
169
+
170
+ module.exports = { ApprovalRegistry, DEFAULTS };
@@ -88,14 +88,17 @@ function resolveDailyNotes(vault, options = {}) {
88
88
  const yyyy = sessionDate.getFullYear();
89
89
  const yyyyMm = `${yyyy}-${String(sessionDate.getMonth() + 1).padStart(2, "0")}`;
90
90
  const monthDir = path.join(dailiesDir(vault), yyyyMm);
91
- out.totalThisMonth = safeListDir(monthDir, { extension: ".md" }).length;
91
+ // Count only real daily notes — cloud-sync artifacts ("Daily - 2026-07-10
92
+ // (conflicted copy).md", "Daily - 2026-07-10 2.md") inflated the totals.
93
+ const isDailyNote = (e) => /^Daily - \d{4}-\d{2}-\d{2}\.md$/.test(e.name);
94
+ out.totalThisMonth = safeListDir(monthDir, { extension: ".md" }).filter(isDailyNote).length;
92
95
 
93
96
  // This year — sum across all month folders
94
97
  const yearDirs = safeListDir(dailiesDir(vault), { dirsOnly: true })
95
98
  .filter(e => e.name.startsWith(`${yyyy}-`));
96
99
  let yearCount = 0;
97
100
  for (const yd of yearDirs) {
98
- yearCount += safeListDir(yd.path, { extension: ".md" }).length;
101
+ yearCount += safeListDir(yd.path, { extension: ".md" }).filter(isDailyNote).length;
99
102
  }
100
103
  out.totalThisYear = yearCount;
101
104
 
@@ -133,7 +136,21 @@ function parseJournal(text) {
133
136
  const nextTop = sect.search(/^#{1,2}\s/m);
134
137
  if (nextTop > 0) sect = sect.slice(0, nextTop);
135
138
  const under = (re) => { const m = sect.match(re); return m ? cleanLine(m[1]) : null; };
136
- const num = (line) => { if (!line) return null; const m = line.match(/\b(?:10|[1-9])\b/); return m ? Number(m[0]) : null; };
139
+ // A score must be ANCHORED, never the leftmost digit in prose "Day 3 in a
140
+ // row, honestly a 7/10" read as 3 and corrupted the calibration gap (R5-A).
141
+ // Accepted shapes, in order: "7/10" anywhere; "Score: 7" / "Energy: 7";
142
+ // a leading first-token number (the journal endpoint's own format,
143
+ // "7 — scattered but shipped") unless it's a time/duration ("5 hours…").
144
+ // Anything else = null (never a guess).
145
+ const num = (line) => {
146
+ if (!line) return null;
147
+ let m = line.match(/\b(10|[1-9])\s*\/\s*10\b/);
148
+ if (m) return Number(m[1]);
149
+ m = line.match(/\b(?:score|energy)\s*[:=]?\s*(10|[1-9])\b/i);
150
+ if (m) return Number(m[1]);
151
+ m = line.match(/^\s*(10|[1-9])\b(?!\s*(?:am|pm|h\b|hr|hour|min|:|%))/i);
152
+ return m ? Number(m[1]) : null;
153
+ };
137
154
 
138
155
  const evScoreLine = under(/###[^\n]*evening[^\n]*score[^\n]*\n+([^\n#][^\n]*)/i);
139
156
  const evening = {
@@ -32,4 +32,12 @@ function sessionDateKey(date = new Date()) {
32
32
  return localDateKey(d);
33
33
  }
34
34
 
35
- module.exports = { localDateKey, localMidnight, sessionDateKey };
35
+ // Midnight of the SESSION day (see sessionDateKey) as a Date — for parsers
36
+ // that bucket by day and must agree with the T245 convention.
37
+ function sessionMidnight(date = new Date()) {
38
+ const d = date instanceof Date ? new Date(date.getTime()) : new Date(date);
39
+ if (d.getHours() < 5) d.setDate(d.getDate() - 1);
40
+ return localMidnight(d);
41
+ }
42
+
43
+ module.exports = { localDateKey, localMidnight, sessionDateKey, sessionMidnight };