create-pmos 0.41.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.41.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@ddb2c06
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-03T21:04:06Z"}
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-03T21:04:06Z"}
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-03T21:04:06Z"}
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.
@@ -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.
@@ -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
+ ]
@@ -0,0 +1,266 @@
1
+ 'use strict';
2
+ /* kg.js — the repository's knowledge graph, derived from what its files already state (knowledge-graph K1).
3
+ *
4
+ * Pure: takes [{path, text}] for the tracked Markdown files and returns nodes, edges and the dangling links.
5
+ * No filesystem, no git, no network, no model. Same input → byte-identical output.
6
+ *
7
+ * WHAT IS A NODE. One tracked .md file. It carries a CARD (the frontmatter `description`, else the first paragraph,
8
+ * cut at 240 characters), a token estimate (characters ÷ 4 — an estimate, not a measurement), and its headings with
9
+ * line ranges so a caller can read ONE section and not the file.
10
+ *
11
+ * WHAT IS AN EDGE. Only a relation a file writes down:
12
+ * link a Markdown link to a tracked file basis stated
13
+ * frontmatter a path in prd_path / rubric_path / explainer_path / sources basis stated
14
+ * supersedes · amended_by · cites (typed keys a document opts into) basis stated
15
+ * initiative initiative_id (frontmatter or an identity bullet) → its record basis stated
16
+ * anchor parent_kr / parent_KR `health:<id>` or a KR id → the file naming it basis stated
17
+ * cites a `D<nn>` mention in prose → that decision's ADR view basis mention
18
+ * A link to a file that does not exist is a DANGLING item, never an edge. A link-like string inside a code fence or
19
+ * an inline code span is not a relation and makes no edge. Nothing is inferred by similarity; workspace/lib/
20
+ * related-docs.js took the same position for alignment, for the same reason.
21
+ *
22
+ * `D<nn>` needs two or more digits on purpose: slice labels such as "D1" and "D3" in the proactive-engine documents
23
+ * would otherwise cite decisions D01 and D03 that they never mention.
24
+ */
25
+ (function (root, factory) {
26
+ const api = factory();
27
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
28
+ else root.Kg = api;
29
+ })(typeof self !== 'undefined' ? self : this, function () {
30
+
31
+ const CARD_MAX = 240;
32
+ const ADR_DIR = 'okf/products/pmos/adr/';
33
+ const PATH_KEYS = ['prd_path', 'rubric_path', 'explainer_path', 'sources'];
34
+ const TYPED_KEYS = ['supersedes', 'amended_by', 'cites'];
35
+ const estTokens = (s) => Math.ceil(String(s || '').length / 4);
36
+
37
+ /* Replace fenced code blocks and inline code spans with spaces of the same length, keeping every newline, so line
38
+ * numbers stay true and nothing inside code is read as a link, a citation or a heading. */
39
+ function stripCode(text) {
40
+ const blank = (s) => s.replace(/[^\n]/g, ' ');
41
+ let out = '';
42
+ let fence = null;
43
+ for (const line of String(text || '').split('\n')) {
44
+ const m = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
45
+ if (fence) { out += blank(line) + '\n'; if (m && m[1][0] === fence[0] && m[1].length >= fence.length) fence = null; continue; }
46
+ if (m) { fence = m[1]; out += blank(line) + '\n'; continue; }
47
+ out += line.replace(/(`+)[^`\n]*?\1/g, (s) => blank(s)) + '\n';
48
+ }
49
+ return out.slice(0, -1);
50
+ }
51
+
52
+ /* Frontmatter values come from the reader workspace/lib/related-docs.js already ships (one-line `key: value`);
53
+ * the body start line is the only thing computed here. */
54
+ const readFm = (typeof require === 'function') ? require('./related-docs').frontmatter : null;
55
+ function frontmatter(text) {
56
+ const m = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(String(text || ''));
57
+ if (!m) return { fm: {}, bodyStart: 1 };
58
+ return { fm: readFm ? readFm(text) : {}, bodyStart: m[0].split('\n').length };
59
+ }
60
+
61
+ function headingsOf(stripped, bodyStart) {
62
+ const lines = stripped.split('\n');
63
+ const found = [];
64
+ for (let i = bodyStart - 1; i < lines.length; i++) {
65
+ const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(lines[i]);
66
+ if (m) found.push({ level: m[1].length, text: m[2].trim(), start: i + 1 });
67
+ }
68
+ for (let k = 0; k < found.length; k++) {
69
+ let end = lines.length;
70
+ for (let j = k + 1; j < found.length; j++) if (found[j].level <= found[k].level) { end = found[j].start - 1; break; }
71
+ found[k].end = end;
72
+ }
73
+ return found;
74
+ }
75
+
76
+ function cardOf(fm, stripped, bodyStart) {
77
+ if (fm.description) return fm.description.slice(0, CARD_MAX);
78
+ const lines = stripped.split('\n').slice(bodyStart - 1);
79
+ const para = [];
80
+ for (const raw of lines) {
81
+ const l = raw.trim();
82
+ if (!l) { if (para.length) break; continue; }
83
+ if (/^(#|\||>|---|[-*]\s|\d+\.\s|<)/.test(l)) { if (para.length) break; continue; }
84
+ para.push(l);
85
+ }
86
+ return para.join(' ').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').slice(0, CARD_MAX);
87
+ }
88
+
89
+ const norm = (from, target) => {
90
+ const t = target.replace(/^<|>$/g, '');
91
+ const parts = (t.startsWith('/') ? t.slice(1) : from.replace(/[^/]*$/, '') + t).split('/');
92
+ const out = [];
93
+ for (const p of parts) { if (p === '.' || p === '') continue; if (p === '..') out.pop(); else out.push(p); }
94
+ return out.join('/');
95
+ };
96
+
97
+ const pathsIn = (value) => [...String(value || '').matchAll(/[\w@./-]+\.md\b/g)].map((m) => m[0].replace(/^\//, ''));
98
+
99
+ function build(files) {
100
+ const list = (files || []).filter((f) => f && typeof f.path === 'string').sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
101
+ const have = new Set(list.map((f) => f.path));
102
+ const adrFor = new Map();
103
+ for (const p of have) { const m = /^okf\/products\/pmos\/adr\/d(\d{2,3})-/.exec(p); if (m) adrFor.set('D' + m[1], p); }
104
+ const krFile = new Map();
105
+ for (const f of list) if (f.path.startsWith('planning/okrs/')) for (const m of f.text.matchAll(/\bO\d+\.KR\d+\b/g)) if (!krFile.has(m[0])) krFile.set(m[0], f.path);
106
+ const budgets = have.has('planning/okrs/health-budgets.md') ? 'planning/okrs/health-budgets.md' : null;
107
+
108
+ const nodes = [];
109
+ const edges = new Map();
110
+ const dangling = [];
111
+ const meta = { decisionLines: {} };
112
+ const addEdge = (from, to, type, basis) => {
113
+ if (from === to || !have.has(to)) return;
114
+ const key = from + '\u0000' + to + '\u0000' + type;
115
+ const cur = edges.get(key);
116
+ if (!cur) edges.set(key, { from, to, type, basis });
117
+ else if (cur.basis === 'mention' && basis === 'stated') cur.basis = 'stated';
118
+ };
119
+
120
+ for (const f of list) {
121
+ const text = String(f.text || '');
122
+ const { fm, bodyStart } = frontmatter(text);
123
+ const stripped = stripCode(text);
124
+ const body = stripped;
125
+ const heads = headingsOf(stripped, bodyStart);
126
+ const title = fm.title || (heads.find((h) => h.level === 1) || {}).text || f.path;
127
+ nodes.push({
128
+ id: f.path, title, type: fm.type || null, card: cardOf(fm, stripped, bodyStart),
129
+ described: !!fm.description, tokens_est: estTokens(text), lines: text.split('\n').length,
130
+ headings: heads.map((h) => ({ level: h.level, text: h.text, start: h.start, end: h.end })),
131
+ status: fm.status || null, stale_after: fm.stale_after || null,
132
+ });
133
+
134
+ for (const m of body.matchAll(/\]\(([^)\s]+?)(?:#[^)]*)?\)/g)) {
135
+ const raw = m[1];
136
+ if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('#')) continue;
137
+ if (!/\.md$/i.test(raw)) continue;
138
+ const to = norm(f.path, raw);
139
+ if (have.has(to)) addEdge(f.path, to, 'link', 'stated');
140
+ else dangling.push({ from: f.path, to: raw });
141
+ }
142
+ for (const m of body.matchAll(/\bD(\d{2,3})\b/g)) {
143
+ const to = adrFor.get('D' + m[1]);
144
+ if (to) addEdge(f.path, to, 'cites', 'mention');
145
+ }
146
+ for (const k of PATH_KEYS) for (const p of pathsIn(fm[k])) addEdge(f.path, p, 'frontmatter', 'stated');
147
+ for (const k of TYPED_KEYS) for (const p of pathsIn(fm[k])) addEdge(f.path, p, k, 'stated');
148
+ const ini = fm.initiative_id || ((/^[-*]?\s*initiative_id:\s*([\w.-]+)/m.exec(body) || [])[1]);
149
+ if (ini) addEdge(f.path, 'planning/initiatives/' + ini + '.md', 'initiative', 'stated');
150
+ const anchor = fm.parent_kr || fm.parent_KR || ((/^[-*]?\s*parent_kr:\s*([\w:.-]+)/im.exec(body) || [])[1]);
151
+ if (anchor) {
152
+ if (/^health:/.test(anchor) && budgets) addEdge(f.path, budgets, 'anchor', 'stated');
153
+ else if (krFile.has(anchor)) addEdge(f.path, krFile.get(anchor), 'anchor', 'stated');
154
+ }
155
+ if (f.path === 'DECISIONS.md') {
156
+ const lines = body.split('\n');
157
+ for (let i = 0; i < lines.length; i++) { const m = /\bAdded \*\*(D\d{2,3})\*\*/.exec(lines[i]); if (m && !(m[1] in meta.decisionLines)) meta.decisionLines[m[1]] = i + 1; }
158
+ }
159
+ }
160
+ const e = [...edges.values()].sort((a, b) => (a.from + a.to + a.type < b.from + b.to + b.type ? -1 : 1));
161
+ dangling.sort((a, b) => (a.from + a.to < b.from + b.to ? -1 : a.from + a.to > b.from + b.to ? 1 : 0));
162
+ return { nodes, edges: e, dangling, meta };
163
+ }
164
+
165
+ /* Split dangling links into "history by design" and "needs a decision" by a readable rule list:
166
+ * [{from?: regex, to?: regex, why: string}] — a rule matches when every field it names matches. */
167
+ function splitDangling(graph, rules) {
168
+ const compiled = (rules || []).map((r) => ({ from: r.from ? new RegExp(r.from) : null, to: r.to ? new RegExp(r.to) : null, why: r.why || '' }));
169
+ const history = []; const needs = [];
170
+ for (const d of graph.dangling) {
171
+ const r = compiled.find((x) => (x.from || x.to) && (!x.from || x.from.test(d.from)) && (!x.to || x.to.test(d.to)));
172
+ if (r) history.push({ ...d, why: r.why }); else needs.push(d);
173
+ }
174
+ return { history, needs };
175
+ }
176
+
177
+ function stats(graph, rules) {
178
+ const byType = {}; const byBasis = { stated: 0, mention: 0 };
179
+ for (const e of graph.edges) { byType[e.type] = (byType[e.type] || 0) + 1; byBasis[e.basis]++; }
180
+ const inbound = new Set(graph.edges.map((e) => e.to));
181
+ const orphans = graph.nodes.filter((n) => !inbound.has(n.id) && !/(^|\/)(index|log)\.md$/.test(n.id)).length;
182
+ const split = splitDangling(graph, rules);
183
+ return {
184
+ nodes: graph.nodes.length, edges: graph.edges.length, byType, byBasis,
185
+ cardsWritten: graph.nodes.filter((n) => n.described).length,
186
+ cardsDerived: graph.nodes.filter((n) => !n.described && n.card).length,
187
+ noCard: graph.nodes.filter((n) => !n.card).length,
188
+ noType: graph.nodes.filter((n) => !n.type).length,
189
+ noIncoming: orphans, dangling: graph.dangling.length,
190
+ danglingHistory: split.history.length, danglingNeedsDecision: split.needs.length, needsDecision: split.needs,
191
+ };
192
+ }
193
+
194
+ const nodeById = (graph, id) => graph.nodes.find((n) => n.id === id) || null;
195
+ const cardText = (n) => `${n.id} [${n.type || 'untyped'}] ${n.title}${n.card ? ' — ' + n.card : ''} (file ~${n.tokens_est} tokens)`;
196
+
197
+ /* Rank: where the term matches (id 3, title 3, heading 2, card 1), then how many files link to the node. */
198
+ function find(graph, term, { limit = 20 } = {}) {
199
+ const t = String(term || '').toLowerCase().trim();
200
+ if (!t) return [];
201
+ const indeg = new Map();
202
+ for (const e of graph.edges) indeg.set(e.to, (indeg.get(e.to) || 0) + 1);
203
+ const out = [];
204
+ for (const n of graph.nodes) {
205
+ let score = 0; const where = [];
206
+ if (n.id.toLowerCase().includes(t)) { score += 3; where.push('id'); }
207
+ if (n.title.toLowerCase().includes(t)) { score += 3; where.push('title'); }
208
+ const h = n.headings.find((x) => x.text.toLowerCase().includes(t));
209
+ if (h) { score += 2; where.push('heading "' + h.text + '"'); }
210
+ if ((n.card || '').toLowerCase().includes(t)) { score += 1; where.push('card'); }
211
+ if (score) out.push({ node: n, score, where, indegree: indeg.get(n.id) || 0 });
212
+ }
213
+ return out.sort((a, b) => b.score - a.score || b.indegree - a.indegree || (a.node.id < b.node.id ? -1 : 1)).slice(0, limit);
214
+ }
215
+
216
+ function neighbors(graph, id, { type = null, depth = 1 } = {}) {
217
+ if (!nodeById(graph, id)) return null;
218
+ const seen = new Map([[id, 0]]); const rows = [];
219
+ let frontier = [id];
220
+ for (let d = 1; d <= depth; d++) {
221
+ const next = [];
222
+ for (const e of graph.edges) {
223
+ if (type && e.type !== type) continue;
224
+ for (const [a, b, dir] of [[e.from, e.to, 'out'], [e.to, e.from, 'in']]) {
225
+ if (!frontier.includes(a) || seen.has(b)) continue;
226
+ seen.set(b, d); next.push(b); rows.push({ id: b, via: e.type, basis: e.basis, direction: dir, depth: d, from: a });
227
+ }
228
+ }
229
+ frontier = next;
230
+ }
231
+ return rows;
232
+ }
233
+
234
+ /* why("D65"): the ADR view, the amendment-log line in DECISIONS.md, and every file that cites the ADR, by kind. */
235
+ function why(graph, dId) {
236
+ const id = String(dId || '').toUpperCase().replace(/^D(\d)$/, 'D0$1');
237
+ const adr = graph.nodes.find((n) => n.id.startsWith(ADR_DIR + id.toLowerCase() + '-'));
238
+ if (!adr) return null;
239
+ const groups = {};
240
+ const byFrom = new Map();
241
+ for (const e of graph.edges) if (e.to === adr.id) {
242
+ const cur = byFrom.get(e.from) || { id: e.from, via: [], basis: 'mention' };
243
+ if (!cur.via.includes(e.type)) cur.via.push(e.type);
244
+ if (e.basis === 'stated') cur.basis = 'stated';
245
+ byFrom.set(e.from, cur);
246
+ }
247
+ for (const r of byFrom.values()) { const n = nodeById(graph, r.id); const k = (n && n.type) || 'untyped'; r.via.sort(); (groups[k] = groups[k] || []).push(r); }
248
+ for (const k of Object.keys(groups)) groups[k].sort((a, b) => (a.id < b.id ? -1 : 1));
249
+ const line = graph.meta && graph.meta.decisionLines ? graph.meta.decisionLines[id] || null : null;
250
+ return { decision: id, adr, amendmentLog: line ? { path: 'DECISIONS.md', line } : null, citedBy: groups };
251
+ }
252
+
253
+ /* section(node, text, spec): spec is "#heading words" or "from-to". Returns {from,to,text} or null. */
254
+ function section(node, text, spec) {
255
+ const lines = String(text || '').split('\n');
256
+ const m = /^(\d+)-(\d+)$/.exec(spec || '');
257
+ if (m) { const from = Math.max(1, +m[1]); const to = Math.min(lines.length, +m[2]); if (from > to) return null; return { from, to, text: lines.slice(from - 1, to).join('\n') }; }
258
+ const want = String(spec || '').replace(/^#/, '').trim().toLowerCase();
259
+ if (!want || !node) return null;
260
+ const h = node.headings.find((x) => x.text.toLowerCase() === want) || node.headings.find((x) => x.text.toLowerCase().includes(want));
261
+ if (!h) return null;
262
+ return { from: h.start, to: h.end, heading: h.text, text: lines.slice(h.start - 1, h.end).join('\n') };
263
+ }
264
+
265
+ return { build, stats, splitDangling, find, neighbors, why, section, nodeById, cardText, stripCode, frontmatter, estTokens, ADR_DIR };
266
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -10,7 +10,7 @@
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
12
  "test": "node test.js && npm run test:modules",
13
- "test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node jev-claims.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js",
13
+ "test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node jev-claims.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js && node kg.test.js",
14
14
  "test:ui": "node ui-test.js",
15
15
  "test:parity": "node design-parity.js --states",
16
16
  "test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",