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.
@@ -67,6 +67,16 @@ bar and a 26px status bar, on the oklch token layer, in both themes:
67
67
  2. **Initiatives** — a channel list. Collapsible objective/KR groups over `#slug` rows, one search
68
68
  box that forces the groups open and reports matches elsewhere, and an add form. Unanchored
69
69
  initiatives are filed honestly and shown **rejected** under D59, never hidden.
70
+ A **`NOW · working on`** group is pinned above the objectives when — and only when — there is
71
+ something to say: every initiative with a live session on its `initiative/<id>` branch, a
72
+ registered working tree on that branch, or a tab open in this window, ordered so whatever is
73
+ **waiting on you** is the first row on the board. The three signals are ranked, never merged, and
74
+ each row's tooltip names which one put it there. On a row with a live session the leading `#`
75
+ becomes that session's own glyph — `◆ needs you`, `● working`, `◇ maybe blocked`, `◼ quiet` —
76
+ in the vocabulary `lib/session-state.js` uses everywhere else, with every session named
77
+ individually on hover. The glyphs differ in **shape**, so the signal survives greyscale.
78
+ The join is the branch convention and nothing else: a session PMOS cannot place on an initiative
79
+ is not placed on one.
70
80
  3. **Initiative detail** — the record's header and pills (type / anchor / flow / branch), the derived
71
81
  footer, **task phases** (collapsible; a phase's status is derived from its tasks, and the tasks
72
82
  are derived from the workflow ladder unless the record authors them), a **links** section for
@@ -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
- // One commit range for the whole board — resolving it per initiative would re-shell git for each.
174
- const range = engine.commitRange(root);
175
- // WHERE the ladder was derived from. `commitRange` resolves merge-base(origin/main, HEAD)..HEAD —
176
- // this BRANCH's commits, and an EMPTY range on main. So `build`/`quality`, and the stage that
177
- // follows from them, are branch-scoped facts. A consumer that prints a stage without saying which
178
- // branch produced it states something the data does not support: pmos-terminal-workspace, shipped
179
- // under D62, derives to `spec` with "no implementation commits in this range" on every branch that
180
- // did not build it. Two worktrees of one repo legitimately print two different ladders.
181
- // This block is what lets a caller say so; it is not decoration (PRD R5, rubric dimension 1).
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
- // The empty range is the trap: range.ok is TRUE with zero commits (you are on main, or level
189
- // with it), so nothing is "degraded" — every build step legitimately reads `pending` and a naive
190
- // reader calls that "never built". Named separately because it needs different words.
191
- empty_range: range.ok ? range.shas.length === 0 : null,
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
- let range, row;
253
- try { range = engine.commitRange(root); }
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 { row = engine.computeWorkflow(id, { root, range }); }
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
- empty_range: range.ok ? range.shas.length === 0 : null,
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
+ };