create-pmos 0.505.0 → 0.507.0

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 (51) hide show
  1. package/package.json +45 -27
  2. package/payload/AGENTS.md +2 -0
  3. package/payload/KIT-VERSION +1 -1
  4. package/payload/planning/evals/eval-calibration.md +1 -1
  5. package/payload/planning/okrs/health-budgets.md +1 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/workspace/changelog.json +44 -0
  8. package/workspace/fixtures/decision-pairs.js +94 -0
  9. package/workspace/fixtures/rehearse-replay.json +159 -0
  10. package/workspace/guide/ask-the-past.md +46 -0
  11. package/workspace/guide/claude-proposes.md +29 -2
  12. package/workspace/guide/index.md +3 -0
  13. package/workspace/guide/rehearse.md +52 -0
  14. package/workspace/guide/what-you-missed.md +42 -0
  15. package/workspace/lib/ai-cli.js +63 -0
  16. package/workspace/lib/ai-guard.js +314 -0
  17. package/workspace/lib/briefing-premium-agent.js +3 -0
  18. package/workspace/lib/decision-ledger.js +236 -0
  19. package/workspace/lib/decision-memory.js +237 -0
  20. package/workspace/lib/drift-cli.js +98 -0
  21. package/workspace/lib/drift-facts.js +151 -0
  22. package/workspace/lib/drift-runs.js +149 -0
  23. package/workspace/lib/drift.js +227 -0
  24. package/workspace/lib/grill-cli.js +22 -1
  25. package/workspace/lib/grill-runs.js +20 -3
  26. package/workspace/lib/grill.js +15 -8
  27. package/workspace/lib/past-chat.js +32 -0
  28. package/workspace/lib/past-cli.js +147 -0
  29. package/workspace/lib/past-repeat.js +126 -0
  30. package/workspace/lib/past.js +353 -0
  31. package/workspace/lib/pmos-verbs.js +12 -0
  32. package/workspace/lib/proposal-runs.js +20 -0
  33. package/workspace/lib/rehearse-cli.js +102 -0
  34. package/workspace/lib/rehearse-guard.js +81 -0
  35. package/workspace/lib/rehearse-read.js +159 -0
  36. package/workspace/lib/rehearse.js +279 -0
  37. package/workspace/lib/route-modules.js +1 -0
  38. package/workspace/lib/routes-drift.js +62 -0
  39. package/workspace/lib/settings-store.js +6 -0
  40. package/workspace/package-lock.json +2 -2
  41. package/workspace/package.json +1 -1
  42. package/workspace/public/pm/grill-words.js +4 -2
  43. package/workspace/public/pm/home-app.js +14 -2
  44. package/workspace/public/pm/home-drift.js +76 -0
  45. package/workspace/server.js +19 -0
  46. package/workspace/skills/grill.skill +4 -2
  47. package/workspace/test-files.json +1 -1
  48. package/workspace/test-lists/ask-the-past.json +5 -0
  49. package/workspace/test-lists/decision-memory.json +6 -0
  50. package/workspace/test-lists/plan-rehearsal.json +5 -0
  51. package/workspace/test-lists/weekly-drift-check.json +5 -0
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+ /* drift-facts.js — what the weekly drift check reads, per product (initiative weekly-drift-check). Read-only: it writes nothing anywhere.
3
+ *
4
+ * factsOf(w, { stateDir, rows? }) → { ws, name, root, initiatives, tasks, jira, notRead } the shape lib/drift.js detect() takes
5
+ *
6
+ * The same records `pmos task list` and `pmos initiative list` read, but kept LINE BY LINE with their place in the file, so each claim cites
7
+ * path:line and that line's own text (lib/ai-guard.js checks the quote), and so a replay can cut every ledger at a past week's end:
8
+ * - task lines: <state>/boards.jsonl and the initiative ledger (lib/task-store.js), task / task-update / task-remove / task-restore and
9
+ * task-milestone; a line copied by the D104 migration is the same text in both and counts once (the initiative ledger's copy is cited);
10
+ * - the initiative rows (lib/initiative-rows.js: live, accepted, stopped, last activity, the record) minus the ones the PM deleted;
11
+ * - closing: the boards ledger's ini-end lines (a dated "won't do"), the acceptances ledger and the row's stopped date;
12
+ * - Jira: the snapshot each linked initiative keeps (lib/jira-status.js), its ticket table line cited.
13
+ * A source that cannot be read is named in notRead, never guessed. */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const TASK_KINDS = new Set(['task', 'task-update', 'task-remove', 'task-restore', 'task-milestone']);
18
+ const cut = (s) => { const t = String(s || '').replace(/\s+$/, ''); return t.length > 400 ? t.slice(0, 400) : t; };
19
+ const rel = (root, f) => path.relative(root, f).split(path.sep).join('/');
20
+
21
+ /** Every JSON line of a file, with its 1-based line number and text. [] when there is no file; null when it cannot be read. */
22
+ function jsonLines(file) {
23
+ let text;
24
+ try { text = fs.readFileSync(file, 'utf8'); } catch (e) { return e && e.code === 'ENOENT' ? [] : null; }
25
+ const out = [];
26
+ text.split('\n').forEach((raw, i) => {
27
+ const t = raw.replace(/\r$/, '');
28
+ if (!t.trim()) return;
29
+ try { const o = JSON.parse(t); if (o && typeof o === 'object' && !Array.isArray(o)) out.push({ o, n: i + 1, text: t }); } catch { /* torn line */ }
30
+ });
31
+ return out;
32
+ }
33
+ const atOf = (o) => { const t = Date.parse(o && o.at); return Number.isFinite(t) ? t : null; };
34
+ const lineSrc = (root, file, l) => ({ ref: rel(root, file) + ':' + l.n, quote: cut(l.text) });
35
+
36
+ function tasksOf(root, boardFile, storeFile, notRead) {
37
+ const board = boardFile ? jsonLines(boardFile) : [];
38
+ const store = storeFile ? jsonLines(storeFile) : [];
39
+ if (board === null) notRead.push('the boards ledger');
40
+ if (store === null) notRead.push('the initiative task ledger');
41
+ const seen = new Set();
42
+ const all = [];
43
+ for (const [file, lines] of [[storeFile, store || []], [boardFile, board || []]]) {
44
+ for (const l of lines) { if (!TASK_KINDS.has(l.o.kind) || seen.has(l.text)) continue; seen.add(l.text); all.push({ file, l }); }
45
+ }
46
+ const tasks = new Map(), milestone = new Map();
47
+ for (const { file, l } of all) {
48
+ const o = l.o, at = atOf(o);
49
+ if (o.kind === 'task-milestone') { if (typeof o.task === 'string' && typeof o.milestone === 'boolean') milestone.set(o.task, { at, v: o.milestone }); continue; }
50
+ if (typeof o.task !== 'string' || at === null) continue;
51
+ if (o.kind === 'task') {
52
+ if (typeof o.initiative !== 'string' || tasks.has(o.task)) continue;
53
+ tasks.set(o.task, { id: o.task, initiative: o.initiative, title: String(o.title || o.task), lines: [] });
54
+ }
55
+ const t = tasks.get(o.task);
56
+ if (!t) continue;
57
+ if (o.kind === 'task-update' && typeof o.initiative === 'string' && o.initiative) t.initiative = o.initiative;
58
+ if (o.kind === 'task-update' && typeof o.title === 'string' && o.title.trim()) t.title = o.title.trim();
59
+ t.lines.push({ at, kind: o.kind, state: typeof o.state === 'string' ? (o.state === 'In review' ? 'In progress' : o.state) : undefined, due: typeof o.due === 'string' ? o.due : undefined, src: lineSrc(root, file, l) });
60
+ }
61
+ for (const t of tasks.values()) { const m = milestone.get(t.id); t.milestone = !!(m && m.v); }
62
+ return { tasks: [...tasks.values()], board: board || [] };
63
+ }
64
+
65
+ /** The line of a record file that names it (title, else id), for the initiative's own citation. */
66
+ function recordSrc(root, record) {
67
+ if (!record) return null;
68
+ const f = path.join(root, record);
69
+ let lines;
70
+ try { lines = fs.readFileSync(f, 'utf8').split('\n'); } catch { return null; }
71
+ const i = Math.max(lines.findIndex((x) => /^title:/.test(x)), lines.findIndex((x) => /^id:/.test(x)));
72
+ return i >= 0 && lines[i].trim().length >= 12 ? { ref: rel(root, f) + ':' + (i + 1), quote: cut(lines[i]) } : null;
73
+ }
74
+
75
+ function acceptancesOf(root) {
76
+ try {
77
+ const dir = require('./plan-ledgers.js').runsDir(root);
78
+ const f = path.join(dir, 'acceptances.jsonl');
79
+ const out = new Map();
80
+ for (const l of jsonLines(f) || []) {
81
+ const o = l.o; if (!o.initiative_id || o.accepted !== true) continue;
82
+ const at = Date.parse(o.accepted_at || o.at);
83
+ if (Number.isFinite(at)) out.set(o.initiative_id, { at, src: lineSrc(root, f, l) });
84
+ }
85
+ return out;
86
+ } catch { return new Map(); }
87
+ }
88
+
89
+ function jiraOf(root, notRead) {
90
+ const out = {};
91
+ let JS;
92
+ try { JS = require('./jira-status.js'); } catch { return out; }
93
+ let linked = [];
94
+ try { linked = JS.linkedInitiatives(root); } catch { notRead.push('the Jira links'); return out; }
95
+ for (const q of linked) {
96
+ const id = q.initiative || q.id;
97
+ if (!id) continue;
98
+ let snap = null, text = '';
99
+ try { snap = JS.readSnapshot(root, id); text = fs.readFileSync(JS.snapshotPath(root, id), 'utf8'); } catch { snap = null; }
100
+ if (!snap) continue;
101
+ const lines = text.split('\n');
102
+ const file = rel(root, JS.snapshotPath(root, id));
103
+ const tickets = [];
104
+ for (const [key, r] of snap.tickets) {
105
+ if (r.stale) continue; // a ticket the last search did not return: its reading is old, so it says nothing now
106
+ const i = lines.findIndex((x) => x.startsWith('|') && x.includes('| ' + key + ' |'));
107
+ tickets.push({ key, status: r.status || '', category: r.category, resolved: r.resolved || null, src: i >= 0 ? { ref: file + ':' + (i + 1), quote: cut(lines[i]) } : null });
108
+ }
109
+ if (tickets.length && tickets.every((k) => k.src)) out[id] = { fetchedAt: snap.fetchedAt, tickets };
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /** w: { id, name, root }. rows: the initiative rows when the caller has them (lib/initiative-rows.js read), else they are derived now. */
115
+ function factsOf(w, { stateDir, rows } = {}) {
116
+ const root = w.root;
117
+ const notRead = [];
118
+ let R = rows;
119
+ if (!R) {
120
+ const IR = require('./initiative-rows.js');
121
+ let r = IR.rowsFor(root);
122
+ if (r && r.pending) r = IR.deriveNow(root);
123
+ R = r && r.ok ? r.rows : null;
124
+ }
125
+ if (!R) notRead.push('the initiative records');
126
+ let storeFile = null;
127
+ try { const s = require('./task-store.js').storeFor(root); storeFile = s && s.file; } catch { storeFile = null; }
128
+ const boardFile = stateDir ? path.join(stateDir, 'boards.jsonl') : null;
129
+ const { tasks, board } = tasksOf(root, boardFile, storeFile, notRead);
130
+ const ends = new Map();
131
+ for (const l of board) {
132
+ const o = l.o; if (o.kind !== 'ini-end' || typeof o.id !== 'string') continue;
133
+ if (o.back === true) ends.delete(o.id); else { const at = atOf(o); if (at !== null) ends.set(o.id, { at, src: lineSrc(root, boardFile, l) }); }
134
+ }
135
+ const acc = acceptancesOf(root);
136
+ const initiatives = [];
137
+ for (const r of R || []) {
138
+ if (!r || r.ok === false || !r.id || (r.group && r.group !== 'work')) continue;
139
+ const end = ends.get(r.id), a = r.accepted ? acc.get(r.id) : null;
140
+ const stopAt = r.stopped && Date.parse(r.stopped.at);
141
+ const closed = end ? { at: end.at, why: 'set to Won\'t do', src: end.src } : a ? { at: a.at, why: 'accepted', src: a.src } : null;
142
+ initiatives.push({
143
+ id: r.id, title: r.title || r.id, live: !r.accepted && !r.stopped && !end && !r.legacy,
144
+ closedAt: closed ? closed.at : Number.isFinite(stopAt) ? stopAt : null, closedWhy: closed ? closed.why : 'stopped', closedSrc: closed ? closed.src : recordSrc(root, r.record),
145
+ lastActivity: Date.parse(r.last_activity) || null, src: recordSrc(root, r.record),
146
+ });
147
+ }
148
+ return { ws: w.id, name: w.name || w.id, root, initiatives, tasks, jira: jiraOf(root, notRead), notRead };
149
+ }
150
+
151
+ module.exports = { factsOf, jsonLines, tasksOf };
@@ -0,0 +1,149 @@
1
+ 'use strict';
2
+ /* drift-runs.js — the week of "What would I have missed" (initiative weekly-drift-check): the weekly run and the replay.
3
+ *
4
+ * off(env) → true when PMOS_DRIFT is off|0|false|no
5
+ * verify(root, source) → true when source.quote is the text at source.ref (path:line, ±2 lines), inside root
6
+ * simulate(facts, weeks, F0) PURE → [{ week, asOf, items }]: what each week would have shown, oldest first, each week's
7
+ * picks counted as shown for the next (R5), as the weekly run does
8
+ * replay({ products, now, F }) → { ok, weeks, failed, ws } the last REPLAY_WEEKS weeks on the real records (PRD R6), checked
9
+ * create(deps) → { tick({ now }), runWeek({ now }) }
10
+ *
11
+ * ONE RUN A WEEK, for the person, across every open product. It is not a Claude session: the signals are structured (PRD R1), so the server
12
+ * reads the records and decides, with no model and no credits. It runs from the Monday of a week, inside the day-check window, for the week
13
+ * before; a week that has a result is not run again. Its only write is the week line of <runtime>/drift/drift.jsonl (lib/drift.js).
14
+ *
15
+ * THE GUARD (the shared guardrail layer, lib/ai-guard.js). Every item goes through AG.file before it is kept: the kill switch (PMOS_AI,
16
+ * the ai settings), the replay gate for (drift, VERSION), a check of every quote against its file, a cap of three and the audit log
17
+ * (<state>/ai-audit.jsonl). When the guard is not installed, nothing is shown: the week is written as held.
18
+ *
19
+ * deps: { runtime() → folder, products() → [{ w: { id, name, root }, stateDir, rows? }] | null (null: not ready, try again), open(now) → bool, env,
20
+ * every? (ms: its own timer), mayRun?() → bool (the server that owns the schedules), warn(what, err) } */
21
+ const crypto = require('crypto');
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const D = require('./drift.js');
25
+ const Facts = require('./drift-facts.js');
26
+
27
+ const REPLAY_WEEKS = 4;
28
+ const PROOF_WEEKS = 12; // how far back the replay looks for one real item to prove the guard on, when the four weeks were quiet
29
+ const flagOff = (v) => /^(off|0|false|no)$/i.test(String(v || '').trim());
30
+ const off = (env) => flagOff((env || process.env).PMOS_DRIFT);
31
+ function guardLib() { try { return require('./ai-guard.js'); } catch { return null; } }
32
+
33
+ const norm = (s) => String(s || '').toLowerCase().replace(/\s+/g, ' ').trim();
34
+ function verify(root, s) {
35
+ const m = /^(.+):(\d+)$/.exec(String((s && s.ref) || ''));
36
+ if (!m || !root || norm(s.quote).length < 12) return false;
37
+ const f = path.resolve(root, m[1]);
38
+ if (f !== path.resolve(root) && !f.startsWith(path.resolve(root) + path.sep)) return false;
39
+ let lines;
40
+ try { lines = fs.readFileSync(f, 'utf8').split('\n'); } catch { return false; }
41
+ const n = Number(m[2]);
42
+ return norm(lines.slice(Math.max(0, n - 3), n + 2).join(' ')).includes(norm(s.quote));
43
+ }
44
+
45
+ /** What each of `weeks` (Mondays, oldest first) would have shown. F0: the ledger before them (its drops and shown items count). */
46
+ function simulate(facts, weeks, F0) {
47
+ const F = { weeks: [...((F0 && F0.weeks) || [])], drops: new Map((F0 && F0.drops) || []) };
48
+ const out = [];
49
+ for (const week of weeks) {
50
+ const asOf = D.weekEnd(week);
51
+ const items = D.pick(D.detect(facts, asOf), F);
52
+ out.push({ week, asOf, items });
53
+ F.weeks.push({ week, items });
54
+ }
55
+ return out;
56
+ }
57
+
58
+ const lastWeeks = (now, n) => { const out = []; let w = D.prevWeek(D.weekOf(now)); for (let i = 0; i < n; i++) { out.unshift(w); w = D.prevWeek(w); } return out; };
59
+
60
+ /** The replay (PRD R6, C3, C5): every week at most three items, every claim with a source, every source's quote found in its file. */
61
+ function replay({ products, now = Date.now(), F, facts: given }) {
62
+ const facts = given || products.map((p) => Facts.factsOf(p.w, { stateDir: p.stateDir, rows: p.rows }));
63
+ const weeks = simulate(facts, lastWeeks(now, REPLAY_WEEKS), { weeks: [], drops: (F && F.drops) || new Map() });
64
+ const rootOf = new Map(facts.map((f) => [f.ws, f.root]));
65
+ const failed = [];
66
+ for (const w of weeks) {
67
+ if (w.items.length > D.MAX) failed.push({ week: w.week, why: 'more than ' + D.MAX + ' items' });
68
+ for (const it of w.items) {
69
+ for (const c of it.claims) {
70
+ if (!c.sources.length) failed.push({ week: w.week, id: it.id, why: 'a claim with no source: ' + c.text });
71
+ for (const s of c.sources) if (!verify(rootOf.get(it.ws), s)) failed.push({ week: w.week, id: it.id, why: 'the quote is not at ' + s.ref });
72
+ }
73
+ }
74
+ }
75
+ return { ok: !failed.length, weeks, failed, ws: facts.map((f) => f.ws), notRead: [...new Set(facts.flatMap((f) => f.notRead.map((x) => f.name + ': ' + x)))], facts };
76
+ }
77
+
78
+ /** The guard's own replay of (drift, VERSION) for one product: each replayed week is a case, plus a quiet week that must say nothing. */
79
+ async function guardReplay(AG, p, weeks, factsOne) {
80
+ const cases = weeks.map((w) => {
81
+ const mine = w.items.filter((it) => it.ws === p.w.id);
82
+ return { name: 'week of ' + w.week, run: async () => mine.map(proposalOf), expect: mine.length ? { filed: mine.length } : { none: true } };
83
+ });
84
+ cases.push({ name: 'a quiet week says nothing', run: async () => D.pick(D.detect([{ ...factsOne, tasks: [], jira: {} }], Date.now()), { weeks: [], drops: new Map() }).map(proposalOf), expect: { none: true } });
85
+ // the guard needs one case that files a cited proposal: when the four weeks were quiet, the latest real item of the last PROOF_WEEKS weeks
86
+ if (!cases.some((c) => c.expect.filed)) {
87
+ const back = lastWeeks(Date.now(), PROOF_WEEKS).reverse();
88
+ const found = back.map((w) => D.detect([factsOne], D.weekEnd(w))[0]).find(Boolean);
89
+ if (found) cases.push({ name: 'a real item from the week before ' + D.localDay(Date.now()), run: async () => [proposalOf(found)], expect: { filed: 1 } });
90
+ }
91
+ return AG.replay(D.JOB, D.VERSION, cases, { root: p.w.root, stateDir: p.stateDir });
92
+ }
93
+
94
+ /** The proposal the guard checks: the shared shape, with this check's own fields under data. */
95
+ function proposalOf(it) {
96
+ return { id: it.id, title: it.title, say: it.say, confidence: it.confidence, claims: it.claims.map((c) => ({ text: c.text, sources: c.sources })),
97
+ data: { ...it.data, kind: it.kind, level: it.level, ws: it.ws, product: it.product, initiative: it.initiative } };
98
+ }
99
+ const keepOf = (it) => ({ id: it.id, kind: it.kind, level: it.level, ws: it.ws, product: it.product, initiative: it.initiative, title: it.title, say: it.say, confidence: it.confidence, claims: it.claims });
100
+
101
+ function create(deps) {
102
+ const warn = deps.warn || (() => {});
103
+ let busy = false;
104
+ const dirNow = () => D.dirOf(deps.runtime && deps.runtime());
105
+
106
+ async function runWeek({ now = Date.now() } = {}) {
107
+ const dir = dirNow(); if (!dir) return { started: null, error: 'There is no runtime folder.' };
108
+ const F = D.read(dir), reviewed = D.prevWeek(D.weekOf(now));
109
+ if (F.weeks.some((w) => w.week === reviewed)) return { started: null, done: true };
110
+ const run = 'dr' + now.toString(36) + crypto.randomBytes(3).toString('hex');
111
+ const products = deps.products();
112
+ if (!products) return { started: null, waiting: true }; // a product's initiative rows are still being read: the next tick tries again
113
+ const facts = [];
114
+ for (const p of products) { try { facts.push(Facts.factsOf(p.w, { stateDir: p.stateDir, rows: p.rows })); } catch (e) { warn('drift facts ' + p.w.id, e); } }
115
+ const picked = D.pick(D.detect(facts, D.weekEnd(reviewed)), F);
116
+ const notRead = [...new Set(facts.flatMap((f) => f.notRead.map((x) => f.name + ': ' + x)))];
117
+ const AG = guardLib();
118
+ let kept = [], held = false;
119
+ if (!AG) held = picked.length > 0;
120
+ else {
121
+ for (const p of products) {
122
+ const mine = picked.filter((it) => it.ws === p.w.id);
123
+ if (!mine.length) continue;
124
+ const r = await AG.file({ job: D.JOB, version: D.VERSION, run, root: p.w.root, stateDir: p.stateDir, env: deps.env || process.env, max: D.MAX }, mine.map(proposalOf),
125
+ async (acc) => { const ids = new Set(acc.map((a) => a.id)); kept = kept.concat(mine.filter((it) => ids.has(it.id))); });
126
+ if (r && (r.held || r.off)) held = true;
127
+ }
128
+ }
129
+ D.append(dir, { kind: 'week', week: reviewed, run, items: kept.map(keepOf), notRead, ...(held ? { held: true } : {}) }, new Date(now).toISOString());
130
+ return { started: run, items: kept.length, held };
131
+ }
132
+
133
+ async function tick({ now = Date.now() } = {}) {
134
+ if (off(deps.env) || busy) return { off: off(deps.env), running: busy };
135
+ let open = true;
136
+ try { open = deps.open ? !!deps.open(now) : true; } catch { open = true; }
137
+ if (!open) return { started: null, closed: true };
138
+ busy = true;
139
+ try { return await runWeek({ now }); } catch (e) { warn('drift week', e); return { started: null, error: String((e && e.message) || e) }; } finally { busy = false; }
140
+ }
141
+ // its own timer when the server asks for one (deps.every): unref'd, so it never holds the process open
142
+ if (deps.every > 0) {
143
+ const t = setInterval(() => { try { if (deps.mayRun && !deps.mayRun()) return; } catch { /* no lock: run */ } tick({}).catch((e) => warn('drift tick', e)); }, deps.every);
144
+ if (t.unref) t.unref();
145
+ }
146
+ return { tick, runWeek };
147
+ }
148
+
149
+ module.exports = { off, verify, simulate, replay, guardReplay, proposalOf, keepOf, lastWeeks, create, REPLAY_WEEKS };
@@ -0,0 +1,227 @@
1
+ 'use strict';
2
+ /* drift.js — "What would I have missed": the weekly check for quiet drift (initiative weekly-drift-check). THE PURE CORE and THE ONE WRITER of
3
+ * <runtime>/drift/drift.jsonl. lib/drift-facts.js reads the records; lib/drift-runs.js runs the week; lib/routes-drift.js and pm/home-drift.js
4
+ * show it. Pure apart from read/append: every time comes in.
5
+ *
6
+ * STRUCTURED SIGNALS ONLY (PRD R1). An item is made only from records: task lines and their due-date history, initiative rows, the boards ledger's
7
+ * ini-end lines and the Jira snapshots. No model, no chat or meeting text. Three kinds:
8
+ * stalled a live initiative with an open task and no task line and no record activity for STALL_DAYS
9
+ * slipped an open task whose due date moved later at least twice, the last move inside the week under review
10
+ * jira (a) the initiative is closed and one of its Jira tickets is still open; (b) every ticket (two or more) was resolved at least
11
+ * JIRA_DONE_DAYS ago and the initiative is still live
12
+ * At most MAX items (R2). Every item carries `claims`, each with the records it came from as { label, ref, quote }: `ref` is path:line from the
13
+ * repository root and `quote` is that line's own text, so lib/ai-guard.js can check every quote against the file (R3).
14
+ *
15
+ * detect(facts, asOf) PURE → candidates [{ id, kind, level, score, ws, title, say, confidence, claims, data }]
16
+ * pick(cands, F) PURE → at most MAX, without what the PM dropped, and without what was shown at the same level (R5)
17
+ * weekOf(ms) · weekEnd(week) the Monday (YYYY-MM-DD, local) that starts the week of ms · the Monday 00:00 after it, in ms
18
+ * fold(lines) → { weeks, drops, looked, replays } · checkView(F, thisWeek) → the GET body when a check is to show, else null
19
+ * dirOf(runtime) · ledgerFile(dir) · read(dir) · append(dir, line, at)
20
+ *
21
+ * THE LEDGER (PRIVATE, in the per-user runtime folder, outside every repository). One line per event, { at, kind, ... }:
22
+ * week { week, run, items: [proposal], notRead: [string] } the week's result (an empty items list is a quiet week; it shows nothing)
23
+ * drop { id, level } the PM's ⋯ "Not right, drop it" undo { id } looked { week }
24
+ * replay { version, ws: [id], weeks: [{ week, ids }], ok, failed } `pmos drift replay`; a product's items show only after one with ok (R6)
25
+ */
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+
29
+ const VERSION = 'drift-1';
30
+ const JOB = 'drift';
31
+ const DAY = 86400000;
32
+ const MAX = 3;
33
+ const STALL_DAYS = 14;
34
+ const JIRA_DONE_DAYS = 7;
35
+ const OPEN = new Set(['To do', 'In progress']);
36
+ const LEDGER = 'drift.jsonl';
37
+ const LINES = new Set(['week', 'drop', 'undo', 'looked', 'replay']);
38
+ const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
39
+ const isDate = (s) => /^\d{4}-\d{2}-\d{2}$/.test(String(s || '').slice(0, 10));
40
+ const dayMs = (s) => Date.parse(String(s).slice(0, 10) + 'T00:00:00Z');
41
+ /** 5 Nov, or 5 Nov 2027 when the year is not `year` (a date that moved into another year must not read as moving earlier). */
42
+ const short = (s, year) => { const t = dayMs(s); if (!Number.isFinite(t)) return String(s); const d = new Date(t); return d.getUTCDate() + ' ' + MON[d.getUTCMonth()] + (year && d.getUTCFullYear() !== year ? ' ' + d.getUTCFullYear() : ''); };
43
+ const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t; };
44
+ const days = (n) => (n === 1 ? '1 day' : n + ' days');
45
+ const pad = (n) => String(n).padStart(2, '0');
46
+ const localDay = (ms) => { const d = new Date(ms); return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); };
47
+
48
+ /* ── weeks (local time: the PM's Monday) ─────────────────────────────────────────────────────────────────────── */
49
+ function weekOf(ms) { const d = new Date(ms); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); return localDay(d.getTime()); }
50
+ function weekStart(week) { const [y, m, dd] = String(week).split('-').map(Number); return new Date(y, m - 1, dd).getTime(); }
51
+ function weekEnd(week) { const d = new Date(weekStart(week)); d.setDate(d.getDate() + 7); return d.getTime(); }
52
+ function prevWeek(week) { const d = new Date(weekStart(week)); d.setDate(d.getDate() - 7); return localDay(d.getTime()); }
53
+
54
+ /* ── detection ──────────────────────────────────────────────────────────────────────────────────────────────── */
55
+ const src = (label, s) => (s && s.ref && s.quote ? { label: clip(label, 60), ref: s.ref, quote: s.quote } : null);
56
+ const claim = (text, sources) => ({ text: clip(text, 240), sources: sources.filter(Boolean) });
57
+
58
+ /** A task as it stood at asOf: its state, due and the due moves, from its own lines only. null when it did not exist yet or was removed. */
59
+ function taskAt(t, asOf) {
60
+ const lines = (t.lines || []).filter((l) => Number.isFinite(l.at) && l.at <= asOf).sort((a, b) => a.at - b.at);
61
+ if (!lines.length || lines[0].kind !== 'task') return null;
62
+ let state = lines[0].state || 'To do', due = isDate(lines[0].due) ? lines[0].due.slice(0, 10) : null, removed = false;
63
+ // A SLIP is a day on which the due date ended later than it began. Several moves in one day are one decision (a re-plan, a drag and its
64
+ // fix-ups), never several slips: the replay on real ledgers showed three "slips" made minutes apart in one sitting.
65
+ const slips = [];
66
+ let day = null, dayFrom = null, dayLast = null;
67
+ const closeDay = () => { if (dayLast && dayFrom && dayLast.to && dayLast.to > dayFrom) slips.push({ at: dayLast.at, from: dayFrom, to: dayLast.to, src: dayLast.src }); };
68
+ for (const l of lines.slice(1)) {
69
+ if (l.kind === 'task-remove') { removed = true; continue; }
70
+ if (l.kind === 'task-restore') { removed = false; continue; }
71
+ if (l.kind !== 'task-update') continue;
72
+ if (l.state) state = l.state;
73
+ if (typeof l.due === 'string') {
74
+ const nd = isDate(l.due) ? l.due.slice(0, 10) : null;
75
+ const d = localDay(l.at);
76
+ if (d !== day) { closeDay(); day = d; dayFrom = due; dayLast = null; }
77
+ dayLast = { at: l.at, to: nd, src: l.src };
78
+ due = nd;
79
+ }
80
+ }
81
+ closeDay();
82
+ if (removed) return null;
83
+ return { id: t.id, initiative: t.initiative, title: t.title, milestone: !!t.milestone, state, due, slips, last: lines[lines.length - 1] };
84
+ }
85
+
86
+ function stalledOf(P, ini, tasks, asOf) {
87
+ const open = tasks.filter((t) => OPEN.has(t.state));
88
+ if (!open.length) return null;
89
+ const last = tasks.map((t) => t.last).sort((a, b) => b.at - a.at)[0];
90
+ const act = Number.isFinite(ini.lastActivity) && ini.lastActivity <= asOf ? ini.lastActivity : null;
91
+ const moved = Math.max(last.at, act || 0);
92
+ const quiet = Math.floor((asOf - moved) / DAY);
93
+ if (quiet < STALL_DAYS) return null;
94
+ const level = Math.floor(quiet / STALL_DAYS);
95
+ const lastWhen = localDay(last.at);
96
+ return {
97
+ id: 'stalled:' + P.ws + ':' + ini.id, kind: 'stalled', level, score: 40 + Math.min(quiet, 60) / 2, ws: P.ws, product: P.name, initiative: ini.id,
98
+ title: clip(ini.title, 90) + ' has gone quiet',
99
+ say: 'Nothing on ' + clip(ini.title, 90) + ' has moved for ' + days(quiet) + ', while ' + open.length + (open.length === 1 ? ' task is' : ' tasks are') + ' still open. It still looks live on the Board.',
100
+ confidence: 'high',
101
+ claims: [
102
+ claim(open.length + (open.length === 1 ? ' open task: ' : ' open tasks, the first: ') + clip(open[0].title, 80) + ' (' + open[0].state + ')', [src(open[0].id, open[0].last.src)]),
103
+ claim('The last task change was on ' + short(lastWhen) + (act && act > last.at ? ', and the last record change on ' + short(localDay(act)) : ''), [src('last change', last.src), act && act > last.at ? src('record', ini.src) : null]),
104
+ ],
105
+ data: { ws: P.ws, product: P.name, initiative: ini.id, quiet, open: open.length },
106
+ };
107
+ }
108
+
109
+ function slippedOf(P, ini, t, asOf) {
110
+ if (!OPEN.has(t.state) || t.slips.length < 2) return null;
111
+ const lastSlip = t.slips[t.slips.length - 1];
112
+ if (lastSlip.at <= asOf - 7 * DAY) return null; // the news is a slip inside the week under review
113
+ const first = t.slips[0].from, y = new Date(asOf).getUTCFullYear(), n = t.slips.length, shown = t.slips.slice(-3);
114
+ return {
115
+ id: 'slipped:' + P.ws + ':' + t.id, kind: 'slipped', level: t.slips.length, score: (t.milestone ? 90 : 70) + Math.min(t.slips.length, 6), ws: P.ws, product: P.name, initiative: ini.id,
116
+ title: (t.milestone ? 'Milestone ' : '') + clip(t.title, 80) + ' slipped again',
117
+ say: 'Its date was moved later on ' + n + ' different days, from ' + short(first, y) + ' to ' + short(lastSlip.to, y) + (n > shown.length ? '. The last ' + shown.length + ' are below' : '') + '. It is in ' + clip(ini.title, 80) + '.',
118
+ confidence: 'high',
119
+ claims: shown.map((s) => claim('Moved from ' + short(s.from, y) + ' to ' + short(s.to, y) + ' on ' + short(localDay(s.at), y), [src(t.id, s.src)])),
120
+ data: { ws: P.ws, product: P.name, initiative: ini.id, task: t.id, slips: t.slips.length, milestone: t.milestone },
121
+ };
122
+ }
123
+
124
+ function jiraOf(P, ini, J, asOf) {
125
+ if (!J || !J.tickets || !J.tickets.length) return null;
126
+ const openAt = (k) => !(k.resolved && dayMs(k.resolved) <= asOf) && (k.category !== 'done' || (k.resolved && dayMs(k.resolved) > asOf));
127
+ const closedAt = ini.closedAt != null && ini.closedAt <= asOf;
128
+ if (closedAt) {
129
+ const open = J.tickets.filter(openAt);
130
+ if (!open.length) return null;
131
+ return {
132
+ id: 'jira:' + P.ws + ':' + ini.id, kind: 'jira', level: open.length, score: 80, ws: P.ws, product: P.name, initiative: ini.id,
133
+ title: clip(ini.title, 80) + ' is closed, but Jira is not',
134
+ say: 'It was ' + ini.closedWhy + ' on ' + short(localDay(ini.closedAt)) + ', while ' + open.length + (open.length === 1 ? ' of its Jira tickets is' : ' of its Jira tickets are') + ' still open' + (open.length > 3 ? '. The first 3 are below.' : '.'),
135
+ confidence: 'high',
136
+ claims: [claim('Closed: ' + ini.closedWhy, [src('closed', ini.closedSrc)]),
137
+ ...open.slice(0, 3).map((k) => claim(k.key + ' is ' + (k.status || 'open') + ' in Jira (read ' + short(J.fetchedAt) + ')', [src(k.key, k.src)]))],
138
+ data: { ws: P.ws, product: P.name, initiative: ini.id, open: open.map((k) => k.key) },
139
+ };
140
+ }
141
+ if (!ini.live || J.tickets.length < 2) return null;
142
+ const res = J.tickets.map((k) => (k.resolved && dayMs(k.resolved) <= asOf ? dayMs(k.resolved) : null));
143
+ if (res.some((r) => r == null)) return null;
144
+ const lastDone = Math.max(...res);
145
+ if (asOf - lastDone < JIRA_DONE_DAYS * DAY) return null;
146
+ return {
147
+ id: 'jira-done:' + P.ws + ':' + ini.id, kind: 'jira', level: 1, score: 60, ws: P.ws, product: P.name, initiative: ini.id,
148
+ title: 'Jira finished ' + clip(ini.title, 70) + ', PMOS did not',
149
+ say: 'All ' + J.tickets.length + ' of its Jira tickets were resolved by ' + short(new Date(lastDone).toISOString()) + ', but the initiative is still open in PMOS.',
150
+ confidence: 'high',
151
+ claims: J.tickets.slice(0, 3).map((k) => claim(k.key + ' resolved ' + short(k.resolved), [src(k.key, k.src)])),
152
+ data: { ws: P.ws, product: P.name, initiative: ini.id, tickets: J.tickets.length },
153
+ };
154
+ }
155
+
156
+ /** facts: [{ ws, name, initiatives: [{ id, title, live, closedAt, closedWhy, closedSrc, lastActivity, src }], tasks: [{ id, initiative, title,
157
+ * milestone, lines: [{ at, kind, state?, due?, src }] }], jira: { [initiative]: { fetchedAt, tickets: [{ key, status, category, resolved, src }] } } }] */
158
+ function detect(facts, asOf) {
159
+ const out = [];
160
+ for (const P of Array.isArray(facts) ? facts : []) {
161
+ const byIni = new Map();
162
+ for (const t of P.tasks || []) { const a = taskAt(t, asOf); if (!a) continue; if (!byIni.has(a.initiative)) byIni.set(a.initiative, []); byIni.get(a.initiative).push(a); }
163
+ for (const ini of P.initiatives || []) {
164
+ try {
165
+ const tasks = byIni.get(ini.id) || [];
166
+ if (ini.live && tasks.length) { const s = stalledOf(P, ini, tasks, asOf); if (s) out.push(s); }
167
+ if (ini.live) for (const t of tasks) { const s = slippedOf(P, ini, t, asOf); if (s) out.push(s); }
168
+ const j = jiraOf(P, ini, (P.jira || {})[ini.id], asOf); if (j) out.push(j);
169
+ } catch { /* one record that cannot be read costs its own item, nothing more */ }
170
+ }
171
+ }
172
+ for (const c of out) c.confidence = new Set(c.claims.flatMap((x) => x.sources.map((s) => s.ref))).size >= 2 ? 'high' : 'medium';
173
+ return out.filter((c) => c.claims.length && c.claims.every((x) => x.sources.length)).sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
174
+ }
175
+
176
+ /** At most MAX: never one the PM dropped (at any level), never one already shown at the same or a higher level (R5). */
177
+ function pick(cands, F, max = MAX) {
178
+ const shown = new Map();
179
+ for (const w of (F && F.weeks) || []) for (const it of w.items || []) shown.set(it.id, Math.max(shown.get(it.id) || 0, it.level || 0));
180
+ const dropped = (F && F.drops) || new Map();
181
+ return (cands || []).filter((c) => !dropped.has(c.id) && !(shown.has(c.id) && shown.get(c.id) >= c.level)).slice(0, max);
182
+ }
183
+
184
+ /* ── the ledger ─────────────────────────────────────────────────────────────────────────────────────────────── */
185
+ const dirOf = (runtime) => (runtime ? path.join(runtime, 'drift') : null);
186
+ const ledgerFile = (dir) => path.join(dir, LEDGER);
187
+ function readLines(file) {
188
+ let text = '';
189
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
190
+ const out = [];
191
+ for (const raw of text.split('\n')) { if (!raw.trim()) continue; try { const o = JSON.parse(raw); if (o && LINES.has(o.kind)) out.push(o); } catch { /* a torn line has no effect */ } }
192
+ return out;
193
+ }
194
+ function fold(lines) {
195
+ const F = { weeks: [], drops: new Map(), looked: new Set(), replays: [] };
196
+ for (const l of lines || []) {
197
+ if (l.kind === 'week' && typeof l.week === 'string') F.weeks.push({ week: l.week, at: l.at, run: l.run, items: Array.isArray(l.items) ? l.items : [], notRead: Array.isArray(l.notRead) ? l.notRead : [], ...(l.held ? { held: true } : {}) });
198
+ else if (l.kind === 'drop' && typeof l.id === 'string') F.drops.set(l.id, l.level || 0);
199
+ else if (l.kind === 'undo' && typeof l.id === 'string') F.drops.delete(l.id);
200
+ else if (l.kind === 'looked' && typeof l.week === 'string') F.looked.add(l.week);
201
+ else if (l.kind === 'replay') F.replays.push(l);
202
+ }
203
+ return F;
204
+ }
205
+ const read = (dir) => fold(readLines(ledgerFile(dir)));
206
+ function append(dir, line, at) {
207
+ if (!dir || !LINES.has(line && line.kind)) throw new Error('not a drift ledger line');
208
+ fs.mkdirSync(dir, { recursive: true });
209
+ fs.appendFileSync(ledgerFile(dir), JSON.stringify({ at: at || new Date().toISOString(), ...line }) + '\n', { mode: 0o600 });
210
+ }
211
+ /** The products whose replay of this version passed: only their items show (R6). */
212
+ const replayed = (F, version = VERSION) => new Set((F.replays || []).filter((r) => r.ok === true && r.version === version).flatMap((r) => (Array.isArray(r.ws) ? r.ws : [])));
213
+
214
+ /** The card for the week before `thisWeek`, while that is the latest result. null in a quiet week, before a replay, or with nothing left. */
215
+ function checkView(F, thisWeek) {
216
+ const ok = replayed(F);
217
+ if (!ok.size) return null;
218
+ const want = prevWeek(thisWeek);
219
+ const w = [...F.weeks].reverse().find((x) => x.week === want);
220
+ const items = w ? w.items.filter((it) => ok.has(it.ws)).map((it) => ({ ...it, dropped: F.drops.has(it.id) })) : [];
221
+ if (!items.length) return null;
222
+ if (items.every((x) => x.dropped) && F.looked.has(w.week)) return null;
223
+ return { week: w.week, label: 'Last week', looked: F.looked.has(w.week), items, notRead: w.notRead };
224
+ }
225
+
226
+ module.exports = { VERSION, JOB, MAX, STALL_DAYS, JIRA_DONE_DAYS, DAY, detect, pick, taskAt, weekOf, weekStart, weekEnd, prevWeek, localDay,
227
+ dirOf, ledgerFile, readLines, fold, read, append, replayed, checkView };
@@ -5,6 +5,9 @@
5
5
  * pmos grill find <angle> --json <file|-> one finding or a list: { "title", "why", "next", "sources": [{ "label", "ref" }], "key"?, "rank"? }
