create-pmos 0.22.0 → 0.24.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 +28 -0
- package/package.json +5 -3
- package/payload/AGENTS.md +7 -0
- package/payload/KIT-VERSION +1 -1
- package/payload/README.md +16 -1
- package/payload/scripts/anchor-resolve.js +277 -0
- package/payload/scripts/delivery-index.js +278 -0
- package/payload/scripts/workflow-state.js +1139 -0
- package/payload/scripts/workflow-sweep.js +764 -0
- package/workspace/README.md +10 -0
- package/workspace/lib/console-state.js +39 -18
- package/workspace/lib/engine/delivery-index.js +278 -0
- package/workspace/lib/engine/workflow-state.js +165 -21
- package/workspace/lib/session-state.js +56 -2
- package/workspace/lib/working-set.js +224 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +3 -3
- package/workspace/public/index.html +7 -0
- package/workspace/public/pm/data.js +28 -1
- package/workspace/public/pm/panel-initiatives.js +172 -17
- package/workspace/server.js +42 -51
package/README.md
CHANGED
|
@@ -333,6 +333,34 @@ 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.24.0
|
|
337
|
+
|
|
338
|
+
**The initiative board says which work is yours, and which of it has an agent running.** Run
|
|
339
|
+
`npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
|
|
340
|
+
|
|
341
|
+
- **A `NOW · working on` group is pinned at the top of the board.** With a few dozen initiatives
|
|
342
|
+
filed by objective, the list is in the right *filing* order and the wrong *attention* order — the
|
|
343
|
+
one you have an agent running on sits wherever its KR happens to fall. This group collects what
|
|
344
|
+
you are actually on, ordered so whatever is **waiting on you** is the first row on the screen.
|
|
345
|
+
- **Three signals, ranked and told apart.** An initiative is in the group because a session is live
|
|
346
|
+
on its `initiative/<id>` branch, or a working tree is checked out on that branch with nothing
|
|
347
|
+
running in it, or you have a tab open on it in this window. They are never merged into one vague
|
|
348
|
+
"active" badge — each row's tooltip says which one put it there, and the two weaker ones say
|
|
349
|
+
plainly that nothing is running.
|
|
350
|
+
- **A live session shows on its own row.** The `#` beside an initiative becomes that session's own
|
|
351
|
+
glyph — `◆ needs you`, `● working`, `◇ maybe blocked`, `◼ quiet` — in the same vocabulary the
|
|
352
|
+
session sidebar uses. Hovering names **every** session on that initiative with its state, rather
|
|
353
|
+
than counting them: the status bar already says how many and never which. Several sessions fold to
|
|
354
|
+
the one that most wants you, and `maybe blocked` outranks `working`.
|
|
355
|
+
- **The glyphs differ in shape, not just colour**, so the board still reads in greyscale and for a
|
|
356
|
+
colour-blind reader.
|
|
357
|
+
- **Nothing is re-homed and nothing is invented.** An initiative still appears under its own
|
|
358
|
+
objective and KR — the group is a shortcut, not a second filing system — and it disappears
|
|
359
|
+
entirely when there is nothing to say. A session PMOS cannot place on an initiative is not placed
|
|
360
|
+
on one.
|
|
361
|
+
- **Searching for a session name now finds it.** It only ever matched initiatives you had already
|
|
362
|
+
opened in that window, and returned nothing for the rest without saying it had not looked.
|
|
363
|
+
|
|
336
364
|
## What's new in 0.21.0
|
|
337
365
|
|
|
338
366
|
**The workspace terminal's lines are no longer double-spaced.** Run
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Scaffold the PMOS portable kit
|
|
3
|
+
"version": "0.24.0",
|
|
4
|
+
"description": "Scaffold the PMOS portable kit \u2014 run the PMOS product-management workflow (initiative \u2192 lane-fit spec \u2192 contract \u2192 build \u2192 three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-pmos": "bin/create-pmos.js"
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
"create",
|
|
26
26
|
"workspace",
|
|
27
27
|
"terminal",
|
|
28
|
-
"gui"
|
|
28
|
+
"gui",
|
|
29
|
+
"workflow",
|
|
30
|
+
"audit"
|
|
29
31
|
]
|
|
30
32
|
}
|
package/payload/AGENTS.md
CHANGED
|
@@ -136,6 +136,13 @@ in **dependency order**, from [templates/ticket-template.md](templates/ticket-te
|
|
|
136
136
|
- Frontmatter: `id` (the NNN), `title`, `status` (`todo | doing | done`), and `blocked_by` — an
|
|
137
137
|
explicit list of the ticket ids that must be `done` first. Blocking edges are **text**, not a
|
|
138
138
|
hidden graph.
|
|
139
|
+
- **Sweep the state you derive from.** `node pmos/scripts/workflow-sweep.js` audits the records and
|
|
140
|
+
the three ledgers everything else reads. Run it before a review and after any bulk edit. It reports
|
|
141
|
+
**defects** (state that contradicts reality — a torn ledger line, a row naming no initiative, a
|
|
142
|
+
duplicate `run_id`, a record pointing at a file nobody wrote) separately from **owed** work (a
|
|
143
|
+
merged initiative with no run logged, an open Acceptance Gate). `--fix` repairs only the mechanical
|
|
144
|
+
defects and writes no `.jsonl` and no judgement — closing your own loops is your job and the
|
|
145
|
+
Acceptance Gate is yours alone (D12).
|
|
139
146
|
- **Work the frontier.** `pmos/scripts/frontier.sh` lists exactly the tickets that are not done and
|
|
140
147
|
whose every blocker is done — the workable set. Start only from there. If it reports the frontier
|
|
141
148
|
is *blocked*, there is a cycle or an undone blocker to resolve before more work is startable.
|
package/payload/KIT-VERSION
CHANGED
package/payload/README.md
CHANGED
|
@@ -36,7 +36,22 @@ personally-owned PMOS backend).
|
|
|
36
36
|
initiative into ordered, dependency-numbered work (file-mode's board — see AGENTS.md).
|
|
37
37
|
- `scripts/` — `gate-initiative.sh` (the fail-closed CI gate), `metrics.py` (north star +
|
|
38
38
|
calibration + Goodhart tripwire), `kit-update.sh` (kernel refresh), `frontier.sh` (lists the
|
|
39
|
-
workable tickets — those not done whose blockers are all done).
|
|
39
|
+
workable tickets — those not done whose blockers are all done), `workflow-sweep.js` (audits the
|
|
40
|
+
state everything above derives from — see below), plus `workflow-state.js`, `anchor-resolve.js`
|
|
41
|
+
and `delivery-index.js`, the readers it and the ladder share.
|
|
42
|
+
- **`scripts/workflow-sweep.js` — the state audit.** Everything PMOS reports is *derived*, which
|
|
43
|
+
makes it self-maintaining and does **not** make it self-correcting: a derivation is only as true
|
|
44
|
+
as what it reads. Run `node pmos/scripts/workflow-sweep.js` to find state that misreports —
|
|
45
|
+
ledger lines that do not parse (every reader drops them silently, so a torn acceptance reads as
|
|
46
|
+
"never accepted"), rows naming an initiative that does not exist, duplicate `run_id`s, records
|
|
47
|
+
pointing at files nobody wrote, merged branches with no record.
|
|
48
|
+
|
|
49
|
+
It separates two things and never mixes them: a **defect** is state that contradicts reality —
|
|
50
|
+
the board is lying — and an **owed** finding is work that honestly has not happened. `--fix`
|
|
51
|
+
repairs only the mechanical defects (a stale pointer, text an earlier commit can restore); it
|
|
52
|
+
**never** writes a run, an eval, an acceptance or any `.jsonl`, because a tool that closes your
|
|
53
|
+
loops by writing the rows produces a green board out of evidence nobody produced. `--gate` exits
|
|
54
|
+
non-zero on defects only, so it can run in CI without failing on your backlog.
|
|
40
55
|
- `templates/pmos-gate.yml` — CI drop-in; copy to `.github/workflows/`.
|
|
41
56
|
- `KIT-VERSION` — which PMOS kernel this was assembled from.
|
|
42
57
|
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/*
|
|
4
|
+
* PMOS delivery evidence — the one place git's history answers "did THIS initiative ship?".
|
|
5
|
+
*
|
|
6
|
+
* const D = require('./delivery-index');
|
|
7
|
+
* const idx = D.deliveryIndex(root, ids); // { ok, reason?, byBranch:Set, byMention:Set }
|
|
8
|
+
* D.attribution(idx, id) // 'branch' | 'mention' | null | 'unknown'
|
|
9
|
+
* D.isDeliveryPath('workspace/server.js') // true — a build path, not planning prose
|
|
10
|
+
*
|
|
11
|
+
* WHY THIS EXISTS. This derivation shipped inside `workspace/server.js` as a module-private
|
|
12
|
+
* function, so it answered for exactly one surface. The engine — `scripts/workflow-state.js`, which
|
|
13
|
+
* CI, the CLI and the workspace ladder all run — could not see it, and derived its `build` rung from
|
|
14
|
+
* a signal carrying no attribution at all: `buildCommits` counted every commit in a range that
|
|
15
|
+
* touched a build path, with no idea which initiative it was answering about. Over a pull-request
|
|
16
|
+
* range that is correct, because the range IS one initiative's work. Over the trunk it reported the
|
|
17
|
+
* same 488 commits as evidence for all 177 initiatives, including ones that shipped nothing.
|
|
18
|
+
*
|
|
19
|
+
* So the GUI's initiative board and the engine's ladder held two different opinions about the same
|
|
20
|
+
* question, in the same process, three hundred lines apart — one attributed, one not. That is the
|
|
21
|
+
* shape `scripts/anchor-resolve.js` was extracted to end for `parent_kr`, stated in its own header,
|
|
22
|
+
* and delivery is the same kind of fact. See
|
|
23
|
+
* planning/explainers/workflow-status-sweep-surface-explainer.md.
|
|
24
|
+
*
|
|
25
|
+
* TWO SIGNALS, because one is not enough (board-delivery-truth R-2):
|
|
26
|
+
*
|
|
27
|
+
* 1. THE BRANCH CONVENTION is precise and incomplete. A merge whose subject names
|
|
28
|
+
* `initiative/<id>` is unambiguous — but 31% of this repository's merged pull requests shipped
|
|
29
|
+
* on `claude/*` branches, which match no id, and a convention nothing enforces cannot be the
|
|
30
|
+
* only signal.
|
|
31
|
+
*
|
|
32
|
+
* 2. A COMMIT THAT MENTIONS THE ID AND TOUCHES A BUILD PATH. The build-path requirement is what
|
|
33
|
+
* keeps the mention signal honest: `feat(planning): add <id> PRD` mentions the id while
|
|
34
|
+
* delivering nothing, and without the path test every SPECIFIED initiative would read as
|
|
35
|
+
* shipped — which is the same class of false green this module exists to remove.
|
|
36
|
+
*
|
|
37
|
+
* HONESTY BY CONSTRUCTION. `ok:false` is a real answer and never degrades into "nothing was
|
|
38
|
+
* delivered". A caller that cannot read the history must say so; silently reporting `planning` for
|
|
39
|
+
* every shipped initiative is precisely the failure being fixed. `attribution()` returns the string
|
|
40
|
+
* `'unknown'` for that case, and it is deliberately not `null` — "I cannot tell" and "I looked and
|
|
41
|
+
* there is nothing" call for opposite responses from every caller.
|
|
42
|
+
*
|
|
43
|
+
* Zero dependencies, stock Node. It runs in CI, in a terminal, inside the workspace server, and
|
|
44
|
+
* inside an adopter's vendored kit.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const { spawnSync } = require('child_process');
|
|
48
|
+
|
|
49
|
+
/*
|
|
50
|
+
* A path counts as DELIVERY when it is neither a planning artifact nor governance prose.
|
|
51
|
+
*
|
|
52
|
+
* This is deliberately a SEPARATE rule from the engine's `isBuildPath`, and the difference is not an
|
|
53
|
+
* oversight. `isBuildPath` answers "is this commit implementation work for the lane under review?"
|
|
54
|
+
* and therefore lane-gates `prototype/` — in the prototype lane the specimen IS the deliverable
|
|
55
|
+
* (D60), in every other lane it is somebody else's leg-A work. This answers a coarser question
|
|
56
|
+
* asked of the whole trunk at once, where no single lane is under review, so `prototype/` is always
|
|
57
|
+
* excluded: a prototype never ships (D01), so a commit touching only `prototype/` is never evidence
|
|
58
|
+
* that something was delivered. Merging the two rules would force one of the two questions to be
|
|
59
|
+
* answered wrongly. They are kept apart, and each says why in the place it is written.
|
|
60
|
+
*/
|
|
61
|
+
const EXCLUDED_PREFIX = /^(planning|okf|prototype)\//;
|
|
62
|
+
function isDeliveryPath(f) {
|
|
63
|
+
if (!f) return false;
|
|
64
|
+
if (EXCLUDED_PREFIX.test(f)) return false;
|
|
65
|
+
// Root-level governance prose (DECISIONS.md, CLAUDE.md, AGENTS.md…) is not a build. A markdown
|
|
66
|
+
// file deeper in the tree may well be — `workspace/docs/*.md` ships with the app — so the test is
|
|
67
|
+
// on the shape of the path, not on the extension alone.
|
|
68
|
+
return !/^[^/]+\.md$/.test(f);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function gitCap(root, args) {
|
|
72
|
+
try {
|
|
73
|
+
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
74
|
+
if (r.error || r.status !== 0) {
|
|
75
|
+
return { ok: false, out: '', err: (r.stderr || (r.error && r.error.message) || '').trim() };
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, out: r.stdout || '' };
|
|
78
|
+
} catch (e) { return { ok: false, out: '', err: String((e && e.message) || e) }; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function trunkRef(root) {
|
|
82
|
+
for (const c of ['origin/main', 'main', 'origin/master', 'master']) {
|
|
83
|
+
const r = gitCap(root, ['rev-parse', '--verify', '--quiet', c]);
|
|
84
|
+
if (r.ok && r.out.trim()) return { ref: c, head: r.out.trim() };
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ONE `git log` per trunk head, memoised on that sha — ~0.3 s and ~800 KB on this repository's
|
|
90
|
+
// 1,200 commits, paid once and reused by every caller until the trunk moves. The cache key is the
|
|
91
|
+
// head sha rather than the root, so a fetch that advances the trunk invalidates it by construction
|
|
92
|
+
// rather than by anyone remembering to.
|
|
93
|
+
const deliveryCache = new Map(); // root -> { head, index }
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Which initiatives the trunk's own history says were delivered.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} root repository root
|
|
99
|
+
* @param {Iterable<string>} ids the known initiative ids — the mention signal can only recognise
|
|
100
|
+
* ids it was told about, and matching arbitrary tokens would turn
|
|
101
|
+
* any commit subject into a delivery claim
|
|
102
|
+
* @returns {{ok:boolean, reason?:string, byBranch:Set<string>, byMention:Set<string>}}
|
|
103
|
+
*/
|
|
104
|
+
function deliveryIndex(root, ids) {
|
|
105
|
+
const t = trunkRef(root);
|
|
106
|
+
if (!t) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
reason: 'no trunk branch (main/master) is reachable from this repository — delivery cannot be derived',
|
|
110
|
+
byBranch: new Set(), byMention: new Set(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const hit = deliveryCache.get(root);
|
|
114
|
+
if (hit && hit.head === t.head) return hit.index;
|
|
115
|
+
|
|
116
|
+
// %x1e separates commits, %x1f separates the message from --name-only's file list.
|
|
117
|
+
const r = gitCap(root, ['log', '--format=%x1e%s %b%x1f', '--name-only', t.head]);
|
|
118
|
+
if (!r.ok) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
reason: `git could not read the history at ${t.ref}${r.err ? ` — ${r.err}` : ''}`,
|
|
122
|
+
byBranch: new Set(), byMention: new Set(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const byBranch = new Set();
|
|
126
|
+
const byMention = new Set();
|
|
127
|
+
const known = Array.isArray(ids) ? ids : [...(ids || [])];
|
|
128
|
+
for (const chunk of r.out.split('\x1e')) {
|
|
129
|
+
if (!chunk.trim()) continue;
|
|
130
|
+
const cut = chunk.indexOf('\x1f');
|
|
131
|
+
const msg = cut < 0 ? chunk : chunk.slice(0, cut);
|
|
132
|
+
const files = cut < 0 ? [] : chunk.slice(cut + 1).split('\n').map((s) => s.trim()).filter(Boolean);
|
|
133
|
+
for (const m of msg.matchAll(/initiative\/([A-Za-z0-9][\w.-]*)/g)) byBranch.add(m[1]);
|
|
134
|
+
if (!files.some(isDeliveryPath)) continue;
|
|
135
|
+
// `id.length > 3` guards against a short id matching incidentally inside an unrelated word.
|
|
136
|
+
// It is a heuristic and is named as one in the PRD's risks; a miss reads as `pending`, which is
|
|
137
|
+
// the safe direction and the one the surface already errs in.
|
|
138
|
+
for (const id of known) if (id.length > 3 && msg.includes(id)) byMention.add(id);
|
|
139
|
+
}
|
|
140
|
+
const index = { ok: true, byBranch, byMention };
|
|
141
|
+
deliveryCache.set(root, { head: t.head, index });
|
|
142
|
+
return index;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/*
|
|
146
|
+
* The branches that actually MERGED — a strictly narrower signal than `byBranch`, and the two are
|
|
147
|
+
* kept apart on purpose.
|
|
148
|
+
*
|
|
149
|
+
* `deliveryIndex` scans every commit's subject AND body, because a squash-merged pull request often
|
|
150
|
+
* names its branch only in the body. That breadth is right for ATTRIBUTION, where the question is
|
|
151
|
+
* "is there evidence this initiative's work landed" and a stray match against an id that already has
|
|
152
|
+
* a record costs nothing.
|
|
153
|
+
*
|
|
154
|
+
* It is wrong for any caller that RE-CREATES something from the match. A commit body quoting
|
|
155
|
+
* `initiative/foo` inside a code example is a real occurrence in this repository's history, and a
|
|
156
|
+
* caller that wrote a record for every token it found would manufacture initiatives named `foo`,
|
|
157
|
+
* `save_okr` and `pmos-operating-guide.` out of prose. So this reads MERGE SUBJECTS only, and
|
|
158
|
+
* requires the id to have the shape ids actually have. On this repository that is the difference
|
|
159
|
+
* between 4 fabricated records and the 1 that is genuinely missing.
|
|
160
|
+
*/
|
|
161
|
+
const ID_SHAPE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
162
|
+
function mergedBranches(root) {
|
|
163
|
+
const t = trunkRef(root);
|
|
164
|
+
if (!t) return { ok: false, reason: 'no trunk branch (main/master) is reachable from this repository', ids: new Map() };
|
|
165
|
+
const r = gitCap(root, ['log', '--merges', '--format=%x1e%H %ct%x1f%s', t.head]);
|
|
166
|
+
if (!r.ok) return { ok: false, reason: `git could not read the merge history at ${t.ref}`, ids: new Map() };
|
|
167
|
+
const ids = new Map(); // id -> { sha, at (unix s), subject }
|
|
168
|
+
for (const chunk of r.out.split('\x1e')) {
|
|
169
|
+
if (!chunk.trim()) continue;
|
|
170
|
+
const [head, subject] = chunk.split('\x1f');
|
|
171
|
+
const [sha, ct] = String(head || '').trim().split(/\s+/);
|
|
172
|
+
const subj = String(subject || '').replace(/\s+/g, ' ').trim();
|
|
173
|
+
/*
|
|
174
|
+
* A BACK-merge — `merge: main into initiative/x` — merges the trunk INTO the branch, and it is
|
|
175
|
+
* still EVIDENCE THE BRANCH LANDED: it is only reachable from the trunk at all because the
|
|
176
|
+
* branch was subsequently merged in. What it must not do is supply the record's title and date,
|
|
177
|
+
* because it describes mid-flight housekeeping rather than the work, and its date is the
|
|
178
|
+
* housekeeping's. So it counts, and a landing merge outranks it whenever one exists. The
|
|
179
|
+
* direction is stated in the subject, so this is a fact rather than a guess.
|
|
180
|
+
*/
|
|
181
|
+
const back = /\binto\s+\S*initiative\//i.test(subj);
|
|
182
|
+
for (const m of subj.matchAll(/initiative\/([A-Za-z0-9][\w.-]*)/g)) {
|
|
183
|
+
const id = m[1].replace(/[.,;`'")\]]+$/, '');
|
|
184
|
+
if (!ID_SHAPE.test(id)) continue;
|
|
185
|
+
const held = ids.get(id);
|
|
186
|
+
// The log runs newest-first, so the first LANDING merge seen is the most recent one — and it
|
|
187
|
+
// always wins over a back-merge, whenever it is found.
|
|
188
|
+
if (held && (!held.back || back)) continue;
|
|
189
|
+
ids.set(id, { sha: sha || null, at: Number.isFinite(parseInt(ct, 10)) ? parseInt(ct, 10) : null, subject: subj, back });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { ok: true, ids };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/*
|
|
196
|
+
* `delivered_by:` — the PM's lever, and the check that keeps it from becoming a status field (R-2b).
|
|
197
|
+
*
|
|
198
|
+
* Both signals above are CONVENTIONS. Work delivered under a neighbouring initiative's branch and
|
|
199
|
+
* commit names satisfies neither, and on this repository that is not hypothetical: the initiative the
|
|
200
|
+
* PM opened has a PRD, a rubric and shipped code, and no commit on the trunk names it. `null` is the
|
|
201
|
+
* honest reading of the evidence and it leaves the PM holding a true statement with no way to correct
|
|
202
|
+
* it.
|
|
203
|
+
*
|
|
204
|
+
* So a record may name the commit its work landed in. The reason this does not reintroduce the
|
|
205
|
+
* declared status that D64 and the engine's own header forbid: `delivered_by` declares a **fact git
|
|
206
|
+
* can check**, in the shape `reconstructed_from:` already established for a merge date — not a
|
|
207
|
+
* verdict about where the work stands. The check is the whole point, and it is deliberately strict in
|
|
208
|
+
* BOTH directions:
|
|
209
|
+
*
|
|
210
|
+
* · reachable from the trunk AND touching at least one build path → `{ok:true}`; the caller
|
|
211
|
+
* attributes with signal `declared` and shows the sha it verified.
|
|
212
|
+
* · unreachable, unparseable, or reachable while touching nothing that counts as a build →
|
|
213
|
+
* `{ok:false, reason}`. The caller renders that BLOCKED, never `done`. A declaration that fails
|
|
214
|
+
* its own check is worse than no declaration, because a reader takes it for evidence.
|
|
215
|
+
*/
|
|
216
|
+
function declaredDelivery(root, declared) {
|
|
217
|
+
const raw = String(declared == null ? '' : declared).trim();
|
|
218
|
+
if (!raw) return { declared: false };
|
|
219
|
+
// Take the leading token: the field is `<commit-ish> — free prose`, in the same shape as the
|
|
220
|
+
// record's other declared keys, so the prose stays available to the PM without breaking the check.
|
|
221
|
+
const token = raw.split(/[\s,]/)[0].replace(/[.,;]+$/, '');
|
|
222
|
+
if (!token) return { declared: true, ok: false, reason: 'delivered_by is present but names nothing' };
|
|
223
|
+
const t = trunkRef(root);
|
|
224
|
+
if (!t) {
|
|
225
|
+
return { declared: true, ok: false, unknown: true, reason: 'no trunk branch (main/master) is reachable — delivered_by cannot be verified here' };
|
|
226
|
+
}
|
|
227
|
+
const rev = gitCap(root, ['rev-parse', '--verify', '--quiet', `${token}^{commit}`]);
|
|
228
|
+
if (!rev.ok || !rev.out.trim()) {
|
|
229
|
+
return { declared: true, ok: false, token, reason: `delivered_by names '${token}', which is not a commit in this repository` };
|
|
230
|
+
}
|
|
231
|
+
const sha = rev.out.trim();
|
|
232
|
+
const anc = gitCap(root, ['merge-base', '--is-ancestor', sha, t.head]);
|
|
233
|
+
if (!anc.ok) {
|
|
234
|
+
return { declared: true, ok: false, token, sha, reason: `delivered_by names ${sha.slice(0, 8)}, which is not reachable from ${t.ref} — it has not landed on the trunk` };
|
|
235
|
+
}
|
|
236
|
+
const files = gitCap(root, ['show', '--format=', '--name-only', sha]);
|
|
237
|
+
const touched = files.ok ? files.out.split('\n').map((s) => s.trim()).filter(Boolean) : [];
|
|
238
|
+
if (!touched.some(isDeliveryPath)) {
|
|
239
|
+
return {
|
|
240
|
+
declared: true, ok: false, token, sha,
|
|
241
|
+
reason: `delivered_by names ${sha.slice(0, 8)}, which changes no build path — naming a planning commit as the delivery is the false green this check exists to catch`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return { declared: true, ok: true, token, sha };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Which signal — if any — attributes a delivery to this initiative.
|
|
249
|
+
*
|
|
250
|
+
* `'unknown'` is returned when the index could not be built, and is deliberately NOT `null`. A
|
|
251
|
+
* caller that collapses the two reports "nothing was delivered" for a repository whose history it
|
|
252
|
+
* failed to read, which is a claim the missing evidence cannot support.
|
|
253
|
+
*
|
|
254
|
+
* @returns {'branch'|'mention'|'unknown'|null}
|
|
255
|
+
*/
|
|
256
|
+
function attribution(index, id) {
|
|
257
|
+
if (!index || !index.ok) return 'unknown';
|
|
258
|
+
if (index.byBranch.has(id)) return 'branch';
|
|
259
|
+
if (index.byMention.has(id)) return 'mention';
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The sentence a surface shows for an attribution, so no caller writes its own wording. */
|
|
264
|
+
function attributionEvidence(signal, id, extra) {
|
|
265
|
+
if (signal === 'branch') return `an initiative/${id} branch merged to the trunk`;
|
|
266
|
+
if (signal === 'mention') return 'a commit on the trunk names this initiative and changes build files';
|
|
267
|
+
if (signal === 'declared') return `delivered_by ${String(extra || '').slice(0, 8)} — verified on the trunk, and it changes build paths`;
|
|
268
|
+
if (signal === 'unknown') return 'the trunk history could not be read — delivery cannot be derived here';
|
|
269
|
+
return 'no commit on the trunk is attributable to this initiative';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Exported for tests that need a clean read after mutating a fixture's history in place.
|
|
273
|
+
function _resetCache() { deliveryCache.clear(); }
|
|
274
|
+
|
|
275
|
+
module.exports = {
|
|
276
|
+
deliveryIndex, attribution, attributionEvidence, declaredDelivery, mergedBranches,
|
|
277
|
+
isDeliveryPath, trunkRef, _resetCache,
|
|
278
|
+
};
|