create-pmos 0.505.0 → 0.506.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 (43) hide show
  1. package/package.json +32 -14
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/changelog.json +31 -0
  7. package/workspace/fixtures/decision-pairs.js +94 -0
  8. package/workspace/guide/ask-the-past.md +46 -0
  9. package/workspace/guide/claude-proposes.md +29 -2
  10. package/workspace/guide/index.md +2 -0
  11. package/workspace/guide/what-you-missed.md +42 -0
  12. package/workspace/lib/ai-cli.js +63 -0
  13. package/workspace/lib/ai-guard.js +314 -0
  14. package/workspace/lib/briefing-premium-agent.js +3 -0
  15. package/workspace/lib/decision-ledger.js +236 -0
  16. package/workspace/lib/decision-memory.js +237 -0
  17. package/workspace/lib/drift-cli.js +98 -0
  18. package/workspace/lib/drift-facts.js +151 -0
  19. package/workspace/lib/drift-runs.js +149 -0
  20. package/workspace/lib/drift.js +227 -0
  21. package/workspace/lib/grill-cli.js +22 -1
  22. package/workspace/lib/grill-runs.js +20 -3
  23. package/workspace/lib/grill.js +15 -8
  24. package/workspace/lib/past-chat.js +32 -0
  25. package/workspace/lib/past-cli.js +147 -0
  26. package/workspace/lib/past-repeat.js +126 -0
  27. package/workspace/lib/past.js +353 -0
  28. package/workspace/lib/pmos-verbs.js +9 -0
  29. package/workspace/lib/proposal-runs.js +20 -0
  30. package/workspace/lib/route-modules.js +1 -0
  31. package/workspace/lib/routes-drift.js +62 -0
  32. package/workspace/lib/settings-store.js +6 -0
  33. package/workspace/package-lock.json +2 -2
  34. package/workspace/package.json +1 -1
  35. package/workspace/public/pm/grill-words.js +4 -2
  36. package/workspace/public/pm/home-app.js +14 -2
  37. package/workspace/public/pm/home-drift.js +76 -0
  38. package/workspace/server.js +19 -0
  39. package/workspace/skills/grill.skill +4 -2
  40. package/workspace/test-files.json +1 -1
  41. package/workspace/test-lists/ask-the-past.json +5 -0
  42. package/workspace/test-lists/decision-memory.json +6 -0
  43. package/workspace/test-lists/weekly-drift-check.json +5 -0
