create-pmos 0.38.8 → 0.40.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 (49) hide show
  1. package/package.json +1 -1
  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/README.md +37 -1
  7. package/workspace/bin/pmos.js +8 -1
  8. package/workspace/guide/index.md +40 -0
  9. package/workspace/guide/initiatives-and-next-steps.md +72 -0
  10. package/workspace/guide/layout.md +73 -0
  11. package/workspace/guide/proactive-engine.md +75 -0
  12. package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
  13. package/workspace/guide/statuses.md +53 -0
  14. package/workspace/guide/validate-with-typesafe.md +80 -0
  15. package/workspace/lib/brief.js +113 -0
  16. package/workspace/lib/claims.js +605 -0
  17. package/workspace/lib/doc-profiles.js +74 -0
  18. package/workspace/lib/initiative-files.js +37 -6
  19. package/workspace/lib/judgment.js +65 -0
  20. package/workspace/lib/mobile-nav.js +1 -1
  21. package/workspace/lib/next-step-actions.js +139 -0
  22. package/workspace/lib/next-steps.js +27 -7
  23. package/workspace/lib/preview-kind.js +17 -2
  24. package/workspace/lib/proactive-cli.js +137 -0
  25. package/workspace/lib/proactive.js +208 -0
  26. package/workspace/lib/repo-watch.js +120 -0
  27. package/workspace/lib/schedule-cli.js +5 -2
  28. package/workspace/lib/secret-scan.js +28 -0
  29. package/workspace/lib/session-budget.js +35 -0
  30. package/workspace/lib/status-groups.js +53 -10
  31. package/workspace/lib/typesafe-settings.js +122 -0
  32. package/workspace/lib/typesafe.js +124 -0
  33. package/workspace/lib/validate.js +304 -0
  34. package/workspace/package-lock.json +1 -1
  35. package/workspace/package.json +3 -3
  36. package/workspace/public/pm/app.js +53 -18
  37. package/workspace/public/pm/data.js +18 -3
  38. package/workspace/public/pm/mobile.js +1 -1
  39. package/workspace/public/pm/modes-live.js +373 -0
  40. package/workspace/public/pm/modes.js +26 -0
  41. package/workspace/public/pm/next-step-ui.js +185 -0
  42. package/workspace/public/pm/panel-detail.js +47 -11
  43. package/workspace/public/pm/panel-initiatives.js +26 -4
  44. package/workspace/public/pm/panel-repos.js +2 -2
  45. package/workspace/public/pm/state.js +3 -3
  46. package/workspace/public/pm/status-banner.js +87 -0
  47. package/workspace/public/pm/tab-doc.js +66 -8
  48. package/workspace/server.js +562 -18
  49. package/workspace/skills/morning-brief.skill +43 -0
