create-pmos 0.11.0 → 0.12.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/README.md +59 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/lib/console-state.js +39 -6
- package/workspace/lib/engine/anchor-resolve.js +277 -0
- package/workspace/lib/engine/workflow-state.js +878 -0
- package/workspace/lib/harness-parse.js +8 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/app.js +106 -13
- package/workspace/public/index.html +6 -1
- package/workspace/public/styles.css +10 -0
- package/workspace/server.js +32 -7
package/README.md
CHANGED
|
@@ -333,6 +333,65 @@ 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.12.0
|
|
337
|
+
|
|
338
|
+
**The sidebar says what is actually true.** 0.11.x told you things were *unreadable* when they were
|
|
339
|
+
simply *not there* — and used the same red for both.
|
|
340
|
+
|
|
341
|
+
If you pointed the workspace at an ordinary folder or a repository that isn't a PMOS repo, almost
|
|
342
|
+
everything read as an error. None of it was.
|
|
343
|
+
|
|
344
|
+
- **A folder that isn't a git repository** now says so, quietly. It used to say `branch unreadable`,
|
|
345
|
+
which is an alarm about a completely ordinary directory.
|
|
346
|
+
- **A repository with no initiative records** reads **no initiatives yet** — a fact about the
|
|
347
|
+
repository, calmly stated. Pointing the workspace at any repo you like is a supported thing to do.
|
|
348
|
+
- **A shell session** no longer reports its non-existent transcript as a failed identification. A
|
|
349
|
+
shell is a prompt, not an agent narrating itself.
|
|
350
|
+
- **A row keeps its name.** Previously a plain folder rendered as a row titled `branch unreadable` —
|
|
351
|
+
a row with no name at all, unfindable in a tree of them.
|
|
352
|
+
|
|
353
|
+
Red still means red: a corrupt state queue, a genuine git failure, or an engine that is installed but
|
|
354
|
+
throws all still draw the alarm. The point is that they no longer share it with the normal case.
|
|
355
|
+
|
|
356
|
+
Three controls also stopped being ambiguous:
|
|
357
|
+
|
|
358
|
+
- **`+ new session — run an agent here`** now names the repository and branch it will start in.
|
|
359
|
+
"Here" meant the selected tree all along; with every repository on screen at once, nothing said
|
|
360
|
+
which that was.
|
|
361
|
+
- **`+ session here`** starts a session. It used to select the tree and stop.
|
|
362
|
+
- **A row with no sessions no longer expands** just because something needs your attention — that
|
|
363
|
+
count is already shown on the row, and opening an empty container to "reveal" it taught you to
|
|
364
|
+
distrust the tree.
|
|
365
|
+
|
|
366
|
+
And the pane is called **repos & sessions** rather than `work`.
|
|
367
|
+
|
|
368
|
+
> **One disclosure.** The Review Gate for this work was not scored before release. Its rubric makes
|
|
369
|
+
> the honesty checks *runnable* precisely so they cannot be satisfied by reading the code, and they
|
|
370
|
+
> were not run by an independent evaluator. Everything above was verified by the automated suites
|
|
371
|
+
> (412 checks) and by hand; none of it was independently reviewed.
|
|
372
|
+
|
|
373
|
+
## What's new in 0.11.1
|
|
374
|
+
|
|
375
|
+
**Fixes: the ladder never actually worked.** If you installed 0.11.0 and every row in the new
|
|
376
|
+
sidebar read `ladder unreadable` in red — that was us, not your repository.
|
|
377
|
+
|
|
378
|
+
The engine that derives an initiative's stage, next step and blockers was **not included in any
|
|
379
|
+
published package**. It lives at `scripts/workflow-state.js` in the PMOS repo, and the workspace
|
|
380
|
+
looked for it two directories above itself — a path that exists in a development checkout and in no
|
|
381
|
+
install. The surface 0.11.0 was released for could not run for anyone who installed it.
|
|
382
|
+
|
|
383
|
+
0.11.1 bundles the engine with the app, and adds the check that would have caught this: publishing
|
|
384
|
+
now **derives a ladder from the packed tarball** and fails if it cannot, rather than only confirming
|
|
385
|
+
that files are present.
|
|
386
|
+
|
|
387
|
+
So rows now show what 0.11.0 described: lane, derived stage, and the next step or the blocker —
|
|
388
|
+
each one naming the branch it was derived from.
|
|
389
|
+
|
|
390
|
+
A repository with no `planning/initiatives/` records is no longer an error; it simply has no
|
|
391
|
+
initiatives, and reads calmly. Some of the wording around that is still terser than it should be
|
|
392
|
+
(a folder that is not a git repository currently reports an unreadable branch rather than saying it
|
|
393
|
+
is not a repository) — that is being fixed separately and is a label, not a failure.
|
|
394
|
+
|
|
336
395
|
## What's new in 0.11.0
|
|
337
396
|
|
|
338
397
|
**The workspace navigates by initiative instead of by folder.** If you upgrade and the left column
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -126,14 +126,45 @@ function radar({ stateDir, snapshotDir }) {
|
|
|
126
126
|
* option. It is deliberately not `require`d out of the opened directory: the engine only reads
|
|
127
127
|
* files and runs `git -C <root> log`, so no code from a workspace the PM merely opened is ever
|
|
128
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.
|
|
129
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
|
+
}
|
|
130
163
|
function workflow({ root }) {
|
|
131
164
|
if (!root) return { ok: false, error: 'no workspace root resolved for the workflow surface' };
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
return { ok: false, error: 'the workflow engine (scripts/workflow-state.js) is not present in this install — the ladder cannot be derived here' };
|
|
136
|
-
}
|
|
165
|
+
const loaded = loadEngine();
|
|
166
|
+
if (!loaded.ok) return { ok: false, error: loaded.error };
|
|
167
|
+
const engine = loaded.engine;
|
|
137
168
|
let ids;
|
|
138
169
|
try { ids = engine.listInitiatives(root); }
|
|
139
170
|
catch (e) { return { ok: false, error: 'could not list initiative records: ' + String((e && e.message) || 'error').slice(0, 120) }; }
|
|
@@ -189,4 +220,6 @@ function workflow({ root }) {
|
|
|
189
220
|
};
|
|
190
221
|
}
|
|
191
222
|
|
|
192
|
-
|
|
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
|
+
};
|