create-pmos 0.10.1 → 0.11.1

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/README.md CHANGED
@@ -333,6 +333,70 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
333
333
  `okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
334
334
  exactly what you're running.
335
335
 
336
+ ## What's new in 0.11.1
337
+
338
+ **Fixes: the ladder never actually worked.** If you installed 0.11.0 and every row in the new
339
+ sidebar read `ladder unreadable` in red — that was us, not your repository.
340
+
341
+ The engine that derives an initiative's stage, next step and blockers was **not included in any
342
+ published package**. It lives at `scripts/workflow-state.js` in the PMOS repo, and the workspace
343
+ looked for it two directories above itself — a path that exists in a development checkout and in no
344
+ install. The surface 0.11.0 was released for could not run for anyone who installed it.
345
+
346
+ 0.11.1 bundles the engine with the app, and adds the check that would have caught this: publishing
347
+ now **derives a ladder from the packed tarball** and fails if it cannot, rather than only confirming
348
+ that files are present.
349
+
350
+ So rows now show what 0.11.0 described: lane, derived stage, and the next step or the blocker —
351
+ each one naming the branch it was derived from.
352
+
353
+ A repository with no `planning/initiatives/` records is no longer an error; it simply has no
354
+ initiatives, and reads calmly. Some of the wording around that is still terser than it should be
355
+ (a folder that is not a git repository currently reports an unreadable branch rather than saying it
356
+ is not a repository) — that is being fixed separately and is a label, not a failure.
357
+
358
+ ## What's new in 0.11.0
359
+
360
+ **The workspace navigates by initiative instead of by folder.** If you upgrade and the left column
361
+ looks unfamiliar, this is why.
362
+
363
+ - **One tree instead of two panes.** The sidebar was a list of working trees named after
364
+ directories, plus a separate list of running agents. It is now a single tree: your repository,
365
+ the working trees under it, and each tree's sessions beneath that. A branch called
366
+ `initiative/<id>` is labelled with the initiative, not the folder — and the folder name stays on
367
+ the row, because switching a branch moves every session in that tree and a row that renamed
368
+ itself would look like a row that vanished.
369
+
370
+ - **Each row says where the work is and what is next** — its lane, its derived stage, and either
371
+ the next step or what is blocking it. Nothing here is a status anyone types; it is derived from
372
+ your records and git, by the same engine the gate runs.
373
+
374
+ - **Every stage names the branch it was derived from.** This one matters more than it sounds.
375
+ Stage is computed over `merge-base(main, HEAD)..HEAD` — *this branch's* commits — so an
376
+ initiative you shipped months ago legitimately reads "spec" on a branch that did not build it.
377
+ The surface now says which branch produced the answer instead of implying it is absolute.
378
+
379
+ - **The rail follows your selection.** The four lenses (initiatives · folders · scheduled ·
380
+ history) are replaced by one detail: pick a repository and you get its folder map, schedules and
381
+ history; pick an initiative and you get its dossier — where it is, what it still owes, and every
382
+ artifact it owns, grouped by what the *lane* asks for. A chore is not shown as a feature missing
383
+ a PRD. Opening a dossier is a read: it never switches your working tree.
384
+
385
+ - **The workflow panel no longer freezes the app.** Deriving the board shells `git` once per
386
+ initiative — around 30 seconds on a large repo — and it used to run on the request path, locking
387
+ the server and every terminal session with it. It now runs in a separate process and every answer
388
+ is stamped with when it was derived. On a big repository the first read says "deriving" rather
389
+ than showing nothing.
390
+
391
+ - **Prototypes, user stories and copywriting are visible to the library.** Specimens in
392
+ `prototype/<id>/` were outside the scanned tree, so a D60 prototype never appeared on the
393
+ initiative that produced it. They do now, and `-user-stories`, `-copy-brief` and `-copy` are
394
+ recognised artifact roles.
395
+
396
+ Honest note on what this release did *not* get: the 30-second derivation is **cached, not fixed** —
397
+ the underlying per-file git lookups are still unbatched. And the Review Gate for this work was never
398
+ scored; it was accepted on green CI and PM judgement, without an independent adversarial pass.
399
+
336
400
  ## What's new in 0.10.1
337
401
 
338
402
  **Two fixes found by installing 0.10.0 and using it, rather than by reading the code.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
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. `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@84f5b38
2
- assembled: 2026-07-28
1
+ kernel-source: pmos@cae81d8
2
+ assembled: 2026-07-29
3
3
  mode: vendor
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+ /*
3
+ * board-cache.js — the workflow board, cached and derived OFF the event loop.
4
+ *
5
+ * WHY THIS EXISTS. `console-state.workflow()` runs `scripts/workflow-state.js` over every
6
+ * initiative record, and the engine resolves each artifact's FIRST APPEARANCE by shelling `git`.
7
+ * Measured on the PMOS repo, 2026-07-29: ~260 ms per initiative × 109 records = **~30 seconds**, and
8
+ * every millisecond of it is SYNCHRONOUS. Today that cost is paid only when the PM opens the
9
+ * `workflow` lens, where it freezes the server — and with it every PTY WebSocket — for half a minute.
10
+ *
11
+ * PRD R4 puts the same board behind EVERY sidebar row, so an uncached in-process derivation would
12
+ * turn a 30-second freeze into a freeze on every render. Two rules follow, and they are the whole
13
+ * design:
14
+ *
15
+ * 1. NEVER derive on the request path. A miss spawns a CHILD PROCESS and returns immediately.
16
+ * The event loop is never blocked, however cold the cache is.
17
+ * 2. NEVER serve a stale board as if it were fresh. Every hit carries `as_of` and `stale`, and a
18
+ * cold cache returns `pending: true` rather than an empty board. A surface that cannot say how
19
+ * old its answer is has no business showing a stage — that is R5's rule applied to time
20
+ * instead of to branch.
21
+ *
22
+ * The signature is HEAD + the initiative records' own mtimes, so editing a record refreshes the
23
+ * board without a commit, and switching branch refreshes it without an edit.
24
+ */
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const { spawnSync, spawn } = require('child_process');
28
+
29
+ const MAX_AGE_MS = 10 * 60 * 1000; // a board older than this is refreshed even if the signature holds
30
+ const REFRESH_TIMEOUT_MS = 120000; // a derivation that cannot finish in 2 minutes is reported, not awaited
31
+
32
+ const cache = new Map(); // root -> { sig, at, value, error }
33
+ const inflight = new Map(); // root -> true
34
+
35
+ function headSha(root) {
36
+ const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', timeout: 5000 });
37
+ return r.status === 0 && r.stdout ? r.stdout.trim() : 'no-head';
38
+ }
39
+
40
+ // The records' own state, so an UNCOMMITTED edit still invalidates. Cheap: one readdir + one stat
41
+ // per record, no git, no file contents.
42
+ function recordsSig(root) {
43
+ const dir = path.join(root, 'planning', 'initiatives');
44
+ let names = [];
45
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort(); } catch { return 'none'; }
46
+ let newest = 0;
47
+ for (const n of names) {
48
+ try { const st = fs.statSync(path.join(dir, n)); if (st.mtimeMs > newest) newest = st.mtimeMs; }
49
+ catch { /* a record that vanished mid-scan just does not raise the watermark */ }
50
+ }
51
+ return `${names.length}:${Math.round(newest)}`;
52
+ }
53
+
54
+ function signature(root) { return `${headSha(root)}|${recordsSig(root)}`; }
55
+
56
+ // The child runs the SAME reader the lens uses — no second implementation of the board to drift.
57
+ function refresh(root, sig) {
58
+ if (inflight.get(root)) return;
59
+ inflight.set(root, true);
60
+ const script = `
61
+ const cs = require(${JSON.stringify(path.join(__dirname, 'console-state.js'))});
62
+ process.stdout.write(JSON.stringify(cs.workflow({ root: ${JSON.stringify(root)} })));
63
+ `;
64
+ let out = '';
65
+ let child;
66
+ try { child = spawn(process.execPath, ['-e', script], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }); }
67
+ catch (e) {
68
+ inflight.delete(root);
69
+ cache.set(root, { sig, at: Date.now(), value: null, error: 'could not start the board derivation: ' + (e.code || e.message) });
70
+ return;
71
+ }
72
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, REFRESH_TIMEOUT_MS);
73
+ if (timer.unref) timer.unref();
74
+ child.stdout.on('data', (c) => { out += c; });
75
+ child.on('error', (e) => {
76
+ clearTimeout(timer); inflight.delete(root);
77
+ cache.set(root, { sig, at: Date.now(), value: null, error: 'the board derivation failed to run: ' + (e.code || e.message) });
78
+ });
79
+ child.on('close', (code, signal) => {
80
+ clearTimeout(timer); inflight.delete(root);
81
+ if (signal === 'SIGKILL') {
82
+ cache.set(root, { sig, at: Date.now(), value: null, error: `the board took longer than ${Math.round(REFRESH_TIMEOUT_MS / 1000)}s to derive and was stopped` });
83
+ return;
84
+ }
85
+ if (code !== 0 || !out) {
86
+ cache.set(root, { sig, at: Date.now(), value: null, error: 'the board derivation exited without a result' });
87
+ return;
88
+ }
89
+ try { cache.set(root, { sig, at: Date.now(), value: JSON.parse(out), error: null }); }
90
+ catch { cache.set(root, { sig, at: Date.now(), value: null, error: 'the board derivation returned unreadable output' }); }
91
+ });
92
+ }
93
+
94
+ /*
95
+ * The read the request path uses. Always returns immediately.
96
+ * { pending: true } nothing cached yet; a derivation was started
97
+ * { ok:false, error, as_of, stale } the derivation failed — render RED, never a calm empty
98
+ * { ...workflowPayload, as_of, stale } a board; `stale` means a refresh is running behind it
99
+ */
100
+ function board(root, { refreshNow = false } = {}) {
101
+ if (!root) return { ok: false, error: 'no workspace root resolved for the workflow board', as_of: null, stale: false };
102
+ let sig;
103
+ try { sig = signature(root); } catch { sig = 'unreadable'; }
104
+ const hit = cache.get(root);
105
+ const fresh = hit && hit.sig === sig && (Date.now() - hit.at) < MAX_AGE_MS;
106
+ if (!fresh || refreshNow) refresh(root, sig);
107
+ if (!hit) return { pending: true, refreshing: true, as_of: null, stale: false };
108
+ const envelope = { as_of: new Date(hit.at).toISOString(), stale: !fresh, refreshing: !!inflight.get(root) };
109
+ if (hit.error) return { ok: false, error: hit.error, ...envelope };
110
+ return { ...hit.value, ...envelope };
111
+ }
112
+
113
+ // Test/maintenance seam: drop everything, or one root.
114
+ function invalidate(root) { if (root) { cache.delete(root); inflight.delete(root); } else { cache.clear(); inflight.clear(); } }
115
+
116
+ module.exports = { board, invalidate, signature, MAX_AGE_MS, REFRESH_TIMEOUT_MS };
@@ -18,6 +18,7 @@
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
20
  const reviewGate = require('./review-gate');