@@ -0,0 +1,605 @@
1
+ 'use strict';
2
+ /* claims.js — the claims ledger and the local checks (claims-ledger C2–C7).
3
+ *
4
+ * WHAT IT DOES. It reads the documents and config files of a repository, classifies each (lib/doc-profiles.js),
5
+ * extracts SHORT STATEMENTS from them by structure (a lineage path, a requirement id, a rubric weight, a cited
6
+ * decision, a link, a date), and verifies each statement LOCALLY against the truth the repository already holds:
7
+ * the decisions file, the initiative records, the PRD's own requirement list, git. It sends nothing anywhere.
8
+ *
9
+ * WHAT A CLAIM ENDS AS.
10
+ * verified the truth source agrees.
11
+ * contradicted the truth source says otherwise; `truth` says what it says.
12
+ * stale a document it derives from changed after it did, or its `stale_after` date has passed.
13
+ * undecided structure cannot decide it (a counterpart that does not name this document, a rubric with no
14
+ * weights table). Stage 3 may hand an undecided claim to a model; this stage only reports it.
15
+ *
16
+ * WHY IT IS CHEAP. Reads are limited to files whose mtime or size moved; unchanged hashes skip extraction; a
17
+ * changed file re-verifies itself and the documents that cite it, and no others; the ledger gains a line only when
18
+ * a claim's status, truth or text changed, so a repeat run appends nothing.
19
+ *
20
+ * WHAT IT NEVER DOES. It does not edit a repository document, a decision or an acceptance. Its one write into the
21
+ * repository is `claims.jsonl`, appended through lib/sink.js. Its caches (stamps, index) live in the per-user
22
+ * runtime directory, outside every repository.
23
+ *
24
+ * WHAT IT CANNOT SEE. Prose with no structured statement is not extracted. A *Consistent* verdict means consistent
25
+ * by structure and ledger; the page says so.
26
+ */
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const crypto = require('crypto');
30
+ const { execFile } = require('child_process');
31
+ const { classify, extOf, IN_SCOPE_EXT, REFERENCES } = require('./doc-profiles');
32
+ const { createSinkGuard } = require('./sink');
33
+
34
+ const MAX_FILES = 4000;
35
+ const MAX_DOC_BYTES = 400 * 1024;
36
+ const STALE_SECS = 3600; // a source must be newer than its dependent by this much to make it stale
37
+ const WEIGHT_TOLERANCE = 0.011;
38
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.next', 'dist', 'build', 'vendor']);
39
+ const REQ_RE = /^\s*[-*]\s+\*\*([A-Z]{1,3}\d{1,3})\b[^*]*\*\*/;
40
+ const sha = (s) => crypto.createHash('sha1').update(s).digest('hex');
41
+ const nowISO = () => new Date().toISOString();
42
+
43
+ /* ── git, asynchronously. The existing git helpers use spawnSync and block the event loop; a poller must not. ── */
44
+ function git(root, args) {
45
+ return new Promise((resolve) => {
46
+ execFile('git', args, { cwd: root, maxBuffer: 64 << 20, timeout: 20000, encoding: 'utf8' }, (err, out) => resolve(err ? null : out));
47
+ });
48
+ }
49
+
50
+ /* ── reading ───────────────────────────────────────────────────────────────────────────────────── */
51
+ function walk(root) {
52
+ const found = [];
53
+ (function go(dir, rel) {
54
+ let entries = [];
55
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
56
+ for (const e of entries) {
57
+ if (e.isSymbolicLink()) continue;
58
+ const r = rel ? rel + '/' + e.name : e.name;
59
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) go(path.join(dir, e.name), r); }
60
+ else found.push(r);
61
+ }
62
+ })(root, '');
63
+ return found;
64
+ }
65
+ async function listScope(root) {
66
+ // Tracked files AND new files nobody has added yet (an agent's output), minus whatever .gitignore excludes.
67
+ const out = await git(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
68
+ const all = out ? out.split('\0').filter(Boolean) : walk(root);
69
+ const files = all.filter((r) => IN_SCOPE_EXT.has(extOf(r)) && !r.split('/').some((s) => SKIP_DIRS.has(s))).sort();
70
+ return { files: files.slice(0, MAX_FILES), total: files.length, truncated: files.length > MAX_FILES };
71
+ }
72
+
73
+ /** Last change time (epoch seconds) per path: git's latest commit that touched it, or the mtime for a dirty file. */
74
+ async function lastChanges(root, dirty) {
75
+ const map = new Map();
76
+ const out = await git(root, ['log', '-n', '4000', '--format=%x01%ct', '--name-only']);
77
+ if (out) {
78
+ let t = 0;
79
+ for (const line of out.split('\n')) {
80
+ if (line.startsWith('\u0001')) { t = Number(line.slice(1)) || 0; continue; }
81
+ if (line && !map.has(line)) map.set(line, t);
82
+ }
83
+ }
84
+ for (const rel of dirty) {
85
+ try { map.set(rel, Math.floor(fs.statSync(path.join(root, rel)).mtimeMs / 1000)); } catch { /* deleted */ }
86
+ }
87
+ return map;
88
+ }
89
+ async function dirtyFiles(root) {
90
+ const out = await git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
91
+ if (!out) return new Set();
92
+ const set = new Set();
93
+ for (const ent of out.split('\0')) { if (ent.length > 3) set.add(ent.slice(3)); }
94
+ return set;
95
+ }
96
+
97
+ /* ── small parsers ─────────────────────────────────────────────────────────────────────────────── */
98
+ function frontmatter(text) {
99
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
100
+ const fm = {};
101
+ if (!m) return fm;
102
+ for (const line of m[1].split(/\r?\n/)) {
103
+ const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
104
+ if (kv) fm[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, '');
105
+ }
106
+ return fm;
107
+ }
108
+ /* A value as a person wrote it: `backticks`, **bold** and quotes are decoration, not part of the value. */
109
+ const cleanValue = (v) => String(v == null ? '' : v).trim().replace(/^[`*"']+|[`*"']+$/g, '').trim();
110
+ /* An initiative id as written in a bullet: the first token, without the decoration that often follows it. */
111
+ const idOf = (v) => cleanValue(v).split(/[\s`·,;(]/)[0];
112
+ /* A placeholder is not a claim: "(set per initiative)", "<per-use>", "TBD". */
113
+ const isPlaceholder = (v) => /^[<(\[{].*[>)\]}]$/.test(String(v).trim()) || /\b(per[- ]use|per initiative|tbd|n\/a|none yet)\b/i.test(String(v));
114
+ /** `- key: value` lines in the document's Identity block (the first 80 lines after the frontmatter). */
115
+ function bodyKeys(text) {
116
+ const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
117
+ const keys = {};
118
+ for (const line of body.split(/\r?\n/).slice(0, 80)) {
119
+ const kv = /^\s*[-*]\s+([A-Za-z_][\w -]*?):\s*(.*)$/.exec(line);
120
+ if (kv && !(kv[1].toLowerCase() in keys)) keys[kv[1].toLowerCase().replace(/\s+/g, '_')] = cleanValue(kv[2]);
121
+ }
122
+ return keys;
123
+ }
124
+ function linksOf(text) {
125
+ const out = [];
126
+ const re = /\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
127
+ let m;
128
+ while ((m = re.exec(text.replace(/```[\s\S]*?```/g, '')))) out.push(m[1]);
129
+ return out;
130
+ }
131
+ /** Resolve a link or path to a repo-relative path, or null when it is external, an anchor, or leaves the repository. */
132
+ function resolveRef(docRel, target) {
133
+ const t = String(target || '').trim();
134
+ if (!t || /^(?:[a-z][a-z0-9+.-]*:|#|\/\/)/i.test(t)) return null;
135
+ let clean;
136
+ try { clean = decodeURI(t.split('#')[0].split('?')[0]); } catch { return null; }
137
+ if (!clean) return null;
138
+ const p = clean.startsWith('/') ? clean.slice(1) : path.posix.join(path.posix.dirname(docRel), clean);
139
+ const norm = path.posix.normalize(p);
140
+ return norm.startsWith('..') ? null : norm;
141
+ }
142
+ /* The first file a value points at: a markdown link, else the first token that looks like a path to a .md file.
143
+ * Anything else ("paired", "(set per use)") points at nothing, and is not guessed at. */
144
+ const firstLinkPath = (docRel, value) => {
145
+ const v = String(value || '');
146
+ const m = /\]\(([^)\s]+)\)/.exec(v);
147
+ if (m) return resolveRef(docRel, m[1]);
148
+ const t = /([\w./-]+\.md)\b/.exec(v.replace(/[`*]/g, ''));
149
+ if (!t) return null;
150
+ // A bare path in a bullet is written from the repository root; a bare file name is beside the document.
151
+ return t[1].includes('/') ? resolveRef(docRel, '/' + t[1].replace(/^\/+/, '')) : resolveRef(docRel, t[1]);
152
+ };
153
+ const headingsOf = (text) => Array.from(text.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)).map((m) => m[1].toLowerCase());
154
+ const norm = (p) => (p ? path.posix.normalize(String(p).replace(/^\/+/, '')) : '');
155
+
156
+ /* Every weights table in a rubric, as [{sum, rows}]. The Weight column is found by its header, so a table that
157
+ * puts it second (Dimension | Weight | Score) and one that puts it third (# | dimension | weight) both read. A rubric
158
+ * may hold two tables (a second round of dimensions); each is checked on its own. */
159
+ function rubricWeights(text) {
160
+ const lines = text.split('\n');
161
+ const tables = [];
162
+ for (let i = 0; i < lines.length; i++) {
163
+ if (!/^\s*\|/.test(lines[i])) continue;
164
+ const cells = lines[i].split('|').slice(1, -1).map((c) => c.trim().toLowerCase());
165
+ const col = cells.findIndex((c) => /^\**weight\**$/.test(c));
166
+ if (col < 0) continue;
167
+ const rows = [];
168
+ let j = i + 1;
169
+ if (/^\s*\|[\s:|-]+\|\s*$/.test(lines[j] || '')) j++;
170
+ for (; j < lines.length && /^\s*\|/.test(lines[j]); j++) {
171
+ const rowCells = lines[j].split('|').slice(1, -1);
172
+ if (rowCells.some((c) => /^\**total\**$/i.test(c.trim()))) continue; // the Total row is not a dimension
173
+ const cell = (rowCells[col] || '').replace(/[*`%\s]/g, '');
174
+ if (/^(?:0?\.\d+|1(?:\.0+)?)$/.test(cell)) rows.push(Number(cell));
175
+ }
176
+ if (rows.length) tables.push({ sum: Math.round(rows.reduce((a, b) => a + b, 0) * 1000) / 1000, rows: rows.length });
177
+ i = j;
178
+ }
179
+ return tables;
180
+ }
181
+
182
+ /* ── extraction: structure only, by type ───────────────────────────────────────────────────────── */
183
+ function extract(rel, text, cls, fm, body) {
184
+ const out = [];
185
+ const add = (kind, subject, statement, extra = {}) => out.push({ doc: rel, kind, subject: String(subject), text: String(statement).slice(0, 220), refs: [], ...extra });
186
+
187
+ const seen = new Set();
188
+ for (const t of linksOf(text)) {
189
+ const r = resolveRef(rel, t);
190
+ if (!r || seen.has(r) || seen.size >= 40) continue;
191
+ seen.add(r);
192
+ add('link', r, `links to ${r}`, { refs: [r] });
193
+ }
194
+ if (/^\d{4}-\d{2}-\d{2}/.test(fm.stale_after || '')) add('time', 'stale_after', `stale_after ${fm.stale_after.slice(0, 10)}`, { value: fm.stale_after.slice(0, 10) });
195
+
196
+ const type = cls.type;
197
+ if (type === 'Config') {
198
+ if (extOf(rel) === 'json') {
199
+ let err = null;
200
+ try { JSON.parse(text); } catch (e) { err = (e && e.message) || 'parse error'; }
201
+ add('syntax', 'json', 'is valid JSON', { value: err });
202
+ }
203
+ return out;
204
+ }
205
+ if (type === 'Initiative' && fm.id) {
206
+ for (const key of ['prd_path', 'rubric_path', 'explainer_path', 'brief_path', 'doc_path']) {
207
+ if (fm[key] && !isPlaceholder(fm[key])) add('lineage', key, `${key} is ${fm[key]}`, { value: norm(fm[key]), initiative: fm.id, refs: [norm(fm[key]), 'init:' + fm.id] });
208
+ }
209
+ add('anchor', 'parent_kr', 'has an anchor', { value: fm.parent_kr || '' });
210
+ }
211
+ if (type === 'PRD') {
212
+ const ids = new Map();
213
+ for (const line of text.split('\n')) {
214
+ const m = REQ_RE.exec(line);
215
+ if (m) { ids.set(m[1], (ids.get(m[1]) || 0) + 1); }
216
+ }
217
+ for (const [id, n] of ids) add('req', id, `requirement ${id}`, { value: n });
218
+ const iid = idOf(body.initiative_id || fm.initiative_id);
219
+ if (iid && !isPlaceholder(iid)) add('prd_initiative', iid, `names initiative ${iid}`, { value: iid, refs: ['init:' + iid] });
220
+ if (body.rubric) { const p = firstLinkPath(rel, body.rubric); if (p) add('prd_rubric', p, `names rubric ${p}`, { value: p, refs: [p] }); }
221
+ const cited = new Set();
222
+ for (const m of text.matchAll(/\[D(\d{1,3})\b[^\]]*\]\([^)]*\)/g)) cited.add(Number(m[1]));
223
+ for (const n of cited) add('decision', 'D' + n, `cites decision D${n}`, { value: n, refs: ['decisions'] });
224
+ const hs = headingsOf(text);
225
+ for (const seg of ['identity', 'requirements']) add('segment', seg, `has a ${seg} section`, { value: hs.some((h) => h.includes(seg)) });
226
+ }
227
+ if (type === 'Rubric') {
228
+ const tables = rubricWeights(text);
229
+ if (!tables.length) add('weights', 'sum', 'dimension weights sum to 1.00', { value: null, rows: 0 });
230
+ tables.forEach((t, i) => add('weights', tables.length > 1 ? 'table ' + (i + 1) : 'sum', tables.length > 1 ? `weights table ${i + 1} sums to 1.00` : 'dimension weights sum to 1.00', { value: t.sum, rows: t.rows }));
231
+ const rf = body.rubric_for ? firstLinkPath(rel, body.rubric_for) : null;
232
+ if (rf) add('rubric_for', rf, `is the rubric for ${rf}`, { value: rf, refs: [rf] });
233
+ const iid = idOf(body.initiative_id || fm.initiative_id);
234
+ if (iid && !isPlaceholder(iid)) add('prd_initiative', iid, `names initiative ${iid}`, { value: iid, refs: ['init:' + iid] });
235
+ const hs = headingsOf(text);
236
+ for (const seg of ['dimensions', 'sprint contract']) add('segment', seg, `has a ${seg} section`, { value: hs.some((h) => h.includes(seg)) });
237
+ }
238
+ if (type === 'Story') {
239
+ const iid = idOf(fm.initiative_id || body.initiative_id);
240
+ const cited = new Set();
241
+ for (const m of text.matchAll(/\b([A-RT-Z]{1,3}\d{1,3})\b/g)) cited.add(m[1]);
242
+ for (const id of Array.from(cited).slice(0, 40)) add('story_ref', id, `cites requirement ${id}`, { value: iid || '', refs: iid ? ['init:' + iid] : [] });
243
+ }
244
+ if (type === 'ADR') {
245
+ const m = /(?:^|\/)d(\d{1,3})-/i.exec(rel);
246
+ if (m) {
247
+ const n = Number(m[1]);
248
+ add('adr_number', 'D' + n, `is decision D${n}`, { value: n, refs: ['decisions'] });
249
+ add('adr_title', 'D' + n, 'title starts with its decision number', { value: fm.title || '', n });
250
+ }
251
+ }
252
+ return out;
253
+ }
254
+
255
+ /* ── the truth sources ─────────────────────────────────────────────────────────────────────────── */
256
+ function loadDecisions(root, files) {
257
+ const ids = new Set();
258
+ let available = false;
259
+ const dec = files.find((f) => f.toLowerCase() === 'decisions.md');
260
+ if (dec) {
261
+ available = true;
262
+ let t = '';
263
+ try { t = fs.readFileSync(path.join(root, dec), 'utf8'); } catch { /* unreadable: treated as empty */ }
264
+ for (const m of t.matchAll(/^###\s+D(\d{1,3})\b/gm)) ids.add(Number(m[1]));
265
+ for (const m of t.matchAll(/\*\*D(\d{1,3})\*\*/g)) ids.add(Number(m[1]));
266
+ }
267
+ for (const f of files) {
268
+ const m = /(?:^|\/)adr\/d(\d{1,3})-[^/]+\.md$/i.exec(f);
269
+ if (m) { available = true; ids.add(Number(m[1])); }
270
+ }
271
+ return { ids, available, file: dec || null };
272
+ }
273
+
274
+ /* ── verification ──────────────────────────────────────────────────────────────────────────────── */
275
+ const date = (secs) => new Date(secs * 1000).toISOString().slice(0, 16).replace('T', ' ');
276
+
277
+ function verifyClaim(c, ctx) {
278
+ const doc = ctx.docs.get(c.doc);
279
+ const V = (status, truth, source, sentence) => ({ status, truth: truth || null, source: source || null, sentence });
280
+ const exists = (r) => ctx.exists(r);
281
+ switch (c.kind) {
282
+ case 'link':
283
+ return exists(c.subject) ? V('verified') : V('contradicted', 'there is no such file', null, `${c.doc} links to ${c.subject}, which does not exist.`);
284
+ case 'time': {
285
+ const today = new Date(ctx.now).toISOString().slice(0, 10);
286
+ return c.value < today ? V('stale', `that date passed`, null, `${c.doc} says it is current until ${c.value}. That date has passed.`) : V('verified');
287
+ }
288
+ case 'syntax':
289
+ return c.value ? V('contradicted', c.value, null, `${c.doc} is not valid JSON: ${c.value}.`) : V('verified');
290
+ case 'anchor':
291
+ return c.value ? V('verified') : V('contradicted', 'no anchor is set', null, `The initiative record ${c.doc} has no anchor (parent_kr).`);
292
+ case 'lineage': {
293
+ if (!exists(c.value)) return V('contradicted', 'there is no such file', null, `The initiative record ${c.doc} says ${c.subject} is ${c.value}, but there is no such file.`);
294
+ const target = ctx.docs.get(c.value);
295
+ if (target && (c.subject === 'prd_path' || c.subject === 'rubric_path')) {
296
+ const named = idOf(target.body.initiative_id || target.fm.initiative_id);
297
+ if (named && named !== c.initiative) return V('undecided', `it names initiative ${named}`, c.value, `${c.doc} points ${c.subject} at ${c.value}, but that file names initiative ${named}, not ${c.initiative}. It may be shared on purpose.`);
298
+ if (!named) return V('undecided', 'the file does not name this initiative', c.value, `${c.value} does not say which initiative it belongs to, so the link from ${c.doc} cannot be confirmed from its side.`);
299
+ }
300
+ return V('verified');
301
+ }
302
+ case 'req':
303
+ return c.value > 1 ? V('undecided', `the id appears ${c.value} times`, null, `${c.doc} uses requirement id ${c.subject} ${c.value} times, so a story or rubric that cites ${c.subject} cannot say which one it means.`) : V('verified');
304
+ case 'segment':
305
+ return c.value ? V('verified') : V('undecided', 'the section is missing', null, `${c.doc} has no ${c.subject} section, which the template for its type expects. It may predate the template.`);
306
+ case 'weights':
307
+ if (c.value === null) return V('undecided', 'no weights table was found', null, `${c.doc}: no dimension weights were found, so their sum cannot be checked.`);
308
+ return Math.abs(c.value - 1) <= WEIGHT_TOLERANCE ? V('verified') : V('contradicted', `the weights sum to ${c.value}`, null, `The weights in ${c.doc} sum to ${c.value}, not 1.00.`);
309
+ case 'prd_initiative': {
310
+ if (!ctx.initiatives.size) return null;
311
+ const rec = ctx.initiatives.get(c.value);
312
+ if (!rec) return V('contradicted', 'there is no initiative record with that id', null, `${c.doc} names initiative ${c.value}, but no initiative record has that id.`);
313
+ const role = doc && doc.cls.type === 'Rubric' ? 'rubric_path' : 'prd_path';
314
+ if (!rec.fm[role]) return V('undecided', `the initiative record has no ${role}`, rec.rel, `${c.doc} names initiative ${c.value}, whose record does not list it under ${role}.`);
315
+ if (norm(rec.fm[role]) !== c.doc) return V('undecided', `the record's ${role} is ${norm(rec.fm[role])}`, rec.rel, `${c.doc} names initiative ${c.value}, but that record's ${role} is ${norm(rec.fm[role])}. This may be a second document for the same initiative.`);
316
+ return V('verified');
317
+ }
318
+ case 'prd_rubric': {
319
+ if (!exists(c.value)) return V('contradicted', 'there is no such file', null, `${c.doc} names the rubric ${c.value}, but there is no such file.`);
320
+ const r = ctx.docs.get(c.value);
321
+ const back = r && r.body.rubric_for ? firstLinkPath(c.value, r.body.rubric_for) : null;
322
+ if (back && back !== c.doc) return V('contradicted', `the rubric says it is for ${back}`, c.value, `${c.doc} names ${c.value} as its rubric, but that rubric says it is for ${back}.`);
323
+ if (!back) return V('undecided', 'the rubric does not name this PRD', c.value, `${c.value} does not say which PRD it grades, so the link from ${c.doc} cannot be confirmed from its side.`);
324
+ return V('verified');
325
+ }
326
+ case 'rubric_for': {
327
+ if (!exists(c.value)) return V('contradicted', 'there is no such file', null, `${c.doc} says it is the rubric for ${c.value}, but there is no such file.`);
328
+ const p = ctx.docs.get(c.value);
329
+ const back = p && p.body.rubric ? firstLinkPath(c.value, p.body.rubric) : null;
330
+ if (back && back !== c.doc) return V('contradicted', `the PRD names ${back} as its rubric`, c.value, `${c.doc} says it grades ${c.value}, but that PRD names ${back} as its rubric.`);
331
+ if (!back) return V('undecided', 'the PRD does not name this rubric', c.value, `${c.value} does not name ${c.doc} as its rubric, so the link cannot be confirmed from the PRD's side.`);
332
+ return V('verified');
333
+ }
334
+ case 'decision':
335
+ case 'adr_number': {
336
+ if (!ctx.decisions.available) return null;
337
+ return ctx.decisions.ids.has(c.value) ? V('verified', null, ctx.decisions.file)
338
+ : V('contradicted', `no decision D${c.value} in ${ctx.decisions.file || 'the decisions file'} or the ADR folder`, ctx.decisions.file,
339
+ c.kind === 'decision' ? `${c.doc} cites decision D${c.value}, which is not in the decisions file or the ADR folder.` : `${c.doc} is decision D${c.value}, which the decisions file does not record.`);
340
+ }
341
+ case 'adr_title': {
342
+ const m = /^\s*D0*(\d{1,3})\b/i.exec(c.value);
343
+ return m && Number(m[1]) === c.n ? V('verified') : V('contradicted', `the title is "${String(c.value).slice(0, 60)}"`, null, `${c.doc} is decision D${c.n}, but its title does not start with D${c.n}.`);
344
+ }
345
+ case 'story_ref': {
346
+ const rec = ctx.initiatives.get(c.value);
347
+ const prdRel = rec && rec.fm.prd_path ? norm(rec.fm.prd_path) : null;
348
+ const prd = prdRel ? ctx.docs.get(prdRel) : null;
349
+ if (!prd) return V('undecided', 'no PRD was found for this story', null, `${c.doc} cites ${c.subject}, but no PRD could be found for it to be checked against.`);
350
+ const have = prd.claims.some((x) => x.kind === 'req' && x.subject === c.subject);
351
+ if (!prd.claims.some((x) => x.kind === 'req')) return V('undecided', 'the PRD lists no requirement ids', prdRel, `${prdRel} lists no requirement ids, so ${c.subject} in ${c.doc} cannot be checked.`);
352
+ return have ? V('verified', null, prdRel) : V('contradicted', `${prdRel} has no requirement ${c.subject}`, prdRel, `${c.doc} cites requirement ${c.subject}, which ${prdRel} does not have.`);
353
+ }
354
+ default:
355
+ return null;
356
+ }
357
+ }
358
+
359
+ /** Sources a document derives from, as repo-relative paths: a rubric and a story from their PRD; a PRD from the
360
+ * explainer and brief its initiative names. Used for the freshness rule and for finding dependents. */
361
+ function derivesFrom(rel, ctx) {
362
+ const d = ctx.docs.get(rel);
363
+ if (!d) return [];
364
+ const t = d.cls.type;
365
+ const out = new Set();
366
+ const iid = idOf(d.body.initiative_id || d.fm.initiative_id);
367
+ const rec = iid ? ctx.initiatives.get(iid) : null;
368
+ if (t === 'Rubric') {
369
+ const rf = d.body.rubric_for ? firstLinkPath(rel, d.body.rubric_for) : null;
370
+ if (rf) out.add(rf); else if (rec && rec.fm.prd_path) out.add(norm(rec.fm.prd_path));
371
+ }
372
+ if (t === 'Story' && rec && rec.fm.prd_path) out.add(norm(rec.fm.prd_path));
373
+ if (t === 'PRD' && rec) for (const k of ['explainer_path', 'brief_path']) if (rec.fm[k]) out.add(norm(rec.fm[k]));
374
+ return Array.from(out).filter((p) => ctx.exists(p) && p !== rel);
375
+ }
376
+
377
+ function derivedClaims(rel, ctx) {
378
+ const out = [];
379
+ for (const src of derivesFrom(rel, ctx)) {
380
+ const mine = ctx.lastChange.get(rel), theirs = ctx.lastChange.get(src);
381
+ const claim = { doc: rel, kind: 'freshness', subject: src, text: `derives from ${src}`, refs: [src] };
382
+ let r;
383
+ if (mine == null || theirs == null) r = { status: 'verified', truth: null, source: src, sentence: null, note: 'no change times known' };
384
+ else if (theirs > mine + STALE_SECS) r = { status: 'stale', truth: `${src} changed ${date(theirs)}; this document last changed ${date(mine)}`, source: src, sentence: `${src} changed after ${rel} did (${date(theirs)} against ${date(mine)}). ${rel} may no longer agree with it.` };
385
+ else r = { status: 'verified', truth: null, source: src, sentence: null };
386
+ out.push({ claim, result: r });
387
+ }
388
+ return out;
389
+ }
390
+
391
+ /* ── the ledger: append-only events, folded by claim id ───────────────────────────────────────── */
392
+ function foldLedger(file) {
393
+ const map = new Map();
394
+ let raw = '';
395
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { return map; }
396
+ for (const line of raw.split('\n')) {
397
+ if (!line) continue;
398
+ let e; try { e = JSON.parse(line); } catch { continue; } // a torn line is skipped, never fatal
399
+ if (e && e.type === 'claim' && e.claim_id) map.set(e.claim_id, e);
400
+ }
401
+ return map;
402
+ }
403
+ const claimId = (c) => sha(c.doc + '|' + c.kind + '|' + c.subject).slice(0, 12);
404
+
405
+ /* ── the cache (per-user runtime directory) ───────────────────────────────────────────────────── */
406
+ function readJson(f, dflt) { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return dflt; } }
407
+ function writeJson(f, v) {
408
+ try { fs.mkdirSync(path.dirname(f), { recursive: true }); const tmp = f + '.' + process.pid + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(v)); fs.renameSync(tmp, f); }
409
+ catch { /* a cache that cannot be written only costs a full read next time */ }
410
+ }
411
+
412
+ /**
413
+ * Run one check.
414
+ *
415
+ * What the ledger holds: every claim that is NOT verified, and every change back to verified. A claim that
416
+ * verifies the first time it is seen is not written, because a first run over a large repository would otherwise
417
+ * put thousands of lines into a tracked file. How many claims verified, and what each document was compared with,
418
+ * is kept per document in the per-user cache and read back by `buildView`.
419
+ *
420
+ * @param {object} a
421
+ * @param {string} a.root repository root
422
+ * @param {string} a.cacheDir per-user runtime directory for this repository
423
+ * @param {string} a.ledgerFile where claims.jsonl lives
424
+ * @param {boolean} [a.sweep] re-verify every document (the periodic time-rule pass)
425
+ * @param {number} [a.now]
426
+ */
427
+ async function check({ root, cacheDir, ledgerFile, sweep = false, now = Date.now() }) {
428
+ const scope = await listScope(root);
429
+ const stampsFile = path.join(cacheDir, 'stamps.json');
430
+ const indexFile = path.join(cacheDir, 'index.json');
431
+ const stamps = readJson(stampsFile, {});
432
+ const index = readJson(indexFile, {});
433
+ const nextStamps = {};
434
+ const changed = new Set();
435
+ const present = new Set(scope.files);
436
+ const fresh = new Map(); // rel → text, for files read this run
437
+ let reads = 0;
438
+
439
+ // 1 · read only what moved
440
+ for (const rel of scope.files) {
441
+ let st; try { st = fs.statSync(path.join(root, rel)); } catch { continue; }
442
+ if (!st.isFile() || st.size > MAX_DOC_BYTES) continue;
443
+ const prev = stamps[rel];
444
+ if (prev && prev.m === st.mtimeMs && prev.s === st.size && index[rel]) { nextStamps[rel] = prev; continue; }
445
+ let text; try { text = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { continue; }
446
+ reads++;
447
+ const h = sha(text);
448
+ nextStamps[rel] = { m: st.mtimeMs, s: st.size, h };
449
+ if (prev && prev.h === h && index[rel]) continue; // touched, not changed
450
+ changed.add(rel);
451
+ fresh.set(rel, text);
452
+ index[rel] = { h };
453
+ }
454
+ const removed = Object.keys(index).filter((r) => !present.has(r) || !nextStamps[r]);
455
+ for (const r of removed) { changed.add(r); delete index[r]; }
456
+
457
+ // 2 · parse what changed; build the initiative records and the "named by" map
458
+ const files = scope.files.filter((r) => nextStamps[r] && index[r]);
459
+ for (const rel of fresh.keys()) {
460
+ const text = fresh.get(rel);
461
+ index[rel].fm = extOf(rel) === 'md' ? frontmatter(text) : {};
462
+ index[rel].body = extOf(rel) === 'md' ? bodyKeys(text) : {};
463
+ }
464
+ const initiatives = new Map();
465
+ const named = new Map();
466
+ for (const rel of files) {
467
+ const fm = index[rel].fm || {};
468
+ if (fm.type === 'Initiative' && fm.id) {
469
+ initiatives.set(fm.id, { rel, fm });
470
+ for (const key of ['prd_path', 'rubric_path', 'explainer_path', 'brief_path', 'doc_path']) {
471
+ if (fm[key]) { const p = norm(fm[key]); named.set(p, (named.get(p) || []).concat([{ role: key, id: fm.id }])); }
472
+ }
473
+ }
474
+ }
475
+
476
+ // 3 · classify and extract: changed files from their text; an unchanged file only if an initiative record now names it
477
+ const readText = (rel) => (fresh.has(rel) ? fresh.get(rel) : (() => { try { return fs.readFileSync(path.join(root, rel), 'utf8'); } catch { return null; } })());
478
+ const extractNow = (rel, text) => {
479
+ const ent = index[rel];
480
+ ent.cls = classify({ rel, fm: ent.fm, text, named });
481
+ ent.claims = extract(rel, text, ent.cls, ent.fm, ent.body);
482
+ ent.claims.forEach((c) => { c.claim_id = claimId(c); });
483
+ };
484
+ for (const rel of files) {
485
+ const ent = index[rel];
486
+ if (fresh.has(rel) || !ent.cls) { const t = readText(rel); if (t != null) extractNow(rel, t); continue; }
487
+ const c2 = classify({ rel, fm: ent.fm, text: '', named });
488
+ if (c2.classified && c2.type !== ent.cls.type) { const t = readText(rel); if (t != null) { extractNow(rel, t); changed.add(rel); } }
489
+ }
490
+ const docs = new Map();
491
+ for (const rel of files) { const e = index[rel]; if (e && e.cls) docs.set(rel, { cls: e.cls, fm: e.fm, body: e.body, claims: e.claims || [] }); }
492
+
493
+ // 4 · truth sources and time
494
+ const dirty = await dirtyFiles(root);
495
+ const lastChange = await lastChanges(root, dirty);
496
+ const decisions = loadDecisions(root, files);
497
+ const ctx = { docs, initiatives, decisions, now, lastChange, exists: (r) => nextStamps[r] != null || fs.existsSync(path.join(root, r)) };
498
+
499
+ // 5 · who is affected: the changed files, the documents that cite them, and no others
500
+ const tokens = new Set();
501
+ for (const r of changed) {
502
+ tokens.add(r);
503
+ for (const [id, rec] of initiatives) if (rec.rel === r) tokens.add('init:' + id);
504
+ }
505
+ for (const [id] of initiatives) if (changed.has(initiatives.get(id).rel)) tokens.add('init:' + id);
506
+ if ((decisions.file && changed.has(decisions.file)) || Array.from(changed).some((r) => /(?:^|\/)adr\/d\d+-/i.test(r))) tokens.add('decisions');
507
+ const affected = new Set();
508
+ for (const [rel, d] of docs) {
509
+ if (sweep || changed.has(rel)) { affected.add(rel); continue; }
510
+ if (d.claims.some((c) => c.kind === 'time')) { affected.add(rel); continue; } // the time rule is applied on every run
511
+ if (d.claims.some((c) => (c.refs || []).some((t) => tokens.has(t)))) { affected.add(rel); continue; }
512
+ if (derivesFrom(rel, ctx).some((s) => tokens.has(s))) affected.add(rel);
513
+ }
514
+
515
+ // 6 · verify the affected documents; append only what is not verified, or has changed back to verified
516
+ const folded = foldLedger(ledgerFile);
517
+ const guard = createSinkGuard({ sink: ledgerFile, what: 'claims' });
518
+ const events = [];
519
+ const seenIds = new Set();
520
+ const stamp = new Date(now).toISOString();
521
+ for (const rel of affected) {
522
+ const d = docs.get(rel);
523
+ const items = d.claims.map((c) => ({ claim: c, result: verifyClaim(c, ctx) })).filter((x) => x.result);
524
+ for (const x of derivedClaims(rel, ctx)) { x.claim.claim_id = claimId(x.claim); items.push(x); }
525
+ const compared = new Set();
526
+ for (const { claim, result } of items) {
527
+ seenIds.add(claim.claim_id);
528
+ for (const r of claim.refs || []) if (!r.startsWith('init:') && r !== 'decisions' && r !== rel && ctx.exists(r)) compared.add(r);
529
+ if (result.source && result.source !== rel) compared.add(result.source);
530
+ if (['decision', 'adr_number'].includes(claim.kind) && decisions.file) compared.add(decisions.file);
531
+ if (claim.kind === 'prd_initiative') { const rec = initiatives.get(claim.value); if (rec) compared.add(rec.rel); }
532
+ const prev = folded.get(claim.claim_id);
533
+ if (!prev && result.status === 'verified') continue; // verified on first sight: not written
534
+ if (prev && prev.status === result.status && (prev.truth || null) === (result.truth || null) && prev.text === claim.text && (prev.sentence || null) === (result.sentence || null)) continue;
535
+ events.push({
536
+ type: 'claim', claim_id: claim.claim_id, doc: rel, doc_type: d.cls.type, kind: claim.kind, subject: claim.subject, text: claim.text,
537
+ status: result.status, truth: result.truth || null, source: result.source || null, sentence: result.sentence || null,
538
+ as_of: lastChange.get(rel) ? new Date(lastChange.get(rel) * 1000).toISOString() : null, verified_at: stamp, at: stamp,
539
+ });
540
+ }
541
+ index[rel].v = { verified: items.filter((x) => x.result.status === 'verified').length, total: items.length, compared: Array.from(compared).sort(), at: stamp };
542
+ }
543
+ // a claim that is gone from an affected or removed document is withdrawn, not forgotten
544
+ for (const [id, e] of folded) {
545
+ if (e.status === 'withdrawn' || e.status === 'verified' || seenIds.has(id)) continue;
546
+ if (affected.has(e.doc) || removed.includes(e.doc)) events.push({ ...e, status: 'withdrawn', truth: null, sentence: null, verified_at: stamp, at: stamp });
547
+ }
548
+ let appended = 0;
549
+ try { for (const e of events) { guard.appendLine(ledgerFile, e); appended++; } }
550
+ catch (e) { return { ok: false, error: 'the claims could not be recorded — ' + String((e && e.message) || e).slice(0, 200) }; }
551
+
552
+ writeJson(stampsFile, nextStamps);
553
+ writeJson(indexFile, index);
554
+ const view = buildView({ ledgerFile, index, scope, now });
555
+ return { ok: true, ...view, run: { changed: changed.size, affected: affected.size, affected_docs: Array.from(affected).sort().slice(0, 200), appended, reads, sweep, files: files.length } };
556
+ }
557
+
558
+ /* ── the view: what the page shows, from the folded ledger and the per-document cache ─────────── */
559
+ function buildView({ ledgerFile, index, scope, now = Date.now() }) {
560
+ const folded = foldLedger(ledgerFile);
561
+ const byDoc = new Map();
562
+ for (const e of folded.values()) {
563
+ if (e.status === 'withdrawn' || e.status === 'verified') continue;
564
+ if (!byDoc.has(e.doc)) byDoc.set(e.doc, []);
565
+ byDoc.get(e.doc).push(e);
566
+ }
567
+ const rank = { contradicted: 0, stale: 1, undecided: 2 };
568
+ const docsOut = [];
569
+ let problems = 0, stale = 0, undecided = 0, verified = 0, unclassified = 0, internalOnly = 0;
570
+ for (const [rel, ent] of Object.entries(index)) {
571
+ if (!ent.cls) continue;
572
+ const claims = (byDoc.get(rel) || []).sort((a, b) => rank[a.status] - rank[b.status]);
573
+ const cls = ent.cls;
574
+ const count = (st) => claims.filter((c) => c.status === st).length;
575
+ const v = ent.v || { verified: 0, compared: [] };
576
+ const entry = {
577
+ rel, type: cls.type, via: cls.via, classified: !!cls.classified,
578
+ references: REFERENCES[cls.type] || [],
579
+ compared_with: v.compared || [],
580
+ problems: count('contradicted'), stale: count('stale'), undecided: count('undecided'), verified: v.verified || 0,
581
+ findings: claims.slice(0, 30).map((c) => ({ kind: c.kind, status: c.status, sentence: c.sentence || c.text, truth: c.truth, source: c.source })),
582
+ };
583
+ docsOut.push(entry);
584
+ problems += entry.problems; stale += entry.stale; undecided += entry.undecided; verified += entry.verified;
585
+ if (!cls.classified) unclassified++;
586
+ else if (!REFERENCES[cls.type]) internalOnly++;
587
+ }
588
+ docsOut.sort((a, b) => (b.problems - a.problems) || (b.stale - a.stale) || (b.undecided - a.undecided) || a.rel.localeCompare(b.rel));
589
+ const checked = docsOut.length;
590
+ const verdict = !checked ? 'not-checked' : problems ? 'problems' : 'consistent';
591
+ return {
592
+ verdict,
593
+ counts: { documents: checked, problems, stale, undecided, verified, unclassified, internal_only: internalOnly, truncated: !!(scope && scope.truncated) },
594
+ docs: docsOut, as_of: new Date(now).toISOString(),
595
+ };
596
+ }
597
+
598
+ /** The current view without running a check: from the per-user cache and the folded ledger. */
599
+ function readView({ cacheDir, ledgerFile, now = Date.now() }) {
600
+ const index = readJson(path.join(cacheDir, 'index.json'), null);
601
+ if (!index) return { verdict: 'not-checked', counts: { documents: 0, problems: 0, stale: 0, undecided: 0, verified: 0, unclassified: 0, internal_only: 0, truncated: false }, docs: [], as_of: null };
602
+ return buildView({ ledgerFile, index, scope: null, now });
603
+ }
604
+
605
+ module.exports = { readView, idOf, cleanValue, check, buildView, extract, verifyClaim, foldLedger, listScope, frontmatter, bodyKeys, resolveRef, rubricWeights, loadDecisions, STALE_SECS };