6
6
  * pmos grill voices --sources <n> how many stored research and meeting-note sources the run found for Voices
7
7
  * pmos grill status the open, kept and dropped findings on this initiative (in a run)
8
+ * pmos grill decisions [--all] the decisions that stand, one line each (--all: the superseded and reverted ones too)
9
+ * pmos grill find decision --json <file|-> a plan that goes against a decision that stands (lib/decision-memory.js checks it, and
10
+ * lib/ai-guard.js replays, audits and files it)
8
11
  * pmos grill mode [full|exits|off] when Claude grills by itself (read, or set)
9
12
  * pmos grill cap [<dollars>] the monthly credit cap (read, or set)
10
13
  * pmos grill spend this month: spent, cap, runs, paused
@@ -15,7 +18,7 @@ const fs = require('fs');
15
18
  const G = require('./grill.js');
16
19
  const PR = require('./proposals.js');
17
20
 
18
- const COMMANDS = { grill: ['find', 'voices', 'status', 'mode', 'cap', 'spend'] };
21
+ const COMMANDS = { grill: ['find', 'voices', 'status', 'decisions', 'mode', 'cap', 'spend'] };
19
22
  const USAGE = [
20
23
  'usage: pmos grill find|voices|status|mode|cap|spend — Claude grills an initiative by itself; each finding waits under its ✦',
21
24
  ' pmos grill find <' + G.ANGLES.join('|') + '> --json <file|->',
@@ -23,6 +26,9 @@ const USAGE = [
23
26
  ' voices needs ' + G.VOICES_MIN + ' sources or more and is tagged "' + G.SIMULATED.voices + '"; whatif is tagged "' + G.SIMULATED.whatif + '".',
24
27
  ' pmos grill voices --sources <n> the stored research and meeting notes you found for Voices (under ' + G.VOICES_MIN + ': no Voices)',
25
28
  ' pmos grill status',
29
+ ' pmos grill decisions [--all] the decisions that stand (with --all: what superseded the others)',
30
+ ' pmos grill find decision --json - { "decision": "D79", "quote": "<its words>", "plan": { "label", "ref": "<path>:<line>", "quote": "<the plan\'s words>" },',
31
+ ' "conflict": "<one line>", "recommend": "keep|change|drop", "next": "<one next step>", "confidence": "high|medium" }',
26
32
  ' pmos grill mode [full|exits|off] when Claude grills by itself',
27
33
  ' pmos grill cap [<dollars>] the monthly credit cap; at the cap Claude pauses',
28
34
  ' pmos grill spend what this month spent',
@@ -42,6 +48,15 @@ async function run(argv, { say = (s) => console.log(s), env = process.env, stdin
42
48
  if (!verb || ['help', '--help', '-h'].includes(verb)) { for (const l of USAGE) say(l); return verb ? 0 : 1; }
43
49
  if (!COMMANDS.grill.includes(verb)) { say(`unknown grill command "${verb}" — one of ${COMMANDS.grill.join(', ')}`); return 2; }
44
50
  const inRun = !!env.PMOS_GRILL_RUN;
51
+ const rootOf = () => env.PMOS_GRILL_ROOT || (() => { try { return require('child_process').execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return process.cwd(); } })();
52
+
53
+ /* ── the decisions that stand (decision memory): readable in a run and out of it ── */
54
+ if (verb === 'decisions') {
55
+ const L = require('./decision-ledger.js').load(rootOf());
56
+ if (!L) { say('no DECISIONS.md in this repository'); return 0; }
57
+ for (const l of require('./decision-ledger.js').index(L, { all: args.includes('--all') })) say(l);
58
+ return 0;
59
+ }
45
60
 
46
61
  /* ── the PM's verbs ── */
47
62
  if (verb === 'mode' || verb === 'cap' || verb === 'spend') {
@@ -84,6 +99,12 @@ async function run(argv, { say = (s) => console.log(s), env = process.env, stdin
84
99
  }
85
100
  const angle = args[1];
86
101
  if (!G.ANGLES.includes(angle)) { say('✗ the angle is one of ' + G.ANGLES.join(', ')); return 2; }
102
+ if (angle === 'decision') {
103
+ const jsonAt = args.indexOf('--json'), src = jsonAt >= 0 ? args[jsonAt + 1] : null;
104
+ if (!src) { say('usage: pmos grill find decision --json <file|->'); return 2; }
105
+ const r = readJson(src, stdin); if (r.error) { say('✗ ' + r.error); return 1; }
106
+ return require('./decision-memory.js').fileRun({ root: rootOf(), file, run: runId, ini, board, env, now, say, P, settingsDir: stateOf(env) }, Array.isArray(r.body) ? r.body : [r.body]);
107
+ }
87
108
  if (angle === 'voices' && !(me.voices >= G.VOICES_MIN)) { say(`✗ Voices needs ${G.VOICES_MIN} stored sources or more: run pmos grill voices --sources <n> first, and leave Voices out under ${G.VOICES_MIN}`); return 1; }
88
109
  const jsonAt = args.indexOf('--json'), src = jsonAt >= 0 ? args[jsonAt + 1] : null;
89
110
  if (!src) { say(`usage: pmos grill find ${angle} --json <file|->`); return 2; }