create-pmos 0.40.0 → 0.42.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.
@@ -0,0 +1,266 @@
1
+ 'use strict';
2
+ /* jev-claims.js — ask Jev about pairs of short claim texts, only for what changed (jev-claim-checks, D80).
3
+ *
4
+ * This is the BRIDGE between the claims ledger and the one TypeSafe module (lib/typesafe.js). `claims.js` and
5
+ * `repo-watch.js` never reach that module; this file, `validate.js` and `server.js` are the only ones that do.
6
+ *
7
+ * WHAT MAY LEAVE THE MACHINE. A pair of short texts (lib/claims.js `pairsFor`) and one Noul question, only when ALL
8
+ * FOUR gates hold: a saved key, D79 consent for the repository, the watch switch, and the "check with Jev
9
+ * automatically" switch. A text under an excluded path, or one the secret scan matches, is withheld and listed.
10
+ *
11
+ * HOW IT STAYS CHEAP. The first check after the switch goes on records every existing pair as a BASELINE and sends
12
+ * nothing. Afterwards a pair is sent only when either of its texts differs from the one last recorded. A verdict is
13
+ * cached by the hash of both texts, the question and the model, so an unchanged pair, or a revert, is never asked
14
+ * twice. There is no request cap: the baseline and the cache are what bound the cost. "Check the backlog" is a
15
+ * separate press that shows its request count first.
16
+ *
17
+ * WHAT A RESULT IS. `suspected`: advisory, outside the problem total, shown in words. A Noul answer is P(yes) with no
18
+ * separate confidence, so the page shows words, never the number. Two rejected keys or three failed asks in a row
19
+ * pause the repository until the PM resumes it.
20
+ *
21
+ * WHAT IT NEVER DOES. It edits no repository document, decision or acceptance. Its one write into the repository is
22
+ * the claims ledger, through lib/sink.js. Its state (baseline, cache, pause) lives in the per-user cache directory.
23
+ */
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const crypto = require('crypto');
27
+ const claims = require('./claims');
28
+ const scanner = require('./secret-scan');
29
+ const settings = require('./typesafe-settings');
30
+ const typesafe = require('./typesafe');
31
+ const { createSinkGuard } = require('./sink');
32
+
33
+ const MAX_CACHE = 5000;
34
+ const MAX_LISTED = 20;
35
+ const REJECTED_LIMIT = 2;
36
+ const UNREACHABLE_LIMIT = 3;
37
+ const sha = (s) => crypto.createHash('sha1').update(s).digest('hex');
38
+
39
+ const QUESTION_LABEL = {
40
+ coverage: ['REQUIREMENT', 'RUBRIC DIMENSIONS'],
41
+ delivers: ['STORY SECTION', 'REQUIREMENTS IT CITES'],
42
+ supports: ['SENTENCE THAT CITES A DECISION', 'THE DECISION'],
43
+ sections: ['FIRST SECTION', 'SECOND SECTION'],
44
+ };
45
+
46
+ /** Words, never the number. */
47
+ const wordsFor = (p) => (p >= 0.85 ? 'very likely' : p >= 0.65 ? 'likely' : 'possible');
48
+
49
+ /** Which of the four gates is shut. `open` only when none is. */
50
+ function gates({ key, consent, watch, jev }) {
51
+ const missing = [];
52
+ if (!key) missing.push('key');
53
+ if (!consent) missing.push('consent');
54
+ if (!watch) missing.push('watch');
55
+ if (!jev) missing.push('jev');
56
+ return { open: missing.length === 0, missing };
57
+ }
58
+ const GATE_WORDS = {
59
+ key: 'No TypeSafe key is saved.',
60
+ consent: 'Validation is not switched on for this repository.',
61
+ watch: 'Keeping this repository checked as files change is off.',
62
+ jev: 'Checking with Jev automatically is off.',
63
+ };
64
+ const gateSentence = (missing) => missing.map((m) => GATE_WORDS[m]).join(' ');
65
+
66
+ /* ── per-user state ────────────────────────────────────────────────────────────────────────────── */
67
+ const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
68
+ function writeJson(f, v) {
69
+ try { fs.mkdirSync(path.dirname(f), { recursive: true }); const tmp = f + '.' + process.pid + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(v)); fs.renameSync(tmp, f); return true; }
70
+ catch { return false; }
71
+ }
72
+ const stateFile = (cacheDir) => path.join(cacheDir, 'jev.json');
73
+ const cacheFile = (cacheDir) => path.join(cacheDir, 'jev-cache.json');
74
+ const emptyState = () => ({ baselined: false, baselined_at: null, pairs: {}, pending: 0, paused: null, rejected: 0, unreachable: 0, last: null });
75
+ function readState(cacheDir) {
76
+ const s = readJson(stateFile(cacheDir), null);
77
+ if (!s || typeof s !== 'object') return emptyState();
78
+ return { ...emptyState(), ...s, pairs: s.pairs && typeof s.pairs === 'object' ? s.pairs : {} };
79
+ }
80
+ const writeState = (cacheDir, s) => writeJson(stateFile(cacheDir), s);
81
+ function readCache(cacheDir) { const c = readJson(cacheFile(cacheDir), {}); return c && typeof c === 'object' ? c : {}; }
82
+ function writeCache(cacheDir, c) {
83
+ const keys = Object.keys(c);
84
+ if (keys.length > MAX_CACHE) for (const k of keys.sort((a, b) => String(c[a].at).localeCompare(String(c[b].at))).slice(0, keys.length - MAX_CACHE)) delete c[k];
85
+ return writeJson(cacheFile(cacheDir), c);
86
+ }
87
+
88
+ /** What the page may know about the bridge for one repository. */
89
+ function status(cacheDir) {
90
+ const s = readState(cacheDir);
91
+ return { baselined: s.baselined, baselined_at: s.baselined_at, paused: s.paused, last: s.last, tracked: Object.keys(s.pairs).length };
92
+ }
93
+ /** The PM's press: lift a pause and forget the failure counts. */
94
+ function resume(cacheDir) {
95
+ const s = readState(cacheDir);
96
+ s.paused = null; s.rejected = 0; s.unreachable = 0;
97
+ return writeState(cacheDir, s);
98
+ }
99
+
100
+ /* ── one pair: what would happen to it ─────────────────────────────────────────────────────────── */
101
+ const hashes = (p) => { const hA = sha(p.a), hB = sha(p.b); return { hA, hB, h: sha(hA + '\0' + hB), ck: sha([hA, hB, p.kind, typesafe.DEFAULT_MODEL].join('\0')) }; };
102
+ const requestFor = (p) => {
103
+ const [la, lb] = QUESTION_LABEL[p.kind];
104
+ return { state: `${la}:\n${p.a}\n\n${lb}:\n${p.b}`, questions: { [p.kind]: { type: 'noul', instructions: p.question } } };
105
+ };
106
+ function sentenceFor(p, p1) {
107
+ const w = wordsFor(p1);
108
+ switch (p.kind) {
109
+ case 'coverage': return `Jev rates it ${w} that ${p.a_label} in ${p.doc} is graded by no dimension of ${p.partner}.`;
110
+ case 'delivers': return `Jev rates it ${w} that the story section "${p.subject}" in ${p.doc} does not deliver what ${(p.ids || []).join(', ')} in ${p.partner} asks for.`;
111
+ case 'supports': return `Jev rates it ${w} that ${p.partner} does not support what ${p.doc} says it decides in the sentence that cites it.`;
112
+ default: return `Jev rates it ${w} that the ${p.a_label} sections of ${p.doc} contradict each other.`;
113
+ }
114
+ }
115
+
116
+ /** Triage every pair: excluded, withheld by the secret scan, answered from the cache, or to ask. Pure; sends nothing. */
117
+ function triage(pairs, { exclude, cache, state, onlyChanged }) {
118
+ const out = { ask: [], cached: [], withheld: [], excluded: 0, unchanged: 0 };
119
+ for (const p of pairs) {
120
+ const hs = hashes(p);
121
+ if (onlyChanged && state.pairs[p.id] === hs.h) { out.unchanged++; continue; }
122
+ if (settings.isExcluded(exclude, p.doc) || (p.partner && /\.\w+$/.test(p.partner) && settings.isExcluded(exclude, p.partner))) { out.excluded++; continue; }
123
+ const rules = Array.from(new Set(scanner.scan(p.a).concat(scanner.scan(p.b))));
124
+ if (rules.length) { out.withheld.push({ doc: p.doc, kind: p.kind, rules }); continue; }
125
+ const hit = cache[hs.ck];
126
+ if (hit) out.cached.push({ p, hs, verdict: hit }); else out.ask.push({ p, hs });
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /* ── the ledger: suspected claims in, withdrawn claims out ─────────────────────────────────────── */
132
+ const claimIdOf = (p) => 'j' + sha('jev|' + p.doc + '|' + p.kind + '|' + p.subject).slice(0, 11);
133
+
134
+ function recordResults({ ledgerFile, pairs, evaluated, now }) {
135
+ const folded = claims.foldLedger(ledgerFile);
136
+ const guard = createSinkGuard({ sink: ledgerFile, what: 'claims' });
137
+ const at = new Date(now).toISOString();
138
+ const currentIds = new Set(pairs.map(claimIdOf));
139
+ let written = 0;
140
+ const append = (e) => { guard.appendLine(ledgerFile, e); written++; };
141
+ for (const [p, verdict] of evaluated) {
142
+ const id = claimIdOf(p); const prev = folded.get(id);
143
+ if (verdict.p >= 0.5) {
144
+ const sentence = sentenceFor(p, verdict.p); const likelihood = wordsFor(verdict.p);
145
+ if (prev && prev.status === 'suspected' && prev.likelihood === likelihood && prev.text === p.a.slice(0, 220) && prev.sentence === sentence) continue;
146
+ append({ type: 'claim', claim_id: id, doc: p.doc, doc_type: 'PRD', kind: 'jev_' + p.kind, subject: p.subject, text: p.a.slice(0, 220), status: 'suspected',
147
+ probability: Math.round(verdict.p * 1000) / 1000, likelihood, truth: null, source: p.partner || null, sentence, model: verdict.model || null, verified_at: at, at });
148
+ } else if (prev && prev.status === 'suspected') {
149
+ append({ ...prev, status: 'withdrawn', sentence: null, verified_at: at, at });
150
+ }
151
+ }
152
+ // a suspected claim whose pair no longer exists (the requirement or section was removed) is withdrawn
153
+ for (const [id, e] of folded) if (claims.isJev(e) && e.status === 'suspected' && !currentIds.has(id)) append({ ...e, status: 'withdrawn', sentence: null, verified_at: at, at });
154
+ return written;
155
+ }
156
+
157
+ /* ── asking ────────────────────────────────────────────────────────────────────────────────────── */
158
+ async function askAll({ items, key, ask, askOpts, state, cache, now }) {
159
+ const done = []; let failed = 0, unreadable = 0, sent = 0, lastError = null;
160
+ for (const it of items) {
161
+ if (state.paused) break;
162
+ const req = requestFor(it.p);
163
+ const res = await ask({ key, state: req.state, questions: req.questions, ...askOpts });
164
+ if (!res.ok) {
165
+ failed++; lastError = res.reason;
166
+ if (res.status === 401) { state.rejected++; state.unreachable = 0; } else { state.unreachable++; state.rejected = 0; }
167
+ if (state.rejected >= REJECTED_LIMIT) state.paused = { reason: 'The service rejected the key twice, so automatic checking is paused.', at: new Date(now).toISOString() };
168
+ else if (state.unreachable >= UNREACHABLE_LIMIT) state.paused = { reason: 'The service could not be reached three times in a row, so automatic checking is paused.', at: new Date(now).toISOString() };
169
+ continue; // not recorded as seen: it is asked again next time
170
+ }
171
+ state.rejected = 0; state.unreachable = 0; sent++;
172
+ const ans = res.answers && res.answers[it.p.kind];
173
+ const v = ans && (ans.type == null || ans.type === 'noul') ? Number(ans.noul) : NaN;
174
+ if (!Number.isFinite(v) || v < 0 || v > 1) { unreadable++; state.pairs[it.p.id] = it.hs.h; continue; }
175
+ const verdict = { p: v, model: res.model || null, at: new Date(now).toISOString() };
176
+ cache[it.hs.ck] = verdict;
177
+ state.pairs[it.p.id] = it.hs.h;
178
+ done.push([it.p, verdict]);
179
+ }
180
+ return { done, failed, unreadable, sent, lastError };
181
+ }
182
+
183
+ const summarize = (extra) => ({ at: new Date().toISOString(), baseline: 0, sent: 0, cached: 0, withheld: [], excluded: 0, failed: 0, suspected: 0, ...extra });
184
+
185
+ /**
186
+ * The automatic pass, called after a local check. Does nothing unless all four gates hold.
187
+ * @param {object} a
188
+ * @param {string} a.root, a.cacheDir, a.ledgerFile
189
+ * @param {string|null} a.key the TypeSafe key, or null
190
+ * @param {{consent:boolean, watch:boolean, jev:boolean}} a.flags
191
+ * @param {string[]} [a.exclude]
192
+ * @param {number} [a.changed] files the local check saw change; 0 after the baseline means nothing to do
193
+ */
194
+ async function run({ root, cacheDir, ledgerFile, key, flags, exclude = [], changed = null, ask = typesafe.ask, askOpts = {}, now = Date.now() }) {
195
+ const g = gates({ key: typesafe.validKey(key), consent: !!(flags && flags.consent), watch: !!(flags && flags.watch), jev: !!(flags && flags.jev) });
196
+ if (!g.open) return { ok: true, skipped: 'gates', missing: g.missing, sent: 0 };
197
+ const state = readState(cacheDir);
198
+ if (state.paused) return { ok: true, skipped: 'paused', paused: state.paused, sent: 0 };
199
+ if (state.baselined && changed === 0 && !state.pending) return { ok: true, skipped: 'nothing-changed', sent: 0 };
200
+ const { pairs } = claims.pairsFor({ root, cacheDir });
201
+ if (!state.baselined) {
202
+ for (const p of pairs) state.pairs[p.id] = hashes(p).h;
203
+ state.baselined = true; state.baselined_at = new Date(now).toISOString();
204
+ state.last = summarize({ baseline: pairs.length });
205
+ writeState(cacheDir, state);
206
+ return { ok: true, baseline: pairs.length, sent: 0 };
207
+ }
208
+ const cache = readCache(cacheDir);
209
+ const t = triage(pairs, { exclude, cache, state, onlyChanged: true });
210
+ const evaluated = t.cached.map((c) => [c.p, c.verdict]);
211
+ for (const c of t.cached) state.pairs[c.p.id] = c.hs.h;
212
+ const r = await askAll({ items: t.ask, key, ask, askOpts, state, cache, now });
213
+ evaluated.push(...r.done);
214
+ state.pending = t.ask.length - r.done.length - r.unreadable; // pairs not recorded as seen: asked again next pass
215
+ // an excluded or withheld pair is recorded as seen, so it is not re-triaged on every check
216
+ for (const p of pairs) {
217
+ const hs = hashes(p);
218
+ if (state.pairs[p.id] === hs.h) continue;
219
+ if (settings.isExcluded(exclude, p.doc) || scanner.scan(p.a).length || scanner.scan(p.b).length) state.pairs[p.id] = hs.h;
220
+ }
221
+ let written = 0;
222
+ try { written = recordResults({ ledgerFile, pairs, evaluated, now }); }
223
+ catch (e) { return { ok: false, error: 'the Jev results could not be recorded — ' + String((e && e.message) || e).slice(0, 200) }; }
224
+ const suspected = evaluated.filter(([, v]) => v.p >= 0.5).length;
225
+ state.last = summarize({ sent: r.sent, cached: t.cached.length, withheld: t.withheld.slice(0, MAX_LISTED), withheld_count: t.withheld.length, excluded: t.excluded, failed: r.failed, unreadable: r.unreadable, suspected, error: r.lastError, paused: state.paused });
226
+ writeCache(cacheDir, cache); writeState(cacheDir, state);
227
+ return { ok: true, sent: r.sent, cached: t.cached.length, withheld: t.withheld.length, excluded: t.excluded, failed: r.failed, suspected, written, paused: state.paused };
228
+ }
229
+
230
+ /* ── the backlog: every pair not already answered, after the PM has seen the count ─────────────── */
231
+ function backlogPlan({ root, cacheDir, exclude = [] }) {
232
+ const { pairs } = claims.pairsFor({ root, cacheDir });
233
+ const t = triage(pairs, { exclude, cache: readCache(cacheDir), state: readState(cacheDir), onlyChanged: false });
234
+ const reqs = t.ask.map((x) => requestFor(x.p));
235
+ return { pairs: pairs.length, requests: reqs.length, characters: reqs.reduce((n, r) => n + r.state.length, 0), cached: t.cached.length, withheld: t.withheld.slice(0, MAX_LISTED), withheld_count: t.withheld.length, excluded: t.excluded, host: typesafe.HOST };
236
+ }
237
+
238
+ /** Send the backlog. Refuses unless `expectRequests` is what the plan counts now, so the PM never confirms one plan and runs another. */
239
+ async function runBacklog({ root, cacheDir, ledgerFile, key, flags, exclude = [], expectRequests, ask = typesafe.ask, askOpts = {}, now = Date.now() }) {
240
+ const g = gates({ key: typesafe.validKey(key), consent: !!(flags && flags.consent), watch: true, jev: !!(flags && flags.jev) });
241
+ if (!g.open) return { ok: false, error: gateSentence(g.missing) };
242
+ const state = readState(cacheDir);
243
+ if (state.paused) return { ok: false, error: state.paused.reason + ' Resume it first.' };
244
+ const { pairs } = claims.pairsFor({ root, cacheDir });
245
+ const cache = readCache(cacheDir);
246
+ const t = triage(pairs, { exclude, cache, state, onlyChanged: false });
247
+ if (!Number.isInteger(expectRequests) || expectRequests !== t.ask.length) {
248
+ return { ok: false, conflict: true, error: `the plan changed — it now has ${t.ask.length} request(s), not ${expectRequests}. Review it again.` };
249
+ }
250
+ const evaluated = t.cached.map((c) => [c.p, c.verdict]);
251
+ for (const c of t.cached) state.pairs[c.p.id] = c.hs.h;
252
+ const r = await askAll({ items: t.ask, key, ask, askOpts, state, cache, now });
253
+ evaluated.push(...r.done);
254
+ state.pending = t.ask.length - r.done.length - r.unreadable;
255
+ for (const p of pairs) { const hs = hashes(p); if (state.pairs[p.id] !== hs.h && (settings.isExcluded(exclude, p.doc) || scanner.scan(p.a).length || scanner.scan(p.b).length)) state.pairs[p.id] = hs.h; }
256
+ state.baselined = true; state.baselined_at = state.baselined_at || new Date(now).toISOString();
257
+ let written = 0;
258
+ try { written = recordResults({ ledgerFile, pairs, evaluated, now }); }
259
+ catch (e) { return { ok: false, error: 'the Jev results could not be recorded — ' + String((e && e.message) || e).slice(0, 200) }; }
260
+ const suspected = evaluated.filter(([, v]) => v.p >= 0.5).length;
261
+ state.last = summarize({ backlog: true, sent: r.sent, cached: t.cached.length, withheld: t.withheld.slice(0, MAX_LISTED), withheld_count: t.withheld.length, excluded: t.excluded, failed: r.failed, unreadable: r.unreadable, suspected, error: r.lastError, paused: state.paused });
262
+ writeCache(cacheDir, cache); writeState(cacheDir, state);
263
+ return { ok: true, sent: r.sent, cached: t.cached.length, withheld: t.withheld.length, excluded: t.excluded, failed: r.failed, suspected, written, paused: state.paused };
264
+ }
265
+
266
+ module.exports = { run, runBacklog, backlogPlan, status, resume, gates, gateSentence, wordsFor, triage, requestFor, sentenceFor, REJECTED_LIMIT, UNREACHABLE_LIMIT };
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+ /* kg-cli.js — `pmos kg …`: query the repository's knowledge graph from the terminal (knowledge-graph K1).
3
+ *
4
+ * pmos kg find <term> cards whose id, title, headings or card match, with their token cost
5
+ * pmos kg card <id> one card
6
+ * pmos kg neighbors <id> [--type T] [--depth 1|2]
7
+ * pmos kg section <path>#<heading> the lines of one section
8
+ * pmos kg section <path> <from>-<to> a line range
9
+ * pmos kg why <D-id> the decision's ADR view, its amendment-log line, and the files citing it
10
+ * pmos kg stats counts, not claims
11
+ * --max-tokens N stop at a card boundary and say how many results were not shown
12
+ *
13
+ * Reads the repository's tracked .md files straight from disk — no server, no model, no network. The graph is cached in
14
+ * the runtime state directory (never in the repository), keyed by git HEAD plus the modification times of uncommitted
15
+ * .md files, so a stale graph is rebuilt and a pull request never carries a graph diff. The only write is that cache.
16
+ */
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+ const crypto = require('crypto');
21
+ const { execFileSync } = require('child_process');
22
+ const Kg = require('./kg');
23
+ const { defaultRoot } = require('./state-root');
24
+ const { parseArgs } = require('./schedule-cli');
25
+
26
+ const COMMANDS = ['find', 'card', 'neighbors', 'section', 'why', 'stats'];
27
+ const RULES_FILE = path.join(__dirname, 'kg-dangling-rules.json');
28
+ const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
29
+
30
+ function repoRoot(cwd) { return git(cwd, ['rev-parse', '--show-toplevel']).trim(); }
31
+
32
+ function loadFiles(root) {
33
+ const out = []; const unreadable = [];
34
+ for (const rel of git(root, ['ls-files', '-z', '*.md']).split('\0').filter(Boolean)) {
35
+ try { out.push({ path: rel, text: fs.readFileSync(path.join(root, rel), 'utf8') }); }
36
+ catch (e) { unreadable.push({ path: rel, error: e.code || e.message }); }
37
+ }
38
+ return { files: out, unreadable };
39
+ }
40
+
41
+ function cacheKey(root) {
42
+ const head = git(root, ['rev-parse', 'HEAD']).trim();
43
+ const dirty = git(root, ['status', '--porcelain', '-z', '--untracked-files=no']).split('\0').filter(Boolean)
44
+ .map((l) => l.slice(3)).filter((p) => /\.md$/.test(p)).sort()
45
+ .map((p) => { try { return p + ':' + fs.statSync(path.join(root, p)).mtimeMs; } catch { return p + ':gone'; } });
46
+ return crypto.createHash('sha1').update(head + '\n' + dirty.join('\n')).digest('hex');
47
+ }
48
+
49
+ function graphFor(root, { stateDir = defaultRoot() } = {}) {
50
+ const dir = path.join(stateDir, 'kg');
51
+ const file = path.join(dir, crypto.createHash('sha1').update(root).digest('hex').slice(0, 12) + '.json');
52
+ const key = cacheKey(root);
53
+ try { const c = JSON.parse(fs.readFileSync(file, 'utf8')); if (c.key === key) return { graph: c.graph, unreadable: c.unreadable, cached: true }; } catch { /* rebuild */ }
54
+ const { files, unreadable } = loadFiles(root);
55
+ const graph = Kg.build(files);
56
+ try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(file, JSON.stringify({ key, graph, unreadable })); } catch { /* the cache is an optimisation, never required */ }
57
+ return { graph, unreadable, cached: false };
58
+ }
59
+
60
+ function run(argv, { say, cwd = process.cwd(), stateDir } = {}) {
61
+ const [group, verb, ...rest] = argv;
62
+ const { pos, flags } = parseArgs(rest);
63
+ const fail = (m) => { say('✗ ' + m); return 1; };
64
+ if (group !== 'kg' || !verb || flags.help || verb === 'help') { say(`usage: pmos kg <${COMMANDS.join('|')}> … [--max-tokens N]`); return verb ? 0 : 1; }
65
+ if (!COMMANDS.includes(verb)) return fail(`unknown command "${verb}" — use ${COMMANDS.join(', ')}`);
66
+ let root;
67
+ try { root = repoRoot(cwd); } catch { return fail('this is not a git repository, so there are no tracked files to read'); }
68
+ const { graph, unreadable, cached } = graphFor(root, { stateDir });
69
+ const budget = flags['max-tokens'] === undefined ? Infinity : Number(flags['max-tokens']);
70
+ if (!(budget > 0)) return fail('--max-tokens takes a positive number');
71
+ for (const u of unreadable || []) say(`not read — ${u.path}: ${u.error}`);
72
+
73
+ /* print blocks until the budget is spent, stopping at a block boundary; say how many were not shown */
74
+ const emit = (blocks) => {
75
+ let used = 0; let shown = 0;
76
+ for (const b of blocks) {
77
+ const cost = Kg.estTokens(b);
78
+ if (shown && used + cost > budget) break;
79
+ say(b); used += cost; shown++;
80
+ }
81
+ if (shown < blocks.length) say(`… ${blocks.length - shown} more result(s) not shown (--max-tokens ${budget}). ~${used} tokens printed.`);
82
+ else if (budget !== Infinity) say(`~${used} tokens printed.`);
83
+ };
84
+
85
+ if (verb === 'find') {
86
+ if (!pos[0]) return fail('find needs a term');
87
+ const hits = Kg.find(graph, pos.join(' '), { limit: 50 });
88
+ if (!hits.length) { say(`nothing matches "${pos.join(' ')}"`); return 0; }
89
+ emit(hits.map((h) => `${Kg.cardText(h.node)}\n matched: ${h.where.join(', ')} · linked from ${h.indegree} file(s)`));
90
+ return 0;
91
+ }
92
+ if (verb === 'card') {
93
+ const n = Kg.nodeById(graph, pos[0]);
94
+ if (!n) return fail(`no file "${pos[0] || ''}" in the graph`);
95
+ say(Kg.cardText(n));
96
+ for (const h of n.headings) say(`${' '.repeat(h.level - 1)}${h.text} (lines ${h.start}-${h.end})`);
97
+ return 0;
98
+ }
99
+ if (verb === 'neighbors') {
100
+ const depth = flags.depth === undefined ? 1 : Number(flags.depth);
101
+ if (![1, 2].includes(depth)) return fail('--depth takes 1 or 2');
102
+ const rows = Kg.neighbors(graph, pos[0], { type: flags.type || null, depth });
103
+ if (!rows) return fail(`no file "${pos[0] || ''}" in the graph`);
104
+ if (!rows.length) { say('no neighbours' + (flags.type ? ` of type ${flags.type}` : '')); return 0; }
105
+ emit(rows.map((r) => `${r.direction === 'out' ? '→' : '←'} ${r.via} (${r.basis}${r.depth > 1 ? ', depth ' + r.depth : ''}) ${Kg.cardText(Kg.nodeById(graph, r.id))}`));
106
+ return 0;
107
+ }
108
+ if (verb === 'section') {
109
+ let id = pos[0] || ''; let spec = pos[1] || '';
110
+ const hash = id.indexOf('#');
111
+ if (hash > 0) { spec = id.slice(hash); id = id.slice(0, hash); }
112
+ const n = Kg.nodeById(graph, id);
113
+ if (!n) return fail(`no file "${id}" in the graph`);
114
+ if (!spec) return fail('section needs #heading or from-to');
115
+ const s = Kg.section(n, fs.readFileSync(path.join(root, id), 'utf8'), spec);
116
+ if (!s) return fail(`no section "${spec}" in ${id} — run: pmos kg card ${id}`);
117
+ say(`${id} lines ${s.from}-${s.to} (~${Kg.estTokens(s.text)} tokens of ${n.tokens_est})`);
118
+ say(s.text);
119
+ return 0;
120
+ }
121
+ if (verb === 'why') {
122
+ const w = Kg.why(graph, pos[0]);
123
+ if (!w) return fail(`no decision "${pos[0] || ''}" — the ADR views are okf/products/pmos/adr/dNN-*.md`);
124
+ say(Kg.cardText(w.adr));
125
+ say(w.amendmentLog ? `amendment log: ${w.amendmentLog.path} line ${w.amendmentLog.line}` : 'amendment log: no "Added **D…**" line found');
126
+ const blocks = [];
127
+ for (const [kind, rows] of Object.entries(w.citedBy).sort()) blocks.push(`${kind} (${rows.length}):\n` + rows.map((r) => ` ${r.id} [${r.via.join('+')}, ${r.basis}]`).join('\n'));
128
+ emit(blocks.length ? blocks : ['no file cites this decision']);
129
+ return 0;
130
+ }
131
+ if (verb === 'stats') {
132
+ const rules = JSON.parse(fs.readFileSync(RULES_FILE, 'utf8'));
133
+ const s = Kg.stats(graph, rules);
134
+ say(`${s.nodes} files, ${s.edges} edges (${s.byBasis.stated} stated, ${s.byBasis.mention} mention) — graph ${cached ? 'from cache' : 'rebuilt'}`);
135
+ say('edges by type: ' + Object.entries(s.byType).sort().map(([k, v]) => `${k} ${v}`).join(', '));
136
+ say(`cards: ${s.cardsWritten} written (description), ${s.cardsDerived} derived from the first paragraph, ${s.noCard} none`);
137
+ say(`${s.noType} file(s) have no type · ${s.noIncoming} have no incoming link (index and log files excluded)`);
138
+ say(`dangling links: ${s.dangling} — ${s.danglingHistory} history by design (${path.relative(root, RULES_FILE) || RULES_FILE}), ${s.danglingNeedsDecision} need a decision`);
139
+ for (const d of s.needsDecision) say(` ${d.from} → ${d.to}`);
140
+ say('Only relations written in the files are found. Token counts are characters ÷ 4, an estimate.');
141
+ return 0;
142
+ }
143
+ return 0;
144
+ }
145
+
146
+ module.exports = { run, graphFor, loadFiles, cacheKey, COMMANDS };
@@ -0,0 +1,6 @@
1
+ [
2
+ { "from": "^okf/log\\.md$", "why": "the OKF log records references to documents that were removed on purpose" },
3
+ { "from": "^okf/products/pmos/", "to": "(mcp-tools|data-model|references/metrics/v_)", "why": "D66 deleted the hosted-backend reference documents; the text that cites them is history" },
4
+ { "from": "/blind-plants/", "why": "a blind-plant specimen is planted with broken references on purpose" },
5
+ { "to": "^/planning/prd/x\\.md$", "why": "an example path in an explainer, not a reference" }
6
+ ]