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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.40.0",
3
+ "version": "0.42.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@cd7bc4a
2
- assembled: 2026-10-03
1
+ kernel-source: pmos@e99d9d4
2
+ assembled: 2026-10-04
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-03T10:44:27Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-04T01:33:06Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-03T10:44:27Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-04T01:33:06Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-03T10:44:27Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-04T01:33:06Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  Newest first, ISO dates.
4
4
 
5
- ## 2026-10-03
5
+ ## 2026-10-04
6
6
 
7
7
  - Adopted OKF v0.2 (D73).
@@ -82,6 +82,10 @@ After the session ends, `GET /api/next-step/summary` returns the files the branc
82
82
 
83
83
  The top of Today is a brief: a greeting, the day's calendar, ranked priorities and a worth-asking list. The server does not build it. The built-in skill `skills/morning-brief.skill` runs in an agent session, reads the sources the settings allow (calendar, wispr, mail, jira, confluence; mail is off by default) and the derived list (`pmos proactive today`), and submits one brief with `pmos proactive brief submit --file`. `POST /api/proactive/brief` checks it with `lib/brief.js` and stores `briefs/<date>.json` in the runtime state folder, replacing any earlier brief for the same day; `GET` returns the newest one with a `stale` flag when its date is not today. The check refuses, with the reason and without storing, a wrong shape, an unknown source, a confidence outside 0 to 1, more than 12 items or 20 events, an over-long field, an item that rests on a source not reported as read, a source reported as read that the settings switched off, and a claim of typed scoring when that setting is off. The bucket is derived: under 0.5 confidence is "worth asking". The order is fixed: confidence, then PM-owned, then title. `POST /api/schedules/seed` with `set: "brief"` (one press in Today, or `pmos proactive brief schedule`) makes the daily 06:45 schedule once; as with every schedule, one made after its time of day runs once within a minute. The routes make no model or network call and write only the brief file. Typed scoring is a standing opt-in setting that is off by default. Tests: `proactive.test.js` G, H, I and `pm-r22-test.js` B1-B11. The guide page is `guide/proactive-engine.md`.
84
84
 
85
+ ## Find knowledge without reading whole files (`knowledge-graph`, slice K1)
86
+
87
+ `pmos kg find|card|neighbors|section|why|stats` builds a map of the repository's tracked Markdown files from relations they already write down (links, frontmatter paths, `D<nn>` citations, `initiative_id`, `parent_kr`) and answers from it, so an agent reads one card or one section and not a 130 KB file. `lib/kg.js` is pure; `lib/kg-cli.js` reads the files, keeps the map in the runtime state directory (rebuilt when HEAD or an uncommitted `.md` changes) and writes nothing in the repository. A link to a missing file is listed as dangling and is never an edge; `lib/kg-dangling-rules.json` says which dangling links are history by design. It does not guess, use embeddings, or call a model or the network. `node scripts/kg-bench.js` replays two written strategies over the fixed questions in `planning/evals/knowledge-graph-questions.md`; it is a simulation, and says "NOT REPORTABLE" until three of the questions are the PM's. Guide page: `guide/knowledge-graph.md`. Tests: `kg.test.js`.
88
+
85
89
  ## Scheduled tasks from the terminal (`schedule-cli`)
86
90
 
87
91
  `pmos schedule` and `pmos skill` talk to the running workspace server, so the terminal and the workspace share one schedule store and one validator. Every change is saved the moment the command succeeds. A refused value names what is allowed and changes nothing. With no server running the command says so and exits 1.
@@ -135,7 +139,7 @@ The three-column layout is retired. The surface is now **four collapsible panels
135
139
  **Fixture mode.** `?fixture=proto` renders the whole surface from a bundled seed file, with the server unable to read any repository — that is how the design comparison is run (`npm run test:parity`), and it is never the default.
136
140
 
137
141
  ## Artifact links are declarations, not integrations