@@ -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; }
@@ -2,7 +2,7 @@
2
2
  /* grill-runs.js — Claude's runs that grill an initiative by themselves (Grill and Forecast, part G-A; lib/grill.js says what and when).
3
3
  *
4
4
  * create(deps) → { tick(opts), start({ ws, board, ini, why, key, detail, now }), stop(run), active() }
5
- * promptOf(skill, o) → the prompt of one run. o: { run, why, detail, ini, title, intent, stage, stages, today, open, dropped, budget }
5
+ * promptOf(skill, o) → the prompt of one run. o: { run, why, detail, ini, title, intent, stage, stages, today, open, dropped, budget, decisions }
6
6
  *
7
7
  * ONE RUN KIND (headless kind `grill`, origin `grill:<ws>:<initiative>:<run>`). The run reads the repository, the stored reads, the
8
8
  * stored research and meeting notes, and files with `pmos grill find`, pinned by the server to this initiative and run (PMOS_GRILL_FILE,
@@ -12,6 +12,8 @@
12
12
  * deps: { workspaces() → [w], isLead(w), stateDirFor(w), runtimeDir(), rowOf(w, id) → { title, intent, stage, stages } | null,
13
13
  * exits(w) → [{ ini, board, stage }] (stages about to end), shapes(w) → [{ ini, board, n, end }] (open tasks, latest due),
14
14
  * looks(w) → [{ ini, board, n, end }] (running initiatives with open tasks, for the monthly look),
15
+ * plans(w) → [{ ini, board, path, sig }] (optional: each initiative's PRD and a signature of its text, for the `plan` trigger;
16
+ * without it, lib/decision-memory.js plansIn reads the records of the initiatives looks(w) returns),
15
17
  * goalInis(w, goalId) → [{ id, endAt, status }] (the initiatives anchored to that goal), checks(w, day) → [{ ini, board, tasks }] (late or at-risk work), turned(dir, sinceIso) → [forecast `turned` line] (lib/forecast.js),
16
18
  * api { available(env), run({ prompt, root, env, budget, signal }) → Promise<{ ok, code, costUsd, note?, stopped? }> } (lib/grill-api.js; optional),
17
19
  * setting(key) (lib/settings-store.js get), open(now) → bool, headlessOff() → bool, env, promptFor(skillId), spawn(spec), stop(session), warn }
@@ -28,6 +30,7 @@ const WHY = {
28
30
  exit: 'The initiative is about to leave its stage. Grill it before it does: what would make the next stage fail?',
29
31
  forecast: 'Its forecast just turned. Grill it: why, and what would bring it back?',
30
32
  move: 'Its scope or dates moved a lot. Grill it: is the plan still the right plan?',
33
+ plan: 'Its PRD changed. Check the new plan against the decisions that stand (the decision angle) first, then grill it.',
31
34
  monthly: 'The monthly look at running work (late work first, then the initiatives grilled longest ago). Grill it.',
32
35
  ask: 'The PM asked for it in the initiative\'s chat. Grill it now: what would make it fail, and what is the next step?',
33
36
  };
@@ -39,6 +42,7 @@ function promptOf(skill, o) {
39
42
  + `- Its workflow's stages, in order:\n${list((o.stages || []).map((s) => s.key + ' · ' + s.name), 'not known')}\n`
40
43
  + `- Open findings already under its ✦ (do not file them again):\n${list(o.open, 'none')}\n`
41
44
  + `- Dropped by the PM (do not file these again unless the evidence is new):\n${list(o.dropped, 'none')}\n`
45
+ + (o.decisions && o.decisions.length ? `- The decisions that stand (for the decision angle; Read the line for a decision's words):\n${list(o.decisions, 'none')}\n` : '')
42
46
  + `- Your budget: $${o.budget}. Stop when you have your findings.\n\n`
43
47
  + 'File with `pmos grill find` only (the server pins this initiative and run). Send nothing.';
44
48
  }
@@ -101,9 +105,12 @@ function create(deps) {
101
105
  const mine = [...P.props.values()].filter((p) => p.ini === ini && p.group === 'grill');
102
106
  const dropped = mine.filter((p) => P.drops.has(PR.sigOf(p))).slice(0, 20).map((p) => one(p.title, 120));
103
107
  const open = mine.filter((p) => !P.drops.has(PR.sigOf(p)) && !kept.has(p.id)).slice(0, 20).map((p) => one(p.title, 120));
104
- const prompt = promptOf(skill, { run, why, detail, ini, title: row.title || ini, intent: row.intent || '', stage: row.stage || '', stages: row.stages || [], today: localDay(now), open, dropped, budget: b.budget });
108
+ // decision memory: the decisions that stand go in the prompt, so the run checks the plan against all of them, not a search's hits
109
+ let decisions = [];
110
+ try { const DL = require('./decision-ledger.js'), L = w.root ? DL.load(w.root) : null; decisions = L ? DL.index(L) : []; } catch (e) { warn('grill decisions', e); }
111
+ const prompt = promptOf(skill, { run, why, detail, ini, title: row.title || ini, intent: row.intent || '', stage: row.stage || '', stages: row.stages || [], today: localDay(now), open, dropped, budget: b.budget, decisions });
105
112
  active.set(run, { session: null });
106
- const env = { PMOS_GRILL_FILE: PR.ledgerFile(dir), PMOS_GRILL_LOG: G.logFile(rt), PMOS_GRILL_WS: ws, PMOS_GRILL_BOARD: board || '', PMOS_GRILL_INI: ini, PMOS_GRILL_RUN: run, PMOS_GRILL_BUDGET: String(b.budget) };
113
+ const env = { PMOS_GRILL_FILE: PR.ledgerFile(dir), PMOS_GRILL_LOG: G.logFile(rt), PMOS_GRILL_ROOT: w.root || '', PMOS_GRILL_WS: ws, PMOS_GRILL_BOARD: board || '', PMOS_GRILL_INI: ini, PMOS_GRILL_RUN: run, PMOS_GRILL_BUDGET: String(b.budget) };
107
114
  // With an API key the run goes to the Claude API (lib/grill-api.js); without one it stays a background agent session
108
115
  if (deps.api && deps.api.available(deps.env)) {
109
116
  const ctl = new AbortController();
@@ -158,6 +165,16 @@ function create(deps) {
158
165
  const what = G.moved(prev, cur);
159
166
  if (what) out.push({ ws: w.id, board: s.board, ini: s.ini, why: 'move', key: `move:${w.id}:${s.ini}:${cur.n}:${cur.end}`, detail: what });
160
167
  }
168
+ // decision memory: a PRD that changed since the last look is checked against the decisions that stand. The first look only records it
169
+ // the PRDs come from deps.plans, else from the running initiatives' records in the workspace (lib/decision-memory.js plansIn)
170
+ const plans = deps.plans ? () => deps.plans(w) : () => (w.root && deps.looks ? require('./decision-memory.js').plansIn(w.root, deps.looks(w)) : []);
171
+ for (const p of tryList(plans)) {
172
+ if (!p || !p.ini || !p.sig) continue;
173
+ const prev = F.plans.get(w.id + '|' + p.ini);
174
+ if (prev === p.sig) continue;
175
+ try { G.append(deps.runtimeDir(), { kind: 'plan', ws: w.id, ini: p.ini, sig: p.sig }, now); } catch (e) { warn('grill plan', e); }
176
+ if (prev) out.push({ ws: w.id, board: p.board || '', ini: p.ini, why: 'plan', key: `plan:${w.id}:${p.ini}:${p.sig}`, detail: 'its PRD changed: ' + one(p.path, 120) });
177
+ }
161
178
  // the monthly look: every running initiative with open work, at most once in 30 days each (Wawan 2026-11-02: "why only one?").
162
179
  // Late work first, then the ones never grilled, then the ones grilled longest ago; the cap decides how far down the list a month goes.
163
180
  const late = tryList(() => deps.checks && deps.checks(w, localDay(now))), seen = new Set();
@@ -7,7 +7,8 @@
7
7
  * finding is one proposal under the initiative's blue ✦ (lib/proposals.js, group `grill`, type = the angle), with a next step. The PM keeps,
8
8
  * drops or undoes it there. Nothing changes before the PM's ✓; the ✓ adds the next step as a task. Zero findings is silence.
9
9
  *
10
- * THE ANGLES: premortem · redteam · voices · reframe · scope · whatif. Voices are simulated from stored research and meeting notes only,
10
+ * THE ANGLES: premortem · redteam · voices · reframe · scope · whatif · decision. `decision` (decision memory) is a plan that goes against an
11
+ * earlier decision that still stands; it quotes both sides verbatim and is checked by lib/decision-memory.js, not by finding(). Voices are simulated from stored research and meeting notes only,
11
12
  * and only with 3 sources or more ("Simulated, not evidence"). A what-if is "Simulated, not the plan". The angle and the tag are in the
12
13
  * proposal's own title, so the ✦ list shows them as it is.
13
14
  *
@@ -15,6 +16,8 @@
15
16
  * exit the initiative is about to leave its stage (most of the stage's tasks are closed), once per stage
16
17
  * forecast its forecast, or the forecast of a goal it moves, turned at-risk or will-miss (lib/forecast.js `turned` lines), once per turn
17
18
  * move its scope or dates moved a lot since the last look (tasks ±30% and 3 or more, or the end ±14 days)
19
+ * plan its PRD changed since the last look (decision memory: a new plan is checked against the decisions that stand). The first
20
+ * look only records the PRD; a run starts when it changes after that
18
21
  * monthly every running initiative with open work, at most once in 30 days each: late work first, then the ones grilled longest ago
19
22
  * `exits` runs only the first. Every run counts against the monthly credit cap (grill.cap, US dollars): before a run the cap must hold
20
23
  * the run's whole budget, which goes to the run as --max-budget-usd, so the month never goes beyond the cap. At the cap Claude pauses and
@@ -28,10 +31,11 @@
28
31
  * voices { run, n } how many stored research and note sources the run found for Voices
29
32
  * end { run, ok, cost, error? } cost: US dollars the run spent (lib/headless.js parseResult costUsd)
30
33
  * snap { ws, ini, n, end } the open tasks and the latest due date the last look saw (for `move`)
34
+ * plan { ws, ini, sig } the signature of its PRD the last look saw (for `plan`)
31
35
  * paused { month, spent, cap } the cap held a run back this month; written once per month
32
36
  *
33
37
  * logFile(dir) · readLines(file) · read(dir) → fold · append(dir, line)
34
- * fold(lines) PURE → { runs: Map run → r, keys: Set, lastAt: Map ws|ini → iso, snaps: Map, paused: Set month }
38
+ * fold(lines) PURE → { runs: Map run → r, keys: Set, lastAt: Map ws|ini → iso, snaps: Map, plans: Map, paused: Set month }
35
39
  * monthOf(ms) · spentIn(F, month) the month's spend (an open run counts its whole budget)
36
40
  * budgetFor(F, { cap, now }) PURE → { ok, budget, spent } | { ok: false, spent, why: 'cap' }
37
41
  * moved(prev, cur) PURE → '' | what moved, in words
@@ -44,11 +48,11 @@ const path = require('path');
44
48
  const crypto = require('crypto');
45
49
 
46
50
  const LOG = 'grill-runs.jsonl';
47
- const KINDS = new Set(['start', 'voices', 'end', 'snap', 'paused']);
48
- const ANGLES = Object.freeze(['premortem', 'redteam', 'voices', 'reframe', 'scope', 'whatif']);
49
- const LABEL = Object.freeze({ premortem: 'Premortem', redteam: 'Red team', voices: 'Voices', reframe: 'Reframe', scope: 'Scope', whatif: 'What-if' });
51
+ const KINDS = new Set(['start', 'voices', 'end', 'snap', 'plan', 'paused']);
52
+ const ANGLES = Object.freeze(['premortem', 'redteam', 'voices', 'reframe', 'scope', 'whatif', 'decision']);
53
+ const LABEL = Object.freeze({ premortem: 'Premortem', redteam: 'Red team', voices: 'Voices', reframe: 'Reframe', scope: 'Scope', whatif: 'What-if', decision: 'Earlier decision' });
50
54
  const SIMULATED = Object.freeze({ voices: 'Simulated, not evidence', whatif: 'Simulated, not the plan' });
51
- const WHYS = Object.freeze(['exit', 'forecast', 'move', 'monthly', 'ask']);
55
+ const WHYS = Object.freeze(['exit', 'forecast', 'move', 'plan', 'monthly', 'ask']);
52
56
  const MODES = Object.freeze(['full', 'exits', 'off']);
53
57
  const VOICES_MIN = 3;
54
58
  const RUN_MAX_USD = 2; // one run never spends more than this
@@ -92,7 +96,7 @@ function append(dir, ev, at = new Date()) {
92
96
  }
93
97
 
94
98
  function fold(lines) {
95
- const runs = new Map(), keys = new Set(), lastAt = new Map(), snaps = new Map(), paused = new Set();
99
+ const runs = new Map(), keys = new Set(), lastAt = new Map(), snaps = new Map(), plans = new Map(), paused = new Set();
96
100
  for (const l of lines || []) {
97
101
  if (!isObj(l)) continue;
98
102
  if (l.kind === 'start' && str(l.run)) {
@@ -102,9 +106,10 @@ function fold(lines) {
102
106
  } else if (l.kind === 'voices') { const r = runs.get(l.run); if (r) r.voices = Number(l.n) || 0; }
103
107
  else if (l.kind === 'end') { const r = runs.get(l.run); if (r) { r.ended = true; r.ok = !!l.ok; r.cost = Number.isFinite(Number(l.cost)) && l.cost !== null ? Number(l.cost) : null; r.endAt = str(l.at); if (l.error) r.error = str(l.error); } }
104
108
  else if (l.kind === 'snap') snaps.set(l.ws + '|' + l.ini, { n: Number(l.n) || 0, end: str(l.end), at: str(l.at) });
109
+ else if (l.kind === 'plan') plans.set(l.ws + '|' + l.ini, str(l.sig));
105
110
  else if (l.kind === 'paused') paused.add(str(l.month));
106
111
  }
107
- return { runs, keys, lastAt, snaps, paused };
112
+ return { runs, keys, lastAt, snaps, plans, paused };
108
113
  }
109
114
 
110
115
  /* ── the credit cap ── */
