create-pmos 0.504.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 (50) hide show
  1. package/package.json +45 -27
  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 +44 -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/notes.md +8 -1
  12. package/workspace/guide/what-you-missed.md +42 -0
  13. package/workspace/lib/ai-cli.js +63 -0
  14. package/workspace/lib/ai-guard.js +314 -0
  15. package/workspace/lib/briefing-premium-agent.js +3 -0
  16. package/workspace/lib/decision-ledger.js +236 -0
  17. package/workspace/lib/decision-memory.js +237 -0
  18. package/workspace/lib/drift-cli.js +98 -0
  19. package/workspace/lib/drift-facts.js +151 -0
  20. package/workspace/lib/drift-runs.js +149 -0
  21. package/workspace/lib/drift.js +227 -0
  22. package/workspace/lib/grill-cli.js +22 -1
  23. package/workspace/lib/grill-runs.js +20 -3
  24. package/workspace/lib/grill.js +15 -8
  25. package/workspace/lib/past-chat.js +32 -0
  26. package/workspace/lib/past-cli.js +147 -0
  27. package/workspace/lib/past-repeat.js +126 -0
  28. package/workspace/lib/past.js +353 -0
  29. package/workspace/lib/pmos-verbs.js +9 -0
  30. package/workspace/lib/proposal-runs.js +20 -0
  31. package/workspace/lib/route-modules.js +1 -0
  32. package/workspace/lib/routes-drift.js +62 -0
  33. package/workspace/lib/settings-store.js +6 -0
  34. package/workspace/package-lock.json +2 -2
  35. package/workspace/package.json +1 -1
  36. package/workspace/public/pm/cozy-home-rows.css +8 -0
  37. package/workspace/public/pm/cozy-live-pill.css +10 -0
  38. package/workspace/public/pm/cozy-live-pill.js +13 -5
  39. package/workspace/public/pm/cozy-rec-tab.js +5 -2
  40. package/workspace/public/pm/grill-words.js +4 -2
  41. package/workspace/public/pm/home-app.js +14 -2
  42. package/workspace/public/pm/home-drift.js +76 -0
  43. package/workspace/public/pm/home-rec-row.js +11 -2
  44. package/workspace/public/pm/rec-state.js +30 -4
  45. package/workspace/server.js +19 -0
  46. package/workspace/skills/grill.skill +4 -2
  47. package/workspace/test-files.json +1 -1
  48. package/workspace/test-lists/ask-the-past.json +5 -0
  49. package/workspace/test-lists/decision-memory.json +6 -0
  50. package/workspace/test-lists/weekly-drift-check.json +5 -0
