mover-os 4.7.8 → 4.7.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -24
- package/install.js +2229 -204
- package/package.json +3 -2
- package/src/dashboard/build.js +41 -3
- package/src/dashboard/lib/active-context-parser.js +16 -4
- package/src/dashboard/lib/agent-session.js +70 -49
- package/src/dashboard/lib/approval-registry.js +170 -0
- package/src/dashboard/lib/daily-note-resolver.js +20 -3
- package/src/dashboard/lib/date-utils.js +9 -1
- package/src/dashboard/lib/distribution-parser.js +69 -10
- package/src/dashboard/lib/drift-history.js +6 -1
- package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
- package/src/dashboard/lib/engine-health.js +4 -1
- package/src/dashboard/lib/engine-writer.js +157 -11
- package/src/dashboard/lib/goal-forecast.js +154 -31
- package/src/dashboard/lib/library-indexer-v2.js +14 -0
- package/src/dashboard/lib/library-search.js +290 -0
- package/src/dashboard/lib/log-activation.sh +0 -0
- package/src/dashboard/lib/memory-index.js +61 -12
- package/src/dashboard/lib/pid-markers.js +80 -0
- package/src/dashboard/lib/regenerate-manifest.js +0 -0
- package/src/dashboard/lib/run-registry.js +75 -15
- package/src/dashboard/lib/state-core/backfill.js +298 -0
- package/src/dashboard/lib/state-core/dryrun.js +188 -0
- package/src/dashboard/lib/state-core/event-log.js +615 -0
- package/src/dashboard/lib/state-core/events.js +265 -0
- package/src/dashboard/lib/state-core/projections.js +376 -0
- package/src/dashboard/lib/state-core/start-close.js +162 -0
- package/src/dashboard/lib/state-core/trial.js +96 -0
- package/src/dashboard/lib/strategy-parser.js +3 -0
- package/src/dashboard/lib/suggested-now.js +2 -2
- package/src/dashboard/lib/transcript-parser.js +48 -8
- package/src/dashboard/server.js +422 -44
- package/src/dashboard/shortcut.js +0 -0
- package/src/dashboard/ui/dist/assets/index-BoxTW_kj.js +161 -0
- package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
- package/src/dashboard/ui/dist/assets/index-wnamvqxp.js +34 -0
- package/src/dashboard/ui/dist/index.html +2 -2
- package/src/dashboard/ui/dist/sw.js +1 -1
- package/src/system/counts.json +7 -0
- package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
- package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
- package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +0 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mover-os",
|
|
3
|
-
"version": "4.7.
|
|
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",
|
package/src/dashboard/build.js
CHANGED
|
@@ -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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
const
|
|
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
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|