@@ -152,6 +157,8 @@ const LIM = { title: 140, why: 300, next: 140, label: 80, ref: 500 };
152
157
  /** → { prop } | { error }. input: { title, why, next, sources: [{ label, ref }], key?, rank? } */
153
158
  function finding(angle, input) {
154
159
  if (!ANGLES.includes(angle)) return { error: 'the angle is one of ' + ANGLES.join(', ') };
160
+ // an earlier decision has its own shape and its own checks (lib/decision-memory.js); `pmos grill find decision` files it
161
+ if (angle === 'decision') return { error: 'the decision angle files through lib/decision-memory.js (pmos grill find decision)' };
155
162
  if (!isObj(input)) return { error: 'the finding is a JSON object' };
156
163
  const title = one(input.title, LIM.title), why = one(input.why, LIM.why), next = one(input.next, LIM.next);
157
164
  if (!title) return { error: 'title is the finding in one short line' };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+ /* past-chat.js — what a chat knows when the PM asks it about the past (initiative ask-the-past). There is no search box and no button: the PM
3
+ * asks in the initiative chat ("why did we drop X?", "did we ever decide Y?"), and this rule reaches the session first, as lib/program-chat.js
4
+ * does for programs.
5
+ *
6
+ * THE GUARDRAIL IS THE RULE'S ORDER. The chat runs `pmos past ask`, answers ONLY from the lines it prints, cites each as path:line, checks its
7
+ * draft with `pmos past verify`, and says "No record found" when the records hold nothing. It never guesses a reason, never quotes a
8
+ * superseded decision as the live rule, and never changes a record while answering.
9
+ *
10
+ * isAsk(text) → whether a message asks about the past (lib/past.js isPastAsk)
11
+ * rule() → the rule's lines
12
+ * wrap(text) → the message as PMOS types it into a live session
13
+ */
14
+ const P = require('./past.js');
15
+
16
+ const MARK = '[PMOS past]';
17
+ const SAYS = 'The PM asks: ';
18
+ const RULE = [
19
+ 'The PM asks about the past. Answer from the records PMOS keeps, never from memory:',
20
+ '1. Run `pmos past ask "<the PM\'s question, in their words>"`. It prints the records that answer it, each with its path:line and its own words, and how sure the match is.',
21
+ '2. Answer in two to four sentences, from those lines only. Put the path:line after each claim, as printed. Say when it was decided and by which record (for example D66).',
22
+ '3. A line marked SUPERSEDED is history: say what replaced it. A weak match says so in your answer.',
23
+ '4. If it prints "No record found", or its lines do not really answer the question, say "No record found" and name what PMOS read. Never guess a reason.',
24
+ '5. Before you answer, run `pmos past verify --text "<your answer>"` and fix every citation it marks ✗.',
25
+ 'Do not change any record, task or decision while you answer. If the PM then wants something changed, ask first.',
26
+ ].join('\n');
27
+
28
+ const isAsk = (text) => P.isPastAsk(text);
29
+ const rule = () => RULE;
30
+ function wrap(text) { return MARK + ' ' + RULE + '\n\n' + SAYS + String(text == null ? '' : text); }
31
+
32
+ module.exports = { MARK, isAsk, rule, wrap, RULE };
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+ /* past-cli.js — `pmos past …`: ask PMOS about the past (initiative ask-the-past; lib/past.js is the engine).
3
+ *
4
+ * pmos past ask "<question>" [--json] [--limit N] the records that answer it, each with its path:line and its own words, and how sure
5
+ * the match is; "No record found" when nothing answers it
6
+ * pmos past check "<idea>" [--json] what was decided or declined before about this idea ("you decided this before")
7
+ * pmos past verify --text "<answer>" | --file <f> every path:line the answer cites is a record PMOS read, at a line that exists
8
+ * pmos past sources which records were read, and which were not on this machine
9
+ *
10
+ * WHAT IT READS. The repository's tracked records: DECISIONS.md, the ADRs, pm-decisions.md, planning/out-of-scope/, planning/initiatives/
11
+ * and discoveries.md; and, in the per-user state folder, the decisions caught in meeting notes that the PM did not mark wrong or delete
12
+ * (lib/meeting-points.js). A private note is never read. Chat transcripts and Jira tickets are not past records PMOS keeps on this machine,
13
+ * so they are named as not read rather than guessed at.
14
+ *
15
+ * WHAT IT NEVER DOES. It writes nothing, sends nothing and calls no model. The answer in words is the chat's: the initiative chat runs
16
+ * `pmos past ask`, answers only from what it prints and cites each line (lib/past-chat.js).
17
+ */
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { execFileSync } = require('child_process');
21
+ const P = require('./past.js');
22
+ const { defaultRoot } = require('./state-root');
23
+ const { parseArgs } = require('./schedule-cli');
24
+
25
+ const COMMANDS = { past: ['ask', 'check', 'verify', 'sources'] };
26
+ const USAGE = [
27
+ 'usage: pmos past ask|check|verify|sources — ask PMOS about the past, with the source of every answer',
28
+ ' pmos past ask "<question>" [--json] [--limit N]',
29
+ ' pmos past check "<idea>" [--json]',
30
+ ' pmos past verify --text "<answer>" | --file <path>',
31
+ ' pmos past sources',
32
+ ];
33
+ const REPO_RE = /^(DECISIONS\.md|pm-decisions\.md|discoveries\.md|okf\/products\/pmos\/adr\/[^/]+\.md|planning\/out-of-scope\/[^/]+\.md|planning\/initiatives\/[^/]+\.md)$/;
34
+ const NOT_READ = ['chat transcripts (PMOS keeps no searchable copy)', 'Jira tickets (no ticket history on this machine)'];
35
+ const KIND_WORD = { decision: 'decision', amendment: 'amendment', adr: 'decision record', 'pm-decision': 'PM journal', 'out-of-scope': 'declined request',
36
+ initiative: 'initiative', discovery: 'run log', meeting: 'meeting decision', claim: 'claim', ticket: 'ticket' };
37
+
38
+ const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
39
+
40
+ /** The repository's past records: [{ path, text }]. */
41
+ function repoFiles(root) {
42
+ const out = [];
43
+ for (const rel of git(root, ['ls-files', '-z', '*.md']).split('\0').filter((p) => REPO_RE.test(p))) {
44
+ try { out.push({ path: rel, text: fs.readFileSync(path.join(root, rel), 'utf8') }); } catch { /* a file that cannot be read is not a record */ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /** The decisions caught in the PM's meeting notes: [{ kind: 'meeting', ref, title, text, date, quote }]. A private note, and a point marked
50
+ * wrong or deleted, is left out. */
51
+ function meetingRecords(stateDir) {
52
+ const dir = path.join(stateDir || '', 'notes');
53
+ let names = [];
54
+ try { names = fs.readdirSync(dir).filter((f) => /^[A-Za-z0-9_-]{1,80}\.json$/.test(f)); } catch { return { records: [], read: false, notes: 0 }; }
55
+ let MR = null, MP = null;
56
+ try { MR = require('./meeting-replay.js'); MP = require('./meeting-points.js'); } catch { return { records: [], read: false, notes: 0 }; }
57
+ const out = []; let notes = 0;
58
+ for (const f of names) {
59
+ let n = null;
60
+ try { n = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch { continue; }
61
+ if (!n || n.private) continue;
62
+ notes++;
63
+ const id = f.slice(0, -5);
64
+ const verdicts = MP.pointsOf(stateDir, id);
65
+ const caught = MR.caughtOf(n);
66
+ const when = String((n.meeting && n.meeting.start) || n.created_at || '').slice(0, 10);
67
+ const title = String((n.meeting && n.meeting.title) || n.title || 'Meeting').slice(0, 100);
68
+ caught.decision.forEach((d, i) => {
69
+ const pid = 'd' + (i + 1);
70
+ if (verdicts[pid] === 'wrong' || verdicts[pid] === 'deleted') return;
71
+ out.push({ kind: 'meeting', ref: 'state:notes/' + f + ':1', title: title + ' — ' + d.text, text: d.text + (d.quote ? '\n' + d.quote : ''), date: when, quote: d.text,
72
+ ...(verdicts[pid] === 'confirmed' ? { confirmed: true } : {}) });
73
+ });
74
+ }
75
+ return { records: out, read: true, notes };
76
+ }
77
+
78
+ function corpusFor(cwd, stateDir) {
79
+ let root;
80
+ try { root = git(cwd, ['rev-parse', '--show-toplevel']).trim(); } catch { root = null; }
81
+ const files = root ? repoFiles(root) : [];
82
+ const m = meetingRecords(stateDir);
83
+ return { root, C: P.build(files, m.records), meetings: m };
84
+ }
85
+
86
+ function sourcesLine(C, meetings) {
87
+ const k = C.kinds || {};
88
+ const parts = [];
89
+ const n = (key, word) => { if (k[key]) parts.push(k[key] + ' ' + word); };
90
+ n('decision', 'decisions'); n('adr', 'decision records'); n('amendment', 'amendments'); n('pm-decision', 'PM journal entries');
91
+ n('out-of-scope', 'declined requests'); n('initiative', 'initiatives'); n('discovery', 'run-log entries');
92
+ parts.push(meetings.read ? (k.meeting || 0) + ' meeting decisions from ' + meetings.notes + ' notes' : 'no meeting notes on this machine');
93
+ return 'Read: ' + parts.join(', ') + '. Not read: ' + NOT_READ.join('; ') + '.';
94
+ }
95
+
96
+ function hitLines(h, i) {
97
+ const st = h.status === 'reverted' ? ' · REVERTED: not the live rule' : h.status === 'superseded' ? ` · SUPERSEDED${h.partial ? ' in part' : ''}${h.supersededBy && h.supersededBy.length ? ' by ' + h.supersededBy.join(', ') : ''}: not the live rule`
98
+ : h.status === 'amended' ? ` · amended${h.amendedBy && h.amendedBy.length ? ' by ' + h.amendedBy.join(', ') : ''}` : '';
99
+ const out = [`${i + 1}. ${h.title} (${KIND_WORD[h.kind] || h.kind}${h.date ? ', ' + h.date : ''}${st})`, ` ${h.ref} "${h.quote}"`];
100
+ if (h.recordStatus) out.push(` its status: ${h.recordStatus}`);
101
+ if (h.dropSaid === false) out.push(' (names the subject, but does not say it was dropped)');
102
+ return out;
103
+ }
104
+
105
+ const CONF_WORD = { high: 'a strong match: two or more records agree', medium: 'a fair match', low: 'a weak match: read the lines before you rely on them' };
106
+
107
+ function run(argv, { say = console.log, cwd = process.cwd(), stateDir = defaultRoot() } = {}) {
108
+ const args = argv[0] === 'past' ? argv.slice(1) : argv.slice();
109
+ const verb = args[0];
110
+ if (!verb || verb === 'help' || verb === '--help') { for (const l of USAGE) say(l); return verb ? 0 : 2; }
111
+ if (!COMMANDS.past.includes(verb)) { say(`✗ unknown past command "${verb}" — one of ${COMMANDS.past.join(', ')}`); return 2; }
112
+ const { pos, flags } = parseArgs(args.slice(1));
113
+ const { C, meetings, root } = corpusFor(cwd, stateDir);
114
+ if (!root && !meetings.read) { say('✗ this is not a git repository and there are no meeting notes here, so there is no past to read'); return 1; }
115
+
116
+ if (verb === 'sources') { say(sourcesLine(C, meetings)); return 0; }
117
+
118
+ if (verb === 'verify') {
119
+ let text = flags.text;
120
+ if (flags.file) { try { text = fs.readFileSync(String(flags.file), 'utf8'); } catch (e) { say('✗ cannot read ' + flags.file + ': ' + (e.code || e.message)); return 1; } }
121
+ if (typeof text !== 'string' || !text.trim()) { say('✗ give the answer with --text "<answer>" or --file <path>'); return 2; }
122
+ const v = P.verify(C, text);
123
+ if (!v.refs.length) { say('✗ the answer cites no record (path:line). Cite the lines `pmos past ask` printed, or say no record was found.'); return 1; }
124
+ for (const r of v.refs) say(`${r.ok ? '✓' : '✗'} ${r.ref}${r.ok ? '' : ' — ' + r.why}`);
125
+ say(v.ok ? 'Every citation is a record PMOS read.' : 'Remove or fix the citations marked ✗ before you answer.');
126
+ return v.ok ? 0 : 1;
127
+ }
128
+
129
+ const q = pos.join(' ').trim();
130
+ if (!q) { say(`✗ give the ${verb === 'ask' ? 'question' : 'idea'} in quotes: pmos past ${verb} "<${verb === 'ask' ? 'question' : 'idea'}>"`); return 2; }
131
+ const limit = Math.max(1, Math.min(10, Number(flags.limit) || 5));
132
+ const r = verb === 'ask' ? P.search(C, q, { limit }) : P.repeats(C, q, { limit });
133
+ if (flags.json) { say(JSON.stringify({ question: q, confidence: r.confidence, none: r.none, hits: r.hits, read: sourcesLine(C, meetings) }, null, 1)); return 0; }
134
+ if (r.none) {
135
+ say(verb === 'ask' ? `No record found for "${q}".` : `No earlier decision found about "${q}".`);
136
+ say('Nothing PMOS keeps answers it. Say so plainly, and do not guess a reason.');
137
+ say(sourcesLine(C, meetings));
138
+ return 0;
139
+ }
140
+ say(`${verb === 'ask' ? 'What the records say' : 'You decided this before'}: ${CONF_WORD[r.confidence]}.`);
141
+ r.hits.forEach((h, i) => { for (const l of hitLines(h, i)) say(l); });
142
+ say('Answer only from these lines. Cite each one as path:line. A superseded decision is history, not the rule. If they do not answer the question, say no record was found.');
143
+ say(sourcesLine(C, meetings));
144
+ return 0;
145
+ }
146
+
147
+ module.exports = { run, COMMANDS, USAGE, corpusFor, repoFiles, meetingRecords, REPO_RE };