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,353 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* past.js — ask PMOS about the past (initiative ask-the-past). THE ONE RETRIEVAL LAYER over what PMOS already wrote down about earlier
|
|
3
|
+
* choices: "why did we drop X?", "when did we decide Y?", and "did we decide this before?" for a new idea. lib/decision-ledger.js
|
|
4
|
+
* (decision memory) and the initiative chat both read through it; nobody writes a second matcher.
|
|
5
|
+
*
|
|
6
|
+
* PURE. Takes the text of the records and returns passages, each with the line it rests on. No filesystem, no git, no network, no model.
|
|
7
|
+
* Same input → the same output. lib/past-cli.js does the reading (`pmos past ask|check|verify`).
|
|
8
|
+
*
|
|
9
|
+
* WHAT IS AN ENTRY. One thing that was decided, rejected or recorded, cut where the file itself cuts it:
|
|
10
|
+
* decision DECISIONS.md, one `### Dnn — title` section id Dnn
|
|
11
|
+
* amendment DECISIONS.md, one dated bullet of the amendment log ids: the D-ids it names
|
|
12
|
+
* adr okf/products/pmos/adr/dNN-*.md, the whole file id Dnn
|
|
13
|
+
* pm-decision pm-decisions.md, one `## YYYY-MM-DD — title` entry
|
|
14
|
+
* out-of-scope planning/out-of-scope/*.md, the whole file (a declined request)
|
|
15
|
+
* initiative planning/initiatives/*.md, the record: its title, status, why
|
|
16
|
+
* discovery discoveries.md, one `## ` entry (the run log; weighs less)
|
|
17
|
+
* meeting a decision caught in a meeting note, not marked wrong or deleted (records, from the per-user state folder)
|
|
18
|
+
* claim a Jev claim the claims ledger holds (records)
|
|
19
|
+
* ticket a Jira ticket draft (records)
|
|
20
|
+
* A record that cannot be read is not an entry; the CLI says which sources it read and which were not on this machine.
|
|
21
|
+
*
|
|
22
|
+
* HOW A PASSAGE IS FOUND. Words, not similarity of meaning: the question's content words (stop words and question words out, a light
|
|
23
|
+
* stem) are scored BM25 over each entry, its title counting three times. "Drop", "reject", "retire", "remove", "decline" and their kin are
|
|
24
|
+
* one family, so "why did we drop X" finds "X was retired". A D-id in the question ("D66") finds that decision first. Nothing is inferred.
|
|
25
|
+
*
|
|
26
|
+
* HONEST CONFIDENCE. An answer's confidence is the share of the question's distinct content (by weight) that the best entry holds:
|
|
27
|
+
* high ≥ 0.75 of it, and a second entry agrees (≥ 0.5)
|
|
28
|
+
* medium ≥ 0.6
|
|
29
|
+
* low ≥ 0.45 (shown, with "a weak match")
|
|
30
|
+
* none under 0.45, or no content word at all → "No record found". It never guesses.
|
|
31
|
+
*
|
|
32
|
+
* WHAT A HIT IS. { kind, id?, path, line, title, date, quote, ref: 'path:line', score, cover, status?, supersededBy? }. `quote` is the
|
|
33
|
+
* line's own text, verbatim (cut at 220 characters), so lib/ai-guard.js check() finds it at `ref`. A superseded or amended decision says
|
|
34
|
+
* so (from lib/decision-ledger.js when present, else from the ADR's own `superseded_by` / `amended_by` keys): an answer never quotes a
|
|
35
|
+
* dead decision as live.
|
|
36
|
+
*
|
|
37
|
+
* build(files, records?) → corpus files: [{ path, text }] (repository Markdown); records: [{ kind, ref, title, text, date, quote? }]
|
|
38
|
+
* search(corpus, question, o) → { hits: [hit], none, confidence, terms } o: { limit = 5, kinds }
|
|
39
|
+
* repeats(corpus, idea, o) → { hits, none, confidence } only what was decided or declined (not the run log); the shape decision
|
|
40
|
+
* memory reads: [{ kind, path, line, quote, date, id?, score }], ranked, none: true when nothing passes
|
|
41
|
+
* verify(corpus, text) → { refs: [{ ref, ok, why? }], ok } every `path:line` an answer cites is one the corpus holds
|
|
42
|
+
* isPastAsk(text) → whether a message asks about the past ("why did we…", "did we ever…", "what did we decide…")
|
|
43
|
+
* statusOf(corpus, id) → { status, supersededBy, amendedBy } for a D-id
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const STOP = new Set(('a an and are as at be been being but by can could did do does doing done for from had has have having how i if in into '
|
|
47
|
+
+ 'is it its me my no not of on or our ours should so than that the their them then there these they this those to too us was we were '
|
|
48
|
+
+ 'what when where which who whom whose why will with would you your yours ever again any some all about over also just very really '
|
|
49
|
+
+ 'pmos decide decided decision decisions choose chose chosen pick picked go went make made said say says tell told know think thought '
|
|
50
|
+
+ 'don doesn didn isn aren wasn weren won haven hasn cannot must every need needs '
|
|
51
|
+
+ 'one two use used using get got thing things way still now new old before after earlier first last').split(' '));
|
|
52
|
+
/* One family: a "why did we drop X" question meets the words the records use for it. */
|
|
53
|
+
const FAMILY = {
|
|
54
|
+
drop: ['drop', 'dropp', 'retir', 'remov', 'reject', 'declin', 'deprecat', 'cut', 'kill', 'delet', 'revert', 'abandon', 'repeal', 'lift', 'out-of-scope', 'scrap', 'stop', 'supersed'],
|
|
55
|
+
};
|
|
56
|
+
const FAMILY_OF = new Map();
|
|
57
|
+
for (const [k, list] of Object.entries(FAMILY)) for (const w of list) FAMILY_OF.set(w, k);
|
|
58
|
+
const KIND_WEIGHT = { decision: 1.15, adr: 1.0, amendment: 0.95, 'pm-decision': 1.0, 'out-of-scope': 1.1, initiative: 0.85, meeting: 1.0, claim: 0.6, ticket: 0.7, discovery: 0.55 };
|
|
59
|
+
const DECIDED = new Set(['decision', 'adr', 'amendment', 'pm-decision', 'out-of-scope', 'initiative', 'meeting']);
|
|
60
|
+
const QUOTE_MAX = 220;
|
|
61
|
+
const TITLE_X = 3;
|
|
62
|
+
const K1 = 1.2, B = 0.75;
|
|
63
|
+
const CONF = { high: 0.75, medium: 0.6, low: 0.45 };
|
|
64
|
+
|
|
65
|
+
/** A light stem: enough that "retired", "retiring" and "retire" meet, never so much that two words merge. */
|
|
66
|
+
function stem(w) {
|
|
67
|
+
let s = w;
|
|
68
|
+
if (s.length > 5 && s.endsWith('ies')) s = s.slice(0, -3) + 'y';
|
|
69
|
+
else if (s.length > 5 && /(ing|ion)$/.test(s)) s = s.slice(0, -3);
|
|
70
|
+
else if (s.length > 4 && s.endsWith('ed')) s = s.slice(0, -2);
|
|
71
|
+
else if (s.length > 4 && s.endsWith('es') && !s.endsWith('ses')) s = s.slice(0, -2);
|
|
72
|
+
else if (s.length > 3 && s.endsWith('s') && !s.endsWith('ss')) s = s.slice(0, -1);
|
|
73
|
+
if (s.length > 4 && s.endsWith('e')) s = s.slice(0, -1);
|
|
74
|
+
return s;
|
|
75
|
+
}
|
|
76
|
+
const fold = (t) => String(t == null ? '' : t).toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
|
|
77
|
+
function words(t) { return fold(t).replace(/\bout of scope\b/g, 'out-of-scope').match(/[a-z0-9][a-z0-9-]*[a-z0-9]|[a-z0-9]/g) || []; }
|
|
78
|
+
/** A word as the index holds it: a D-id stays whole ("d66"), a family word becomes its family, else its stem. */
|
|
79
|
+
function termOf(w) {
|
|
80
|
+
const d = /^d0*(\d{2,3})$/.exec(w); if (d) return 'd' + d[1];
|
|
81
|
+
if (w === 'out-of-scope') return 'drop';
|
|
82
|
+
const s = stem(w);
|
|
83
|
+
for (const [root, fam] of FAMILY_OF) if (s === root || s.startsWith(root) && s.length - root.length <= 3) return fam;
|
|
84
|
+
return s;
|
|
85
|
+
}
|
|
86
|
+
function termsOf(text, { keepStop = false } = {}) {
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const w of words(text)) {
|
|
89
|
+
if (w.includes('-')) { for (const p of w.split('-')) if (p && (keepStop || !STOP.has(p)) && p.length > 1) out.push(termOf(p)); if (w === 'out-of-scope') out.push('drop'); continue; }
|
|
90
|
+
if (!keepStop && STOP.has(w)) continue;
|
|
91
|
+
if (w.length < 2 && !/\d/.test(w)) continue;
|
|
92
|
+
out.push(termOf(w));
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
const did = (s) => { const m = /\bD0*(\d{2,3})\b/i.exec(String(s || '')); return m ? 'D' + (m[1].length < 2 ? '0' + m[1] : m[1]) : null; };
|
|
97
|
+
const dids = (s) => [...new Set((String(s || '').match(/\bD\d{2,3}\b/g) || []))];
|
|
98
|
+
const isoOf = (s) => { const m = /\b(20\d{2}-\d{2}-\d{2})\b/.exec(String(s || '')); return m ? m[1] : null; };
|
|
99
|
+
|
|
100
|
+
/* ── frontmatter (one-line `key: value`, as lib/related-docs.js reads it) ── */
|
|
101
|
+
function front(text) {
|
|
102
|
+
const m = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(String(text || ''));
|
|
103
|
+
if (!m) return { fm: {}, bodyLine: 1 };
|
|
104
|
+
const fm = {};
|
|
105
|
+
for (const l of m[1].split('\n')) { const k = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(l); if (k) fm[k[1]] = k[2].replace(/^["']|["']$/g, '').trim(); }
|
|
106
|
+
return { fm, bodyLine: m[0].split('\n').length };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ── cutting files into entries ── */
|
|
110
|
+
function sections(path, text, re, make) {
|
|
111
|
+
const lines = String(text || '').split('\n');
|
|
112
|
+
const out = [];
|
|
113
|
+
let cur = null, fence = false;
|
|
114
|
+
for (let i = 0; i < lines.length; i++) {
|
|
115
|
+
if (/^\s{0,3}(```|~~~)/.test(lines[i])) fence = !fence;
|
|
116
|
+
const m = !fence && re.exec(lines[i]);
|
|
117
|
+
if (m) { if (cur) out.push(cur); cur = make(m, i + 1); if (cur) cur.lines = [lines[i]]; continue; }
|
|
118
|
+
if (cur) { if (/^#{1,2}\s/.test(lines[i]) && !fence) { out.push(cur); cur = null; continue; } cur.lines.push(lines[i]); }
|
|
119
|
+
}
|
|
120
|
+
if (cur) out.push(cur);
|
|
121
|
+
return out.map((e) => ({ ...e, path, text: e.lines.join('\n') }));
|
|
122
|
+
}
|
|
123
|
+
function wholeFile(path, text, kind, extra = {}) {
|
|
124
|
+
const { fm, bodyLine } = front(text);
|
|
125
|
+
const lines = String(text || '').split('\n');
|
|
126
|
+
const h1 = lines.findIndex((l, i) => i >= bodyLine - 1 && /^#\s+/.test(l));
|
|
127
|
+
const title = fm.title || (h1 >= 0 ? lines[h1].replace(/^#\s+/, '') : path.split('/').pop().replace(/\.md$/, ''));
|
|
128
|
+
return { kind, path, line: h1 >= 0 ? h1 + 1 : bodyLine, title, date: isoOf(fm.date) || isoOf(fm.timestamp), text: lines.slice(bodyLine - 1).join('\n'), lines: lines.slice(bodyLine - 1), from: bodyLine, fm, ...extra };
|
|
129
|
+
}
|
|
130
|
+
function entriesOf(file) {
|
|
131
|
+
const p = file.path, t = file.text;
|
|
132
|
+
if (p === 'DECISIONS.md') {
|
|
133
|
+
const dec = sections(p, t, /^###\s+(D\d{2,3})\s+[—–-]\s+(.+?)\s*$/, (m, line) => ({ kind: 'decision', id: m[1], title: m[1] + ' — ' + m[2], line, from: line }));
|
|
134
|
+
const amend = [];
|
|
135
|
+
const lines = String(t).split('\n');
|
|
136
|
+
const logAt = lines.findIndex((l) => /^##\s+Amendment log/i.test(l));
|
|
137
|
+
if (logAt >= 0) for (let i = logAt + 1; i < lines.length; i++) {
|
|
138
|
+
const m = /^-\s+(20\d{2}-\d{2}-\d{2})\s+[—–-]\s+(.+)$/.exec(lines[i]);
|
|
139
|
+
if (!m) continue;
|
|
140
|
+
const ids = dids(m[2]);
|
|
141
|
+
amend.push({ kind: 'amendment', id: ids[0] || null, ids, path: p, line: i + 1, from: i + 1, date: m[1], title: 'Amendment ' + m[1] + (ids.length ? ' (' + ids.join(', ') + ')' : ''), text: m[2], lines: [lines[i]] });
|
|
142
|
+
}
|
|
143
|
+
return dec.concat(amend);
|
|
144
|
+
}
|
|
145
|
+
if (p === 'pm-decisions.md') return sections(p, t, /^##\s+(20\d{2}-\d{2}-\d{2})\s+[—–-]\s+(.+?)\s*$/, (m, line) => ({ kind: 'pm-decision', title: m[2], date: m[1], line, from: line, ids: dids(m[2]) }));
|
|
146
|
+
if (p === 'discoveries.md') return sections(p, t, /^##\s+(.+?)\s*$/, (m, line) => ({ kind: 'discovery', title: m[1], date: isoOf(m[1]), line, from: line }));
|
|
147
|
+
let m = /^okf\/products\/pmos\/adr\/(d\d{2,3})[-.]/i.exec(p);
|
|
148
|
+
if (m) { const e = wholeFile(p, t, 'adr'); e.id = 'D' + m[1].slice(1).replace(/^0(?=\d{2})/, ''); return [e]; }
|
|
149
|
+
if (/^planning\/out-of-scope\/(?!README\.md$|index\.md$)[^/]+\.md$/.test(p)) {
|
|
150
|
+
const e = wholeFile(p, t, 'out-of-scope');
|
|
151
|
+
const dl = /^-\s*date:\s*(.+)$/m.exec(e.text); if (dl && !e.date) e.date = isoOf(dl[1]);
|
|
152
|
+
return [e];
|
|
153
|
+
}
|
|
154
|
+
if (/^planning\/initiatives\/[^/]+\.md$/.test(p) && !/\/(index|README)\.md$/.test(p)) {
|
|
155
|
+
const e = wholeFile(p, t, 'initiative');
|
|
156
|
+
e.id = e.fm.id || null; e.status = e.fm.status || '';
|
|
157
|
+
// the record's own words about where it stands count as text (a "status:" line often holds the why of a drop)
|
|
158
|
+
if (e.status) e.text = e.title + '\n' + e.status + '\n' + e.text;
|
|
159
|
+
return [e];
|
|
160
|
+
}
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ── the index ── */
|
|
165
|
+
function indexEntry(e) {
|
|
166
|
+
const tTerms = termsOf(e.title || ''), bTerms = termsOf(e.text || '');
|
|
167
|
+
const tf = new Map();
|
|
168
|
+
for (const w of tTerms) tf.set(w, (tf.get(w) || 0) + TITLE_X);
|
|
169
|
+
for (const w of bTerms) tf.set(w, (tf.get(w) || 0) + 1);
|
|
170
|
+
for (const x of e.ids || (e.id ? [e.id] : [])) { const k = 'd' + String(x).slice(1).replace(/^0(?=\d)/, ''); tf.set(k, (tf.get(k) || 0) + TITLE_X); }
|
|
171
|
+
e.tf = tf; e.len = tTerms.length * TITLE_X + bTerms.length;
|
|
172
|
+
return e;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** files: [{ path, text }]; records: [{ kind, ref, title, text, date, quote?, line? }] from the per-user state folder. */
|
|
176
|
+
function build(files, records = []) {
|
|
177
|
+
const entries = [];
|
|
178
|
+
const adrMeta = new Map();
|
|
179
|
+
for (const f of files || []) {
|
|
180
|
+
if (!f || typeof f.path !== 'string') continue;
|
|
181
|
+
for (const e of entriesOf(f)) entries.push(e);
|
|
182
|
+
}
|
|
183
|
+
for (const e of entries) if (e.kind === 'adr' && e.id) adrMeta.set(e.id, { superseded_by: e.fm.superseded_by || e.fm.supersededBy || '', amended_by: e.fm.amended_by || '', status: e.fm.status || '' });
|
|
184
|
+
for (const r of records || []) {
|
|
185
|
+
if (!r || !r.ref || !(r.text || r.title)) continue;
|
|
186
|
+
const m = /^(.*):(\d+)$/.exec(String(r.ref));
|
|
187
|
+
entries.push({ kind: r.kind || 'meeting', path: m ? m[1] : String(r.ref), line: m ? +m[2] : (r.line || 1), from: m ? +m[2] : 1, title: String(r.title || r.text).slice(0, 160), date: isoOf(r.date) || null,
|
|
188
|
+
text: String(r.text || ''), lines: [String(r.quote || r.text || '')], fixedQuote: r.quote || null, ids: dids(r.text) });
|
|
189
|
+
}
|
|
190
|
+
// decision status: lib/decision-ledger.js (decision memory) when it is here, else the ADR's own keys
|
|
191
|
+
let ledger = null;
|
|
192
|
+
try {
|
|
193
|
+
const DL = require('./decision-ledger.js');
|
|
194
|
+
const dec = (files || []).find((f) => f && f.path === 'DECISIONS.md');
|
|
195
|
+
if (DL && typeof DL.parse === 'function' && dec) {
|
|
196
|
+
ledger = DL.parse(dec.text, { path: 'DECISIONS.md' });
|
|
197
|
+
if (typeof DL.addAdr === 'function') for (const f of files || []) if (f && /^okf\/products\/pmos\/adr\//.test(f.path)) DL.addAdr(ledger, f);
|
|
198
|
+
}
|
|
199
|
+
} catch { ledger = null; }
|
|
200
|
+
const status = new Map();
|
|
201
|
+
if (ledger && ledger.decisions instanceof Map) for (const d of ledger.decisions.values()) status.set(d.id, { status: d.status || 'active', supersededBy: d.supersededBy || [], amendedBy: d.amendedBy || [], ...(d.partial ? { partial: true } : {}) });
|
|
202
|
+
// the ADR's own keys fill in what the ledger does not say (or stand alone when there is no ledger)
|
|
203
|
+
for (const [id, a] of adrMeta) {
|
|
204
|
+
const sup = dids(a.superseded_by), am = dids(a.amended_by), had = status.get(id);
|
|
205
|
+
if (had && had.status !== 'active') continue;
|
|
206
|
+
const st = sup.length || /supersed|revert/i.test(a.status) ? 'superseded' : am.length ? 'amended' : 'active';
|
|
207
|
+
if (had && st === 'active') continue;
|
|
208
|
+
status.set(id, { status: st, supersededBy: sup, amendedBy: am });
|
|
209
|
+
}
|
|
210
|
+
for (const e of entries) indexEntry(e);
|
|
211
|
+
const df = new Map();
|
|
212
|
+
for (const e of entries) for (const w of e.tf.keys()) df.set(w, (df.get(w) || 0) + 1);
|
|
213
|
+
const avg = entries.reduce((s, e) => s + e.len, 0) / Math.max(1, entries.length);
|
|
214
|
+
const lineSet = new Map();
|
|
215
|
+
const recPaths = new Set(entries.map((e) => e.path));
|
|
216
|
+
for (const f of files || []) if (f && typeof f.path === 'string' && recPaths.has(f.path)) lineSet.set(f.path, String(f.text || '').split('\n').length);
|
|
217
|
+
for (const r of records || []) if (r && r.ref) { const m = /^(.*):(\d+)$/.exec(String(r.ref)); const p = m ? m[1] : String(r.ref); lineSet.set(p, Math.max(lineSet.get(p) || 0, m ? +m[2] + 2 : 3)); }
|
|
218
|
+
const kinds = {};
|
|
219
|
+
for (const e of entries) kinds[e.kind] = (kinds[e.kind] || 0) + 1;
|
|
220
|
+
return { entries, df, avg, n: entries.length, status, lineSet, kinds, ledger: !!ledger };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const idf = (C, w) => { const n = C.df.get(w) || 0; return Math.log(1 + (C.n - n + 0.5) / (n + 0.5)); };
|
|
224
|
+
function bm25(C, e, q) {
|
|
225
|
+
let s = 0;
|
|
226
|
+
for (const w of q) {
|
|
227
|
+
const f = e.tf.get(w); if (!f) continue;
|
|
228
|
+
s += idf(C, w) * (f * (K1 + 1)) / (f + K1 * (1 - B + B * e.len / C.avg));
|
|
229
|
+
}
|
|
230
|
+
return s;
|
|
231
|
+
}
|
|
232
|
+
/** The line of the entry that holds most of the question (by weight): its own number and its own words. */
|
|
233
|
+
function bestLine(C, e, q, at = 0) {
|
|
234
|
+
if (e.fixedQuote) return { line: e.line, quote: clip(e.fixedQuote) };
|
|
235
|
+
let best = { line: e.line, quote: clip((e.lines || [])[0] || e.title), w: -1 };
|
|
236
|
+
const ls = e.lines || [];
|
|
237
|
+
for (let i = at; i < Math.min(ls.length, at + WINDOW); i++) {
|
|
238
|
+
const raw = ls[i]; if (!raw || !raw.trim() || /^---\s*$/.test(raw)) continue;
|
|
239
|
+
const have = new Set(termsOf(raw));
|
|
240
|
+
let w = 0; for (const t of q) if (have.has(t)) w += idf(C, t);
|
|
241
|
+
if (w > best.w + 1e-9) best = { line: e.from + i, quote: clip(raw), w };
|
|
242
|
+
}
|
|
243
|
+
return best;
|
|
244
|
+
}
|
|
245
|
+
function clip(s) {
|
|
246
|
+
let t = String(s == null ? '' : s).trim().replace(/^[-*>#\s]+/, '').trim();
|
|
247
|
+
if (t.length > QUOTE_MAX) { t = t.slice(0, QUOTE_MAX); const sp = t.lastIndexOf(' '); if (sp > QUOTE_MAX * 0.6) t = t.slice(0, sp); }
|
|
248
|
+
return t;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const WINDOW = 4;
|
|
252
|
+
/** The terms of the entry's title, joined with the best run of WINDOW lines (by how many of the question's terms it holds). */
|
|
253
|
+
function local(e, q) {
|
|
254
|
+
const title = new Set(termsOf(e.title || ''));
|
|
255
|
+
for (const x of e.ids || (e.id ? [e.id] : [])) title.add('d' + String(x).slice(1).replace(/^0(?=\d)/, ''));
|
|
256
|
+
const ls = e.lines || [];
|
|
257
|
+
const per = ls.map((l) => new Set(termsOf(l)));
|
|
258
|
+
let best = { n: -1, terms: title, at: 0 };
|
|
259
|
+
for (let i = 0; i < Math.max(1, per.length); i++) {
|
|
260
|
+
const u = new Set(title);
|
|
261
|
+
for (let j = i; j < Math.min(per.length, i + WINDOW); j++) for (const t of per[j]) u.add(t);
|
|
262
|
+
let n = 0; for (const t of q) if (u.has(t)) n++;
|
|
263
|
+
if (n > best.n) best = { n, terms: u, at: i };
|
|
264
|
+
}
|
|
265
|
+
return best;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function statusOf(C, id) { return (C && C.status && C.status.get(id)) || null; }
|
|
269
|
+
|
|
270
|
+
function rank(C, text, { limit = 5, kinds = null } = {}) {
|
|
271
|
+
const all = termsOf(text);
|
|
272
|
+
const q = [...new Set(all)];
|
|
273
|
+
if (!q.length) return { hits: [], none: true, confidence: 'none', terms: [] };
|
|
274
|
+
// the weight of what was asked: the family word counts less than the subject (it says how, not what)
|
|
275
|
+
const wq = (t) => idf(C, t) * (t === 'drop' ? 0.35 : 1);
|
|
276
|
+
const total = q.reduce((s, t) => s + wq(t), 0) || 1;
|
|
277
|
+
const asked = new Set(q.filter((t) => /^d\d{2,3}$/.test(t)));
|
|
278
|
+
const scored = [];
|
|
279
|
+
for (const e of C.entries) {
|
|
280
|
+
if (kinds && !kinds.has(e.kind)) continue;
|
|
281
|
+
let s = bm25(C, e, q);
|
|
282
|
+
if (!s) continue;
|
|
283
|
+
s *= (KIND_WEIGHT[e.kind] || 0.8);
|
|
284
|
+
// a D-id the question names finds that decision first
|
|
285
|
+
if (asked.size && [...(e.ids || (e.id ? [e.id] : []))].some((d) => asked.has('d' + String(d).slice(1).replace(/^0(?=\d)/, '')))) s *= 2.5;
|
|
286
|
+
scored.push({ e, s });
|
|
287
|
+
}
|
|
288
|
+
scored.sort((a, b) => b.s - a.s);
|
|
289
|
+
// what the question asks must sit TOGETHER: in the title, or within a few lines of each other (a long record that names each word
|
|
290
|
+
// somewhere is not an answer). The cover is that local share, and it is what the confidence reads.
|
|
291
|
+
const pool = scored.slice(0, 60);
|
|
292
|
+
for (const x of pool) {
|
|
293
|
+
const L = local(x.e, q);
|
|
294
|
+
let have = 0; for (const t of q) if (L.terms.has(t)) have += wq(t);
|
|
295
|
+
x.cover = have / total; x.verb = !q.includes('drop') || L.terms.has('drop'); x.at = L.at;
|
|
296
|
+
x.s *= (0.4 + x.cover) * (x.verb ? 1 : 0.85);
|
|
297
|
+
}
|
|
298
|
+
scored.length = 0;
|
|
299
|
+
scored.push(...pool);
|
|
300
|
+
scored.sort((a, b) => b.s - a.s || String(a.e.path).localeCompare(String(b.e.path)) || a.e.line - b.e.line);
|
|
301
|
+
// one hit per decision: the section, its ADR and its amendment lines are the same decision; keep the best of each, then the next one
|
|
302
|
+
const seen = new Set(), hits = [];
|
|
303
|
+
for (const x of scored) {
|
|
304
|
+
const key = x.e.kind === 'amendment' ? x.e.path + ':' + x.e.line : (x.e.id && (x.e.kind === 'decision' || x.e.kind === 'adr') ? 'D:' + x.e.id : x.e.path + ':' + x.e.line);
|
|
305
|
+
if (seen.has(key)) continue; seen.add(key);
|
|
306
|
+
const b = bestLine(C, x.e, q, x.at);
|
|
307
|
+
const st = x.e.id && (x.e.kind === 'decision' || x.e.kind === 'adr') ? statusOf(C, x.e.id) : null;
|
|
308
|
+
hits.push({ kind: x.e.kind, ...(x.e.id ? { id: x.e.id } : {}), path: x.e.path, line: b.line, ref: x.e.path + ':' + b.line, title: x.e.title, date: x.e.date || null, quote: b.quote,
|
|
309
|
+
score: Math.round(x.s * 1000) / 1000, cover: Math.round(x.cover * 100) / 100, ...(x.verb ? {} : { dropSaid: false }), ...(x.e.kind === 'initiative' && x.e.status ? { recordStatus: clip(x.e.status) } : {}),
|
|
310
|
+
...(st && st.status !== 'active' ? { status: st.status, supersededBy: st.supersededBy, amendedBy: st.amendedBy, ...(st.partial ? { partial: true } : {}) } : {}) });
|
|
311
|
+
if (hits.length >= limit) break;
|
|
312
|
+
}
|
|
313
|
+
const top = hits[0];
|
|
314
|
+
let confidence = 'none';
|
|
315
|
+
if (top) {
|
|
316
|
+
if (top.dropSaid === false && top.cover >= CONF.low) confidence = 'low';
|
|
317
|
+
else if (top.cover >= CONF.high && hits.slice(1).some((h) => h.cover >= 0.5)) confidence = 'high';
|
|
318
|
+
else if (top.cover >= CONF.medium) confidence = 'medium';
|
|
319
|
+
else if (top.cover >= CONF.low) confidence = 'low';
|
|
320
|
+
}
|
|
321
|
+
const none = confidence === 'none';
|
|
322
|
+
return { hits: none ? [] : hits.filter((h) => h.cover >= CONF.low * 0.8), none, confidence, terms: q };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** "why did we drop X?" → the passages that answer it, best first. */
|
|
326
|
+
function search(C, question, o = {}) { return rank(C, question, { limit: o.limit || 5, kinds: o.kinds ? new Set(o.kinds) : null }); }
|
|
327
|
+
|
|
328
|
+
/** A new idea → what was decided or declined about it before. The run log is not a decision; an idea is long, so it is cut to its
|
|
329
|
+
* 12 most telling words first (the words rarest across the records), and the bar is the same honest one. */
|
|
330
|
+
function repeats(C, idea, o = {}) {
|
|
331
|
+
// a word no record holds still counts (rarest of all): an idea made of new words is not a repeat, however well one old word matches
|
|
332
|
+
const q = [...new Set(termsOf(idea))].sort((a, b) => idf(C, b) - idf(C, a) || a.localeCompare(b)).slice(0, o.words || 12);
|
|
333
|
+
const r = rank(C, q.join(' '), { limit: o.limit || 5, kinds: new Set(o.kinds || [...DECIDED]) });
|
|
334
|
+
return { hits: r.hits, none: r.none, confidence: r.confidence, terms: r.terms };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Every `path:line` an answer cites must be one the records hold, at a line that exists. */
|
|
338
|
+
function verify(C, text) {
|
|
339
|
+
const refs = [];
|
|
340
|
+
const re = /(?:^|[\s(`[])((?:state:)?[A-Za-z0-9_./-]+\.(?:md|jsonl|json)):(\d+)\b/g;
|
|
341
|
+
let m;
|
|
342
|
+
while ((m = re.exec(String(text || '')))) {
|
|
343
|
+
const p = m[1].replace(/^\.\//, ''), line = +m[2];
|
|
344
|
+
const n = C.lineSet.get(p);
|
|
345
|
+
refs.push(n == null ? { ref: p + ':' + line, ok: false, why: 'not a record PMOS read' } : line < 1 || line > n ? { ref: p + ':' + line, ok: false, why: 'that line is not in the file' } : { ref: p + ':' + line, ok: true });
|
|
346
|
+
}
|
|
347
|
+
return { refs, ok: refs.length > 0 && refs.every((r) => r.ok) };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const ASK_RE = /(\bwhy (did|didn'?t|had) (we|i|you|pmos|the team)\b|\bwhy (was|were) .{1,80}\b(dropped|rejected|retired|removed|declined|chosen|picked|stopped|cut|killed|reverted|deprecated|decided|added|made|changed)\b|\bwhy (did|do) we (ever )?(decide|choose|pick|drop|stop|reject|retire|remove)\b|\bwhen did (we|i|you|pmos)\b|\bdid (we|i|you) (ever|already|decide|drop|try|reject|retire)\b|\bha(ve|s) (we|i|you|pmos) (ever|already) (decided|dropped|tried|rejected|retired|discussed|done)\b|\bwhat did (we|i|you) decide\b|\b(we|i) decided (this|that|it) before\b|\bis (this|that|it) a repeat\b|\bask (pmos )?(about )?the past\b)/i;
|
|
351
|
+
const isPastAsk = (text) => { const s = String(text == null ? '' : text).trim(); return !s.startsWith('/') && ASK_RE.test(s); };
|
|
352
|
+
|
|
353
|
+
module.exports = { build, search, repeats, verify, isPastAsk, statusOf, termsOf, stem, entriesOf, CONF, KIND_WEIGHT, QUOTE_MAX };
|
|
@@ -56,6 +56,9 @@ const VERBS = Object.freeze({
|
|
|
56
56
|
// Not a pm-os-absorb verb (run: null): briefing-source-flow added it through the same table.
|
|
57
57
|
source: Object.freeze({ module: 'source-cli.js', run: null, guide: 'data-freshness.md',
|
|
58
58
|
help: 'pmos source save|status|show — the stored read of each tool the Briefing uses: Mail, Slack, Confluence and the rest (pmos source help)' }),
|
|
59
|
+
// Not a pm-os-absorb verb (run: null): ask-the-past added it through the same table.
|
|
60
|
+
past: Object.freeze({ module: 'past-cli.js', run: null, guide: 'ask-the-past.md',
|
|
61
|
+
help: 'pmos past ask|check|verify|sources … — why did we decide or drop something: the records that say so, each with its line (pmos past help)' }),
|
|
59
62
|
// Not a pm-os-absorb verb (run: null): Home's Mentions A+ (premium-agreed-design) added it through the same table.
|
|
60
63
|
mention: Object.freeze({ module: 'mention-cli.js', run: null, guide: 'home.md',
|
|
61
64
|
help: 'pmos mention list|draft — Home\'s Mentions: what waits on you, and the proposed reply Claude changes from a chat (pmos mention help)' }),
|
|
@@ -78,6 +81,12 @@ const VERBS = Object.freeze({
|
|
|
78
81
|
// Not a pm-os-absorb verb (run: null): feedback batch 12 items 2, 3 and 4 (Claude proposes, the one ✦) added it through the same table.
|
|
79
82
|
propose: Object.freeze({ module: 'propose-cli.js', run: null, guide: 'claude-proposes.md',
|
|
80
83
|
help: 'pmos propose look|found|date|done|name|status … — what Claude\'s run files under an initiative\'s ✦ for the PM to keep or drop (pmos propose help)' }),
|
|
84
|
+
// Not a pm-os-absorb verb (run: null): the AI-ideas round (2026-11-03) added it through the same table: the kill switch and audit of lib/ai-guard.js.
|
|
85
|
+
ai: Object.freeze({ module: 'ai-cli.js', run: null, guide: 'claude-proposes.md',
|
|
86
|
+
help: 'pmos ai status|off|on|log … — the AI jobs: their kill switch, replay state and audit (pmos ai help)' }),
|
|
87
|
+
// Not a pm-os-absorb verb (run: null): the AI-ideas round (2026-11-03, feature 5, plan-rehearsal) added it through the same table.
|
|
88
|
+
rehearse: Object.freeze({ module: 'rehearse-cli.js', run: null, guide: 'rehearse.md',
|
|
89
|
+
help: 'pmos rehearse move|end|cut|log … — what a change would break or help in other initiatives, goals and dates, before the PM approves it (pmos rehearse help)' }),
|
|
81
90
|
// Not a pm-os-absorb verb (run: null): Grill and Forecast part G-A (Claude grills an initiative by itself) added it through the same table.
|
|
82
91
|
grill: Object.freeze({ module: 'grill-cli.js', run: null, guide: 'claude-proposes.md',
|
|
83
92
|
help: 'pmos grill find|voices|status|mode|cap|spend … — what Claude\'s grill run files under an initiative\'s ✦, and when it grills and what it may spend (pmos grill help)' }),
|
|
@@ -87,6 +96,9 @@ const VERBS = Object.freeze({
|
|
|
87
96
|
// Not a pm-os-absorb verb (run: null): batch 16 item 7 (Your impact) added it through the same table.
|
|
88
97
|
impact: Object.freeze({ module: 'impact-cli.js', run: null, guide: 'your-impact.md',
|
|
89
98
|
help: 'pmos impact add|end|status … — what Claude\'s impact run files for you to check, ✓ real or ✕ not real (pmos impact help)' }),
|
|
99
|
+
// Not a pm-os-absorb verb (run: null): initiative weekly-drift-check added it through the same table.
|
|
100
|
+
drift: Object.freeze({ module: 'drift-cli.js', run: null, guide: 'what-you-missed.md',
|
|
101
|
+
help: 'pmos drift show|replay|status — the weekly check for quiet drift: what it would show, its replay on your real records, and its last result (pmos drift help)' }),
|
|
90
102
|
driver: Object.freeze({ module: 'driver-cli.js', run: null, guide: 'initiative-driver.md',
|
|
91
103
|
help: 'pmos driver propose|status|keep|return|drop|pause|resume|mode … — the Initiative Driver: drafts for you to keep, send back or drop (pmos driver help)' }),
|
|
92
104
|
// Not a pm-os-absorb verb (run: null): initiative horizon-scan added it through the same table; horizon-scan-portfolio added section, propose, setting and --boards.
|
|
@@ -71,6 +71,7 @@ function create(deps) {
|
|
|
71
71
|
const active = new Map(); // run → { ws, ini, dir, session, why }
|
|
72
72
|
const warn = deps.warn || (() => {});
|
|
73
73
|
const lastScan = new Map(); // ws → ms of the last morning-check scan
|
|
74
|
+
const pastSeen = new Set(); // ws|initiative: the "You decided this before" look ran (ask-the-past)
|
|
74
75
|
|
|
75
76
|
function ended(run, r, now = Date.now()) {
|
|
76
77
|
const a = active.get(run); if (!a) return;
|
|
@@ -170,6 +171,24 @@ function create(deps) {
|
|
|
170
171
|
return null;
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
/* ask-the-past: a new initiative that repeats what was decided or declined before gets one "You decided this before" finding under its ✦
|
|
175
|
+
* (lib/past-repeat.js, through lib/ai-guard.js). Deterministic and local: no session, no model. Once per initiative while the server runs;
|
|
176
|
+
* a second pass files the same id, so a restart repeats nothing the PM sees. */
|
|
177
|
+
function pastOnce(w, dir, ini) {
|
|
178
|
+
const k = w.id + '|' + ini;
|
|
179
|
+
if (pastSeen.has(k) || !w.root) return;
|
|
180
|
+
pastSeen.add(k);
|
|
181
|
+
let row = {};
|
|
182
|
+
try { row = (deps.rowOf && deps.rowOf(w, ini)) || {}; } catch { row = {}; }
|
|
183
|
+
let board = '';
|
|
184
|
+
try { board = (deps.boardOf && deps.boardOf(w, ini)) || ''; } catch { board = ''; }
|
|
185
|
+
const PRP = require('./past-repeat.js');
|
|
186
|
+
let runtime = null;
|
|
187
|
+
try { runtime = require('./state-root.js').defaultRoot(); } catch { runtime = null; }
|
|
188
|
+
Promise.resolve().then(() => PRP.file({ root: w.root, dir, runtime, ws: w.id, board, ini, title: row.title || ini, intent: row.intent || '', env: deps.env || process.env }))
|
|
189
|
+
.catch((e) => warn('you decided this before', e));
|
|
190
|
+
}
|
|
191
|
+
|
|
173
192
|
function stop(run) { const a = active.get(run); if (a && a.session && deps.stop) deps.stop(a.session); }
|
|
174
193
|
|
|
175
194
|
function tick({ now = Date.now(), env } = {}) {
|
|
@@ -188,6 +207,7 @@ function create(deps) {
|
|
|
188
207
|
try { created = deps.created(w) || []; } catch { created = []; }
|
|
189
208
|
for (const c of created) {
|
|
190
209
|
if (!c || !c.initiative || String(c.at || '') < epoch || F.looks.has(c.initiative)) continue;
|
|
210
|
+
pastOnce(w, dir, c.initiative);
|
|
191
211
|
const r = start({ ws: w.id, ini: c.initiative, why: 'populate', press: false, now });
|
|
192
212
|
if (r.ok) return { started: c.initiative, why: 'populate' };
|
|
193
213
|
if (r.held) return { held: true }; // the governor said wait: the next tick tries again
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* rehearse-cli.js — `pmos rehearse`: what a change would break or help, before the PM approves it (initiative plan-rehearsal).
|
|
3
|
+
*
|
|
4
|
+
* pmos rehearse move <task> --by <days> | --start YYYY-MM-DD [--due YYYY-MM-DD] | --due YYYY-MM-DD a task's dates change
|
|
5
|
+
* pmos rehearse end <initiative> --to YYYY-MM-DD an initiative's end changes
|
|
6
|
+
* pmos rehearse cut <task|initiative> what if we cut this
|
|
7
|
+
* pmos rehearse log [--n N] the last rehearsals (the audit)
|
|
8
|
+
* every command takes --json and --root <repository>
|
|
9
|
+
*
|
|
10
|
+
* It reads through lib/rehearse-read.js, runs lib/rehearse.js and shows only the effects lib/ai-guard.js lets through (lib/rehearse-guard.js).
|
|
11
|
+
* It changes nothing: the one write is the audit line in <runtime>/ai-audit.jsonl. The PM approves the change in their own words; only
|
|
12
|
+
* then does the session make it, with `pmos task set`, `pmos task state` or the PM's own act. Exit codes: 0 done, 1 off or refused,
|
|
13
|
+
* 2 usage. Nothing is sent. */
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { execFileSync } = require('child_process');
|
|
16
|
+
const RH = require('./rehearse.js');
|
|
17
|
+
|
|
18
|
+
const COMMANDS = { rehearse: ['move', 'end', 'cut', 'log'] };
|
|
19
|
+
const USAGE = [
|
|
20
|
+
'usage: pmos rehearse move|end|cut|log — what a change would break or help, before the PM approves it. Nothing changes.',
|
|
21
|
+
' pmos rehearse move <task> --by <days> the task moves later (or earlier, with a minus) by whole days',
|
|
22
|
+
' pmos rehearse move <task> --start YYYY-MM-DD [--due YYYY-MM-DD] | --due YYYY-MM-DD',
|
|
23
|
+
' pmos rehearse end <initiative> --to YYYY-MM-DD the initiative\'s end date changes',
|
|
24
|
+
' pmos rehearse cut <task|initiative> what if we cut this',
|
|
25
|
+
' pmos rehearse log [--n N] the last rehearsals',
|
|
26
|
+
'Each effect names the record it comes from. Show it to the PM, and make the change only after the PM says yes.',
|
|
27
|
+
];
|
|
28
|
+
const VALUE = new Set(['by', 'start', 'due', 'to', 'root', 'today', 'n']);
|
|
29
|
+
const BOOL = new Set(['json']);
|
|
30
|
+
|
|
31
|
+
function parse(args) {
|
|
32
|
+
const pos = [], flags = {};
|
|
33
|
+
for (let i = 0; i < args.length; i++) {
|
|
34
|
+
const m = /^--([a-z-]+)(?:=(.*))?$/.exec(args[i]);
|
|
35
|
+
if (!m) { pos.push(args[i]); continue; }
|
|
36
|
+
if (m[2] !== undefined) flags[m[1]] = m[2];
|
|
37
|
+
else if (VALUE.has(m[1]) && args[i + 1] !== undefined && !/^--[a-z]/.test(args[i + 1])) flags[m[1]] = args[++i];
|
|
38
|
+
else flags[m[1]] = true;
|
|
39
|
+
}
|
|
40
|
+
return { pos, flags };
|
|
41
|
+
}
|
|
42
|
+
function repoRoot(cwd) {
|
|
43
|
+
try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
|
|
44
|
+
catch { return cwd; }
|
|
45
|
+
}
|
|
46
|
+
const runtimeOf = (env) => require('./state-root.js').defaultRoot({ env });
|
|
47
|
+
|
|
48
|
+
async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), env = process.env, stateDir = null, snapshot = null } = {}) {
|
|
49
|
+
const args = argv[0] === 'rehearse' ? argv.slice(1) : argv.slice();
|
|
50
|
+
const verb = args[0];
|
|
51
|
+
if (!verb || ['help', '--help', '-h'].includes(verb)) { for (const l of USAGE) say(l); return verb ? 0 : 1; }
|
|
52
|
+
if (!COMMANDS.rehearse.includes(verb)) { say(`unknown rehearse command "${verb}" — one of ${COMMANDS.rehearse.join(', ')}`); return 2; }
|
|
53
|
+
const { pos, flags } = parse(args.slice(1));
|
|
54
|
+
for (const k of Object.keys(flags)) if (!VALUE.has(k) && !BOOL.has(k)) { say(`unknown option --${k}`); return 2; }
|
|
55
|
+
for (const k of Object.keys(flags)) if (VALUE.has(k) && flags[k] === true) { say(`--${k} needs a value`); return 2; }
|
|
56
|
+
const json = !!flags.json, runtime = stateDir || runtimeOf(env);
|
|
57
|
+
const G = require('./rehearse-guard.js'), AG = require('./ai-guard.js');
|
|
58
|
+
|
|
59
|
+
if (verb === 'log') {
|
|
60
|
+
const n = Math.min(200, Math.max(1, Number(flags.n) || 10));
|
|
61
|
+
const lines = AG.readAudit(runtime, { job: G.JOB, limit: 2000 }).filter((l) => l.event === 'start' || l.event === 'end' || l.event === 'off' || l.event === 'held').slice(-n * 2);
|
|
62
|
+
if (json) { say(JSON.stringify({ ok: true, lines })); return 0; }
|
|
63
|
+
if (!lines.length) { say('No rehearsal yet.'); return 0; }
|
|
64
|
+
for (const l of lines) say(`${String(l.at).slice(0, 16).replace('T', ' ')} ${l.event}${l.change ? ' ' + l.change.kind + ' ' + (l.change.task || l.change.ini || '') : ''}${l.filed != null ? ' · ' + l.filed + ' shown, ' + l.dropped + ' dropped' : ''}${l.why ? ' · ' + l.why : ''}`);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const usage = { move: 'usage: pmos rehearse move <task> --by <days> | --start YYYY-MM-DD [--due YYYY-MM-DD] | --due YYYY-MM-DD', end: 'usage: pmos rehearse end <initiative> --to YYYY-MM-DD', cut: 'usage: pmos rehearse cut <task|initiative>' }[verb];
|
|
69
|
+
if (pos.length !== 1) { say(usage); return 2; }
|
|
70
|
+
const allowed = { move: ['by', 'start', 'due'], end: ['to'], cut: [] }[verb];
|
|
71
|
+
for (const k of ['by', 'start', 'due', 'to']) if (flags[k] !== undefined && !allowed.includes(k)) { say(`--${k} is not an option of ${verb}`); return 2; }
|
|
72
|
+
const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say('✗ ' + error); return code; };
|
|
73
|
+
|
|
74
|
+
// the kill switch first: an off rehearsal reads nothing
|
|
75
|
+
const off = AG.off(G.JOB, { env, settingsDir: runtime });
|
|
76
|
+
if (off) { AG.audit(runtime, { job: G.JOB, version: G.VERSION, event: 'off', why: off }); return fail('rehearsals are off: ' + off + '. `pmos ai on rehearse` turns them on.'); }
|
|
77
|
+
|
|
78
|
+
const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
|
|
79
|
+
const snap = snapshot || require('./rehearse-read.js').snapshotOf(root, { today: typeof flags.today === 'string' ? flags.today : undefined });
|
|
80
|
+
const id = pos[0];
|
|
81
|
+
let change;
|
|
82
|
+
if (verb === 'move') {
|
|
83
|
+
if (flags.by !== undefined && !/^-?\d+$/.test(String(flags.by))) return fail('--by takes whole days, e.g. --by 3 or --by -2', 2);
|
|
84
|
+
change = { kind: 'move', task: id, ...(flags.by !== undefined ? { days: Number(flags.by) } : {}), ...(flags.start ? { start: flags.start } : {}), ...(flags.due ? { due: flags.due } : {}) };
|
|
85
|
+
} else if (verb === 'end') change = { kind: 'end', ini: id, to: flags.to };
|
|
86
|
+
else change = (snap.rows || []).some((r) => r.id === id) ? { kind: 'cut', ini: id } : { kind: 'cut', task: id };
|
|
87
|
+
|
|
88
|
+
const r = RH.rehearse(snap, change);
|
|
89
|
+
if (!r.ok) return fail(r.error, /^(say how|--)/.test(r.error) ? 2 : 1);
|
|
90
|
+
const g = await G.guarded(r, { root, stateDir: runtime, env });
|
|
91
|
+
if (g.off) return fail('rehearsals are off: ' + g.off);
|
|
92
|
+
if (g.held) return fail('the rehearsal replay did not pass, so nothing is shown: ' + (g.failed || []).map((f) => f.name + ' (' + f.why + ')').join('; ') + '. `pmos ai status` shows it.');
|
|
93
|
+
const shown = { ...r, effects: g.effects, counts: { breaks: 0, helps: 0, moves: 0 } };
|
|
94
|
+
for (const e of shown.effects) shown.counts[e.tone]++;
|
|
95
|
+
if (json) { say(JSON.stringify({ ok: true, change, effects: shown.effects, counts: shown.counts, dropped: g.dropped.length, missing: r.missing, changed: false })); return 0; }
|
|
96
|
+
for (const l of RH.textOf(shown).slice(0, -1)) say(l);
|
|
97
|
+
if (g.dropped.length) say(`${g.dropped.length === 1 ? '1 effect is' : g.dropped.length + ' effects are'} not shown: PMOS could not find its source at the line it cites.`);
|
|
98
|
+
say(RH.textOf(shown).slice(-1)[0]);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { run, COMMANDS, USAGE };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* rehearse-guard.js — a rehearsal's effects through the shared door, lib/ai-guard.js (initiative ai-job-guard): the kill switch, the
|
|
3
|
+
* replay gate, the citation check and the audit. Nothing here duplicates the guard; this file only says what a rehearsal files and
|
|
4
|
+
* which recorded cases it must pass.
|
|
5
|
+
*
|
|
6
|
+
* JOB · VERSION 'rehearse', and the version of the engine's output (bump it when lib/rehearse.js changes)
|
|
7
|
+
* proposalsOf(result) → [proposal] one ai-guard proposal per effect: its text, its sources, its tone in data
|
|
8
|
+
* cases(fixture?) → [replay case] the recorded snapshot fixtures/rehearse-replay.json, replayed through the engine
|
|
9
|
+
* fixture(file?) → { root, snap } that snapshot, its cited files written to a temporary folder (root), its sources computed from them
|
|
10
|
+
* guarded(result, o) → Promise<{ off?, held?, effects, dropped }>
|
|
11
|
+
* o: { root, stateDir (the runtime folder), env, run }. Runs the replay first when this version has not passed it, then files
|
|
12
|
+
* the effects. An effect whose source the guard cannot find at its line is dropped and never shown (proven only).
|
|
13
|
+
*
|
|
14
|
+
* The audit lines go to <runtime>/ai-audit.jsonl with the job `rehearse`; `pmos ai log rehearse` reads them, and `pmos ai off rehearse`
|
|
15
|
+
* (or PMOS_AI_REHEARSE=off, or PMOS_AI=off) turns rehearsals off. */
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const AG = require('./ai-guard.js');
|
|
21
|
+
const RH = require('./rehearse.js');
|
|
22
|
+
const RR = require('./rehearse-read.js');
|
|
23
|
+
|
|
24
|
+
const JOB = 'rehearse';
|
|
25
|
+
const VERSION = '2';
|
|
26
|
+
const MAX = 40;
|
|
27
|
+
const FIXTURE = path.join(__dirname, '..', 'fixtures', 'rehearse-replay.json');
|
|
28
|
+
const TITLE = { breaks: 'Breaks', helps: 'Helps', moves: 'Moves with it' };
|
|
29
|
+
|
|
30
|
+
const idOf = (e) => 'rh:' + e.kind + ':' + crypto.createHash('sha1').update(e.text).digest('hex').slice(0, 12);
|
|
31
|
+
function proposalsOf(r) {
|
|
32
|
+
if (!r || !r.ok) return [];
|
|
33
|
+
return r.effects.map((e) => ({
|
|
34
|
+
id: idOf(e), title: TITLE[e.tone] + ': ' + e.kind, say: e.text,
|
|
35
|
+
confidence: new Set(e.evidence.map((s) => s.ref)).size >= 2 ? 'high' : 'medium',
|
|
36
|
+
claims: [{ text: e.text, sources: e.evidence.map((s) => ({ label: s.label, ref: s.ref, quote: s.quote || '' })) }],
|
|
37
|
+
data: { tone: e.tone, kind: e.kind, ini: e.ini || null, task: e.task || null, goal: e.goal || null, program: e.program || null },
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fixture(file = FIXTURE) {
|
|
42
|
+
const snap = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
43
|
+
// the files its sources cite, written where the guard can read them at their lines
|
|
44
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pmos-rehearse-replay-'));
|
|
45
|
+
for (const [name, lines] of Object.entries(snap.contents || {})) fs.writeFileSync(path.join(root, path.basename(name)), lines.join('\n') + '\n');
|
|
46
|
+
const f = snap.files || {}, abs = (x) => path.join(root, x);
|
|
47
|
+
delete snap.contents;
|
|
48
|
+
snap.refs = RR.refsOf(root, snap, { links: abs(f.links), krs: abs(f.krs), programs: abs(f.programs), records: Object.fromEntries(Object.entries(f.records || {}).map(([k, v]) => [k, abs(v)])) });
|
|
49
|
+
return { root, snap };
|
|
50
|
+
}
|
|
51
|
+
/** The recorded cases: the engine on a fixed snapshot, never a model. */
|
|
52
|
+
function cases(F = fixture()) {
|
|
53
|
+
const { snap } = F;
|
|
54
|
+
const run = (change, edit) => async () => proposalsOf(RH.rehearse(edit ? edit(JSON.parse(JSON.stringify(snap))) : snap, change));
|
|
55
|
+
return [
|
|
56
|
+
{ name: 'a change nothing waits on says nothing', run: run({ kind: 'move', task: 'b2', days: 1 }), expect: { none: true } },
|
|
57
|
+
{ name: 'a later task moves another initiative, a milestone and a Jira ticket, with the link lines', run: run({ kind: 'move', task: 'a1', days: 5 }), expect: { min: 5, cites: ['rehearse-links.jsonl', 'rehearse-programs.jsonl'] } },
|
|
58
|
+
{ name: 'cutting an initiative names the goal it moves and what waited on it', run: run({ kind: 'cut', ini: 'checkout' }), expect: { min: 3, cites: ['rehearse-krs.jsonl', 'rehearse-checkout.txt', 'rehearse-links.jsonl'] } },
|
|
59
|
+
{ name: 'a link broken before the change is not blamed on it', run: run({ kind: 'end', ini: 'checkout', to: '2026-11-25' }, (s) => { s.tasks[2].start = '2026-11-18'; return s; }), expect: { none: true } },
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function guarded(result, o = {}) {
|
|
64
|
+
const env = o.env || process.env, stateDir = o.stateDir, run = o.run || 'rh-' + crypto.randomBytes(4).toString('hex');
|
|
65
|
+
const off = AG.off(JOB, { env, settingsDir: stateDir });
|
|
66
|
+
if (off) { AG.audit(stateDir, { job: JOB, version: VERSION, run, event: 'off', why: off }); return { off, effects: [], dropped: [] }; }
|
|
67
|
+
if (!AG.ready(stateDir, JOB, VERSION)) {
|
|
68
|
+
const F = fixture(), rp = await AG.replay(JOB, VERSION, cases(F), { root: F.root, stateDir, max: MAX });
|
|
69
|
+
try { fs.rmSync(F.root, { recursive: true, force: true }); } catch { /* a temporary folder */ }
|
|
70
|
+
if (!rp.ok) return { held: true, failed: rp.failed, effects: [], dropped: [] };
|
|
71
|
+
}
|
|
72
|
+
AG.audit(stateDir, { job: JOB, version: VERSION, run, event: 'start', change: result.change, effects: result.effects.length });
|
|
73
|
+
const P = proposalsOf(result), byId = new Map(P.map((p, i) => [p.id, result.effects[i]]));
|
|
74
|
+
let shown = [];
|
|
75
|
+
const got = await AG.file({ job: JOB, version: VERSION, run, root: o.root, stateDir, settingsDir: stateDir, env, max: MAX }, P, async (filed) => { shown = filed.map((p) => byId.get(p.id)).filter(Boolean); });
|
|
76
|
+
if (got.off) return { off: got.off, effects: [], dropped: [] };
|
|
77
|
+
if (got.held) return { held: true, effects: [], dropped: [] };
|
|
78
|
+
return { effects: shown, dropped: got.dropped || [] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { JOB, VERSION, MAX, FIXTURE, proposalsOf, fixture, cases, guarded };
|