create-pmos 0.131.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.
Files changed (65) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/bin/pmos.js +18 -2
  7. package/workspace/guide/data-freshness.md +40 -0
  8. package/workspace/guide/index.md +2 -0
  9. package/workspace/guide/welcome.md +38 -0
  10. package/workspace/lib/calendar-cli.js +84 -0
  11. package/workspace/lib/facts-async.js +144 -35
  12. package/workspace/lib/freshness.js +167 -0
  13. package/workspace/lib/import-overlay.js +32 -0
  14. package/workspace/lib/offloop.js +94 -0
  15. package/workspace/lib/pmos-verbs.js +3 -0
  16. package/workspace/lib/route-modules.js +2 -0
  17. package/workspace/lib/routes-board.js +3 -1
  18. package/workspace/lib/routes-my-work.js +27 -9
  19. package/workspace/lib/routes-onboarding.js +215 -0
  20. package/workspace/lib/routes-sources.js +64 -0
  21. package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
  22. package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
  23. package/workspace/lib/vendor/scripts/intake.js +78 -0
  24. package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
  25. package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
  26. package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
  27. package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
  28. package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
  29. package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
  30. package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
  31. package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
  32. package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
  33. package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
  34. package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
  35. package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
  36. package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
  37. package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
  38. package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
  39. package/workspace/lib/vendor/scripts/status-facts.js +214 -0
  40. package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
  41. package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
  42. package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
  43. package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
  44. package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
  45. package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
  46. package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
  47. package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
  48. package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
  49. package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
  50. package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
  51. package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
  52. package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
  53. package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
  54. package/workspace/package-lock.json +2 -2
  55. package/workspace/package.json +1 -1
  56. package/workspace/public/pm/c3-shell.js +4 -1
  57. package/workspace/public/pm/components.css +313 -0
  58. package/workspace/public/pm/dest-settings.js +10 -1
  59. package/workspace/public/pm/freshness-line.js +123 -0
  60. package/workspace/public/pm/welcome.js +283 -0
  61. package/workspace/public/welcome.html +20 -0
  62. package/workspace/server.js +41 -4
  63. package/workspace/skills/calendar-sync.skill +30 -0
  64. package/workspace/test-lists/onboarding-firstrun.json +15 -0
  65. package/workspace/test-lists/workspace-data-freshness.json +6 -0
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+ /* routes-sources.js — how old each source of a workspace is, and Refresh now (workspace-data-freshness R6, R7).
3
+ *
4
+ * GET /api/sources?ws=<id> → { ok, ws, version, sources: [row] } lib/freshness.js sources()
5
+ * POST /api/sources/refresh { ws, source } → { ok, source, started: 'rebuild'|'session', session? }
6
+ * POST /api/sources/watch { ws, on } → { ok, watch } the change detector on or off for this repository
7
+ *
8
+ * Both POSTs pass pm-guard requirePagePress: the PM's press from the page. An agent session cannot start a refresh or
9
+ * switch the detector off. A refresh of `repository` or `initiatives` rebuilds the facts in a child process; a refresh of
10
+ * an external source starts that source's skill as a session in the workspace, with the PM's own connections. The server
11
+ * itself calls nothing outside this machine.
12
+ */
13
+ const guard = require('./pm-guard.js');
14
+ const freshness = require('./freshness.js');
15
+
16
+ function wsOf(ctx, id) {
17
+ const w = ctx.getWs(id || '');
18
+ return w && (!id || w.id === id) ? w : null;
19
+ }
20
+ const repoStateDir = (ctx, w) => { try { return ctx.stateDirFor ? ctx.stateDirFor(w) : null; } catch { return null; } };
21
+
22
+ async function handle(req, res, url, ctx) {
23
+ const p = url.pathname;
24
+ const F = ctx.freshness;
25
+ if (!F) return false;
26
+ if (p === '/api/sources') {
27
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
28
+ const w = wsOf(ctx, url.searchParams.get('ws'));
29
+ if (!w) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
30
+ const sources = F.sources({ id: w.id, root: w.root, repoStateDir: repoStateDir(ctx, w) });
31
+ ctx.json(res, { ok: true, ws: w.id, version: F.versions()[w.id] || 0, sources });
32
+ return true;
33
+ }
34
+ if (p !== '/api/sources/refresh' && p !== '/api/sources/watch') return false;
35
+ if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
36
+ const got = await ctx.readJson(req);
37
+ if (!got.ok) { ctx.json(res, { ok: false, error: 'the body is not JSON' }, 400); return true; }
38
+ const body = got.body || {};
39
+ const g = guard.requirePagePress(req, body);
40
+ if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
41
+ const w = wsOf(ctx, typeof body.ws === 'string' ? body.ws : '');
42
+ if (!w) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
43
+
44
+ if (p === '/api/sources/watch') {
45
+ if (typeof body.on !== 'boolean') { ctx.json(res, { ok: false, error: 'on must be true or false' }, 400); return true; }
46
+ if (!F.setEnabled(w.root, body.on)) { ctx.json(res, { ok: false, error: 'the setting could not be saved' }, 500); return true; }
47
+ ctx.json(res, { ok: true, watch: F.enabled(w.root) });
48
+ return true;
49
+ }
50
+
51
+ const src = freshness.SOURCES.find((s) => s.id === body.source);
52
+ if (!src) { ctx.json(res, { ok: false, error: 'source must be one of ' + freshness.SOURCES.map((s) => s.id).join(', ') }, 400); return true; }
53
+ if (!src.skill) {
54
+ ctx.factsRefresh(w.root);
55
+ ctx.json(res, { ok: true, source: src.id, started: 'rebuild' });
56
+ return true;
57
+ }
58
+ const r = ctx.startSkill(w.id, src.skill, `${src.label} · refresh`);
59
+ if (!r.ok) { ctx.json(res, { ok: false, error: r.error }, r.status || 500); return true; }
60
+ ctx.json(res, { ok: true, source: src.id, started: 'session', session: r.session });
61
+ return true;
62
+ }
63
+
64
+ module.exports = { prefixes: ['/api/sources'], handle };
@@ -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
+ };