create-pmos 0.22.0 → 0.23.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 +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/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/package-lock.json +2 -2
- package/workspace/package.json +3 -3
- package/workspace/server.js +38 -51
|
@@ -170,28 +170,43 @@ function workflow({ root }) {
|
|
|
170
170
|
catch (e) { return { ok: false, error: 'could not list initiative records: ' + String((e && e.message) || 'error').slice(0, 120) }; }
|
|
171
171
|
if (!ids.length) return { ok: true, empty: true, source: 'file-mode' };
|
|
172
172
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
173
|
+
/*
|
|
174
|
+
* THE TRUNK, not the pull request (workflow-status-sweep R-3).
|
|
175
|
+
*
|
|
176
|
+
* This used to be `engine.commitRange(root)` — merge-base(origin/main, HEAD)..HEAD — and the trap
|
|
177
|
+
* is documented three lines below in words that were written and then not acted on: on `main`, or
|
|
178
|
+
* on a branch level with it, that range is EMPTY with `range.ok === true`. Nothing reads as
|
|
179
|
+
* degraded, every `build` rung legitimately reports `pending`, and the four-panel fold rolls
|
|
180
|
+
* BUILD/EVAL/ACCEPTED to 0/3 for every initiative in the repository — including ones that shipped
|
|
181
|
+
* months ago. The PM opens the panel to ask what is delivered and is told nothing is.
|
|
182
|
+
*
|
|
183
|
+
* So the panel asks the question the CLI board already asks — "where does this initiative stand?",
|
|
184
|
+
* which only the trunk can answer — and passes the PR range alongside as `changeRange`, which is
|
|
185
|
+
* what `● in this change` means and the one thing the trunk range genuinely cannot answer. Same
|
|
186
|
+
* split, same reasons, stated in `historyRange`'s own header. The `build` rung is attributed per
|
|
187
|
+
* initiative now (R-2), so a trunk-wide range no longer means a trunk-wide answer.
|
|
188
|
+
*/
|
|
189
|
+
const range = engine.historyRange(root);
|
|
190
|
+
const changeRange = engine.commitRange(root);
|
|
191
|
+
// ONE delivery index for the board, built once and shared, exactly as the CLI board does.
|
|
192
|
+
let deliveryIndex = null;
|
|
193
|
+
try { deliveryIndex = require(path.join(path.dirname(loaded.from), 'delivery-index.js')).deliveryIndex(root, ids); }
|
|
194
|
+
catch { deliveryIndex = null; } // absent → each ladder builds its own; the engine handles it
|
|
182
195
|
const bi = gitInfo.branchInfo(root);
|
|
183
196
|
const scope = {
|
|
184
197
|
branch: bi.ok ? (bi.current || 'a detached HEAD') : null,
|
|
185
198
|
branch_error: bi.ok ? null : (bi.error || 'branch could not be read'),
|
|
186
199
|
commits: range.ok ? range.shas.length : null,
|
|
187
200
|
whole_history: range.ok ? !!range.wholeHistory : null,
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
201
|
+
// Kept, and now describing `changeRange` rather than the derivation range. It is no longer the
|
|
202
|
+
// trap it names — the derivation cannot land in it — but a consumer that wants to say "nothing
|
|
203
|
+
// in this branch touches these initiatives" still needs the fact, and deleting a field because
|
|
204
|
+
// the bug it documented was fixed is how the next reader loses the reason.
|
|
205
|
+
empty_range: changeRange.ok ? changeRange.shas.length === 0 : null,
|
|
206
|
+
change_commits: changeRange.ok ? changeRange.shas.length : null,
|
|
192
207
|
};
|
|
193
208
|
const rows = ids.map((id) => {
|
|
194
|
-
try { return engine.computeWorkflow(id, { root, range }); }
|
|
209
|
+
try { return engine.computeWorkflow(id, { root, range, changeRange, deliveryIndex }); }
|
|
195
210
|
catch (e) { return { ok: false, id, error: String((e && e.message) || 'derivation failed').slice(0, 160) }; }
|
|
196
211
|
});
|
|
197
212
|
// Same `inFlight` the CLI and the gate use — the panel must not invent its own idea of what is
|
|
@@ -249,10 +264,14 @@ function ladder({ root, id }) {
|
|
|
249
264
|
let known = null;
|
|
250
265
|
try { known = engine.listInitiatives(root); } catch { known = null; }
|
|
251
266
|
if (Array.isArray(known) && !known.includes(id)) return { ok: true, empty: true, row: null };
|
|
252
|
-
|
|
253
|
-
|
|
267
|
+
// The trunk, for the reason `workflow` above states at length: this is asked about ONE initiative
|
|
268
|
+
// the PM named, and "where does it stand" is a question the pull-request range cannot answer — on
|
|
269
|
+
// main it is empty, so the panel reported `BUILD 0/3` on delivered work (R-3).
|
|
270
|
+
let range, changeRange, row;
|
|
271
|
+
try { range = engine.historyRange(root); }
|
|
254
272
|
catch (e) { return { ok: false, error: 'could not resolve the commit range: ' + String((e && e.message) || 'error').slice(0, 120) }; }
|
|
255
|
-
try {
|
|
273
|
+
try { changeRange = engine.commitRange(root); } catch { changeRange = { ok: false }; }
|
|
274
|
+
try { row = engine.computeWorkflow(id, { root, range, changeRange }); }
|
|
256
275
|
catch (e) { return { ok: false, error: `could not derive the ladder for ${id}: ` + String((e && e.message) || 'error').slice(0, 160) }; }
|
|
257
276
|
if (row && row.ok === false) return { ok: false, error: row.error || `the record for ${id} could not be read` };
|
|
258
277
|
const bi = gitInfo.branchInfo(root);
|
|
@@ -264,7 +283,9 @@ function ladder({ root, id }) {
|
|
|
264
283
|
branch: bi.ok ? (bi.current || 'a detached HEAD') : null,
|
|
265
284
|
branch_error: bi.ok ? null : (bi.error || 'branch could not be read'),
|
|
266
285
|
commits: range.ok ? range.shas.length : null,
|
|
267
|
-
|
|
286
|
+
whole_history: range.ok ? !!range.wholeHistory : null,
|
|
287
|
+
empty_range: changeRange.ok ? changeRange.shas.length === 0 : null,
|
|
288
|
+
change_commits: changeRange.ok ? changeRange.shas.length : null,
|
|
268
289
|
},
|
|
269
290
|
};
|
|
270
291
|
}
|
|
@@ -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
|
+
};
|
|
@@ -45,6 +45,15 @@ const { execFileSync } = require('child_process');
|
|
|
45
45
|
* and the OKR surface previously held separate, incompatible opinions about the same field.
|
|
46
46
|
*/
|
|
47
47
|
const anchors = require('./anchor-resolve');
|
|
48
|
+
/*
|
|
49
|
+
* ONE reader for delivery too (workflow-status-sweep R-1). `buildCommits` used to answer "is there a
|
|
50
|
+
* build commit in this range" — a question about a RANGE — while both of its callers asked a
|
|
51
|
+
* question about an INITIATIVE. Over a pull-request range those coincide, because the range IS one
|
|
52
|
+
* initiative's work; over the trunk they do not, and every initiative read `done` with the same 488
|
|
53
|
+
* commits of evidence. The attribution signal already existed, module-private, inside the workspace
|
|
54
|
+
* server. It is shared now, for the same reason `anchor-resolve` is.
|
|
55
|
+
*/
|
|
56
|
+
const delivery = require('./delivery-index');
|
|
48
57
|
|
|
49
58
|
// The OKR documents and the budget registry are read once per root, not once per initiative: the
|
|
50
59
|
// board derives ~100 ladders in a single pass and re-reading the same three files each time is the
|
|
@@ -114,14 +123,34 @@ function parseLeadingFrontmatter(text) {
|
|
|
114
123
|
function readFrontmatter(file) {
|
|
115
124
|
try { return parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
116
125
|
}
|
|
117
|
-
|
|
126
|
+
/*
|
|
127
|
+
* The ledger reader, and the one place a torn line is REPORTED rather than merely dropped (R-4).
|
|
128
|
+
*
|
|
129
|
+
* A line that does not parse is still never guessed at — that part was always right. What was wrong
|
|
130
|
+
* was the silence: a truncated acceptance row did not make the ladder say "a row here is
|
|
131
|
+
* unreadable", it made the ladder say the initiative was never accepted. The file's own honesty rule
|
|
132
|
+
* ("a gate that cannot see its inputs reports that it cannot see them") held for git ranges and not
|
|
133
|
+
* for ledger lines.
|
|
134
|
+
*
|
|
135
|
+
* The channel is out-of-band — an optional `defects` array the caller passes in — rather than a
|
|
136
|
+
* changed return type, because every existing call site wants the rows and only the sweep wants the
|
|
137
|
+
* damage. Changing the shape would have made ~six callers handle a case none of them can act on.
|
|
138
|
+
*/
|
|
139
|
+
function readJsonl(file, defects) {
|
|
118
140
|
let text;
|
|
119
141
|
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
120
142
|
const out = [];
|
|
121
|
-
|
|
122
|
-
|
|
143
|
+
const lines = text.split('\n');
|
|
144
|
+
for (let i = 0; i < lines.length; i++) {
|
|
145
|
+
const t = lines[i].trim();
|
|
123
146
|
if (!t) continue;
|
|
124
|
-
try { out.push(JSON.parse(t)); }
|
|
147
|
+
try { out.push(JSON.parse(t)); }
|
|
148
|
+
catch (e) {
|
|
149
|
+
// Dropped, never guessed — and now said out loud to anyone who asked to hear it.
|
|
150
|
+
if (Array.isArray(defects)) {
|
|
151
|
+
defects.push({ file, line: i + 1, reason: String((e && e.message) || 'not valid JSON').slice(0, 120), text: t.slice(0, 120) });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
125
154
|
}
|
|
126
155
|
return out;
|
|
127
156
|
}
|
|
@@ -211,21 +240,27 @@ function buildRange(root, { spec, whole }) {
|
|
|
211
240
|
}
|
|
212
241
|
range = base ? `${base}..HEAD` : 'HEAD';
|
|
213
242
|
}
|
|
214
|
-
|
|
243
|
+
// %x1e separates commits; %x1f separates the header from the message and the message from
|
|
244
|
+
// --name-only's file list. The MESSAGE is captured in this same pass because attribution over the
|
|
245
|
+
// trunk needs it (R-2) — a second `git log` per initiative is exactly the O(commits × initiatives)
|
|
246
|
+
// cost this single-pass rewrite existed to remove.
|
|
247
|
+
const log = git(root, ['log', '--reverse', '--format=%x1e%H %ct%x1f%s %b%x1f', '--name-only', range]);
|
|
215
248
|
if (log == null) return { ok: false, reason: 'git history unreadable' };
|
|
216
249
|
|
|
217
250
|
const shas = [];
|
|
218
251
|
const changed = []; // index -> string[] of paths touched
|
|
219
252
|
const dates = []; // index -> commit unix seconds (what `legacy` is decided by)
|
|
253
|
+
const subjects = []; // index -> subject + body, one line, for attribution
|
|
220
254
|
for (const chunk of log.split('\x1e')) {
|
|
221
255
|
if (!chunk.trim()) continue;
|
|
222
|
-
const
|
|
223
|
-
const head = (
|
|
256
|
+
const parts = chunk.split('\x1f');
|
|
257
|
+
const head = (parts[0] || '').trim();
|
|
224
258
|
const [sha, ct] = head.split(/\s+/);
|
|
225
259
|
if (!sha) continue;
|
|
226
260
|
shas.push(sha);
|
|
227
261
|
dates.push(Number.isFinite(parseInt(ct, 10)) ? parseInt(ct, 10) : null);
|
|
228
|
-
|
|
262
|
+
subjects.push(String(parts[1] || '').replace(/\s+/g, ' ').trim());
|
|
263
|
+
changed.push(String(parts[2] || '').split('\n').map((s) => s.trim()).filter(Boolean));
|
|
229
264
|
}
|
|
230
265
|
const index = new Map();
|
|
231
266
|
shas.forEach((sha, i) => index.set(sha, i));
|
|
@@ -245,7 +280,7 @@ function buildRange(root, { spec, whole }) {
|
|
|
245
280
|
}
|
|
246
281
|
}
|
|
247
282
|
}
|
|
248
|
-
return { ok: true, base, index, shas, changed, dates, added, wholeHistory: whole ? true : !base };
|
|
283
|
+
return { ok: true, base, index, shas, changed, dates, subjects, added, wholeHistory: whole ? true : !base };
|
|
249
284
|
}
|
|
250
285
|
|
|
251
286
|
/*
|
|
@@ -273,11 +308,58 @@ function firstAppearance(root, range, rel) {
|
|
|
273
308
|
return range.index.has(sha) ? range.index.get(sha) : -1;
|
|
274
309
|
}
|
|
275
310
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
311
|
+
/*
|
|
312
|
+
* The earliest position in the range at which this initiative's implementation was touched, plus a
|
|
313
|
+
* count. A docs-only PR has no build commits, which makes every "before the build" rule vacuous —
|
|
314
|
+
* reported as n/a rather than as a pass it did not earn.
|
|
315
|
+
*
|
|
316
|
+
* ATTRIBUTION (R-2). This function used to take no id, so it answered a question about the RANGE
|
|
317
|
+
* while both of its callers asked one about an INITIATIVE. Which of those is right depends entirely
|
|
318
|
+
* on what the range is, and the two cases are answered differently on purpose:
|
|
319
|
+
*
|
|
320
|
+
* · A PULL-REQUEST RANGE (`base..HEAD`) — the range IS this initiative's work, by the branch
|
|
321
|
+
* convention the Initiative Gate already enforces. Any build commit in it is therefore this
|
|
322
|
+
* initiative's build commit, and the O1/O2 order rules depend on exactly that reading. This path
|
|
323
|
+
* is UNCHANGED, deliberately: the gate's verdicts must not move for a reporting fix.
|
|
324
|
+
*
|
|
325
|
+
* · THE WHOLE TRUNK (`range.wholeHistory`) — the range is everyone's work. Attribution comes from
|
|
326
|
+
* `delivery-index`: a merged `initiative/<id>` branch, or a commit that names the id and touches
|
|
327
|
+
* a build path. Without it every initiative reported `done` with the identical count, which is
|
|
328
|
+
* the tell that a per-initiative fact was being read from a repository-wide one.
|
|
329
|
+
*
|
|
330
|
+
* `signal` travels back with the answer so the rung can say WHICH evidence decided it. A bare count
|
|
331
|
+
* is what let 488 stand as an explanation for 177 different initiatives.
|
|
332
|
+
*/
|
|
333
|
+
function buildCommits(root, range, lane, opts) {
|
|
280
334
|
if (!range.ok) return { unknown: true };
|
|
335
|
+
const id = opts && opts.id;
|
|
336
|
+
const whole = !!range.wholeHistory;
|
|
337
|
+
|
|
338
|
+
// Over the trunk, the id must be attributable before any commit counts. The index is built once
|
|
339
|
+
// per trunk head by the shared reader and passed in by the board; a caller that did not supply one
|
|
340
|
+
// gets it built here (single-ladder invocations, where one extra `git log` is the whole cost).
|
|
341
|
+
let signal = null;
|
|
342
|
+
let declared = null;
|
|
343
|
+
if (whole && id) {
|
|
344
|
+
// The PM's declaration is consulted FIRST, and its failure is reported before anything else
|
|
345
|
+
// (R-2b). A `delivered_by` that does not check out must not be able to hide behind a convention
|
|
346
|
+
// signal that happens to succeed: a broken declaration is a defect in the record either way, and
|
|
347
|
+
// silently ignoring it would leave a false pointer sitting in the file forever.
|
|
348
|
+
declared = delivery.declaredDelivery(root, opts && opts.declaredDelivery);
|
|
349
|
+
if (declared && declared.declared && !declared.ok) {
|
|
350
|
+
return { unknown: false, failedDeclaration: declared, first: null, count: 0, sample: null, signal: null, attributed: true };
|
|
351
|
+
}
|
|
352
|
+
const index = (opts && opts.deliveryIndex) || delivery.deliveryIndex(root, listInitiatives(root));
|
|
353
|
+
signal = delivery.attribution(index, id);
|
|
354
|
+
if (declared && declared.ok) {
|
|
355
|
+
// A verified declaration attributes the work regardless of what the conventions found. It is
|
|
356
|
+
// the PM naming the commit, checked against git — strictly better evidence than a heuristic.
|
|
357
|
+
return { first: null, count: 0, sample: null, signal: 'declared', declaredSha: declared.sha, attributed: true, mergedOnly: true };
|
|
358
|
+
}
|
|
359
|
+
if (signal === 'unknown') return { unknown: true, reason: delivery.attributionEvidence('unknown', id) };
|
|
360
|
+
if (!signal) return { first: null, count: 0, sample: null, signal: null, attributed: true };
|
|
361
|
+
}
|
|
362
|
+
|
|
281
363
|
let first = null; let count = 0; let sample = null;
|
|
282
364
|
for (let i = 0; i < range.shas.length; i++) {
|
|
283
365
|
// range.changed came from the range's own single pass; the per-commit `git show` this replaced
|
|
@@ -285,10 +367,29 @@ function buildCommits(root, range, lane) {
|
|
|
285
367
|
const files = (range.changed && range.changed[i]) || [];
|
|
286
368
|
const hit = files.find((f) => isBuildPath(f, lane));
|
|
287
369
|
if (!hit) continue;
|
|
370
|
+
// Over the trunk, only commits that carry the id themselves are this initiative's. The branch
|
|
371
|
+
// signal is a MERGE subject, so the merge commit is the one that names it — which makes the
|
|
372
|
+
// merge position the honest "when did this land", and keeps a squashed or rebased history
|
|
373
|
+
// answerable at all. `--name-only` lists no files for a true merge commit, so a branch-attributed
|
|
374
|
+
// initiative is counted from its merge subject below rather than from this per-file loop.
|
|
375
|
+
if (whole && id && !commitNamesId(range, i, id)) continue;
|
|
288
376
|
count++;
|
|
289
377
|
if (first === null) { first = i; sample = hit; }
|
|
290
378
|
}
|
|
291
|
-
|
|
379
|
+
if (whole && id && signal === 'branch' && first === null) {
|
|
380
|
+
// The branch merged and every commit it carried was written on the branch, so no trunk commit
|
|
381
|
+
// subject names the id. The merge itself is the evidence, and saying "0 commits" over a merged
|
|
382
|
+
// branch would be the under-claim in a new place.
|
|
383
|
+
return { first: null, count: 0, sample: null, signal, attributed: true, mergedOnly: true };
|
|
384
|
+
}
|
|
385
|
+
return { first, count, sample, signal, attributed: whole && !!id };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Whether the commit at `i` names this initiative in its own subject or body. The subjects come from
|
|
389
|
+
// the range's own single pass, so this costs nothing per initiative.
|
|
390
|
+
function commitNamesId(range, i, id) {
|
|
391
|
+
const msg = (range.subjects && range.subjects[i]) || '';
|
|
392
|
+
return msg.includes(id);
|
|
292
393
|
}
|
|
293
394
|
|
|
294
395
|
// ── Declaration readers ────────────────────────────────────────────────────────────────────────
|
|
@@ -471,7 +572,11 @@ function computeWorkflow(id, opts) {
|
|
|
471
572
|
const prd = String(fm.prd_path || '').trim();
|
|
472
573
|
const rubric = String(fm.rubric_path || '').trim();
|
|
473
574
|
const prdIdx = prd ? firstAppearance(root, range, prd) : null;
|
|
474
|
-
const build = buildCommits(root, range, effLane
|
|
575
|
+
const build = buildCommits(root, range, effLane, {
|
|
576
|
+
id,
|
|
577
|
+
deliveryIndex: opts && opts.deliveryIndex,
|
|
578
|
+
declaredDelivery: fm.delivered_by,
|
|
579
|
+
});
|
|
475
580
|
|
|
476
581
|
// Shared helper: "A must not come after B", stated as positions, never as clock time.
|
|
477
582
|
function ordered(aIdx, bIdx) {
|
|
@@ -716,11 +821,37 @@ function computeWorkflow(id, opts) {
|
|
|
716
821
|
}
|
|
717
822
|
|
|
718
823
|
// ── build ────────────────────────────────────────────────────────────────────────────────────
|
|
824
|
+
/*
|
|
825
|
+
* The evidence names WHICH signal decided, never a bare count (R-2). `488 commit(s) on
|
|
826
|
+
* implementation paths` was true of the repository and said nothing about the initiative it was
|
|
827
|
+
* printed under — the identical number on every row was the tell.
|
|
828
|
+
*
|
|
829
|
+
* The three pending sentences below are deliberately different from each other. "No commit on the
|
|
830
|
+
* trunk is attributable to this initiative" is a statement about the INITIATIVE; "no implementation
|
|
831
|
+
* commits in this range" is a statement about the RANGE, and on a branch level with `main` that
|
|
832
|
+
* range is legitimately empty. A reader who cannot tell those apart calls the second one "never
|
|
833
|
+
* built", which is the console's half of this defect.
|
|
834
|
+
*/
|
|
719
835
|
steps.push(build.unknown
|
|
720
|
-
? step('build', 'build', 'unknown', range.reason)
|
|
721
|
-
: build.
|
|
722
|
-
|
|
723
|
-
|
|
836
|
+
? step('build', 'build', 'unknown', build.reason || range.reason)
|
|
837
|
+
: build.failedDeclaration
|
|
838
|
+
// REPORTED, never CI-blocking — the same stance R1 takes on an unresolvable anchor. Making a
|
|
839
|
+
// record's own broken pointer fail every unrelated PR would turn one typo into a repo-wide
|
|
840
|
+
// outage; the RED belongs in the surface. `blocked` still closes the accept step below, which
|
|
841
|
+
// is where a false delivery claim actually needs to stop something.
|
|
842
|
+
? step('build', 'build', 'blocked', build.failedDeclaration.reason,
|
|
843
|
+
`point delivered_by at a commit that is reachable from the trunk and changes a build path in ${recRel}, or remove the key — a declaration that fails its own check reads as evidence`,
|
|
844
|
+
{ blocking: false, rule: 'R-2b' })
|
|
845
|
+
: build.mergedOnly
|
|
846
|
+
? step('build', 'build', 'done',
|
|
847
|
+
delivery.attributionEvidence(build.signal || 'branch', id, build.declaredSha))
|
|
848
|
+
: build.first === null
|
|
849
|
+
? step('build', 'build', 'pending', build.attributed
|
|
850
|
+
? delivery.attributionEvidence(null, id)
|
|
851
|
+
: 'no implementation commits in this range')
|
|
852
|
+
: step('build', 'build', 'done', build.signal
|
|
853
|
+
? `${build.count} commit(s) on implementation paths — ${delivery.attributionEvidence(build.signal, id, build.declaredSha)}`
|
|
854
|
+
: `${build.count} commit(s) on implementation paths`));
|
|
724
855
|
|
|
725
856
|
// ── quality ─ CI's verdict, never this engine's ──────────────────────────────────────────────
|
|
726
857
|
steps.push(step('quality', 'quality', 'unknown', 'CI decides on the PR — this engine never claims a test result it did not run'));
|
|
@@ -976,7 +1107,13 @@ function main(argv) {
|
|
|
976
1107
|
// it is the one thing the trunk range genuinely cannot answer.
|
|
977
1108
|
const range = historyRange(root);
|
|
978
1109
|
const changeRange = commitRange(root);
|
|
979
|
-
const
|
|
1110
|
+
const ids = listInitiatives(root);
|
|
1111
|
+
// ONE delivery index for the whole board. Built here rather than inside each ladder for the same
|
|
1112
|
+
// reason the commit range is: `deliveryIndex` memoises per trunk head, so a per-initiative build
|
|
1113
|
+
// would be cheap but the intent — one read, shared — is what keeps the rows consistent with each
|
|
1114
|
+
// other even if the trunk moved mid-run.
|
|
1115
|
+
const deliveryIdx = delivery.deliveryIndex(root, ids);
|
|
1116
|
+
const rows = ids.map((i) => computeWorkflow(i, { root, range, changeRange, deliveryIndex: deliveryIdx }));
|
|
980
1117
|
if (json) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return 0; }
|
|
981
1118
|
if (!rows.length) { process.stdout.write('\n no initiative records found — is this a PMOS repo?\n\n'); return 1; }
|
|
982
1119
|
process.stdout.write(renderBoard(rows, { self, all }));
|
|
@@ -992,4 +1129,11 @@ module.exports = {
|
|
|
992
1129
|
ENFORCED_FROM,
|
|
993
1130
|
};
|
|
994
1131
|
|
|
995
|
-
|
|
1132
|
+
/*
|
|
1133
|
+
* `exitCode`, not `process.exit()`. `process.exit` tears the process down without waiting for stdout
|
|
1134
|
+
* to drain, and when stdout is a PIPE — every CI invocation, every `| jq`, every test harness —
|
|
1135
|
+
* writes larger than the pipe buffer are asynchronous. `--json` on this repository is ~65 KB, so it
|
|
1136
|
+
* arrived TRUNCATED MID-STRING and parsed as invalid JSON, while an interactive run to a TTY looked
|
|
1137
|
+
* perfect. Setting the code and letting the process end naturally flushes first.
|
|
1138
|
+
*/
|
|
1139
|
+
if (require.main === module) process.exitCode = main(process.argv);
|