@@ -0,0 +1,314 @@
1
+ 'use strict';
2
+ /* ai-guard.js — THE ONE DOOR every AI job's output passes before the PM sees it (the AI-ideas round, 2026-11-03: decision memory,
3
+ * the weekly what-I'd-miss check, ask about the past, and the features after them). A job runs in the PM's usual background Claude
4
+ * session (lib/headless.js, as grill does). It may file only proposals, and each one goes through file() below:
5
+ *
6
+ * kill switch → replay gate → check each proposal → audit → deliver(accepted)
7
+ *
8
+ * THE PROPOSAL (the only thing a job may file):
9
+ * { id, title, say, confidence: 'high'|'medium'|'low'|'none',
10
+ * claims: [{ text, sources: [{ label, ref, quote }] }], data? }
11
+ * ref is `path:line` relative to the repository root, or `state:<file>:line` for a ledger in the job's state folder. Other refs
12
+ * (`jira:KEY`, a URL) are kept as context but are unverified. confidence 'none' is "no record": claims may be empty, and that is a
13
+ * valid, complete answer.
14
+ *
15
+ * THE RULES (check):
16
+ * propose-only a key named write, apply, ops, patch, send, command or exec anywhere in the proposal rejects it
17
+ * every claim at least 1 verified source: the quote (12+ characters) is found within ±2 lines of ref, case and spacing ignored
18
+ * honest 'high' needs 2 or more distinct verified refs; 'none' must have no claims
19
+ * bounded at most `max` proposals per run (default 3); the rest are dropped, in the order given
20
+ *
21
+ * THE REPLAY GATE: a job's (job, version) shows nothing until replay() has passed for it. A replay runs the job's own cases (its
22
+ * deterministic part or a recorded model output, never the model) and the guard's negative cases made from the job's first filed
23
+ * proposal: a fabricated quote, a missing source, a ref outside the repository and a write key. Each of those must be dropped.
24
+ *
25
+ * THE KILL SWITCH: PMOS_AI=off (every job), PMOS_AI_<JOB>=off (one job, dashes as underscores), Settings 'ai.on' false, or the job
26
+ * in Settings 'ai.off'. `pmos ai off [job]` and `pmos ai on [job]` set the Settings ones.
27
+ *
28
+ * THE AUDIT: <stateDir>/ai-audit.jsonl, one line per event { at, job, version?, run?, event, ... }. Events: start, filed, dropped,
29
+ * held, off, replay, end. No line holds a file body or a key: only ids, refs, reasons and counts.
30
+ *
31
+ * off(job, { env, settingsDir }) → null | reason
32
+ * check(p, { root, stateDir }) → { ok, p, errors }
33
+ * replay(job, version, cases, { root, stateDir }) → Promise<{ ok, failed: [{ name, why }] }>
34
+ * ready(stateDir, job, version) → bool
35
+ * file({ job, version, run, root, stateDir, settingsDir?, env?, max? }, proposals, deliver) → Promise<{ filed, dropped, held?, off? }>
36
+ * audit(stateDir, line) · readAudit(stateDir, { job?, limit? }) → [line] · status({ stateDir, settingsDir, env }) → summary
37
+ * setOff(settingsDir, job | null, off) → { ok } | { ok: false, error }
38
+ */
39
+ const fs = require('fs');
40
+ const path = require('path');
41
+
42
+ const AUDIT = 'ai-audit.jsonl';
43
+ const REPLAYS = 'ai-replays.json';
44
+ const MAX = 3;
45
+ const QUOTE_MIN = 12;
46
+ const NEAR = 2;
47
+ const CONF = ['high', 'medium', 'low', 'none'];
48
+ const WRITE_KEYS = new Set(['write', 'apply', 'ops', 'patch', 'send', 'command', 'exec']);
49
+ const JOB_ID = /^[a-z][a-z0-9-]{1,40}$/;
50
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
51
+
52
+ const flagOff = (v) => /^(off|0|false|no)$/i.test(String(v == null ? '' : v).trim());
53
+ const one = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
54
+ const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/\s+/g, ' ').trim();
55
+ const envName = (job) => 'PMOS_AI_' + String(job).toUpperCase().replace(/-/g, '_');
56
+
57
+ /* ── kill switch ─────────────────────────────────────────────────────────────────────────── */
58
+
59
+ function settings(settingsDir) {
60
+ if (!settingsDir) return { on: true, off: [] };
61
+ try {
62
+ const S = require('./settings-store.js');
63
+ const offList = S.get(settingsDir, 'ai.off');
64
+ return { on: S.get(settingsDir, 'ai.on') !== false, off: Array.isArray(offList) ? offList : [] };
65
+ } catch { return { on: true, off: [] }; }
66
+ }
67
+
68
+ /** null when the job may run; else the reason it is off. */
69
+ function off(job, { env = process.env, settingsDir = null } = {}) {
70
+ if (flagOff((env || {}).PMOS_AI)) return 'every AI job is off (PMOS_AI)';
71
+ if (job && flagOff((env || {})[envName(job)])) return `${job} is off (${envName(job)})`;
72
+ const s = settings(settingsDir);
73
+ if (!s.on) return 'every AI job is off (Settings)';
74
+ if (job && s.off.includes(job)) return `${job} is off (Settings)`;
75
+ return null;
76
+ }
77
+
78
+ function setOff(settingsDir, job, isOff) {
79
+ const S = require('./settings-store.js');
80
+ if (!job) return S.set(settingsDir, 'ai.on', !isOff);
81
+ if (!JOB_ID.test(String(job))) return { ok: false, error: `${JSON.stringify(String(job))} is not a job id (lowercase letters, digits and dashes)` };
82
+ const cur = settings(settingsDir).off.filter((x) => x !== job);
83
+ return S.set(settingsDir, 'ai.off', isOff ? [...cur, job].sort() : cur);
84
+ }
85
+
86
+ /* ── the citation check ──────────────────────────────────────────────────────────────────── */
87
+
88
+ function linesOf(abs, cache) {
89
+ if (cache.has(abs)) return cache.get(abs);
90
+ let L = null;
91
+ try { const st = fs.statSync(abs); if (st.isFile() && st.size <= 20e6) L = fs.readFileSync(abs, 'utf8').split('\n'); } catch { /* unreadable: unverified */ }
92
+ cache.set(abs, L);
93
+ return L;
94
+ }
95
+
96
+ /** Where a ref points: { abs, line } for a verifiable ref, { context: true } for jira:/http refs, { error } otherwise. */
97
+ function resolveRef(ref, { root, stateDir }) {
98
+ const r = String(ref || '').trim();
99
+ if (!r) return { error: 'a source has no ref' };
100
+ if (/^(https?:\/\/|jira:|slack:|mail:|calendar:)/i.test(r)) return { context: true };
101
+ const m = /^(state:)?(.+):(\d+)$/.exec(r);
102
+ if (!m) return { error: `ref ${JSON.stringify(one(r, 80))} is not path:line` };
103
+ const base = m[1] ? stateDir : root;
104
+ if (!base) return { error: `ref ${JSON.stringify(one(r, 80))} names a folder this run has none of` };
105
+ const baseAbs = path.resolve(base);
106
+ const abs = path.resolve(baseAbs, m[2]);
107
+ if (abs !== baseAbs && !abs.startsWith(baseAbs + path.sep)) return { error: `ref ${JSON.stringify(one(r, 80))} is outside the ${m[1] ? 'state folder' : 'repository'}` };
108
+ return { abs, line: Number(m[3]) };
109
+ }
110
+
111
+ function quoteAt(L, line, quote) {
112
+ const q = norm(quote);
113
+ if (!L || line < 1 || line > L.length) return false;
114
+ const from = Math.max(0, line - 1 - NEAR), to = Math.min(L.length, line + NEAR);
115
+ return norm(L.slice(from, to).join(' ')).includes(q);
116
+ }
117
+
118
+ function writeKey(v, depth = 0) {
119
+ if (!v || typeof v !== 'object' || depth > 8) return null;
120
+ for (const [k, x] of Object.entries(v)) {
121
+ if (WRITE_KEYS.has(String(k).toLowerCase())) return k;
122
+ const deep = writeKey(x, depth + 1);
123
+ if (deep) return deep;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /** Check one proposal. Pure apart from reading the cited files. → { ok, p (normalized), errors } */
129
+ function check(p, { root, stateDir, cache = new Map() } = {}) {
130
+ const errors = [];
131
+ if (!p || typeof p !== 'object' || Array.isArray(p)) return { ok: false, p: null, errors: ['a proposal must be an object'] };
132
+ const wk = writeKey(p);
133
+ if (wk) errors.push(`propose-only: the key "${wk}" names a write`);
134
+ const id = String(p.id || '').trim();
135
+ if (!ID.test(id)) errors.push('id is missing or has characters other than letters, digits and . _ : -');
136
+ const title = one(p.title, 200), say = one(p.say, 1200);
137
+ if (!title) errors.push('title is empty');
138
+ if (!say) errors.push('say is empty');
139
+ const confidence = String(p.confidence || '').trim();
140
+ if (!CONF.includes(confidence)) errors.push(`confidence must be one of ${CONF.join(', ')}`);
141
+ const claimsIn = p.claims == null ? [] : p.claims;
142
+ if (!Array.isArray(claimsIn)) errors.push('claims must be a list');
143
+ const claims = [];
144
+ const verifiedRefs = new Set();
145
+ (Array.isArray(claimsIn) ? claimsIn : []).slice(0, 20).forEach((c, i) => {
146
+ const text = one(c && c.text, 600);
147
+ if (!text) { errors.push(`claim ${i + 1} has no text`); return; }
148
+ const srcIn = Array.isArray(c.sources) ? c.sources.slice(0, 8) : [];
149
+ if (!srcIn.length) { errors.push(`claim ${i + 1} cites no source`); return; }
150
+ const sources = [];
151
+ let verified = 0;
152
+ for (const s of srcIn) {
153
+ const ref = one(s && s.ref, 300), quote = String((s && s.quote) || '').slice(0, 2000), label = one(s && s.label, 120) || ref;
154
+ const at = resolveRef(ref, { root, stateDir });
155
+ if (at.error) { errors.push(`claim ${i + 1}: ${at.error}`); continue; }
156
+ if (at.context) { sources.push({ label, ref, quote: one(quote, 400), verified: false }); continue; }
157
+ if (norm(quote).length < QUOTE_MIN) { errors.push(`claim ${i + 1}: the quote for ${ref} is shorter than ${QUOTE_MIN} characters`); continue; }
158
+ if (!quoteAt(linesOf(at.abs, cache), at.line, quote)) { errors.push(`claim ${i + 1}: the quote is not at ${ref}`); continue; }
159
+ sources.push({ label, ref, quote: one(quote, 400), verified: true });
160
+ verifiedRefs.add(ref);
161
+ verified++;
162
+ }
163
+ if (!verified) { errors.push(`claim ${i + 1} has no verified source`); return; }
164
+ claims.push({ text, sources });
165
+ });
166
+ if (confidence === 'none' && Array.isArray(claimsIn) && claimsIn.length) errors.push('confidence none ("no record") must have no claims');
167
+ if (confidence && confidence !== 'none' && Array.isArray(claimsIn) && !claimsIn.length) errors.push('a proposal with claims must cite them; use confidence none for "no record"');
168
+ if (confidence === 'high' && verifiedRefs.size < 2) errors.push('confidence high needs 2 or more distinct verified sources');
169
+ const data = p.data && typeof p.data === 'object' && !Array.isArray(p.data) ? p.data : undefined;
170
+ if (errors.length) return { ok: false, p: null, errors };
171
+ return { ok: true, p: { id, title, say, confidence, claims, ...(data ? { data } : {}) }, errors: [] };
172
+ }
173
+
174
+ /* ── audit ───────────────────────────────────────────────────────────────────────────────── */
175
+
176
+ function audit(stateDir, line) {
177
+ if (!stateDir) return;
178
+ try {
179
+ fs.mkdirSync(stateDir, { recursive: true });
180
+ fs.appendFileSync(path.join(stateDir, AUDIT), JSON.stringify({ at: new Date().toISOString(), ...line }) + '\n', { mode: 0o600 });
181
+ } catch { /* the audit never stops a run; a failed append is lost, never thrown */ }
182
+ }
183
+
184
+ function readAudit(stateDir, { job = null, limit = 200 } = {}) {
185
+ let text = '';
186
+ try { text = fs.readFileSync(path.join(stateDir, AUDIT), 'utf8'); } catch { return []; }
187
+ const out = [];
188
+ for (const s of text.split('\n')) {
189
+ if (!s.trim()) continue;
190
+ try { const o = JSON.parse(s); if (!job || o.job === job) out.push(o); } catch { /* a torn line */ }
191
+ }
192
+ return out.slice(-limit);
193
+ }
194
+
195
+ /* ── the replay gate ─────────────────────────────────────────────────────────────────────── */
196
+
197
+ function readReplays(stateDir) {
198
+ try { const o = JSON.parse(fs.readFileSync(path.join(stateDir, REPLAYS), 'utf8')); return o && typeof o === 'object' ? o : {}; } catch { return {}; }
199
+ }
200
+ function writeReplays(stateDir, o) {
201
+ fs.mkdirSync(stateDir, { recursive: true });
202
+ const f = path.join(stateDir, REPLAYS), tmp = f + '.' + process.pid + '.tmp';
203
+ fs.writeFileSync(tmp, JSON.stringify(o, null, 2) + '\n', { mode: 0o600 });
204
+ fs.renameSync(tmp, f);
205
+ }
206
+ const keyOf = (job, version) => `${job}@${version}`;
207
+
208
+ function ready(stateDir, job, version) {
209
+ const r = readReplays(stateDir)[keyOf(job, version)];
210
+ return !!(r && r.ok === true);
211
+ }
212
+
213
+ /* What the guard alone would file from a list: the same checks as file(), without the gate, audit or deliver. */
214
+ function sift(list, opts) {
215
+ const cache = new Map(), filed = [], dropped = [];
216
+ const arr = Array.isArray(list) ? list : [];
217
+ for (const [i, p] of arr.entries()) {
218
+ const id = p && p.id ? String(p.id) : `#${i + 1}`;
219
+ if (filed.length >= (opts.max || MAX)) { dropped.push({ id, why: `over the ${opts.max || MAX} proposals a run may file` }); continue; }
220
+ const c = check(p, { ...opts, cache });
221
+ if (c.ok) filed.push(c.p); else dropped.push({ id, why: c.errors.join('; ') });
222
+ }
223
+ return { filed, dropped };
224
+ }
225
+
226
+ /* The guard's own negative cases, made from a proposal that passed. Each must be dropped. */
227
+ function negatives(p) {
228
+ const clone = () => JSON.parse(JSON.stringify(p));
229
+ const first = (q) => q.claims[0].sources.find((s) => s.verified !== false) || q.claims[0].sources[0];
230
+ const out = [];
231
+ if (!p.claims || !p.claims.length) return out;
232
+ { const q = clone(); first(q).quote = 'this sentence was never written anywhere in the sources ' + Date.now(); out.push({ name: 'a fabricated quote is dropped', p: q }); }
233
+ { const q = clone(); q.claims[0].sources = []; out.push({ name: 'a claim with no source is dropped', p: q }); }
234
+ { const q = clone(); first(q).ref = '../../etc/passwd:1'; out.push({ name: 'a ref outside the repository is dropped', p: q }); }
235
+ { const q = clone(); q.data = { ...(q.data || {}), apply: { op: 'set' } }; out.push({ name: 'a write key is dropped', p: q }); }
236
+ return out;
237
+ }
238
+
239
+ function expectFails(name, expect, got) {
240
+ const e = expect || {};
241
+ if (e.none && got.filed.length) return `expected nothing filed, got ${got.filed.length}`;
242
+ if (Number.isInteger(e.filed) && got.filed.length !== e.filed) return `expected ${e.filed} filed, got ${got.filed.length}${got.dropped.length ? ' (dropped: ' + one(got.dropped.map((d) => d.why).join(' | '), 300) + ')' : ''}`;
243
+ if (Number.isInteger(e.min) && got.filed.length < e.min) return `expected at least ${e.min} filed, got ${got.filed.length}`;
244
+ if (Array.isArray(e.ids)) for (const id of e.ids) if (!got.filed.some((p) => p.id === id)) return `expected ${id} to be filed`;
245
+ if (Array.isArray(e.cites)) for (const c of e.cites) if (!got.filed.some((p) => p.claims.some((cl) => cl.sources.some((s) => s.verified && s.ref.split(':').slice(0, -1).join(':').replace(/^state:/, '') === c)))) return `expected a verified citation of ${c}`;
246
+ if (Array.isArray(e.dropped)) for (const sub of e.dropped) if (!got.dropped.some((d) => d.why.includes(sub))) return `expected a drop that says "${sub}"`;
247
+ return null;
248
+ }
249
+
250
+ /** Run a job's replay cases plus the guard's negatives; record the result for (job, version). */
251
+ async function replay(job, version, cases, { root, stateDir, max = MAX } = {}) {
252
+ const failed = [];
253
+ let seed = null;
254
+ if (!JOB_ID.test(String(job || ''))) return { ok: false, failed: [{ name: 'job', why: 'the job id is not valid' }] };
255
+ if (!Array.isArray(cases) || !cases.length) failed.push({ name: '(cases)', why: 'a job needs at least 1 replay case' });
256
+ for (const c of Array.isArray(cases) ? cases : []) {
257
+ const name = one(c && c.name, 120) || '(unnamed)';
258
+ let list;
259
+ try { list = await c.run(); } catch (e) { failed.push({ name, why: 'the case threw: ' + one(e && e.message, 200) }); continue; }
260
+ const got = sift(list, { root, stateDir, max });
261
+ const why = expectFails(name, c.expect, got);
262
+ if (why) failed.push({ name, why });
263
+ if (!seed) seed = got.filed.find((p) => p.claims.length) || null;
264
+ }
265
+ if (seed) for (const n of negatives(seed)) {
266
+ const got = sift([n.p], { root, stateDir, max });
267
+ if (got.filed.length) failed.push({ name: n.name, why: 'the guard filed it' });
268
+ } else if (!failed.length) failed.push({ name: '(negatives)', why: 'no case filed a cited proposal, so the guard could not test its own drops; add a case that files one' });
269
+ const ok = !failed.length;
270
+ if (stateDir) {
271
+ const all = readReplays(stateDir);
272
+ all[keyOf(job, version)] = { ok, at: new Date().toISOString(), cases: (cases || []).length, failed: failed.slice(0, 20) };
273
+ try { writeReplays(stateDir, all); } catch { /* an unwritten pass keeps the gate shut: the safe side */ }
274
+ audit(stateDir, { job, version: String(version), event: 'replay', ok, failed: failed.map((f) => f.name + ': ' + one(f.why, 160)).slice(0, 10) });
275
+ }
276
+ return { ok, failed };
277
+ }
278
+
279
+ /* ── the door ────────────────────────────────────────────────────────────────────────────── */
280
+
281
+ async function file(o, proposals, deliver) {
282
+ const { job, version, run = null, root, stateDir, settingsDir = stateDir, env = process.env, max = MAX } = o || {};
283
+ const base = { job, version: String(version), ...(run ? { run } : {}) };
284
+ if (!JOB_ID.test(String(job || ''))) return { filed: [], dropped: [], error: 'the job id is not valid' };
285
+ const why = off(job, { env, settingsDir });
286
+ if (why) { audit(stateDir, { ...base, event: 'off', why }); return { filed: [], dropped: [], off: why }; }
287
+ const list = Array.isArray(proposals) ? proposals : [];
288
+ if (!ready(stateDir, job, version)) {
289
+ audit(stateDir, { ...base, event: 'held', n: list.length, why: 'no passing replay for this version' });
290
+ return { filed: [], dropped: [], held: true };
291
+ }
292
+ const got = sift(list, { root, stateDir, max });
293
+ for (const d of got.dropped) audit(stateDir, { ...base, event: 'dropped', id: d.id, why: one(d.why, 400) });
294
+ if (!got.filed.length) { audit(stateDir, { ...base, event: 'end', filed: 0, dropped: got.dropped.length }); return { filed: [], dropped: got.dropped }; }
295
+ try { await deliver(got.filed); } catch (e) {
296
+ audit(stateDir, { ...base, event: 'end', filed: 0, dropped: got.dropped.length, error: 'deliver failed: ' + one(e && e.message, 200) });
297
+ return { filed: [], dropped: got.dropped, error: 'deliver failed' };
298
+ }
299
+ for (const p of got.filed) audit(stateDir, { ...base, event: 'filed', id: p.id, confidence: p.confidence, refs: p.claims.flatMap((c) => c.sources.map((s) => s.ref)).slice(0, 12) });
300
+ audit(stateDir, { ...base, event: 'end', filed: got.filed.length, dropped: got.dropped.length });
301
+ return { filed: got.filed.map((p) => p.id), dropped: got.dropped };
302
+ }
303
+
304
+ /** What `pmos ai status` shows: the switch, each job's replay state and its last run. */
305
+ function status({ stateDir, settingsDir = stateDir, env = process.env } = {}) {
306
+ const s = settings(settingsDir), reps = readReplays(stateDir), lines = readAudit(stateDir, { limit: 2000 });
307
+ const jobs = {};
308
+ for (const [k, r] of Object.entries(reps)) { const [job, version] = k.split('@'); (jobs[job] = jobs[job] || { job }).replay = { version, ok: r.ok, at: r.at }; }
309
+ for (const l of lines) if (l.job && l.event === 'end') (jobs[l.job] = jobs[l.job] || { job: l.job }).last = { at: l.at, filed: l.filed, dropped: l.dropped };
310
+ for (const j of Object.values(jobs)) j.off = off(j.job, { env, settingsDir });
311
+ return { on: !flagOff((env || {}).PMOS_AI) && s.on, settingsOff: s.off, jobs: Object.values(jobs).sort((a, b) => a.job.localeCompare(b.job)) };
312
+ }
313
+
314
+ module.exports = { AUDIT, REPLAYS, MAX, QUOTE_MIN, off, setOff, check, replay, ready, file, audit, readAudit, status, envName, resolveRef };
@@ -17,6 +17,7 @@ const headless = require('./headless.js');
17
17
  const Goal = require('./goal-chat.js');
18
18
  const ReportChat = require('./report-chat.js');
19
19
  const ProgramChat = require('./program-chat.js');
20
+ const PastChat = require('./past-chat.js');
20
21
  const WorkflowChat = require('./workflow-chat.js');
21
22
  const IniChat = require('./initiative-chat.js');
22
23
 
@@ -221,6 +222,8 @@ function chat({ runtimeDir, key, text, model, context, sessions, wsId, iniInfo,
221
222
  if (cmd) { const b = Goal.begin({ runtimeDir, key, text: cmd.text, now }); if (b.error) return fail(400, b.error); msg = b.prompt; goal = b.id; }
222
223
  // D99: the New workflow chat (Settings › Workflows) carries the workflow rule on every message (lib/workflow-chat.js)
223
224
  else if (WorkflowChat.isKey(key) && !q.startsWith('/')) msg = WorkflowChat.wrap(q);
225
+ // ask-the-past: "why did we drop X?" in any chat carries the past rule: answer from `pmos past ask`, cite every line, "No record found" when none
226
+ else if (PastChat.isAsk(q)) msg = PastChat.wrap(q);
224
227
  // bug 2026-10-25: in a live initiative chat, a message about a stage, a workflow or a move carries the initiative's workflow and stage, read now
225
228
  else if (alive && /^init:/.test(key) && IniChat.isAsk(q)) msg = IniChat.wrap(q, info(), iniId(key));
226
229
  // premium-reports-from-chat R1: a message about a report, a chart or a dashboard carries the pmos-report rule (lib/report-chat.js)
@@ -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 };