138
- The PMOS server makes no outbound request and holds no credential, with one named exception ([D79](/okf/products/pmos/adr/d79-typesafe-validation-egress.md)): when you save a TypeSafe key and turn validation on for a repository, the **validate** page sends that repository's document text to `api.typesafe.ai`, from one module, only after you confirm the plan. With no key, nothing is sent. The key stays on this machine, is never shown again, and is never given to a terminal. The consistency checks at the top of the **validate** page are local: they read files and git and send nothing. Automatic checking is off until you switch it on for a repository, and then the workspace appends problems to that repository's `claims.jsonl`. The workspace makes no request you did not ask for. Its git network verbs are named and press-only: `Sync` runs `fetch`, a fast-forward or an own-commit rebase, a commit of record paths, and `push` — only when you press it, never on a timer or on load, never forced ([D70](/okf/products/pmos/adr/d70-stopped-initiatives-and-sync.md)). A web page opens in a tab only when you type or click its address, and a restored web tab loads only when you press "load this page" ([D69](/okf/products/pmos/adr/d69-web-tab.md)). Linking an artifact to an external destination therefore records a **declaration** — `{artifact_path, kind, target}` appended to `pmos/state/artifact-links.jsonl`. The sync panel renders as designed, and with no connector configured its summary line says exactly that: *no connector configured — nothing was read, and nothing will be written*, with the approval buttons disabled. A difference summary it never read would be the watermelon this repo has a word for.
142
+ The PMOS server makes no outbound request and holds no credential, with one named exception ([D79](/okf/products/pmos/adr/d79-typesafe-validation-egress.md)): when you save a TypeSafe key and turn validation on for a repository, the **validate** page sends that repository's document text to `api.typesafe.ai`, from one module, only after you confirm the plan. With no key, nothing is sent. The key stays on this machine, is never shown again, and is never given to a terminal. The consistency checks at the top of the **validate** page are local: they read files and git and send nothing. Automatic checking is off until you switch it on for a repository, and then the workspace appends problems to that repository's `claims.jsonl`. A second switch, **check with Jev automatically** ([D80](/okf/products/pmos/adr/d80-jev-claim-pairs.md)), is also off; only when a key, validation, automatic checking and that switch all hold does the workspace send pairs of short claim texts that changed, after a first pass that records what exists and sends nothing. The workspace makes no request you did not ask for. Its git network verbs are named and press-only: `Sync` runs `fetch`, a fast-forward or an own-commit rebase, a commit of record paths, and `push` — only when you press it, never on a timer or on load, never forced ([D70](/okf/products/pmos/adr/d70-stopped-initiatives-and-sync.md)). A web page opens in a tab only when you type or click its address, and a restored web tab loads only when you press "load this page" ([D69](/okf/products/pmos/adr/d69-web-tab.md)). Linking an artifact to an external destination therefore records a **declaration** — `{artifact_path, kind, target}` appended to `pmos/state/artifact-links.jsonl`. The sync panel renders as designed, and with no connector configured its summary line says exactly that: *no connector configured — nothing was read, and nothing will be written*, with the approval buttons disabled. A difference summary it never read would be the watermelon this repo has a word for.
139
143
 
140
144
  ## The gates are real, never automatic (R5)
141
145
  `approve` / `return` / `accept` write the PMOS file-mode vocabulary to `acceptances.jsonl` and clear the matching needs entry — only ever on your click.
@@ -402,6 +402,10 @@ function doctor() {
402
402
  code = await require('../lib/proactive-cli').run(process.argv.slice(2), {
403
403
  say, call: async (m, p, b) => (port ? api(port, m, p, b) : null) });
404
404
  }
