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.
- package/package.json +45 -27
- package/payload/AGENTS.md +2 -0
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/changelog.json +44 -0
- package/workspace/fixtures/decision-pairs.js +94 -0
- package/workspace/fixtures/rehearse-replay.json +159 -0
- package/workspace/guide/ask-the-past.md +46 -0
- package/workspace/guide/claude-proposes.md +29 -2
- package/workspace/guide/index.md +3 -0
- package/workspace/guide/rehearse.md +52 -0
- package/workspace/guide/what-you-missed.md +42 -0
- package/workspace/lib/ai-cli.js +63 -0
- package/workspace/lib/ai-guard.js +314 -0
- package/workspace/lib/briefing-premium-agent.js +3 -0
- package/workspace/lib/decision-ledger.js +236 -0
- package/workspace/lib/decision-memory.js +237 -0
- package/workspace/lib/drift-cli.js +98 -0
- package/workspace/lib/drift-facts.js +151 -0
- package/workspace/lib/drift-runs.js +149 -0
- package/workspace/lib/drift.js +227 -0
- package/workspace/lib/grill-cli.js +22 -1
- package/workspace/lib/grill-runs.js +20 -3
- package/workspace/lib/grill.js +15 -8
- package/workspace/lib/past-chat.js +32 -0
- package/workspace/lib/past-cli.js +147 -0
- package/workspace/lib/past-repeat.js +126 -0
- package/workspace/lib/past.js +353 -0
- package/workspace/lib/pmos-verbs.js +12 -0
- package/workspace/lib/proposal-runs.js +20 -0
- package/workspace/lib/rehearse-cli.js +102 -0
- package/workspace/lib/rehearse-guard.js +81 -0
- package/workspace/lib/rehearse-read.js +159 -0
- package/workspace/lib/rehearse.js +279 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-drift.js +62 -0
- package/workspace/lib/settings-store.js +6 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/grill-words.js +4 -2
- package/workspace/public/pm/home-app.js +14 -2
- package/workspace/public/pm/home-drift.js +76 -0
- package/workspace/server.js +19 -0
- package/workspace/skills/grill.skill +4 -2
- package/workspace/test-files.json +1 -1
- package/workspace/test-lists/ask-the-past.json +5 -0
- package/workspace/test-lists/decision-memory.json +6 -0
- package/workspace/test-lists/plan-rehearsal.json +5 -0
- package/workspace/test-lists/weekly-drift-check.json +5 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* decision-ledger.js — every locked decision, what it says, and whether it still stands (decision memory, part 1).
|
|
3
|
+
*
|
|
4
|
+
* PURE apart from load(): text in, ledger out. No model, no network. Same input → the same ledger.
|
|
5
|
+
*
|
|
6
|
+
* WHERE IT READS. DECISIONS.md: one decision per `### D<nn> — <title>` heading, its text until the next heading, and the dated
|
|
7
|
+
* `## Amendment log` lines ("- 2026-06-29 — Added D40 …"). ADR files (okf/products/pmos/adr/d<nn>-*.md) add their frontmatter typed keys
|
|
8
|
+
* `supersedes:` / `amended_by:` when they carry them.
|
|
9
|
+
*
|
|
10
|
+
* WHAT A RELATION IS. Only what the text says, clause by clause (a clause ends at `.`, `;` or a new line):
|
|
11
|
+
* supersedes "Supersedes D26 …", "supersedes D20's 3-consecutive rule", "drop the D79 decision", "D29 REVERTED"
|
|
12
|
+
* superseded "Superseded … by [D40]" written inside the older decision (the older one names its successor)
|
|
13
|
+
* amends "Amends D17", "D03 not superseded — amended", "narrows D38"
|
|
14
|
+
* A clause that says "No decision superseded", "supersedes nothing", "does not supersede", "without superseding" or "not superseded"
|
|
15
|
+
* makes no supersede edge (it may still make an `amends` edge). In the amendment log the clause belongs to the decision the line adds.
|
|
16
|
+
*
|
|
17
|
+
* STATUS. superseded when a later decision supersedes it, reverted when the log says so, amended when a later decision amends it,
|
|
18
|
+
* else active. A decision that is only partly superseded ("as the calibration standard") is still `superseded`: a pushback must quote the
|
|
19
|
+
* decision that replaced it, not the old one. `partial: true` says the rest of it still stands.
|
|
20
|
+
*
|
|
21
|
+
* parse(text, { path }) → ledger { path, decisions: Map id → d, pairs: [{ newer, older, rel, line }] }
|
|
22
|
+
* d: { id, n, title, line, end, text, date, status, partial, supersededBy: [], supersedes: [], amendedBy: [], amends: [] }
|
|
23
|
+
* addAdr(ledger, { path, text }) the ADR frontmatter's typed keys, as more pairs
|
|
24
|
+
* load(root, { fs }) → ledger | null DECISIONS.md at the root, plus the ADR folder when it exists
|
|
25
|
+
* asOf(ledger, id) → ledger the ledger as it stood before decision `id` was added (it and every later one removed)
|
|
26
|
+
* current(ledger, id) → [id] the decisions that stand in for `id` now (itself when it is active)
|
|
27
|
+
* quoted(ledger, id, quote) → bool the quote is in that decision's text (case and white space ignored)
|
|
28
|
+
* lineOf(ledger, id, quote) → line the file line the quote starts on (for a path:line ref)
|
|
29
|
+
* index(ledger, { all }) → [line] one line per decision: "D40 · active · DECISIONS.md:159 · <title>"
|
|
30
|
+
*/
|
|
31
|
+
const path = require('path');
|
|
32
|
+
|
|
33
|
+
const HEAD = /^###\s+(D(\d{2,3}))\s+[—–-]+\s+(.+?)\s*$/;
|
|
34
|
+
const LOG_HEAD = /^##\s+Amendment log/i;
|
|
35
|
+
const LOG_LINE = /^-\s+(\d{4}-\d{2}-\d{2})\s+[—–-]+\s+(.*)$/;
|
|
36
|
+
const DID = /\bD(\d{2,3})\b/g;
|
|
37
|
+
const NO_SUP = /\b(no (prior |p0 )?decision (is )?superseded|supersedes nothing|does not supersede|without superseding|not superseded|no decision is superseded|nothing superseded)\b/i;
|
|
38
|
+
const norm = (s) => String(s || '').replace(/\]\([^)]*\)/g, ']').replace(/[*_`>#\[\]]/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
39
|
+
const idOf = (n) => 'D' + String(n).padStart(2, '0');
|
|
40
|
+
const uniq = (a) => [...new Set(a)];
|
|
41
|
+
|
|
42
|
+
/* Split a block of text into clauses with the line each starts on. */
|
|
43
|
+
function clauses(lines, firstLine) {
|
|
44
|
+
const out = [];
|
|
45
|
+
lines.forEach((raw, i) => {
|
|
46
|
+
// drop markdown link targets so "[D40](#d40--…)" reads as "D40"
|
|
47
|
+
const t = String(raw).replace(/\]\([^)]*\)/g, ']').replace(/\*\*/g, '');
|
|
48
|
+
for (const c of t.split(/(?<=[.;])\s+|;\s*/)) if (c.trim()) out.push({ text: c, line: firstLine + i });
|
|
49
|
+
});
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
const idsIn = (s) => uniq([...String(s).matchAll(DID)].map((m) => idOf(m[1])));
|
|
53
|
+
/* The ids named after a keyword in a clause (the words that follow it). */
|
|
54
|
+
function after(clause, re) {
|
|
55
|
+
const m = re.exec(clause); if (!m) return [];
|
|
56
|
+
return idsIn(clause.slice(m.index + m[0].length));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* The relations one clause states, for the decision `self`. → [{ newer, older, rel }] */
|
|
60
|
+
function relationsOf(self, clause) {
|
|
61
|
+
const out = [];
|
|
62
|
+
// a parenthetical aside ("(the kit's contract, D54/D55, is unchanged)") and a tail after a dash are not what the clause decides
|
|
63
|
+
const c = String(clause).replace(/\((?:[^()]|\([^()]*\))*\)/g, ' ').split(/\s[—–]\s|\s--\s/)[0];
|
|
64
|
+
const neg = NO_SUP.test(c);
|
|
65
|
+
// "Superseded … by D40" — the older decision names its successor
|
|
66
|
+
const sb = /\bsuperseded\b[^.;]*?\bby\b/i.exec(c);
|
|
67
|
+
if (sb && !neg) for (const id of idsIn(c.slice(sb.index + sb[0].length)).slice(0, 1)) if (id !== self) out.push({ newer: id, older: self, rel: 'supersedes' });
|
|
68
|
+
if (!sb && !neg) {
|
|
69
|
+
for (const id of after(c, /\b(supersedes|supersede|replaces|retires|drops?)\b/i)) if (id !== self) out.push({ newer: self, older: id, rel: 'supersedes' });
|
|
70
|
+
// "D20 … is superseded" written in the newer decision
|
|
71
|
+
const sx = /\b(D\d{2,3})\b[^.;]{0,80}?\b(is|are)\s+\**superseded\b/i.exec(c);
|
|
72
|
+
if (sx && idOf(sx[1].slice(1)) !== self) out.push({ newer: self, older: idOf(sx[1].slice(1)), rel: 'supersedes' });
|
|
73
|
+
for (const m of c.matchAll(/\bdrop(?:s|ped)? the (D\d{2,3})\b/gi)) if (idOf(m[1].slice(1)) !== self) out.push({ newer: self, older: idOf(m[1].slice(1)), rel: 'supersedes' });
|
|
74
|
+
}
|
|
75
|
+
for (const id of after(c, /\b(amends|amended|narrows|widens)\b/i)) if (id !== self && !out.some((r) => r.older === id)) out.push({ newer: self, older: id, rel: 'amends' });
|
|
76
|
+
const am = /\b(D\d{2,3}) not superseded\s*[—–-]+\s*amended\b/i.exec(c);
|
|
77
|
+
if (am && idOf(am[1].slice(1)) !== self) out.push({ newer: self, older: idOf(am[1].slice(1)), rel: 'amends' });
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parse(text, { path: file = 'DECISIONS.md' } = {}) {
|
|
82
|
+
const lines = String(text || '').split('\n');
|
|
83
|
+
const decisions = new Map();
|
|
84
|
+
const pairs = [], dates = new Map();
|
|
85
|
+
let cur = null, inLog = false;
|
|
86
|
+
const close = (end) => { if (cur) { cur.end = end; cur.text = lines.slice(cur.line - 1, end).join('\n'); } };
|
|
87
|
+
const mk = (h, i) => ({ id: idOf(h[2]), n: Number(h[2]), title: h[3].trim(), path: file, line: i + 1, end: 0, text: '', date: '', status: 'active', partial: false, supersededBy: [], supersedes: [], amendedBy: [], amends: [] });
|
|
88
|
+
lines.forEach((l, i) => {
|
|
89
|
+
const h = HEAD.exec(l);
|
|
90
|
+
if (h) { close(i); cur = mk(h, i); if (!decisions.has(cur.id)) decisions.set(cur.id, cur); return; }
|
|
91
|
+
if (LOG_HEAD.test(l)) { close(i); cur = null; inLog = true; return; }
|
|
92
|
+
if (/^##\s/.test(l) && cur) { close(i); cur = null; }
|
|
93
|
+
});
|
|
94
|
+
close(lines.length);
|
|
95
|
+
// relations inside each decision's own text
|
|
96
|
+
for (const d of decisions.values()) {
|
|
97
|
+
for (const c of clauses(d.text.split('\n').slice(1), d.line + 1)) for (const r of relationsOf(d.id, c.text)) pairs.push({ ...r, line: c.line, where: d.path, clause: c.text });
|
|
98
|
+
}
|
|
99
|
+
// the amendment log: each line belongs to the decision it adds ("Added D40 …"); a "D29 REVERTED" line reverts it
|
|
100
|
+
if (inLog) {
|
|
101
|
+
const start = lines.findIndex((l) => LOG_HEAD.test(l));
|
|
102
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
103
|
+
// a log line may hold two entries run together ("… superseded.- 2026-06-29 — …"); split them
|
|
104
|
+
for (const part of lines[i].split(/(?<=\.)-\s+(?=\d{4}-\d{2}-\d{2})/)) {
|
|
105
|
+
const m = LOG_LINE.exec(part.startsWith('-') ? part : '- ' + part); if (!m) continue;
|
|
106
|
+
const date = m[1], body = m[2].replace(/\*\*/g, '');
|
|
107
|
+
const rv = /\b(D\d{2,3})\s+REVERTED\b/.exec(body);
|
|
108
|
+
if (rv) { const d = decisions.get(idOf(rv[1].slice(1))); if (d) { d.status = 'reverted'; d.revertedAt = date; } continue; }
|
|
109
|
+
const added = /\b(?:Added|Landed)\s+(D\d{2,3})\b/.exec(body);
|
|
110
|
+
if (!added) continue;
|
|
111
|
+
const self = idOf(added[1].slice(1)), d = decisions.get(self);
|
|
112
|
+
if (d && !d.date) d.date = date;
|
|
113
|
+
if (!d) dates.set(self, date);
|
|
114
|
+
// the parenthetical summary of what was added is not a relation; read the clauses after it
|
|
115
|
+
const rest = body.replace(/\((?:[^()]|\([^()]*\))*\)/g, ' ');
|
|
116
|
+
for (const c of clauses([rest], i + 1)) for (const r of relationsOf(self, c.text)) pairs.push({ ...r, line: i + 1, where: file, clause: c.text });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const L = finish({ path: file, decisions, pairs });
|
|
121
|
+
L.dates = dates; // the log dates of decisions recorded only as an ADR, for addAdr
|
|
122
|
+
return L;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Dedupe the pairs, keep only pairs between known decisions where the newer one is newer, and set each decision's status. */
|
|
126
|
+
function finish(L) {
|
|
127
|
+
const seen = new Set(), pairs = [], raw = L.raw || L.pairs;
|
|
128
|
+
for (const p of raw) {
|
|
129
|
+
const a = L.decisions.get(p.newer), b = L.decisions.get(p.older);
|
|
130
|
+
if (!a || !b || a.n <= b.n) continue;
|
|
131
|
+
const k = p.newer + '>' + p.older + ':' + p.rel;
|
|
132
|
+
// a supersede wins over an amend of the same pair
|
|
133
|
+
if (p.rel === 'amends' && seen.has(p.newer + '>' + p.older + ':supersedes')) continue;
|
|
134
|
+
if (seen.has(k)) continue;
|
|
135
|
+
seen.add(k);
|
|
136
|
+
if (p.rel === 'supersedes') { const j = pairs.findIndex((q) => q.newer === p.newer && q.older === p.older && q.rel === 'amends'); if (j >= 0) pairs.splice(j, 1); }
|
|
137
|
+
pairs.push(p);
|
|
138
|
+
}
|
|
139
|
+
for (const d of L.decisions.values()) { d.supersededBy = []; d.supersedes = []; d.amendedBy = []; d.amends = []; if (d.status !== 'reverted') d.status = 'active'; d.partial = false; }
|
|
140
|
+
for (const p of pairs) {
|
|
141
|
+
const a = L.decisions.get(p.newer), b = L.decisions.get(p.older);
|
|
142
|
+
if (p.rel === 'supersedes') { a.supersedes.push(b.id); b.supersededBy.push(a.id); } else { a.amends.push(b.id); b.amendedBy.push(a.id); }
|
|
143
|
+
}
|
|
144
|
+
for (const d of L.decisions.values()) {
|
|
145
|
+
if (d.status === 'reverted') continue;
|
|
146
|
+
if (d.supersededBy.length) {
|
|
147
|
+
d.status = 'superseded';
|
|
148
|
+
// "superseded as the calibration standard", "the rule of D69 point 3": the rest of the decision still stands
|
|
149
|
+
d.partial = pairs.filter((p) => p.older === d.id && p.rel === 'supersedes').some((p) => /\b(as the|point \d|rule of|for pmos-self|still stand)/i.test(p.clause || ''));
|
|
150
|
+
} else if (d.amendedBy.length) d.status = 'amended';
|
|
151
|
+
}
|
|
152
|
+
return { path: L.path, decisions: L.decisions, pairs, raw, dates: L.dates };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* ADR frontmatter: `supersedes: [okf/…/d20-….md]` or `amended_by: …` (one-line lists of paths or ids). */
|
|
156
|
+
function addAdr(L, { path: file, text }) {
|
|
157
|
+
const self = /\/d(\d{2,3})-[^/]*\.md$/i.exec(String(file || ''));
|
|
158
|
+
if (!L || !self) return L;
|
|
159
|
+
const id = idOf(self[1]);
|
|
160
|
+
const fm = /^---\n([\s\S]*?)\n---/.exec(String(text || ''));
|
|
161
|
+
if (!fm) return L;
|
|
162
|
+
const more = [];
|
|
163
|
+
if (!L.decisions.has(id)) {
|
|
164
|
+
// a decision recorded only as its ADR (the DECISIONS.md body names it in the amendment log alone)
|
|
165
|
+
const all = String(text).split('\n'), start = fm[0].split('\n').length;
|
|
166
|
+
const t = /^title:\s*"?(.*?)"?\s*$/m.exec(fm[1]);
|
|
167
|
+
const title = (t ? t[1] : id).replace(/^D\d{2,3}\s*[—–-]+\s*/, '');
|
|
168
|
+
const d = { id, n: Number(self[1]), title, path: file, line: start + 1, end: all.length, text: all.slice(start).join('\n'), date: (L.dates && L.dates.get(id)) || (/^timestamp:\s*(\d{4}-\d{2}-\d{2})/m.exec(fm[1]) || [])[1] || '',
|
|
169
|
+
status: 'active', partial: false, supersededBy: [], supersedes: [], amendedBy: [], amends: [] };
|
|
170
|
+
L.decisions.set(id, d);
|
|
171
|
+
for (const c of clauses(d.text.split('\n'), d.line)) for (const r of relationsOf(id, c.text)) more.push({ ...r, line: c.line, where: file, clause: c.text });
|
|
172
|
+
}
|
|
173
|
+
for (const line of fm[1].split('\n')) {
|
|
174
|
+
const m = /^(supersedes|amended_by|amends):\s*(.*)$/.exec(line); if (!m) continue;
|
|
175
|
+
const ids = uniq([...m[2].matchAll(/\bd(\d{2,3})\b/gi)].map((x) => idOf(x[1])));
|
|
176
|
+
for (const o of ids) {
|
|
177
|
+
if (m[1] === 'supersedes') more.push({ newer: id, older: o, rel: 'supersedes', line: 1, where: file });
|
|
178
|
+
else if (m[1] === 'amends') more.push({ newer: id, older: o, rel: 'amends', line: 1, where: file });
|
|
179
|
+
else more.push({ newer: o, older: id, rel: 'amends', line: 1, where: file });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return finish({ ...L, raw: [...(L.raw || L.pairs), ...more] });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function load(root, { fs = require('fs') } = {}) {
|
|
186
|
+
const file = path.join(root, 'DECISIONS.md');
|
|
187
|
+
let text;
|
|
188
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
|
|
189
|
+
let L = parse(text, { path: 'DECISIONS.md' });
|
|
190
|
+
const adr = path.join(root, 'okf', 'products', 'pmos', 'adr');
|
|
191
|
+
let names = [];
|
|
192
|
+
try { names = fs.readdirSync(adr).filter((n) => /^d\d{2,3}-.*\.md$/i.test(n)); } catch { names = []; }
|
|
193
|
+
for (const n of names) { try { L = addAdr(L, { path: 'okf/products/pmos/adr/' + n, text: fs.readFileSync(path.join(adr, n), 'utf8') }); } catch { /* an unreadable ADR adds nothing */ } }
|
|
194
|
+
return L;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The ledger as it stood before `id` was added: it and every later decision removed, the statuses read again. */
|
|
198
|
+
function asOf(L, id) {
|
|
199
|
+
const cut = L.decisions.get(id); if (!cut) return L;
|
|
200
|
+
const decisions = new Map();
|
|
201
|
+
for (const [k, d] of L.decisions) if (d.n < cut.n) decisions.set(k, { ...d });
|
|
202
|
+
return finish({ path: L.path, decisions, raw: (L.raw || L.pairs).filter((p) => decisions.has(p.newer) && decisions.has(p.older)) });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The decisions that stand in for `id` now: itself when it stands, else what superseded it (followed to the end of the chain). */
|
|
206
|
+
function current(L, id, seen = new Set()) {
|
|
207
|
+
const d = L.decisions.get(id); if (!d || seen.has(id)) return [];
|
|
208
|
+
seen.add(id);
|
|
209
|
+
if (d.status !== 'superseded') return [id];
|
|
210
|
+
return uniq(d.supersededBy.flatMap((n) => current(L, n, seen)));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function quoted(L, id, quote) {
|
|
214
|
+
const d = L.decisions.get(id), q = norm(quote);
|
|
215
|
+
return !!(d && q.length >= 12 && norm(d.text).includes(q));
|
|
216
|
+
}
|
|
217
|
+
function lineOf(L, id, quote) {
|
|
218
|
+
const d = L.decisions.get(id); if (!d) return 0;
|
|
219
|
+
const q = norm(quote).slice(0, 40);
|
|
220
|
+
const own = d.text.split('\n');
|
|
221
|
+
for (let i = 0; i < own.length; i++) if (norm(own[i]).includes(q)) return d.line + i;
|
|
222
|
+
return d.line;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function index(L, { all = false } = {}) {
|
|
226
|
+
const out = [];
|
|
227
|
+
for (const d of [...L.decisions.values()].sort((a, b) => a.n - b.n)) {
|
|
228
|
+
if (!all && (d.status === 'superseded' && !d.partial || d.status === 'reverted')) continue;
|
|
229
|
+
const note = d.status === 'superseded' ? ` (superseded by ${d.supersededBy.join(', ')}${d.partial ? '; the rest stands' : ''})`
|
|
230
|
+
: d.status === 'amended' ? ` (amended by ${d.amendedBy.join(', ')})` : d.status === 'reverted' ? ' (reverted)' : '';
|
|
231
|
+
out.push(`${d.id} · ${d.status} · ${d.path}:${d.line}${d.date ? ' · ' + d.date : ''} · ${d.title}${note}`);
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = { parse, addAdr, load, asOf, current, quoted, lineOf, index, relationsOf, norm };
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* decision-memory.js — Claude remembers every decision and why, and says so when a plan goes against one (decision memory, part 2).
|
|
3
|
+
*
|
|
4
|
+
* IT IS THE GRILL'S `decision` ANGLE, not a new feature. A grill run (lib/grill-runs.js, skill grill) reads the initiative's plan, PRD and
|
|
5
|
+
* open ✦, lists the decisions that stand (`pmos grill decisions`), and files a contradiction with `pmos grill find decision`. The finding
|
|
6
|
+
* waits under the initiative's ✦ like every grill finding: the PM keeps, drops or undoes it. It never blocks anything: no gate reads it.
|
|
7
|
+
*
|
|
8
|
+
* WHAT A RUN FILES (one JSON object):
|
|
9
|
+
* { "decision": "D79", "quote": "<the decision's own words, verbatim>",
|
|
10
|
+
* "plan": { "label": "Checkout PRD", "ref": "planning/prd/checkout-prd.md:42", "quote": "<the plan's words at that line, verbatim>" },
|
|
11
|
+
* "conflict": "<how the plan goes against it, one line>", "recommend": "keep" | "change" | "drop", "next": "<one next step>",
|
|
12
|
+
* "confidence": "high" | "medium", "rank"?: 1-9 }
|
|
13
|
+
* keep keep the decision: change the plan
|
|
14
|
+
* change the plan is right and the decision is old: write a new decision that amends it
|
|
15
|
+
* drop the decision no longer holds: write a new decision that supersedes it
|
|
16
|
+
*
|
|
17
|
+
* THE GUARDRAILS, before anything is shown (in this order; each refusal says why):
|
|
18
|
+
* 1. the switch grill mode off, PMOS_DECISION_MEMORY=off, or the shared kill switch (lib/ai-guard.js off)
|
|
19
|
+
* 2. a live decision the decision is in the ledger (lib/decision-ledger.js) and still stands. A superseded or reverted decision is refused
|
|
20
|
+
* with the decision that replaced it, so Claude never pushes back with a dead decision
|
|
21
|
+
* 3. verbatim the decision's quote is in its own text, and the plan's quote is at its ref (±2 lines); nothing is paraphrased
|
|
22
|
+
* 4. honest confidence is high or medium. Low is left out: quiet by default. High needs both sides verified (2 refs)
|
|
23
|
+
* 5. replayed the real supersede pairs replay through the same check (replayCases) and pass for this VERSION, else everything
|
|
24
|
+
* is held (lib/ai-guard.js ready/file)
|
|
25
|
+
* 6. audited every filing, refusal and hold is one line of <state>/ai-audit.jsonl (lib/ai-guard.js audit)
|
|
26
|
+
*
|
|
27
|
+
* JOB · VERSION · versionOf(L)
|
|
28
|
+
* fileRun(o, bodies) → Promise<exit code> what `pmos grill find decision` runs
|
|
29
|
+
* offWhy(env) → '' | why this angle's own switch
|
|
30
|
+
* checkFinding(L, input, { root, read }) → { prop } | { error } PURE apart from reading the plan file at its ref
|
|
31
|
+
* toGrill(prop, input) → the grill proposal for lib/proposals.js (group grill, type decision, section check)
|
|
32
|
+
* replayCases(L, { root }) → [case] the real supersede pairs as lib/ai-guard.js replay cases
|
|
33
|
+
* pairsOf(L) → [{ newer, older }] the supersede pairs the replay uses
|
|
34
|
+
* plansIn(root, inis) → [{ ini, board, path, sig }] each running initiative's PRD, for the grill's `plan` trigger
|
|
35
|
+
*/
|
|
36
|
+
const fs = require('fs');
|
|
37
|
+
const path = require('path');
|
|
38
|
+
const crypto = require('crypto');
|
|
39
|
+
const DL = require('./decision-ledger.js');
|
|
40
|
+
|
|
41
|
+
const JOB = 'decision-memory';
|
|
42
|
+
const VERSION = '1';
|
|
43
|
+
const RECOMMEND = Object.freeze({ keep: 'Keep {D}: change the plan', change: 'Change {D}: write a decision that amends it', drop: 'Drop {D}: write a decision that supersedes it' });
|
|
44
|
+
const LIM = { quote: 400, conflict: 200, next: 140, label: 80, ref: 300 };
|
|
45
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
46
|
+
const one = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
47
|
+
const hash = (s) => crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 10);
|
|
48
|
+
const flagOff = (v) => /^(off|0|false|no)$/i.test(String(v || '').trim());
|
|
49
|
+
const offWhy = (env = process.env) => (flagOff(env.PMOS_DECISION_MEMORY) ? 'decision memory is off (PMOS_DECISION_MEMORY)' : '');
|
|
50
|
+
|
|
51
|
+
/* path:line → { file, line } when it is a repository path; else null */
|
|
52
|
+
function refOf(ref) {
|
|
53
|
+
const m = /^([^:\s][^:]*?):(\d+)$/.exec(String(ref || '').trim());
|
|
54
|
+
if (!m || path.isAbsolute(m[1]) || m[1].split(/[\\/]/).includes('..')) return null;
|
|
55
|
+
return { file: m[1], line: Number(m[2]) };
|
|
56
|
+
}
|
|
57
|
+
/* The quote is at the ref, within ±2 lines (case and white space ignored), as lib/ai-guard.js checks it. */
|
|
58
|
+
function quoteAt(root, ref, quote, read) {
|
|
59
|
+
const r = refOf(ref); if (!r) return false;
|
|
60
|
+
let text; try { text = read(path.join(root, r.file)); } catch { return false; }
|
|
61
|
+
const lines = String(text).split('\n'), q = DL.norm(quote);
|
|
62
|
+
if (q.length < 12) return false;
|
|
63
|
+
const win = DL.norm(lines.slice(Math.max(0, r.line - 3), r.line + 2).join(' '));
|
|
64
|
+
return win.includes(q);
|
|
65
|
+
}
|
|
66
|
+
/* The source lib/ai-guard.js re-checks against the raw file: the raw text of the line the quote starts on (markdown kept, so the guard's
|
|
67
|
+
* own match is exact), at most 200 characters. The quote Claude wrote, checked with the markdown ignored, stays in `say`. */
|
|
68
|
+
function rawSource(root, file, nearLine, quote, read) {
|
|
69
|
+
let lines = [];
|
|
70
|
+
try { lines = String(read(path.join(root, file))).split('\n'); } catch { return null; }
|
|
71
|
+
const head = DL.norm(quote).slice(0, 30);
|
|
72
|
+
for (let i = Math.max(0, nearLine - 3); i < Math.min(lines.length, nearLine + 2); i++) {
|
|
73
|
+
if (DL.norm(lines[i]).includes(head)) { const t = lines[i].trim().slice(0, 200).trim(); return t.length >= 12 ? { ref: `${file}:${i + 1}`, quote: t } : null; }
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** → { prop } | { error }. prop is the lib/ai-guard.js proposal shape. */
|
|
79
|
+
function checkFinding(L, input, { root = '.', read = (f) => fs.readFileSync(f, 'utf8'), ini = '' } = {}) {
|
|
80
|
+
if (!L) return { error: 'no DECISIONS.md in this repository: there is nothing to check against' };
|
|
81
|
+
if (!isObj(input)) return { error: 'the finding is a JSON object' };
|
|
82
|
+
const id = /^D\d{2,3}$/.test(String(input.decision || '')) ? String(input.decision).replace(/^D(\d)$/, 'D0$1') : '';
|
|
83
|
+
const d = id && L.decisions.get(id);
|
|
84
|
+
if (!d) return { error: `decision is a D-id in the ledger (pmos grill decisions); "${one(input.decision, 20)}" is not` };
|
|
85
|
+
if (d.status === 'reverted') return { error: `${id} was reverted: it is not a decision to push back with` };
|
|
86
|
+
if (d.status === 'superseded') {
|
|
87
|
+
const now = DL.current(L, id);
|
|
88
|
+
return { error: `${id} was superseded by ${d.supersededBy.join(', ')}: check the plan against ${now.join(', ') || d.supersededBy.join(', ')} instead` };
|
|
89
|
+
}
|
|
90
|
+
const quote = one(input.quote, LIM.quote);
|
|
91
|
+
if (!DL.quoted(L, id, quote)) return { error: `quote is ${id}'s own words, verbatim (12 characters or more); it is not in ${id}` };
|
|
92
|
+
const plan = isObj(input.plan) ? input.plan : null;
|
|
93
|
+
if (!plan || !one(plan.label, LIM.label) || !refOf(plan.ref)) return { error: 'plan is { label, ref: "<path>:<line>", quote }: where the plan says it' };
|
|
94
|
+
const pquote = one(plan.quote, LIM.quote);
|
|
95
|
+
if (!quoteAt(root, plan.ref, pquote, read)) return { error: `plan.quote is not at ${one(plan.ref, 80)} (±2 lines): quote the plan's own words` };
|
|
96
|
+
const conflict = one(input.conflict, LIM.conflict);
|
|
97
|
+
if (!conflict) return { error: 'conflict is one line: how the plan goes against the decision' };
|
|
98
|
+
const rec = String(input.recommend || '');
|
|
99
|
+
if (!RECOMMEND[rec]) return { error: 'recommend is keep, change or drop' };
|
|
100
|
+
const next = one(input.next, LIM.next);
|
|
101
|
+
if (!next) return { error: 'next is the one next step the PM can take (it becomes a task on ✓)' };
|
|
102
|
+
const conf = String(input.confidence || '');
|
|
103
|
+
if (conf === 'low' || conf === 'none') return { error: 'low confidence is left out: file a contradiction only when you would bet on it' };
|
|
104
|
+
if (conf !== 'high' && conf !== 'medium') return { error: 'confidence is high or medium' };
|
|
105
|
+
const dline = DL.lineOf(L, id, quote), pr = refOf(plan.ref);
|
|
106
|
+
const dsrc = rawSource(root, d.path, dline, quote, read) || { ref: `${d.path}:${dline}`, quote };
|
|
107
|
+
const psrc = rawSource(root, pr.file, pr.line, pquote, read) || { ref: one(plan.ref, LIM.ref), quote: pquote };
|
|
108
|
+
const rank = Number.isInteger(input.rank) && input.rank >= 1 && input.rank <= 9 ? input.rank : 2;
|
|
109
|
+
const pfile = pr.file;
|
|
110
|
+
const prop = {
|
|
111
|
+
id: 'pr-' + hash([ini, 'grill', 'decision', id, pfile].join('|')),
|
|
112
|
+
title: one(`Earlier decision: ${conflict}`, 200),
|
|
113
|
+
say: one(`${id}${d.date ? ' (' + d.date + ')' : ''} says "${quote}". ${one(plan.label, LIM.label)} says "${pquote}". Keep ${id} and change the plan, change ${id}, or drop it. Claude suggests: ${RECOMMEND[rec].replace('{D}', id)}.`, 900),
|
|
114
|
+
confidence: conf,
|
|
115
|
+
claims: [
|
|
116
|
+
{ text: `${id} · ${d.title}`, sources: [{ label: id, ...dsrc }] },
|
|
117
|
+
{ text: conflict, sources: [{ label: one(plan.label, LIM.label), ...psrc }] },
|
|
118
|
+
],
|
|
119
|
+
data: { angle: 'decision', decision: id, recommend: rec, next, rank, confidence: conf },
|
|
120
|
+
};
|
|
121
|
+
return { prop };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The grill proposal lib/proposals.js stores, from a checked prop. */
|
|
125
|
+
function toGrill(prop) {
|
|
126
|
+
const d = prop.data;
|
|
127
|
+
return { id: prop.id, group: 'grill', type: 'decision', section: 'check', title: one(prop.title, 200), why: one(prop.say + ' Next: ' + d.next, 1100),
|
|
128
|
+
sources: prop.claims.flatMap((c) => c.sources.map((s) => ({ label: s.label, ref: s.ref }))), items: [], data: { ...d } };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The supersede pairs the replay uses: newer → older, both in the ledger. */
|
|
132
|
+
const pairsOf = (L) => (L ? L.pairs.filter((p) => p.rel === 'supersedes').map((p) => ({ newer: p.newer, older: p.older })) : []);
|
|
133
|
+
|
|
134
|
+
/* The first verbatim sentence of a decision's text that sits on one line (not a heading), for a replayed finding. */
|
|
135
|
+
function firstQuote(d) {
|
|
136
|
+
for (const raw of d.text.split('\n').slice(1)) {
|
|
137
|
+
const t = raw.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^[\s>*-]+/, '').trim();
|
|
138
|
+
if (!t || /^#/.test(t) || t.length < 24) continue;
|
|
139
|
+
return ((t.match(/^.{24,}?[.;:](?=\s|$)/) || [t])[0]).slice(0, 160).trim();
|
|
140
|
+
}
|
|
141
|
+
return d.title;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The real supersede pairs as replay cases. For each pair (newer N supersedes older O) the replay files a recorded finding that a plan
|
|
146
|
+
* quoting N goes against O, twice:
|
|
147
|
+
* before N the ledger as it stood before N was added: O stands, so the finding is filed (Claude does push back)
|
|
148
|
+
* after N today's ledger: O is superseded, so the finding is refused and names N (Claude never pushes back with a dead decision)
|
|
149
|
+
* The plan side quotes N's own text at its own line, so the verbatim check is real too. No model is called.
|
|
150
|
+
*/
|
|
151
|
+
function replayCases(L, { root = '.', read } = {}) {
|
|
152
|
+
const cases = [];
|
|
153
|
+
const opts = { root, ...(read ? { read } : {}) };
|
|
154
|
+
// filed: the finding as a proposal. refused: nothing, and the refusal must name the decision that replaced it (else the case throws)
|
|
155
|
+
const filed = (led, f) => async () => { const r = checkFinding(led, f, opts); return r.prop ? [r.prop] : []; };
|
|
156
|
+
const refused = (led, f, names) => async () => {
|
|
157
|
+
const r = checkFinding(led, f, opts);
|
|
158
|
+
if (r.prop) return [r.prop];
|
|
159
|
+
if (!names.some((x) => r.error.includes(x))) throw new Error(`refused for another reason: ${r.error}`);
|
|
160
|
+
return [];
|
|
161
|
+
};
|
|
162
|
+
for (const { newer, older } of pairsOf(L)) {
|
|
163
|
+
const N = L.decisions.get(newer), O = L.decisions.get(older);
|
|
164
|
+
if (!N || !O) continue;
|
|
165
|
+
const pq = firstQuote(N), oq = firstQuote(O);
|
|
166
|
+
const finding = { decision: older, quote: oq, plan: { label: newer, ref: `${N.path}:${DL.lineOf(L, newer, pq)}`, quote: pq }, conflict: `${newer} goes against ${older}`, recommend: 'drop', next: `Record that ${newer} supersedes ${older}`, confidence: 'high' };
|
|
167
|
+
const before = DL.asOf(L, newer), was = before.decisions.get(older);
|
|
168
|
+
// an older decision already superseded before N (D64 replaced D24 before D66 did) is refused before N too, naming the earlier successor
|
|
169
|
+
if (was && was.status === 'superseded') cases.push({ name: `${newer} → ${older}: before ${newer}, ${older} was already superseded by ${was.supersededBy.join(', ')}`, run: refused(before, finding, was.supersededBy), expect: { none: true } });
|
|
170
|
+
else cases.push({ name: `${newer} → ${older}: before ${newer}, Claude pushes back`, run: filed(before, finding), expect: { filed: 1, cites: [O.path] } });
|
|
171
|
+
cases.push({ name: `${newer} → ${older}: after ${newer}, ${older} is not quoted as live`, run: refused(L, finding, [newer]), expect: { none: true } });
|
|
172
|
+
}
|
|
173
|
+
return cases;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Each running initiative's PRD and a signature of its text, for the grill's `plan` trigger (lib/grill-runs.js). The PRD is the record's
|
|
178
|
+
* `prd_path` (planning/initiatives/<id>.md); a record or PRD that cannot be read, or a path outside the repository, is left out.
|
|
179
|
+
* inis: [{ ini, board }] → [{ ini, board, path, sig }]
|
|
180
|
+
*/
|
|
181
|
+
function plansIn(root, inis, { read = (f) => fs.readFileSync(f, 'utf8') } = {}) {
|
|
182
|
+
const fmOf = require('./related-docs').frontmatter;
|
|
183
|
+
const out = [];
|
|
184
|
+
for (const x of inis || []) {
|
|
185
|
+
if (!x || !/^[A-Za-z0-9][\w.-]{0,120}$/.test(String(x.ini || ''))) continue;
|
|
186
|
+
let prd = '';
|
|
187
|
+
try { prd = String(fmOf(read(path.join(root, 'planning', 'initiatives', x.ini + '.md'))).prd_path || '').trim(); } catch { continue; }
|
|
188
|
+
if (!prd || path.isAbsolute(prd) || prd.split(/[\\/]/).includes('..')) continue;
|
|
189
|
+
let text; try { text = read(path.join(root, prd)); } catch { continue; }
|
|
190
|
+
out.push({ ini: x.ini, board: x.board || '', path: prd, sig: hash(text) });
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The version the replay pass is recorded for: this module's VERSION and the supersede pairs it replays, so a new decision replays again. */
|
|
196
|
+
const versionOf = (L) => VERSION + '-' + hash(JSON.stringify(pairsOf(L)));
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `pmos grill find decision`: check each finding, then hand the good ones to lib/ai-guard.js, which holds them until the replay of this
|
|
200
|
+
* version passed (the replay runs here when it has not), checks the evidence again, audits, and calls deliver. deliver appends the grill
|
|
201
|
+
* proposal to the initiative's ✦ ledger, as `pmos grill find` does for the other angles. → exit code: 0 filed or nothing to file, 1 refused.
|
|
202
|
+
* o: { root, file (proposals.jsonl), run, ini, board, env, now, say, P (the proposals fold), settingsDir (Settings, for the kill switch) }
|
|
203
|
+
*/
|
|
204
|
+
async function fileRun(o, bodies) {
|
|
205
|
+
const say = o.say || (() => {}), env = o.env || process.env;
|
|
206
|
+
const PR = require('./proposals.js'), AG = require('./ai-guard.js');
|
|
207
|
+
const off = offWhy(env); if (off) { say('✗ ' + off); return 1; }
|
|
208
|
+
const L = DL.load(o.root);
|
|
209
|
+
const stateDir = path.dirname(o.file), version = versionOf(L);
|
|
210
|
+
const props = []; let bad = 0;
|
|
211
|
+
for (const b of bodies) {
|
|
212
|
+
const c = checkFinding(L, b, { root: o.root, ini: o.ini });
|
|
213
|
+
if (c.error) { say('✗ refused: ' + c.error); bad++; try { AG.audit(stateDir, { job: JOB, version, run: o.run, event: 'dropped', why: c.error.slice(0, 200) }); } catch { /* the audit never stops a run */ } continue; }
|
|
214
|
+
props.push(c.prop);
|
|
215
|
+
}
|
|
216
|
+
if (!props.length) return bad ? 1 : 0;
|
|
217
|
+
if (!AG.ready(stateDir, JOB, version)) {
|
|
218
|
+
const rp = await AG.replay(JOB, version, replayCases(L, { root: o.root }), { root: o.root, stateDir });
|
|
219
|
+
if (!rp.ok) { say('✗ held: the replay of the real supersede pairs failed (' + rp.failed.slice(0, 3).map((f) => f.name + ': ' + f.why).join('; ') + '). Nothing is shown'); return 1; }
|
|
220
|
+
}
|
|
221
|
+
const P = o.P || PR.fold(PR.readLines(o.file)), kept = PR.keptIds(P);
|
|
222
|
+
const res = await AG.file({ job: JOB, version, run: o.run, root: o.root, stateDir, ...(o.settingsDir ? { settingsDir: o.settingsDir } : {}), env, max: 3 }, props, async (accepted) => {
|
|
223
|
+
for (const p of accepted) {
|
|
224
|
+
const g = toGrill(p);
|
|
225
|
+
if (P.drops.has(PR.sigOf(g))) { say(`dropped ${g.id}: the PM dropped this from the same evidence`); continue; }
|
|
226
|
+
if (kept.has(g.id)) { say(`kept ${g.id}: the PM kept it already`); continue; }
|
|
227
|
+
fs.appendFileSync(o.file, JSON.stringify({ at: new Date(o.now || Date.now()).toISOString(), by: null, kind: 'propose', ini: o.ini, board: o.board || '', run: o.run, ...g }) + '\n');
|
|
228
|
+
say(`filed ${g.id} · decision · ${g.title}`);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
if (res.off) { say('✗ ' + res.off); return 1; }
|
|
232
|
+
if (res.held) { say('✗ held: the replay has not passed for this version. Nothing is shown'); return 1; }
|
|
233
|
+
for (const d of res.dropped || []) { say(`✗ refused ${d.id}: ${d.why}`); bad++; }
|
|
234
|
+
return bad ? 1 : 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = { JOB, VERSION, versionOf, fileRun, plansIn, RECOMMEND, offWhy, checkFinding, toGrill, replayCases, pairsOf, refOf, quoteAt, firstQuote };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* drift-cli.js — `pmos drift`: the weekly "What would I have missed" check from a terminal (initiative weekly-drift-check; lib/drift.js).
|
|
3
|
+
*
|
|
4
|
+
* pmos drift show [--weeks <n>] [--json] what the check would have shown for each of the last n weeks (default 4) of THIS repository.
|
|
5
|
+
* It reads only and writes nothing.
|
|
6
|
+
* pmos drift replay [--json] the replay of the last 4 weeks of this repository's real records (PRD R6). It checks that no week
|
|
7
|
+
* has more than three items and that every quote is at the line it cites, runs the shared guard's
|
|
8
|
+
* replay (lib/ai-guard.js), and writes one replay line to your own ledger. This product's items show
|
|
9
|
+
* on Home only after a replay that passed.
|
|
10
|
+
* pmos drift status [--json] the last weekly result and the replays on record.
|
|
11
|
+
*
|
|
12
|
+
* Run it in the product's repository. The weekly run itself needs no command: the server runs it each Monday. Nothing here changes a task,
|
|
13
|
+
* an initiative or a ticket, and nothing is sent. Exit codes: 0 done, 1 refused or the replay failed, 2 usage. */
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { execFileSync } = require('child_process');
|
|
16
|
+
const D = require('./drift.js');
|
|
17
|
+
const R = require('./drift-runs.js');
|
|
18
|
+
const Facts = require('./drift-facts.js');
|
|
19
|
+
|
|
20
|
+
const COMMANDS = { drift: ['show', 'replay', 'status'] };
|
|
21
|
+
const USAGE = [
|
|
22
|
+
'usage: pmos drift show|replay|status — the weekly check for quiet drift (What would I have missed)',
|
|
23
|
+
' pmos drift show [--weeks <n>] [--json] what each of the last n weeks (default 4) would have shown for this repository; writes nothing',
|
|
24
|
+
' pmos drift replay [--json] replay the last 4 weeks of this repository on its real records; its items show on Home only after this passes',
|
|
25
|
+
' pmos drift status [--json] the last weekly result and the replays on record',
|
|
26
|
+
'The weekly run starts by itself each Monday. Nothing is changed in a task, an initiative or Jira, and nothing is sent.',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function parse(args) {
|
|
30
|
+
const pos = [], flags = {};
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const a = args[i];
|
|
33
|
+
if (!a.startsWith('--')) { pos.push(a); continue; }
|
|
34
|
+
const k = a.slice(2);
|
|
35
|
+
if (k === 'json') flags.json = true;
|
|
36
|
+
else { flags[k] = args[i + 1] !== undefined && !String(args[i + 1]).startsWith('--') ? args[++i] : ''; }
|
|
37
|
+
}
|
|
38
|
+
return { pos, flags };
|
|
39
|
+
}
|
|
40
|
+
function repoRoot(cwd) {
|
|
41
|
+
try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; } catch { return cwd; }
|
|
42
|
+
}
|
|
43
|
+
const itemLine = (it) => ` ✦ ${it.title}\n ${it.say}\n${it.claims.map((c) => ' · ' + c.text + ' [' + c.sources.map((s) => s.ref).join(', ') + ']').join('\n')}`;
|
|
44
|
+
|
|
45
|
+
async function run(argv, { say = (s) => console.log(s), env = process.env, cwd = process.cwd(), now = Date.now(), runtime, product } = {}) {
|
|
46
|
+
const args = argv[0] === 'drift' ? argv.slice(1) : argv.slice();
|
|
47
|
+
const sub = args[0];
|
|
48
|
+
if (!sub || ['help', '--help', '-h'].includes(sub)) { for (const l of USAGE) say(l); return sub ? 0 : 1; }
|
|
49
|
+
if (!COMMANDS.drift.includes(sub)) { say(`unknown drift command "${sub}". Use one of: ${COMMANDS.drift.join(', ')}`); return 2; }
|
|
50
|
+
const { pos, flags } = parse(args.slice(1));
|
|
51
|
+
if (pos.length) { say(`✗ pmos drift ${sub} takes no argument`); return 2; }
|
|
52
|
+
for (const k of Object.keys(flags)) if (k !== 'json' && !(k === 'weeks' && sub === 'show')) { say(`✗ --${k} is not an option of pmos drift ${sub}`); return 2; }
|
|
53
|
+
const dir = D.dirOf(runtime || require('./state-root.js').defaultRoot({ env }));
|
|
54
|
+
|
|
55
|
+
if (sub === 'status') {
|
|
56
|
+
const F = D.read(dir), last = F.weeks[F.weeks.length - 1] || null;
|
|
57
|
+
const reps = F.replays.map((r) => ({ at: r.at, version: r.version, ws: r.ws, ok: r.ok, failed: (r.failed || []).length }));
|
|
58
|
+
if (flags.json) { say(JSON.stringify({ ok: true, version: D.VERSION, last, replays: reps })); return 0; }
|
|
59
|
+
say(last ? `Last week checked: the week of ${last.week} · ${last.items.length ? last.items.length + ' to look at' : 'quiet'}${last.held ? ' · held by the guard' : ''}` : 'No week checked yet.');
|
|
60
|
+
say(reps.length ? reps.map((r) => `Replay ${r.at} · ${r.version} · ${r.ok ? 'passed' : r.failed + ' failed'}`).join('\n') : 'No replay yet: run pmos drift replay in each product.');
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const root = repoRoot(cwd);
|
|
65
|
+
const p = product || (() => {
|
|
66
|
+
const stateDir = require('./driver.js').stateDirOf(root);
|
|
67
|
+
return { w: { id: require('./driver.js').wsIdOf(root), name: path.basename(root), root }, stateDir };
|
|
68
|
+
})();
|
|
69
|
+
if (!p.stateDir) { say('✗ this repository has no PMOS state folder (pmos/state, state or .pmos)'); return 1; }
|
|
70
|
+
|
|
71
|
+
if (sub === 'show') {
|
|
72
|
+
const n = flags.weeks === undefined ? R.REPLAY_WEEKS : Number(flags.weeks);
|
|
73
|
+
if (!Number.isInteger(n) || n < 1 || n > 12) { say('✗ --weeks is a whole number from 1 to 12'); return 2; }
|
|
74
|
+
const facts = [Facts.factsOf(p.w, { stateDir: p.stateDir, rows: p.rows })];
|
|
75
|
+
const weeks = R.simulate(facts, R.lastWeeks(now, n), { weeks: [], drops: D.read(dir).drops });
|
|
76
|
+
if (flags.json) { say(JSON.stringify({ ok: true, weeks: weeks.map((w) => ({ week: w.week, items: w.items })), notRead: facts[0].notRead })); return 0; }
|
|
77
|
+
for (const w of weeks) say(`Week of ${w.week}: ${w.items.length ? '' : 'quiet, nothing shown'}${w.items.length ? '\n' + w.items.map(itemLine).join('\n') : ''}`);
|
|
78
|
+
if (facts[0].notRead.length) say('Not read: ' + facts[0].notRead.join(', '));
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// replay
|
|
83
|
+
const rep = R.replay({ products: [p], now, F: D.read(dir) });
|
|
84
|
+
let guard = { ok: null, failed: [] };
|
|
85
|
+
let AG = null; try { AG = require('./ai-guard.js'); } catch { AG = null; }
|
|
86
|
+
if (AG) { try { guard = await R.guardReplay(AG, p, rep.weeks, rep.facts[0]); } catch (e) { guard = { ok: false, failed: [{ name: 'guard', why: String((e && e.message) || e).slice(0, 200) }] }; } }
|
|
87
|
+
else guard = { ok: false, failed: [{ name: 'guard', why: 'the shared guard (lib/ai-guard.js) is not installed, so nothing can show yet' }] };
|
|
88
|
+
const ok = rep.ok && guard.ok === true;
|
|
89
|
+
const failed = rep.failed.concat((guard.failed || []).map((f) => ({ week: f.name, why: f.why })));
|
|
90
|
+
D.append(dir, { kind: 'replay', version: D.VERSION, ws: [p.w.id], product: p.w.name, weeks: rep.weeks.map((w) => ({ week: w.week, ids: w.items.map((it) => it.id) })), ok, failed: failed.slice(0, 40) }, new Date(now).toISOString());
|
|
91
|
+
if (flags.json) { say(JSON.stringify({ ok, weeks: rep.weeks.map((w) => ({ week: w.week, items: w.items })), failed, notRead: rep.notRead })); return ok ? 0 : 1; }
|
|
92
|
+
for (const w of rep.weeks) say(`Week of ${w.week}: ${w.items.length ? '' : 'quiet, nothing shown'}${w.items.length ? '\n' + w.items.map(itemLine).join('\n') : ''}`);
|
|
93
|
+
if (rep.notRead.length) say('Not read: ' + rep.notRead.join(', '));
|
|
94
|
+
say(ok ? `✓ replay passed for ${p.w.name}: its items can show on Home from the next weekly run.` : `✗ replay did not pass for ${p.w.name}:\n` + failed.map((f) => ' - ' + (f.week ? f.week + ': ' : '') + f.why).join('\n'));
|
|
95
|
+
return ok ? 0 : 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { run, COMMANDS, USAGE };
|