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.
- package/package.json +1 -1
- 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/README.md +37 -1
- package/workspace/bin/pmos.js +8 -1
- package/workspace/guide/index.md +40 -0
- package/workspace/guide/initiatives-and-next-steps.md +72 -0
- package/workspace/guide/layout.md +73 -0
- package/workspace/guide/proactive-engine.md +75 -0
- package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
- package/workspace/guide/statuses.md +53 -0
- package/workspace/guide/validate-with-typesafe.md +80 -0
- package/workspace/lib/brief.js +113 -0
- package/workspace/lib/claims.js +605 -0
- package/workspace/lib/doc-profiles.js +74 -0
- package/workspace/lib/initiative-files.js +37 -6
- package/workspace/lib/judgment.js +65 -0
- package/workspace/lib/mobile-nav.js +1 -1
- package/workspace/lib/next-step-actions.js +139 -0
- package/workspace/lib/next-steps.js +27 -7
- package/workspace/lib/preview-kind.js +17 -2
- package/workspace/lib/proactive-cli.js +137 -0
- package/workspace/lib/proactive.js +208 -0
- package/workspace/lib/repo-watch.js +120 -0
- package/workspace/lib/schedule-cli.js +5 -2
- package/workspace/lib/secret-scan.js +28 -0
- package/workspace/lib/session-budget.js +35 -0
- package/workspace/lib/status-groups.js +53 -10
- package/workspace/lib/typesafe-settings.js +122 -0
- package/workspace/lib/typesafe.js +124 -0
- package/workspace/lib/validate.js +304 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +3 -3
- package/workspace/public/pm/app.js +53 -18
- package/workspace/public/pm/data.js +18 -3
- package/workspace/public/pm/mobile.js +1 -1
- package/workspace/public/pm/modes-live.js +373 -0
- package/workspace/public/pm/modes.js +26 -0
- package/workspace/public/pm/next-step-ui.js +185 -0
- package/workspace/public/pm/panel-detail.js +47 -11
- package/workspace/public/pm/panel-initiatives.js +26 -4
- package/workspace/public/pm/panel-repos.js +2 -2
- package/workspace/public/pm/state.js +3 -3
- package/workspace/public/pm/status-banner.js +87 -0
- package/workspace/public/pm/tab-doc.js +66 -8
- package/workspace/server.js +562 -18
- 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 };
|