21
+ const gitInfo = require('./git-info'); // R5: the ladder is branch-scoped — name the branch it came from
21
22
 
22
23
  function loadJson(file) {
23
24
  let text;
@@ -125,14 +126,45 @@ function radar({ stateDir, snapshotDir }) {
125
126
  * option. It is deliberately not `require`d out of the opened directory: the engine only reads
126
127
  * files and runs `git -C <root> log`, so no code from a workspace the PM merely opened is ever
127
128
  * executed. An install that does not carry the engine gets an honest error, never a blank panel.
129
+ *
130
+ * WHERE THE ENGINE ACTUALLY LIVES — the defect this list exists to close (incident
131
+ * kit-workflow-engine-missing). "Bundled with the workspace" was the INTENT above; packaging never
132
+ * implemented it. `npm-pkg/package.json` ships only bin/ payload/ workspace/, so in a published
133
+ * install there is no repo two levels up and `../../scripts/workflow-state.js` resolves to nothing.
134
+ * Every adopter row read "ladder unreadable" from 0.11.0 back to whenever this surface shipped —
135
+ * the ladder had never once run outside a PMOS checkout. Two locations, tried in order:
136
+ *
137
+ * 1. `../../scripts/workflow-state.js` — a repo checkout (PMOS-self, any dev clone). Preferred on
138
+ * purpose: it is the very file `.github/scripts/initiative-gate.sh` executes, so a developer's
139
+ * panel can never disagree with what CI is about to print on their PR.
140
+ * 2. `lib/engine/workflow-state.js` — the copy the publish step vendors into the bundle. This is
141
+ * what an adopter runs, and the only reason the ladder derives at all outside this repo.
142
+ *
143
+ * ABSENT and BROKEN are answered differently, because they are different facts (R10). A path that
144
+ * does not exist means "try the next one"; a path that exists and throws is a real fault in an
145
+ * engine that IS installed, and reporting that as "not present" would send the PM looking for a
146
+ * packaging bug they do not have.
128
147
  */
148
+ const ENGINE_PATHS = [
149
+ path.join(__dirname, '..', '..', 'scripts', 'workflow-state.js'),
150
+ path.join(__dirname, 'engine', 'workflow-state.js'),
151
+ ];
152
+ function loadEngine() {
153
+ const tried = [];
154
+ for (const p of ENGINE_PATHS) {
155
+ if (!fs.existsSync(p)) { tried.push(p); continue; }
156
+ try { return { ok: true, engine: require(p), from: p }; }
157
+ catch (e) {
158
+ return { ok: false, error: `the workflow engine at ${p} is installed but failed to load — ${String((e && e.message) || e).slice(0, 160)}` };
159
+ }
160
+ }
161
+ return { ok: false, error: `the workflow engine is not present in this install — the ladder cannot be derived here (looked in: ${tried.join(', ')})` };
162
+ }
129
163
  function workflow({ root }) {
130
164
  if (!root) return { ok: false, error: 'no workspace root resolved for the workflow surface' };
131
- let engine;
132
- try { engine = require(path.join(__dirname, '..', '..', 'scripts', 'workflow-state.js')); }
133
- catch {
134
- return { ok: false, error: 'the workflow engine (scripts/workflow-state.js) is not present in this install — the ladder cannot be derived here' };
135
- }
165
+ const loaded = loadEngine();
166
+ if (!loaded.ok) return { ok: false, error: loaded.error };
167
+ const engine = loaded.engine;
136
168
  let ids;
137
169
  try { ids = engine.listInitiatives(root); }
138
170
  catch (e) { return { ok: false, error: 'could not list initiative records: ' + String((e && e.message) || 'error').slice(0, 120) }; }
@@ -140,6 +172,24 @@ function workflow({ root }) {
140
172
 
141
173
  // One commit range for the whole board — resolving it per initiative would re-shell git for each.
142
174
  const range = engine.commitRange(root);
175
+ // WHERE the ladder was derived from. `commitRange` resolves merge-base(origin/main, HEAD)..HEAD —
176
+ // this BRANCH's commits, and an EMPTY range on main. So `build`/`quality`, and the stage that
177
+ // follows from them, are branch-scoped facts. A consumer that prints a stage without saying which
178
+ // branch produced it states something the data does not support: pmos-terminal-workspace, shipped
179
+ // under D62, derives to `spec` with "no implementation commits in this range" on every branch that
180
+ // did not build it. Two worktrees of one repo legitimately print two different ladders.
181
+ // This block is what lets a caller say so; it is not decoration (PRD R5, rubric dimension 1).
182
+ const bi = gitInfo.branchInfo(root);
183
+ const scope = {
184
+ branch: bi.ok ? (bi.current || 'a detached HEAD') : null,
185
+ branch_error: bi.ok ? null : (bi.error || 'branch could not be read'),
186
+ commits: range.ok ? range.shas.length : null,
187
+ whole_history: range.ok ? !!range.wholeHistory : null,
188
+ // The empty range is the trap: range.ok is TRUE with zero commits (you are on main, or level
189
+ // with it), so nothing is "degraded" — every build step legitimately reads `pending` and a naive
190
+ // reader calls that "never built". Named separately because it needs different words.
191
+ empty_range: range.ok ? range.shas.length === 0 : null,
192
+ };
143
193
  const rows = ids.map((id) => {
144
194
  try { return engine.computeWorkflow(id, { root, range }); }
145
195
  catch (e) { return { ok: false, id, error: String((e && e.message) || 'derivation failed').slice(0, 160) }; }
@@ -164,9 +214,12 @@ function workflow({ root }) {
164
214
  unreadable: rows.filter((r) => !r.ok).map(shape),
165
215
  blocked: shown.filter((r) => r.ok && r.blockers.length).length,
166
216
  degraded: range.ok ? null : range.reason,
217
+ scope, // R5 — the branch this ladder was derived from; never omit it
167
218
  enforced_from: engine.ENFORCED_FROM,
168
219
  },
169
220
  };
170
221
  }
171
222
 
172
- module.exports = { metrics, radar, workflow, computeMetricsFromRuns, loadJson, loadJsonl };
223
+ // ENGINE_PATHS/loadEngine are exported for the packaging guard in workspace/test.js: the incident
224
+ // that made them a list was invisible to every unit test precisely because resolution was inline.
225
+ module.exports = { metrics, radar, workflow, computeMetricsFromRuns, loadJson, loadJsonl, ENGINE_PATHS, loadEngine };
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /*
4
+ * PMOS anchor resolution — the one place a `parent_kr` string becomes a structured answer.
5
+ *
6
+ * const A = require('./anchor-resolve');
7
+ * const index = A.parseOkrIndex(root); // what the OKR documents actually declare
8
+ * const r = A.resolveAnchor(raw, index); // { kind, objective, kr, status, ... }
9
+ * A.canonical(r) // the one normative written form
10
+ *
11
+ * WHY THIS EXISTS. Before this module, `parent_kr` was a string that nothing dereferenced. The
12
+ * ladder's R1 passed any non-empty value that did not contain `health:` as `done` without ever
13
+ * opening the OKR documents — so `parent_kr: banana` passed, and so did an anchor naming a KR that
14
+ * existed in no file (`q3-2026:integrity:kr-a` and `:kr-b` did exactly that for two days). The
15
+ * workspace's OKR surface, meanwhile, never read `parent_kr` at all. One field, several readers,
16
+ * none of them agreeing what it meant. See
17
+ * planning/explainers/okr-anchor-resolution-surface-explainer.md.
18
+ *
19
+ * THREE RULES, all load-bearing (planning/prd/anchor-normalization-spec.md R-2/R-3/R-4):
20
+ *
21
+ * 1. PERMANENTLY BACKWARD-COMPATIBLE (R-2). Every syntax present in the corpus at authoring time
22
+ * is accepted, and keeps being accepted — not behind a migration window. The kit ships to
23
+ * adopters as `create-pmos` (D55), so records written in the legacy forms exist in repos no
24
+ * rewrite here can reach. ACCEPTED_FORMS below is that enumeration, and it is a promise.
25
+ *
26
+ * 2. HEALTH IS A FORM TEST, NEVER A SUBSTRING SEARCH (R-3). The corpus contains
27
+ * `okrs-q3-2026 → O1.KR1 # or health:<id> from planning/okrs/health-budgets.md` — an OKR
28
+ * anchor whose *commentary* mentions health. A substring search classifies it as a health
29
+ * anchor and then rejects it as an undeclared budget: recognised, then refused, for a reason
30
+ * that is not the reader's fault. Classification therefore tests the START of the value.
31
+ *
32
+ * 3. THE OBJECTIVE AND KR PATTERNS WIDEN TOGETHER (R-4). Widening the objective pattern alone
33
+ * would let a previously-unmatched KR attach to the *previous matched* objective rather than
34
+ * stay unmatched — a silent mis-filing, which is worse than the omission it replaces.
35
+ *
36
+ * HONESTY BY CONSTRUCTION. `status` is one of `resolved` · `unresolvable` · `empty` · `unknown`.
37
+ * `unknown` means the OKR documents could not be read and never collapses into either a pass or a
38
+ * failure — a resolver that cannot see its inputs says so rather than guessing. Nothing in this
39
+ * module blocks anything: per the spec's assumption A1 the gates keep accepting any non-empty
40
+ * anchor, and the RED belongs to the surface. This module reports; callers decide.
41
+ *
42
+ * Zero dependencies, stock Node. Runs in CI, in the ladder, and inside the workspace server.
43
+ */
44
+
45
+ const fs = require('fs');
46
+ const path = require('path');
47
+
48
+ /*
49
+ * The census, from the explainer, as executable documentation. Each entry is a real form found in
50
+ * planning/initiatives/ at authoring time (2026-07-28). Adding a form here without a matching
51
+ * pattern below, or vice versa, is the drift this list exists to make visible — the test suite
52
+ * asserts every example resolves.
53
+ */
54
+ const ACCEPTED_FORMS = [
55
+ { example: 'health:tech', note: 'D59 registry form, bare' },
56
+ { example: 'health:tech (D59 — commentary)', note: 'registry form with trailing commentary' },
57
+ { example: 'q3-2026:o1:kr1', note: 'CANONICAL — colon triple' },
58
+ { example: 'q3-2026:integrity:kr-a', note: 'canonical, non-numeric objective + KR ids' },
59
+ { example: 'Q3-2026:O1:KR2', note: 'canonical, upper-case variant' },
60
+ { example: 'okrs-q3-2026 → O1.KR1', note: 'legacy arrow form, filename-prefixed' },
61
+ { example: 'okrs-q3-2026 → O1', note: 'legacy arrow, objective only (no KR)' },
62
+ { example: 'q3-2026 → O1.KR2', note: 'legacy arrow, period-prefixed' },
63
+ { example: 'O1.KR3', note: 'legacy bare objective.KR' },
64
+ { example: '**O1**', note: 'legacy bare objective, markdown-bolded' },
65
+ { example: "okrs-q3-2026 → O2.KR2 / B4 (Cohen's kappa)", note: 'trailing commentary after a slash' },
66
+ { example: 'okrs-q3-2026 → O1.KR1 # or health:<id> from planning/okrs/health-budgets.md',
67
+ note: 'R-3 trap: OKR anchor whose COMMENTARY mentions health — must classify as okr' },
68
+ ];
69
+
70
+ /*
71
+ * Objective and KR tokens are deliberately NOT `O\w+` / `KR\w+`. The OKR document's own H1 is
72
+ * `# OKRs — Q3 2026`, and a loose objective pattern matches `OKRs` there, inventing an objective
73
+ * called `okrs` that every subsequent KR would then attach to. The shape is therefore pinned:
74
+ * an objective is `O` + digits, or `O-` + a word; a KR is `KR` + digits, or `KR-` + a word.
75
+ */
76
+ const OBJ_TOKEN = '(O\\d+|O-[A-Za-z][A-Za-z0-9-]*)';
77
+ const KR_TOKEN = '(KR\\d+|KR-[A-Za-z][A-Za-z0-9-]*)';
78
+ const PERIOD = '((?:q|Q)[1-4]-\\d{4})';
79
+
80
+ const RE_OBJ_HEADING = new RegExp('^#{1,3}\\s+' + OBJ_TOKEN + '\\s*(?:[—–:-]\\s*(.*))?$');
81
+ const RE_KR_BULLET = new RegExp('^\\s*[-*]\\s*\\*\\*\\s*' + KR_TOKEN + '\\s*[—–:-]?\\s*(.+?)\\*\\*');
82
+
83
+ // Anchor forms. Order matters only in that the colon forms are tried before the looser ones.
84
+ const RE_HEALTH = /^health:([a-z0-9_-]+)/i; // START-anchored — rule 2
85
+ const RE_COLON_KR = new RegExp('^(?:okrs-)?' + PERIOD + ':([a-z0-9][a-z0-9-]*):(kr-?[a-z0-9-]+)$', 'i');
86
+ const RE_COLON_OBJ = new RegExp('^(?:okrs-)?' + PERIOD + ':([a-z0-9][a-z0-9-]*)$', 'i');
87
+ const RE_ARROW = new RegExp('^(?:okrs-)?' + PERIOD + '\\s*(?:→|->)\\s*' + OBJ_TOKEN + '(?:\\.' + KR_TOKEN + ')?$', 'i');
88
+ const RE_BARE = new RegExp('^' + OBJ_TOKEN + '(?:\\.' + KR_TOKEN + ')?$', 'i');
89
+
90
+ /** Strip markdown emphasis and trailing commentary. `(`, `#` and `/` never appear inside an id. */
91
+ function stripCommentary(raw) {
92
+ let s = String(raw == null ? '' : raw).replace(/\*\*/g, '').trim();
93
+ const cut = s.search(/[(#/]/);
94
+ if (cut >= 0) s = s.slice(0, cut);
95
+ return s.trim();
96
+ }
97
+
98
+ /** `O-INTEGRITY` → `integrity`; `O1` → `o1`. The anchors write `integrity`, the heading writes
99
+ * `O-INTEGRITY`; the explainer flagged that a widened parser must pick one. This picks: the id is
100
+ * the heading token minus a leading `O-`, lower-cased. */
101
+ function objectiveId(token) {
102
+ const t = String(token || '').trim();
103
+ const m = /^O-(.+)$/i.exec(t);
104
+ return (m ? m[1] : t).toLowerCase();
105
+ }
106
+
107
+ /** `KR1` → `kr1`; `KR-C` → `kr-c`. Case only. */
108
+ function krId(token) {
109
+ return String(token || '').trim().toLowerCase();
110
+ }
111
+
112
+ function listOkrFiles(root) {
113
+ const out = [];
114
+ const seen = new Set();
115
+ const walk = (dir, depth) => {
116
+ if (depth > 4) return;
117
+ let entries;
118
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
119
+ for (const e of entries) {
120
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
121
+ const full = path.join(dir, e.name);
122
+ if (e.isDirectory()) {
123
+ if (e.name === 'okrs') {
124
+ let files;
125
+ try { files = fs.readdirSync(full); } catch { continue; }
126
+ for (const f of files) {
127
+ if (!f.endsWith('.md')) continue;
128
+ const p = path.join(full, f);
129
+ if (!seen.has(p)) { seen.add(p); out.push(p); }
130
+ }
131
+ } else walk(full, depth + 1);
132
+ }
133
+ }
134
+ };
135
+ walk(root, 0);
136
+ return out.sort();
137
+ }
138
+
139
+ /**
140
+ * Read every OKR document under `root` and return what they declare.
141
+ * → { ok, objectives: [{id, token, name, krs:[{id, token, name}], file}], files, error }
142
+ * `ok:false` is a genuine read failure and must render as an error, never as "no objectives".
143
+ */
144
+ function parseOkrIndex(root) {
145
+ const files = listOkrFiles(root);
146
+ if (!files.length) {
147
+ return { ok: false, objectives: [], files: [], error: 'no OKR documents found under ' + root };
148
+ }
149
+ const objectives = [];
150
+ const byId = new Map();
151
+ let period = null;
152
+ for (const file of files) {
153
+ let text;
154
+ try { text = fs.readFileSync(file, 'utf8'); }
155
+ catch (e) {
156
+ return { ok: false, objectives: [], files, error: `could not read ${file}: ${String((e && e.message) || e).slice(0, 120)}` };
157
+ }
158
+ let cur = null;
159
+ for (const line of text.split('\n')) {
160
+ if (!period) {
161
+ const p = /^\s*[-*]?\s*period\s*:\s*(\d{4})-(Q[1-4])\s*$/i.exec(line);
162
+ if (p) period = `${p[2].toLowerCase()}-${p[1]}`;
163
+ }
164
+ const o = RE_OBJ_HEADING.exec(line);
165
+ if (o) {
166
+ const id = objectiveId(o[1]);
167
+ cur = byId.get(id);
168
+ if (!cur) {
169
+ cur = { id, token: o[1], name: (o[2] || '').replace(/\*\*/g, '').trim(), krs: [], file: path.relative(root, file) };
170
+ byId.set(id, cur);
171
+ objectives.push(cur);
172
+ }
173
+ continue;
174
+ }
175
+ const k = RE_KR_BULLET.exec(line);
176
+ // A KR with no open objective stays UNATTACHED (rule 3) — never inherited from a previous one.
177
+ if (k && cur && !cur.krs.some((x) => x.id === krId(k[1]))) {
178
+ cur.krs.push({ id: krId(k[1]), token: k[1], name: (k[2] || '').trim() });
179
+ }
180
+ }
181
+ }
182
+ return { ok: true, objectives, files: files.map((f) => path.relative(root, f)), period: period || null, error: null };
183
+ }
184
+
185
+ function readBudgets(root) {
186
+ const p = path.join(root, 'planning', 'okrs', 'health-budgets.md');
187
+ try { return { ok: true, text: fs.readFileSync(p, 'utf8'), path: path.relative(root, p) }; }
188
+ catch { return { ok: false, text: '', path: path.relative(root, p) }; }
189
+ }
190
+
191
+ /**
192
+ * Resolve one raw `parent_kr` value.
193
+ * → { raw, kind, status, objective, kr, budget, period, form, reason }
194
+ * kind : 'health' | 'okr' | 'empty'
195
+ * status : 'resolved' | 'unresolvable' | 'empty' | 'unknown'
196
+ * `index` comes from parseOkrIndex; `budgets` from readBudgets (optional — omit to skip the
197
+ * registry check, which callers that only need the FORM can do).
198
+ */
199
+ function resolveAnchor(raw, index, budgets) {
200
+ const original = String(raw == null ? '' : raw).trim();
201
+ const base = { raw: original, kind: 'empty', status: 'empty', objective: null, kr: null, budget: null, period: null, form: null, reason: null };
202
+ if (!original) return Object.assign(base, { reason: 'parent_kr is empty — unanchored work is rejected (D59)' });
203
+
204
+ // Rule 2: the FORM decides, not a substring. Commentary is stripped only for the OKR branch,
205
+ // because a health anchor's id must sit at the very start of the raw value.
206
+ const h = RE_HEALTH.exec(original);
207
+ if (h) {
208
+ const id = h[1].toLowerCase();
209
+ const out = Object.assign({}, base, { kind: 'health', budget: id, form: 'health:<id>' });
210
+ if (!budgets) return Object.assign(out, { status: 'unknown', reason: 'health budget registry not supplied to the resolver' });
211
+ if (!budgets.ok) return Object.assign(out, { status: 'unknown', reason: `health-budget registry unreadable at ${budgets.path}` });
212
+ // Same case handling as the classifier (R-3): recognised case-insensitively, looked up the same way.
213
+ const declared = new RegExp('health:' + id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i').test(budgets.text);
214
+ return declared
215
+ ? Object.assign(out, { status: 'resolved', reason: `declared in ${budgets.path}` })
216
+ : Object.assign(out, { status: 'unresolvable', reason: `health:${id} is not a declared budget in ${budgets.path}` });
217
+ }
218
+
219
+ const s = stripCommentary(original);
220
+ if (!s) {
221
+ return Object.assign(base, { kind: 'okr', status: 'unresolvable', reason: 'no anchor left after commentary was stripped' });
222
+ }
223
+
224
+ let period = null, objTok = null, krTok = null, form = null;
225
+ let m;
226
+ if ((m = RE_COLON_KR.exec(s))) { period = m[1].toLowerCase(); objTok = m[2]; krTok = m[3]; form = 'q3-2026:<objective>:<kr>'; }
227
+ else if ((m = RE_COLON_OBJ.exec(s))) { period = m[1].toLowerCase(); objTok = m[2]; form = 'q3-2026:<objective>'; }
228
+ else if ((m = RE_ARROW.exec(s))) { period = m[1].toLowerCase(); objTok = m[2]; krTok = m[3] || null; form = 'legacy arrow'; }
229
+ else if ((m = RE_BARE.exec(s))) { objTok = m[1]; krTok = m[2] || null; form = 'legacy bare'; }
230
+ else {
231
+ return Object.assign(base, { kind: 'okr', status: 'unresolvable', form: 'unrecognised', reason: `'${s}' matches no accepted anchor form` });
232
+ }
233
+
234
+ const out = Object.assign({}, base, {
235
+ kind: 'okr', form, period,
236
+ objective: objectiveId(objTok),
237
+ kr: krTok ? krId(krTok) : null,
238
+ });
239
+
240
+ if (!index) return Object.assign(out, { status: 'unknown', reason: 'OKR index not supplied to the resolver' });
241
+ if (!index.ok) return Object.assign(out, { status: 'unknown', reason: index.error || 'OKR documents unreadable' });
242
+
243
+ const obj = index.objectives.find((o) => o.id === out.objective);
244
+ if (!obj) {
245
+ return Object.assign(out, { status: 'unresolvable', reason: `objective '${out.objective}' is not declared in ${index.files.join(', ')}` });
246
+ }
247
+ if (!out.kr) {
248
+ // Assumption A2: objective-only anchoring is a legitimate resolvable kind, not debt.
249
+ return Object.assign(out, { status: 'resolved', reason: `objective '${obj.id}' — no KR written (objective-level anchor)` });
250
+ }
251
+ const kr = obj.krs.find((k) => k.id === out.kr);
252
+ if (!kr) {
253
+ return Object.assign(out, { status: 'unresolvable', reason: `objective '${obj.id}' declares no KR '${out.kr}' (has: ${obj.krs.map((k) => k.id).join(', ') || 'none'})` });
254
+ }
255
+ return Object.assign(out, { status: 'resolved', reason: `${obj.id}/${kr.id} — ${kr.name}`.slice(0, 160) });
256
+ }
257
+
258
+ /**
259
+ * The one normative written form (R-1, as amended 2026-07-28 by PM decision).
260
+ * Returns null when the input does not resolve to something writable — callers must not invent one.
261
+ */
262
+ function canonical(resolved, fallbackPeriod) {
263
+ if (!resolved) return null;
264
+ if (resolved.kind === 'health') return resolved.budget ? `health:${resolved.budget}` : null;
265
+ if (resolved.kind !== 'okr' || !resolved.objective) return null;
266
+ const period = resolved.period || fallbackPeriod || null;
267
+ if (!period) return null;
268
+ return resolved.kr ? `${period}:${resolved.objective}:${resolved.kr}` : `${period}:${resolved.objective}`;
269
+ }
270
+
271
+ module.exports = {
272
+ parseOkrIndex, readBudgets, resolveAnchor, canonical,
273
+ objectiveId, krId, stripCommentary, listOkrFiles,
274
+ ACCEPTED_FORMS,
275
+ // exported for the tests that assert the widening happened together (R-4)
276
+ RE_OBJ_HEADING, RE_KR_BULLET,
277
+ };