405
+ else if (cmd === 'kg') {
406
+ // Reads tracked .md files straight from disk (lib/kg-cli.js); needs no running server.
407
+ code = require('../lib/kg-cli').run(process.argv.slice(2), { say });
408
+ }
405
409
  else if (cmd === 'upgrade') {
406
410
  // Escape hatch: pull the latest bundled app and force-reinstall over this global copy. Delegates
407
411
  // to the scaffolder (which owns the install path) rather than reinstalling itself in place.
@@ -413,12 +417,13 @@ function doctor() {
413
417
  if (r.error) { say('✗ could not run ' + npx + ' — ' + r.error.message); code = 1; }
414
418
  else code = r.status === null ? 1 : r.status;
415
419
  } else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
416
- say('usage: pmos [<path>|status|ls|stop|doctor|upgrade|schedule|skill|proactive]');
420
+ say('usage: pmos [<path>|status|ls|stop|doctor|upgrade|schedule|skill|proactive|kg]');
417
421
  say(' pmos open the current repo as a workspace (starts the server if needed)');
418
422
  say(' pmos <path> open another repo as a workspace');
419
423
  say(' pmos schedule … create, edit and run scheduled tasks (pmos schedule --help)');
420
424
  say(' pmos skill … list, read, create and edit skills');
421
425
  say(' pmos proactive … what is waiting for you, feedback, settings (pmos proactive --help)');
426
+ say(' pmos kg … find a card, a section or a decision\'s citers without reading whole files (pmos kg --help)');
422
427
  say(' pmos upgrade reinstall the workspace app from the latest published create-pmos');
423
428
  code = 0;
424
429
  } else if (cmd === 'up' || cmd === 'start' || cmd === 'open') {
@@ -38,3 +38,4 @@ The workspace has three columns. [Layout](layout.md) explains where things open.
38
38
  - [Scheduled tasks and the command line](scheduled-tasks-and-cli.md): how to make, edit and run scheduled tasks.
39
39
  - [Validate a repository](validate-with-typesafe.md): what each kind of document is compared with, how to read the verdict, and what leaves your machine.
40
40
  - [Today and the proactive engine](proactive-engine.md): what Today finds, what you can do with an item, the settings, the commands and the limits.
41
+ - [Find knowledge without reading whole files](knowledge-graph.md): what `pmos kg` finds, its commands, how to read its numbers, and its limits.
@@ -0,0 +1,63 @@
1
+ ---
2
+ type: Guide
3
+ title: "Guide: find knowledge without reading whole files"
4
+ timestamp: 2026-10-03
5
+ ---
6
+
7
+ # Guide: find knowledge without reading whole files
8
+
9
+ `pmos kg` reads the Markdown files of a repository and builds a map of how they point at each other. You use the map to find one card, one section or one decision's readers. You do not need to open a long file.
10
+
11
+ ## What it is, and what it is not
12
+
13
+ A **card** is a short summary of one file: its title, its type, its `description` (or its first paragraph) and its size in tokens. A **section** is the lines under one heading.
14
+
15
+ The map has an **edge** only where a file states a relation:
16
+
17
+ | Edge | What states it |
18
+ |---|---|
19
+ | link | A Markdown link to a tracked file. |
20
+ | frontmatter | A path in `prd_path`, `rubric_path`, `explainer_path` or `sources`. |
21
+ | supersedes, amended_by, cites | A document that chooses to carry one of these keys. |
22
+ | initiative | An `initiative_id` that names an initiative record. |
23
+ | anchor | A `parent_kr` of `health:<id>` or a KR id. |
24
+ | cites (mention) | A `D<nn>` in the text, such as `D65`, with two or more digits. |
25
+
26
+ A link that points at a missing file is **dangling**. It is listed and never becomes an edge. A link, a `D<nn>` or a heading inside a code block or a code span is not read.
27
+
28
+ It does not guess. A file that talks about a topic without a link, a path or a `D<nn>` is not connected to it. It does not use embeddings or a model, and it does not use the network.
29
+
30
+ ## Commands
31
+
32
+ | Command | What it does |
33
+ |---|---|
34
+ | `pmos kg find <term>` | Lists cards whose id, title, headings or card contain the term, with the token size of each file. |
35
+ | `pmos kg card <id>` | Prints one card and the headings of the file with their line ranges. |
36
+ | `pmos kg neighbors <id> [--type T] [--depth 1\|2]` | Lists the files that link to this file or that it links to, with the type and the basis of each edge. |
37
+ | `pmos kg section <path>#<heading>` | Prints only that section. `pmos kg section <path> <from>-<to>` prints a line range. |
38
+ | `pmos kg why <D-id>` | Prints the decision's ADR view, its line in the amendment log of `DECISIONS.md`, and the files that cite it, grouped by kind. |
39
+ | `pmos kg stats` | Prints counts: files, edges by type, written and derived cards, files with no incoming link, and the dangling links. |
40
+
41
+ Add `--max-tokens N` to stop printing at a result boundary. The command then says how many results it did not show.
42
+
43
+ An unknown file, heading or decision stops with a message that says what was not found. The exit code is 1.
44
+
45
+ ## What the numbers mean
46
+
47
+ - A token count is the number of characters divided by four. It is an estimate.
48
+ - `stats` splits dangling links into **history by design** and **needs a decision**. The rules are in `workspace/lib/kg-dangling-rules.json`. Read the file and change it if you disagree. A link in `okf/log.md` or in a decision about a removed document is history.
49
+ - A file with no incoming link is not an error. It means no other file links to it.
50
+ - A **derived** card comes from the first paragraph of a file that has no `description`.
51
+
52
+ ## Where the map is kept
53
+
54
+ The command reads the tracked `.md` files of the Git repository you run it in. It keeps the map in the runtime state directory of the workspace, never in the repository. It builds the map again when the Git HEAD changes or when you edit a tracked `.md` file that is not yet committed. It does not use the cache for any other change.
55
+
56
+ The command does not edit a file in the repository.
57
+
58
+ ## Limits
59
+
60
+ - It finds only relations that are written in the files.
61
+ - It reads Markdown files. It does not read the ledgers in `pmos/state/`, a connector, or a session.
62
+ - `find` matches the words you type. It does not know that two different words mean the same thing.
63
+ - The benchmark in `scripts/kg-bench.js` replays two written strategies over fixed questions. It is a simulation. It does not measure the tokens of a real agent session.
@@ -39,6 +39,27 @@ The record of problems is the file `claims.jsonl` in the runs folder of a PMOS r
39
39
 
40
40
  A verdict of *Consistent* means that no recorded statement contradicts its source. The workspace does not read prose. A sentence that no structure records is not checked.
41
41
 
42
+ ## Check changed claim pairs with Jev
43
+
44
+ Some questions need judgement, and the local checks cannot answer them. Does the rubric grade a requirement? Does a story deliver what the requirement asks for? Does a decision support the sentence that cites it? Do two sections of one PRD contradict each other? For these, the workspace can put a **pair of short texts** to Jev. It never sends a whole file.
45
+
46
+ The switch **check with Jev automatically** is off until you turn it on for a repository. Nothing is sent unless all four of these hold:
47
+
48
+ 1. A TypeSafe key is saved.
49
+ 2. Validation is on for this repository.
50
+ 3. Automatic checking is on.
51
+ 4. **Check with Jev automatically** is on.
52
+
53
+ The page says which of the four is missing. If a path is excluded, or a text matches a credential pattern, the pair is not sent and the page lists it.
54
+
55
+ The first check after you turn the switch on records every existing pair as the starting point and sends nothing. After that, the workspace sends a pair only when one of its two texts is new or changed. An answer is kept by the content of both texts. A pair whose texts did not change, or that returns to a text it already asked about, is not asked again. There is no limit on requests, because only changed pairs are sent.
56
+
57
+ To ask about the pairs that already exist, press **check the backlog with Jev**. The page shows how many requests that takes and how many characters. It sends only after you press **confirm and send** with that same number. If the number changed, the workspace refuses and asks you to look again.
58
+
59
+ Jev's answers are **suspected**, not proven. They appear in their own group, **suspected by Jev — advisory, not counted as problems**, as plain sentences with words: *very likely*, *likely* or *possible*. The verdict line adds them apart from the problems, for example "3 problems · Jev suspects 2 more". A suspected finding names the two texts and the question. Jev cannot quote the passage.
60
+
61
+ Two rejected keys, or three failed requests in a row, pause automatic checking for that repository. The page says why and shows **resume**. The workspace appends suspected pairs to `claims.jsonl`. Those lines hold a short snippet of the claim. A pair that Jev found fine is not written.
62
+
42
63
  ## Check with TypeSafe
43
64
 
44
65
  ## What leaves your machine
@@ -271,6 +271,179 @@ function loadDecisions(root, files) {
271
271
  return { ids, available, file: dec || null };
272
272
  }
273
273
 
274
+ /* ── semantic pairs (jev-claim-checks, D80) ──────────────────────────────────────────────────────
275
+ * A pair is two SHORT texts taken by structure, plus the question a model could answer about them. Nothing here
276
+ * sends anything; lib/jev-claims.js decides what may leave the machine. A requirement, section or citation the
277
+ * structure does not find is never turned into a pair, an unclassified file yields none, and a structural gap
278
+ * (a missing counterpart, a repeated requirement id) yields none, because a model cannot repair a gap. */
279
+ const PAIR_QUESTIONS = {
280
+ coverage: 'None of the rubric dimensions would grade the requirement.',
281
+ delivers: 'The story section does not deliver what the requirement it cites asks for.',
282
+ supports: 'The decision does not support what the document says it decides.',
283
+ sections: 'These two sections of the same document contradict each other.',
284
+ };
285
+ const PAIR_CAP = { rubric: 2500, req: 1200, story: 1500, reqs: 1500, sentence: 600, decision: 700, section: 1800 };
286
+ // A link keeps its words, not its path.
287
+ const clip = (t, n) => { const s = String(t).replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[ \t]+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
288
+
289
+ /** Requirement bullets of a PRD as Map id → text, with ids that appear more than once dropped (a structural gap). */
290
+ function requirementTexts(text) {
291
+ const lines = text.split('\n');
292
+ const found = new Map(); const dup = new Set();
293
+ for (let i = 0; i < lines.length; i++) {
294
+ const m = REQ_RE.exec(lines[i]);
295
+ if (!m) continue;
296
+ let body = lines[i].trim().replace(/^[-*]\s+/, '');
297
+ for (let j = i + 1; j < lines.length && j <= i + 4; j++) {
298
+ if (!lines[j].trim() || /^\s*[-*]\s/.test(lines[j]) || /^#{1,6}\s/.test(lines[j]) || !/^\s+\S/.test(lines[j])) break;
299
+ body += ' ' + lines[j].trim();
300
+ }
301
+ if (found.has(m[1])) dup.add(m[1]); else found.set(m[1], clip(body, PAIR_CAP.req));
302
+ }
303
+ for (const d of dup) found.delete(d);
304
+ return found;
305
+ }
306
+
307
+ /** The weights tables' dimension rows of a rubric, as one short text. Empty when the rubric has no such table. */
308
+ function rubricDimensionText(text) {
309
+ const lines = text.split('\n'); const rows = [];
310
+ for (let i = 0; i < lines.length; i++) {
311
+ if (!/^\s*\|/.test(lines[i])) continue;
312
+ const cells = lines[i].split('|').slice(1, -1).map((c) => c.trim().toLowerCase());
313
+ if (!cells.some((c) => /^\**weight\**$/.test(c))) continue;
314
+ let j = i + 1;
315
+ if (/^\s*\|[\s:|-]+\|\s*$/.test(lines[j] || '')) j++;
316
+ for (; j < lines.length && /^\s*\|/.test(lines[j]); j++) {
317
+ const rc = lines[j].split('|').slice(1, -1);
318
+ if (rc.some((c) => /^\**total\**$/i.test(c.trim()))) continue;
319
+ rows.push(rc.map((c) => c.trim()).filter(Boolean).join(' | '));
320
+ }
321
+ i = j;
322
+ }
323
+ return clip(rows.join('\n'), PAIR_CAP.rubric);
324
+ }
325
+
326
+ /** Markdown sections, flat, in order: [{level, title, text}] where text is the body up to the next heading. */
327
+ function sectionsOf(text) {
328
+ const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
329
+ const out = []; let cur = null; let fence = false;
330
+ for (const line of body.split('\n')) {
331
+ if (/^\s*```/.test(line)) fence = !fence;
332
+ const m = !fence && /^(#{1,6})\s+(.+?)\s*$/.exec(line);
333
+ if (m) { cur = { level: m[1].length, title: m[2].trim(), lower: m[2].toLowerCase(), text: '' }; out.push(cur); }
334
+ else if (cur) cur.text += line + '\n';
335
+ }
336
+ return out;
337
+ }
338
+ /** A section and the deeper sections under it, except those whose title matches `skip`. */
339
+ function sectionTree(secs, i, skip) {
340
+ let t = secs[i].text;
341
+ for (let j = i + 1; j < secs.length && secs[j].level > secs[i].level; j++) if (!(skip && skip.test(secs[j].lower))) t += secs[j].text;
342
+ return t;
343
+ }
344
+
345
+ /** The opening of a decision: from its heading in the decisions file, else from its ADR file. */
346
+ function decisionText(root, files, n) {
347
+ const dec = files.find((f) => f.toLowerCase() === 'decisions.md');
348
+ if (dec) {
349
+ let t = ''; try { t = fs.readFileSync(path.join(root, dec), 'utf8'); } catch { /* unreadable */ }
350
+ const m = new RegExp('^###\\s+D0*' + n + '\\b[^\\n]*\\n([\\s\\S]*?)(?=^###\\s|^## )', 'm').exec(t);
351
+ if (m) return clip(m[0], PAIR_CAP.decision);
352
+ }
353
+ const adr = files.find((f) => new RegExp('(?:^|/)adr/d0*' + n + '-[^/]+\\.md$', 'i').test(f));
354
+ if (adr) {
355
+ let t = ''; try { t = fs.readFileSync(path.join(root, adr), 'utf8'); } catch { /* unreadable */ }
356
+ t = t.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
357
+ const h = /^#\s+(.+)$/m.exec(t);
358
+ return clip((h ? h[1] + '\n' : '') + t.replace(/^#.*$/m, '').replace(/^\s*\*\*Status[^\n]*\n/m, ''), PAIR_CAP.decision);
359
+ }
360
+ return null;
361
+ }
362
+
363
+ /**
364
+ * The semantic pairs of a repository, from the per-document index the local check keeps.
365
+ * @param {object} a
366
+ * @param {string} a.root, a.cacheDir
367
+ * @param {Set<string>} [a.only] limit to pairs whose own document is in this set (a changed file and its dependents)
368
+ * @returns {{pairs: object[], reads: number}}
369
+ */
370
+ function pairsFor({ root, cacheDir, only = null }) {
371
+ const index = readJson(path.join(cacheDir, 'index.json'), null);
372
+ if (!index) return { pairs: [], reads: 0 };
373
+ const files = Object.keys(index).filter((r) => index[r] && index[r].cls);
374
+ const initiatives = new Map();
375
+ for (const rel of files) { const fm = index[rel].fm || {}; if (fm.type === 'Initiative' && fm.id) initiatives.set(fm.id, { rel, fm }); }
376
+ const cache = new Map(); let reads = 0;
377
+ const read = (rel) => { if (!cache.has(rel)) { reads++; let t = null; try { t = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { /* gone */ } cache.set(rel, t); } return cache.get(rel); };
378
+ const typeOf = (rel) => (index[rel] && index[rel].cls ? index[rel].cls.type : null);
379
+ const pairs = [];
380
+ const add = (p) => pairs.push({ ...p, id: sha(p.doc + '|' + p.kind + '|' + p.subject).slice(0, 12), question: PAIR_QUESTIONS[p.kind] });
381
+ const prdOf = (rel) => {
382
+ const e = index[rel]; const iid = idOf((e.body || {}).initiative_id || (e.fm || {}).initiative_id);
383
+ const rec = iid ? initiatives.get(iid) : null;
384
+ const p = rec && rec.fm.prd_path ? norm(rec.fm.prd_path) : null;
385
+ return p && typeOf(p) === 'PRD' ? p : null;
386
+ };
387
+ for (const rel of files.sort()) {
388
+ if (only && !only.has(rel)) continue;
389
+ const e = index[rel]; const type = e.cls.type; const fm = e.fm || {}; const body = e.body || {};
390
+ if (type === 'PRD') {
391
+ const text = read(rel); if (text == null) continue;
392
+ // coverage: each requirement against the rubric's dimension rows
393
+ const iid = idOf(body.initiative_id || fm.initiative_id); const rec = iid ? initiatives.get(iid) : null;
394
+ const rubricRel = (body.rubric ? firstLinkPath(rel, body.rubric) : null) || (rec && rec.fm.rubric_path ? norm(rec.fm.rubric_path) : null);
395
+ if (rubricRel && typeOf(rubricRel) === 'Rubric') {
396
+ const rt = read(rubricRel); const dims = rt == null ? '' : rubricDimensionText(rt);
397
+ if (dims) for (const [id, req] of requirementTexts(text)) add({ kind: 'coverage', doc: rel, subject: id, a: req, b: dims, a_label: 'requirement ' + id, partner: rubricRel });
398
+ }
399
+ // supports: the sentence around a [Dnn] citation against that decision's opening
400
+ const files2 = Object.keys(index);
401
+ const seenCites = new Map(); let n = 0;
402
+ for (const unit of text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').split(/\n(?=\s*[-*]\s)|\n\s*\n/)) {
403
+ if (n >= 12) break;
404
+ const cites = Array.from(unit.matchAll(/\[(D\d{1,3})\b[^\]]*\]\([^)]*\)/g)).map((m) => Number(m[1].slice(1)));
405
+ if (!cites.length) continue;
406
+ const plain = unit.replace(/^\s*[-*]\s+/, '').replace(/\*\*/g, '').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/\s+/g, ' ').trim();
407
+ for (const d of Array.from(new Set(cites)).slice(0, 2)) {
408
+ const sentence = clip(plain.split(/(?<=[.!?])\s+/).filter((x) => new RegExp('\\bD0*' + d + '\\b').test(x)).join(' ') || plain, PAIR_CAP.sentence);
409
+ if (sentence.length < 30) continue;
410
+ const dt = decisionText(root, files2, d); if (!dt) continue;
411
+ const ord = (seenCites.get(d) || 0) + 1; seenCites.set(d, ord); n++;
412
+ add({ kind: 'supports', doc: rel, subject: 'D' + d + '#' + ord, a: sentence, b: dt, a_label: 'D' + d + ' citation', partner: 'D' + d });
413
+ }
414
+ }
415
+ // sections: goal/problem and non-goals against requirements; requirements against success criteria
416
+ const secs = sectionsOf(text);
417
+ const find = (re) => secs.findIndex((s) => re.test(s.lower));
418
+ const ri = find(/\brequirements\b/);
419
+ if (ri >= 0) {
420
+ const reqs = clip(sectionTree(secs, ri, /non-?goals/), PAIR_CAP.section);
421
+ const named = (label, re) => { const i = find(re); return i >= 0 ? { label, text: clip(sectionTree(secs, i), PAIR_CAP.section) } : null; };
422
+ const goal = named('goal', /^(goal|problem|situating context|intent)\b/);
423
+ const succ = named('success criteria', /^success (criteria|metrics)/);
424
+ const non = named('non-goals', /non-?goals/);
425
+ if (goal && goal.text.length > 40 && reqs.length > 40) add({ kind: 'sections', doc: rel, subject: goal.label + '>requirements', a: goal.text, b: reqs, a_label: goal.label + ' and requirements', partner: rel });
426
+ if (succ && succ.text.length > 40 && reqs.length > 40) add({ kind: 'sections', doc: rel, subject: 'requirements>success', a: reqs, b: succ.text, a_label: 'requirements and success criteria', partner: rel });
427
+ if (non && non.text.length > 40 && reqs.length > 40) add({ kind: 'sections', doc: rel, subject: 'non-goals>requirements', a: non.text, b: reqs, a_label: 'non-goals and requirements', partner: rel });
428
+ }
429
+ }
430
+ if (type === 'Story') {
431
+ const prdRel = prdOf(rel); if (!prdRel) continue;
432
+ const text = read(rel); const prdText = read(prdRel); if (text == null || prdText == null) continue;
433
+ const reqs = requirementTexts(prdText);
434
+ let k = 0;
435
+ for (const sec of sectionsOf(text)) {
436
+ if (k >= 30) break;
437
+ const ids = Array.from(new Set(Array.from((sec.title + '\n' + sec.text).matchAll(/\b([A-RT-Z]{1,3}\d{1,3})\b/g)).map((m) => m[1]))).filter((id) => reqs.has(id));
438
+ if (!ids.length || (sec.text.trim().length < 30)) continue;
439
+ k++;
440
+ add({ kind: 'delivers', doc: rel, subject: clip(sec.title, 60), a: clip(sec.title + '\n' + sec.text, PAIR_CAP.story), b: clip(ids.map((id) => reqs.get(id)).join('\n'), PAIR_CAP.reqs), a_label: sec.title, partner: prdRel, ids });
441
+ }
442
+ }
443
+ }
444
+ return { pairs, reads };
445
+ }
446
+
274
447
  /* ── verification ──────────────────────────────────────────────────────────────────────────────── */
275
448
  const date = (secs) => new Date(secs * 1000).toISOString().slice(0, 16).replace('T', ' ');
276
449
 
@@ -400,6 +573,7 @@ function foldLedger(file) {
400
573
  }
401
574
  return map;
402
575
  }
576
+ const isJev = (e) => !!e && String(e.kind || '').startsWith('jev_');
403
577
  const claimId = (c) => sha(c.doc + '|' + c.kind + '|' + c.subject).slice(0, 12);
404
578
 
405
579
  /* ── the cache (per-user runtime directory) ───────────────────────────────────────────────────── */
@@ -542,7 +716,7 @@ async function check({ root, cacheDir, ledgerFile, sweep = false, now = Date.now
542
716
  }
543
717
  // a claim that is gone from an affected or removed document is withdrawn, not forgotten
544
718
  for (const [id, e] of folded) {
545
- if (e.status === 'withdrawn' || e.status === 'verified' || seenIds.has(id)) continue;
719
+ if (e.status === 'withdrawn' || e.status === 'verified' || seenIds.has(id) || isJev(e)) continue; // a Jev claim is withdrawn by the Jev pass, not here
546
720
  if (affected.has(e.doc) || removed.includes(e.doc)) events.push({ ...e, status: 'withdrawn', truth: null, sentence: null, verified_at: stamp, at: stamp });
547
721
  }
548
722
  let appended = 0;
@@ -564,9 +738,9 @@ function buildView({ ledgerFile, index, scope, now = Date.now() }) {
564
738
  if (!byDoc.has(e.doc)) byDoc.set(e.doc, []);
565
739
  byDoc.get(e.doc).push(e);
566
740
  }
567
- const rank = { contradicted: 0, stale: 1, undecided: 2 };
741
+ const rank = { contradicted: 0, stale: 1, undecided: 2, suspected: 3 };
568
742
  const docsOut = [];
569
- let problems = 0, stale = 0, undecided = 0, verified = 0, unclassified = 0, internalOnly = 0;
743
+ let problems = 0, stale = 0, undecided = 0, verified = 0, unclassified = 0, internalOnly = 0, suspected = 0;
570
744
  for (const [rel, ent] of Object.entries(index)) {
571
745
  if (!ent.cls) continue;
572
746
  const claims = (byDoc.get(rel) || []).sort((a, b) => rank[a.status] - rank[b.status]);
@@ -577,20 +751,20 @@ function buildView({ ledgerFile, index, scope, now = Date.now() }) {
577
751
  rel, type: cls.type, via: cls.via, classified: !!cls.classified,
578
752
  references: REFERENCES[cls.type] || [],
579
753
  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 })),
754
+ problems: count('contradicted'), stale: count('stale'), undecided: count('undecided'), suspected: count('suspected'), verified: v.verified || 0,
755
+ findings: claims.slice(0, 30).map((c) => ({ kind: c.kind, status: c.status, sentence: c.sentence || c.text, truth: c.truth, source: c.source, likelihood: c.likelihood || null })),
582
756
  };
583
757
  docsOut.push(entry);
584
- problems += entry.problems; stale += entry.stale; undecided += entry.undecided; verified += entry.verified;
758
+ problems += entry.problems; stale += entry.stale; undecided += entry.undecided; verified += entry.verified; suspected += entry.suspected;
585
759
  if (!cls.classified) unclassified++;
586
760
  else if (!REFERENCES[cls.type]) internalOnly++;
587
761
  }
588
- docsOut.sort((a, b) => (b.problems - a.problems) || (b.stale - a.stale) || (b.undecided - a.undecided) || a.rel.localeCompare(b.rel));
762
+ docsOut.sort((a, b) => (b.problems - a.problems) || (b.stale - a.stale) || (b.undecided - a.undecided) || (b.suspected - a.suspected) || a.rel.localeCompare(b.rel));
589
763
  const checked = docsOut.length;
590
764
  const verdict = !checked ? 'not-checked' : problems ? 'problems' : 'consistent';
591
765
  return {
592
766
  verdict,
593
- counts: { documents: checked, problems, stale, undecided, verified, unclassified, internal_only: internalOnly, truncated: !!(scope && scope.truncated) },
767
+ counts: { documents: checked, problems, stale, undecided, suspected, verified, unclassified, internal_only: internalOnly, truncated: !!(scope && scope.truncated) },
594
768
  docs: docsOut, as_of: new Date(now).toISOString(),
595
769
  };
596
770
  }
@@ -598,8 +772,8 @@ function buildView({ ledgerFile, index, scope, now = Date.now() }) {
598
772
  /** The current view without running a check: from the per-user cache and the folded ledger. */
599
773
  function readView({ cacheDir, ledgerFile, now = Date.now() }) {
600
774
  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 };
775
+ if (!index) return { verdict: 'not-checked', counts: { documents: 0, problems: 0, stale: 0, undecided: 0, suspected: 0, verified: 0, unclassified: 0, internal_only: 0, truncated: false }, docs: [], as_of: null };
602
776
  return buildView({ ledgerFile, index, scope: null, now });
603
777
  }
604
778
 
605
- module.exports = { readView, idOf, cleanValue, check, buildView, extract, verifyClaim, foldLedger, listScope, frontmatter, bodyKeys, resolveRef, rubricWeights, loadDecisions, STALE_SECS };
779
+ module.exports = { pairsFor, requirementTexts, rubricDimensionText, sectionsOf, PAIR_QUESTIONS, isJev, readView, idOf, cleanValue, check, buildView, extract, verifyClaim, foldLedger, listScope, frontmatter, bodyKeys, resolveRef, rubricWeights, loadDecisions, STALE_SECS };