create-pmos 0.132.0 → 0.133.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 +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/bin/pmos.js +18 -2
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/welcome.md +38 -0
- package/workspace/lib/import-overlay.js +32 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-onboarding.js +215 -0
- package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
- package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
- package/workspace/lib/vendor/scripts/intake.js +78 -0
- package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
- package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
- package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
- package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
- package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
- package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
- package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
- package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
- package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
- package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
- package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
- package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
- package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
- package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
- package/workspace/lib/vendor/scripts/status-facts.js +214 -0
- package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
- package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
- package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
- package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
- package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
- package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
- package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
- package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
- package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
- package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +280 -0
- package/workspace/public/pm/welcome.js +283 -0
- package/workspace/public/welcome.html +20 -0
- package/workspace/server.js +2 -2
- package/workspace/test-lists/onboarding-firstrun.json +15 -0
|
@@ -0,0 +1,351 @@
|
|
|
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_DOMAIN = /^domain:([a-z0-9][a-z0-9_-]*)/i; // D67 — the third anchor class
|
|
86
|
+
const RE_COLON_KR = new RegExp('^(?:okrs-)?' + PERIOD + ':([a-z0-9][a-z0-9-]*):(kr-?[a-z0-9-]+)$', 'i');
|
|
87
|
+
const RE_COLON_OBJ = new RegExp('^(?:okrs-)?' + PERIOD + ':([a-z0-9][a-z0-9-]*)$', 'i');
|
|
88
|
+
const RE_ARROW = new RegExp('^(?:okrs-)?' + PERIOD + '\\s*(?:→|->)\\s*' + OBJ_TOKEN + '(?:\\.' + KR_TOKEN + ')?$', 'i');
|
|
89
|
+
const RE_BARE = new RegExp('^' + OBJ_TOKEN + '(?:\\.' + KR_TOKEN + ')?$', 'i');
|
|
90
|
+
|
|
91
|
+
/** Strip markdown emphasis and trailing commentary. `(`, `#` and `/` never appear inside an id. */
|
|
92
|
+
function stripCommentary(raw) {
|
|
93
|
+
let s = String(raw == null ? '' : raw).replace(/\*\*/g, '').trim();
|
|
94
|
+
const cut = s.search(/[(#/]/);
|
|
95
|
+
if (cut >= 0) s = s.slice(0, cut);
|
|
96
|
+
return s.trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `O-INTEGRITY` → `integrity`; `O1` → `o1`. The anchors write `integrity`, the heading writes
|
|
100
|
+
* `O-INTEGRITY`; the explainer flagged that a widened parser must pick one. This picks: the id is
|
|
101
|
+
* the heading token minus a leading `O-`, lower-cased. */
|
|
102
|
+
function objectiveId(token) {
|
|
103
|
+
const t = String(token || '').trim();
|
|
104
|
+
const m = /^O-(.+)$/i.exec(t);
|
|
105
|
+
return (m ? m[1] : t).toLowerCase();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `KR1` → `kr1`; `KR-C` → `kr-c`. Case only. */
|
|
109
|
+
function krId(token) {
|
|
110
|
+
return String(token || '').trim().toLowerCase();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function listOkrFiles(root) {
|
|
114
|
+
const out = [];
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
const walk = (dir, depth) => {
|
|
117
|
+
if (depth > 4) return;
|
|
118
|
+
let entries;
|
|
119
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
120
|
+
for (const e of entries) {
|
|
121
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue;
|
|
122
|
+
const full = path.join(dir, e.name);
|
|
123
|
+
if (e.isDirectory()) {
|
|
124
|
+
if (e.name === 'okrs') {
|
|
125
|
+
let files;
|
|
126
|
+
try { files = fs.readdirSync(full); } catch { continue; }
|
|
127
|
+
for (const f of files) {
|
|
128
|
+
if (!f.endsWith('.md')) continue;
|
|
129
|
+
const p = path.join(full, f);
|
|
130
|
+
if (!seen.has(p)) { seen.add(p); out.push(p); }
|
|
131
|
+
}
|
|
132
|
+
} else walk(full, depth + 1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
walk(root, 0);
|
|
137
|
+
return out.sort();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read every OKR document under `root` and return what they declare.
|
|
142
|
+
* → { ok, objectives: [{id, token, name, krs:[{id, token, name}], file}], files, error }
|
|
143
|
+
* `ok:false` is a genuine read failure and must render as an error, never as "no objectives".
|
|
144
|
+
*/
|
|
145
|
+
function parseOkrIndex(root) {
|
|
146
|
+
const files = listOkrFiles(root);
|
|
147
|
+
if (!files.length) {
|
|
148
|
+
return { ok: false, objectives: [], files: [], error: 'no OKR documents found under ' + root };
|
|
149
|
+
}
|
|
150
|
+
const objectives = [];
|
|
151
|
+
const byId = new Map();
|
|
152
|
+
let period = null;
|
|
153
|
+
for (const file of files) {
|
|
154
|
+
let text;
|
|
155
|
+
try { text = fs.readFileSync(file, 'utf8'); }
|
|
156
|
+
catch (e) {
|
|
157
|
+
return { ok: false, objectives: [], files, error: `could not read ${file}: ${String((e && e.message) || e).slice(0, 120)}` };
|
|
158
|
+
}
|
|
159
|
+
let cur = null;
|
|
160
|
+
for (const line of text.split('\n')) {
|
|
161
|
+
if (!period) {
|
|
162
|
+
const p = /^\s*[-*]?\s*period\s*:\s*(\d{4})-(Q[1-4])\s*$/i.exec(line);
|
|
163
|
+
if (p) period = `${p[2].toLowerCase()}-${p[1]}`;
|
|
164
|
+
}
|
|
165
|
+
const o = RE_OBJ_HEADING.exec(line);
|
|
166
|
+
if (o) {
|
|
167
|
+
const id = objectiveId(o[1]);
|
|
168
|
+
cur = byId.get(id);
|
|
169
|
+
if (!cur) {
|
|
170
|
+
cur = { id, token: o[1], name: (o[2] || '').replace(/\*\*/g, '').trim(), krs: [], file: path.relative(root, file) };
|
|
171
|
+
byId.set(id, cur);
|
|
172
|
+
objectives.push(cur);
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const k = RE_KR_BULLET.exec(line);
|
|
177
|
+
// A KR with no open objective stays UNATTACHED (rule 3) — never inherited from a previous one.
|
|
178
|
+
if (k && cur && !cur.krs.some((x) => x.id === krId(k[1]))) {
|
|
179
|
+
cur.krs.push({ id: krId(k[1]), token: k[1], name: (k[2] || '').trim() });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
// the KR's own measure — its `baseline: … → target: …` sub-bullet (overhaul-r2 B4)
|
|
183
|
+
const bl = /^\s+[-*]\s*baseline\s*:\s*(.+)$/i.exec(line);
|
|
184
|
+
if (bl && cur && cur.krs.length && !cur.krs[cur.krs.length - 1].line) cur.krs[cur.krs.length - 1].line = bl[1].trim();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { ok: true, objectives, files: files.map((f) => path.relative(root, f)), period: period || null, error: null };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readBudgets(root) {
|
|
191
|
+
const p = path.join(root, 'planning', 'okrs', 'health-budgets.md');
|
|
192
|
+
let out;
|
|
193
|
+
try { out = { ok: true, text: fs.readFileSync(p, 'utf8'), path: path.relative(root, p) }; }
|
|
194
|
+
catch { out = { ok: false, text: '', path: path.relative(root, p) }; }
|
|
195
|
+
return withDomains(out, path.dirname(p), root);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* D67 — the domain registry, `domains.md` beside `health-budgets.md`. Closed and PM-edited, like the
|
|
200
|
+
* budgets: a domain exists only as a table row `| domain:<id> | standing goal | repositories |`.
|
|
201
|
+
* It rides on the budgets object every caller already passes, so the gate, the ladder, the sweep and
|
|
202
|
+
* the workspace all learn the class without a signature change. Absent file = no domains declared
|
|
203
|
+
* (`ok:false`), which makes any `domain:` anchor `unknown`, never silently resolved.
|
|
204
|
+
*/
|
|
205
|
+
function readDomains(dir, root) {
|
|
206
|
+
const p = path.join(dir, 'domains.md');
|
|
207
|
+
const rel = root ? path.relative(root, p) : p;
|
|
208
|
+
let text;
|
|
209
|
+
try { text = fs.readFileSync(p, 'utf8'); } catch { return { ok: false, path: rel, rows: [] }; }
|
|
210
|
+
const rows = [];
|
|
211
|
+
for (const line of text.split('\n')) {
|
|
212
|
+
const m = /^\|\s*`?domain:([a-z0-9][a-z0-9_-]*)`?\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|/i.exec(line);
|
|
213
|
+
if (m) rows.push({ id: m[1].toLowerCase(), goal: m[2], repos: m[3] });
|
|
214
|
+
}
|
|
215
|
+
return { ok: true, path: rel, rows };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Attach the domain registry to a budgets object (callers that build their own budgets use this). */
|
|
219
|
+
function withDomains(budgets, dir, root) {
|
|
220
|
+
if (!budgets) return budgets;
|
|
221
|
+
return Object.assign(budgets, { domains: readDomains(dir, root) });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Resolve one raw `parent_kr` value.
|
|
226
|
+
* → { raw, kind, status, objective, kr, budget, period, form, reason }
|
|
227
|
+
* kind : 'health' | 'okr' | 'empty'
|
|
228
|
+
* status : 'resolved' | 'unresolvable' | 'empty' | 'unknown'
|
|
229
|
+
* `index` comes from parseOkrIndex; `budgets` from readBudgets (optional — omit to skip the
|
|
230
|
+
* registry check, which callers that only need the FORM can do).
|
|
231
|
+
*/
|
|
232
|
+
/** Strip ONE layer of matching surrounding quotes.
|
|
233
|
+
*
|
|
234
|
+
* `parent_kr` is a YAML scalar, and a value whose prose contains a colon-space MUST be quoted or
|
|
235
|
+
* the document does not parse. The flat frontmatter reader hands the value through with its quotes
|
|
236
|
+
* intact, so a legitimately-quoted anchor arrived here as `"health:security …` and matched no form.
|
|
237
|
+
*
|
|
238
|
+
* This defect occurred TWICE — `sink-discipline-backport` on 2026-08-04 and
|
|
239
|
+
* `separation-invariant-enforcement` on 2026-08-05 — and the first time it was fixed by unquoting
|
|
240
|
+
* the one file. That is the wave's own lesson landing on the person who wrote it down: the unit of
|
|
241
|
+
* attention was the file, the unit of the defect was the rule. "Do not quote it" is a rule that
|
|
242
|
+
* lives in prose and depends on an author remembering; tolerating the quoting is a rule that lives
|
|
243
|
+
* in code.
|
|
244
|
+
*
|
|
245
|
+
* Only a MATCHING outer pair, and only one layer: a stray leading quote is still malformed, and
|
|
246
|
+
* reporting it as such is the point. */
|
|
247
|
+
function unquote(s) {
|
|
248
|
+
const t = s.trim();
|
|
249
|
+
if (t.length >= 2 && ((t[0] === '"' && t[t.length - 1] === '"') || (t[0] === "'" && t[t.length - 1] === "'"))) {
|
|
250
|
+
return t.slice(1, -1).trim();
|
|
251
|
+
}
|
|
252
|
+
return t;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function resolveAnchor(raw, index, budgets) {
|
|
256
|
+
const original = unquote(String(raw == null ? '' : raw).trim());
|
|
257
|
+
const base = { raw: original, kind: 'empty', status: 'empty', objective: null, kr: null, budget: null, period: null, form: null, reason: null,
|
|
258
|
+
// separate fields so no client has to parse `reason` (overhaul-r2 B1)
|
|
259
|
+
objective_name: null, kr_name: null, kr_line: null, domain: null, domain_goal: null };
|
|
260
|
+
if (!original) return Object.assign(base, { reason: 'parent_kr is empty — unanchored work is rejected (D59)' });
|
|
261
|
+
|
|
262
|
+
const d = RE_DOMAIN.exec(original);
|
|
263
|
+
if (d) {
|
|
264
|
+
const id = d[1].toLowerCase();
|
|
265
|
+
const out = Object.assign({}, base, { kind: 'domain', domain: id, form: 'domain:<id>' });
|
|
266
|
+
const reg = budgets && budgets.domains;
|
|
267
|
+
if (!reg) return Object.assign(out, { status: 'unknown', reason: 'domain registry not supplied to the resolver' });
|
|
268
|
+
if (!reg.ok) return Object.assign(out, { status: 'unknown', reason: `no domain registry at ${reg.path} — declare the domain there (D67)` });
|
|
269
|
+
const row = reg.rows.find((r) => r.id === id);
|
|
270
|
+
return row
|
|
271
|
+
? Object.assign(out, { status: 'resolved', domain_goal: row.goal || null, reason: `domain:${id} — ${row.goal || 'no standing goal written'}`.slice(0, 160) })
|
|
272
|
+
: Object.assign(out, { status: 'unresolvable', reason: `domain:${id} is not a declared domain in ${reg.path} (D67)` });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Rule 2: the FORM decides, not a substring. Commentary is stripped only for the OKR branch,
|
|
276
|
+
// because a health anchor's id must sit at the very start of the raw value.
|
|
277
|
+
const h = RE_HEALTH.exec(original);
|
|
278
|
+
if (h) {
|
|
279
|
+
const id = h[1].toLowerCase();
|
|
280
|
+
const out = Object.assign({}, base, { kind: 'health', budget: id, form: 'health:<id>' });
|
|
281
|
+
if (!budgets) return Object.assign(out, { status: 'unknown', reason: 'health budget registry not supplied to the resolver' });
|
|
282
|
+
if (!budgets.ok) return Object.assign(out, { status: 'unknown', reason: `health-budget registry unreadable at ${budgets.path}` });
|
|
283
|
+
// Same case handling as the classifier (R-3): recognised case-insensitively, looked up the same way.
|
|
284
|
+
const declared = new RegExp('health:' + id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i').test(budgets.text);
|
|
285
|
+
return declared
|
|
286
|
+
? Object.assign(out, { status: 'resolved', reason: `declared in ${budgets.path}` })
|
|
287
|
+
: Object.assign(out, { status: 'unresolvable', reason: `health:${id} is not a declared budget in ${budgets.path}` });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const s = stripCommentary(original);
|
|
291
|
+
if (!s) {
|
|
292
|
+
return Object.assign(base, { kind: 'okr', status: 'unresolvable', reason: 'no anchor left after commentary was stripped' });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let period = null, objTok = null, krTok = null, form = null;
|
|
296
|
+
let m;
|
|
297
|
+
if ((m = RE_COLON_KR.exec(s))) { period = m[1].toLowerCase(); objTok = m[2]; krTok = m[3]; form = 'q3-2026:<objective>:<kr>'; }
|
|
298
|
+
else if ((m = RE_COLON_OBJ.exec(s))) { period = m[1].toLowerCase(); objTok = m[2]; form = 'q3-2026:<objective>'; }
|
|
299
|
+
else if ((m = RE_ARROW.exec(s))) { period = m[1].toLowerCase(); objTok = m[2]; krTok = m[3] || null; form = 'legacy arrow'; }
|
|
300
|
+
else if ((m = RE_BARE.exec(s))) { objTok = m[1]; krTok = m[2] || null; form = 'legacy bare'; }
|
|
301
|
+
else {
|
|
302
|
+
return Object.assign(base, { kind: 'okr', status: 'unresolvable', form: 'unrecognised', reason: `'${s}' matches no accepted anchor form` });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const out = Object.assign({}, base, {
|
|
306
|
+
kind: 'okr', form, period,
|
|
307
|
+
objective: objectiveId(objTok),
|
|
308
|
+
kr: krTok ? krId(krTok) : null,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (!index) return Object.assign(out, { status: 'unknown', reason: 'OKR index not supplied to the resolver' });
|
|
312
|
+
if (!index.ok) return Object.assign(out, { status: 'unknown', reason: index.error || 'OKR documents unreadable' });
|
|
313
|
+
|
|
314
|
+
const obj = index.objectives.find((o) => o.id === out.objective);
|
|
315
|
+
if (!obj) {
|
|
316
|
+
return Object.assign(out, { status: 'unresolvable', reason: `objective '${out.objective}' is not declared in ${index.files.join(', ')}` });
|
|
317
|
+
}
|
|
318
|
+
if (!out.kr) {
|
|
319
|
+
// Assumption A2: objective-only anchoring is a legitimate resolvable kind, not debt.
|
|
320
|
+
return Object.assign(out, { status: 'resolved', objective_name: obj.name || null, reason: `objective '${obj.id}' — no KR written (objective-level anchor)` });
|
|
321
|
+
}
|
|
322
|
+
const kr = obj.krs.find((k) => k.id === out.kr);
|
|
323
|
+
if (!kr) {
|
|
324
|
+
return Object.assign(out, { status: 'unresolvable', reason: `objective '${obj.id}' declares no KR '${out.kr}' (has: ${obj.krs.map((k) => k.id).join(', ') || 'none'})` });
|
|
325
|
+
}
|
|
326
|
+
return Object.assign(out, { status: 'resolved', objective_name: obj.name || null, kr_name: kr.name || null, kr_line: kr.line || null,
|
|
327
|
+
reason: `${obj.id}/${kr.id} — ${kr.name}`.slice(0, 160) });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The one normative written form (R-1, as amended 2026-07-28 by PM decision).
|
|
332
|
+
* Returns null when the input does not resolve to something writable — callers must not invent one.
|
|
333
|
+
*/
|
|
334
|
+
function canonical(resolved, fallbackPeriod) {
|
|
335
|
+
if (!resolved) return null;
|
|
336
|
+
if (resolved.kind === 'health') return resolved.budget ? `health:${resolved.budget}` : null;
|
|
337
|
+
if (resolved.kind === 'domain') return resolved.domain ? `domain:${resolved.domain}` : null;
|
|
338
|
+
if (resolved.kind !== 'okr' || !resolved.objective) return null;
|
|
339
|
+
const period = resolved.period || fallbackPeriod || null;
|
|
340
|
+
if (!period) return null;
|
|
341
|
+
return resolved.kr ? `${period}:${resolved.objective}:${resolved.kr}` : `${period}:${resolved.objective}`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = {
|
|
345
|
+
unquote,
|
|
346
|
+
parseOkrIndex, readBudgets, readDomains, withDomains, resolveAnchor, canonical,
|
|
347
|
+
objectiveId, krId, stripCommentary, listOkrFiles,
|
|
348
|
+
ACCEPTED_FORMS,
|
|
349
|
+
// exported for the tests that assert the widening happened together (R-4)
|
|
350
|
+
RE_OBJ_HEADING, RE_KR_BULLET,
|
|
351
|
+
};
|