create-pmos 0.16.1 → 0.17.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 CHANGED
@@ -333,6 +333,36 @@ 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.17.0
337
+
338
+ **Your board stops calling shipped work unstarted.** Run `npx create-pmos@latest workspace`; the
339
+ version bump means an existing install auto-upgrades.
340
+
341
+ - **An initiative's status is now read from what happened, not from what someone typed.** The
342
+ workspace took each initiative's state from a `status:` line written by hand into a PRD and
343
+ matched it against a list of words. In the repo this was built in, that one field held 23
344
+ different values, 33 initiatives had none at all, and several were cut off mid-sentence by a
345
+ stray comma. The plainest value of the lot — `build complete, PR #138` — matched the word
346
+ "build" first and displayed as *in progress*. Status is now derived from your acceptance
347
+ records, your logged runs, your open items, and your repository's own merge history, and each
348
+ row states which of those it was read from. Nothing you type can move it.
349
+ - **`delivered` and `accepted` are no longer the same thing.** Work that merged and work you
350
+ actually signed off are now separate states. That gap is usually the interesting number: in the
351
+ repo this was built in, 62 of 63 delivered initiatives had no acceptance recorded — invisible
352
+ while both wore one label.
353
+ - **The board reads your whole project history, not just the branch you are standing on.** It
354
+ shared a commit range with the CI gate, and that range is *empty* when your branch is level with
355
+ `main` — so every initiative reported "no implementation commits" and fell back to its earliest
356
+ stage. Work finished weeks ago looked identical to work never begun. The gate still reads only
357
+ the pull request, which is correct for judging whether a spec came before its build.
358
+ - **A history it cannot read is now said out loud.** In a shallow clone (some CI checkouts) the
359
+ delivery history genuinely is not there. The board reports that, with the reason, instead of
360
+ quietly showing everything as unstarted — which would look exactly like a project where nothing
361
+ has happened.
362
+
363
+ Unchanged where it matters: **no database, no network at runtime, no telemetry.** The server still
364
+ binds `127.0.0.1` only. The file-mode kit itself is unchanged and still dependency-free.
365
+
336
366
  ## What's new in 0.16.1
337
367
 
338
368
  **Two fixes on the Terminal Workspace's entry path — how a repository reaches a running workspace.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → 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. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@011b77e
1
+ kernel-source: pmos@4cb5198
2
2
  assembled: 2026-07-31
3
3
  mode: vendor
@@ -164,25 +164,88 @@ function git(root, args) {
164
164
  * evaluated has not been satisfied, and saying so is the whole point of the honesty rule above.
165
165
  */
166
166
  function commitRange(root) {
167
+ return buildRange(root, { spec: null });
168
+ }
169
+
170
+ /*
171
+ * The BOARD's range: the whole trunk, not the pull request.
172
+ *
173
+ * These are two different questions and they were sharing an answer. The gate asks "inside this
174
+ * diff, did the spec precede the build?" — only decidable within the PR. The board asks "where does
175
+ * this initiative stand?", which the PR range cannot answer at all: on a branch level with `main`
176
+ * the range is EMPTY, so every initiative reported `no implementation commits in this range` and
177
+ * collapsed to stage `spec`. A delivered initiative rendered identically to one never started.
178
+ *
179
+ * Splitting them means a rule can read `done` on the board and still be evaluated inside the gate.
180
+ * That is intended and is stated in the board's own footer, so it never reads as a discrepancy.
181
+ */
182
+ function historyRange(root) {
183
+ const trunk = ['origin/main', 'main', 'origin/master', 'master']
184
+ .find((c) => git(root, ['rev-parse', '--verify', '--quiet', c]));
185
+ return buildRange(root, { spec: trunk || 'HEAD', whole: true });
186
+ }
187
+
188
+ /*
189
+ * ONE pass over the range, not one git call per question.
190
+ *
191
+ * Two `git log` invocations build every index the ladder needs: which paths each commit CHANGED
192
+ * (for buildCommits) and where each path was ADDED (for firstAppearance). Previously firstAppearance
193
+ * shelled out once per path per initiative and buildCommits ran `git show` once per commit per
194
+ * initiative — 330+ processes for the board on an empty range (23 s), and O(commits × initiatives)
195
+ * on a full one, which is what made full history look impossible rather than merely unimplemented.
196
+ */
197
+ function buildRange(root, { spec, whole }) {
167
198
  if ((git(root, ['rev-parse', '--is-shallow-repository']) || '').trim() === 'true') {
168
199
  return { ok: false, reason: 'shallow clone — no history to order commits by (CI needs fetch-depth: 0)' };
169
200
  }
170
- const candidates = [];
171
- if (process.env.GITHUB_BASE_REF) candidates.push(`origin/${process.env.GITHUB_BASE_REF}`);
172
- candidates.push('origin/main', 'main', 'origin/master', 'master');
173
201
  let base = null;
174
- for (const c of candidates) {
175
- if (!git(root, ['rev-parse', '--verify', '--quiet', c])) continue;
176
- const mb = git(root, ['merge-base', c, 'HEAD']);
177
- if (mb && mb.trim()) { base = mb.trim(); break; }
202
+ let range = spec;
203
+ if (!range) {
204
+ const candidates = [];
205
+ if (process.env.GITHUB_BASE_REF) candidates.push(`origin/${process.env.GITHUB_BASE_REF}`);
206
+ candidates.push('origin/main', 'main', 'origin/master', 'master');
207
+ for (const c of candidates) {
208
+ if (!git(root, ['rev-parse', '--verify', '--quiet', c])) continue;
209
+ const mb = git(root, ['merge-base', c, 'HEAD']);
210
+ if (mb && mb.trim()) { base = mb.trim(); break; }
211
+ }
212
+ range = base ? `${base}..HEAD` : 'HEAD';
178
213
  }
179
- const spec = base ? `${base}..HEAD` : 'HEAD';
180
- const log = git(root, ['log', '--reverse', '--format=%H', spec]);
214
+ const log = git(root, ['log', '--reverse', '--format=%x1e%H %ct', '--name-only', range]);
181
215
  if (log == null) return { ok: false, reason: 'git history unreadable' };
182
- const shas = log.split('\n').map((s) => s.trim()).filter(Boolean);
216
+
217
+ const shas = [];
218
+ const changed = []; // index -> string[] of paths touched
219
+ const dates = []; // index -> commit unix seconds (what `legacy` is decided by)
220
+ for (const chunk of log.split('\x1e')) {
221
+ if (!chunk.trim()) continue;
222
+ const lines = chunk.split('\n');
223
+ const head = (lines.shift() || '').trim();
224
+ const [sha, ct] = head.split(/\s+/);
225
+ if (!sha) continue;
226
+ shas.push(sha);
227
+ dates.push(Number.isFinite(parseInt(ct, 10)) ? parseInt(ct, 10) : null);
228
+ changed.push(lines.map((s) => s.trim()).filter(Boolean));
229
+ }
183
230
  const index = new Map();
184
231
  shas.forEach((sha, i) => index.set(sha, i));
185
- return { ok: true, base, index, shas, wholeHistory: !base };
232
+
233
+ // Where each path was ADDED, as an index into the same range. `--diff-filter=A` in one pass.
234
+ const added = new Map();
235
+ const addLog = git(root, ['log', '--reverse', '--format=%x1e%H', '--diff-filter=A', '--name-only', range]);
236
+ if (addLog != null) {
237
+ for (const chunk of addLog.split('\x1e')) {
238
+ if (!chunk.trim()) continue;
239
+ const lines = chunk.split('\n');
240
+ const sha = (lines.shift() || '').trim();
241
+ const at = index.has(sha) ? index.get(sha) : null;
242
+ if (at == null) continue;
243
+ for (const f of lines.map((s) => s.trim()).filter(Boolean)) {
244
+ if (!added.has(f)) added.set(f, at);
245
+ }
246
+ }
247
+ }
248
+ return { ok: true, base, index, shas, changed, dates, added, wholeHistory: whole ? true : !base };
186
249
  }
187
250
 
188
251
  /*
@@ -200,6 +263,10 @@ function commitRange(root) {
200
263
  function firstAppearance(root, range, rel) {
201
264
  const onDisk = fs.existsSync(path.join(root, rel));
202
265
  if (!range.ok) return onDisk ? undefined : null;
266
+ // The single-pass index answers first, and over the whole trunk it answers everything that has
267
+ // ever been committed — so the fallback below only runs for a PR range, where "added before the
268
+ // range" and "never committed" are genuinely different answers and only git can tell them apart.
269
+ if (range.added && range.added.has(rel)) return range.added.get(rel);
203
270
  const log = git(root, ['log', '--diff-filter=A', '--reverse', '--format=%H', '--', rel]);
204
271
  const sha = log ? log.split('\n').map((s) => s.trim()).filter(Boolean)[0] : null;
205
272
  if (!sha) return onDisk ? Infinity : null;
@@ -213,9 +280,10 @@ function buildCommits(root, range, lane) {
213
280
  if (!range.ok) return { unknown: true };
214
281
  let first = null; let count = 0; let sample = null;
215
282
  for (let i = 0; i < range.shas.length; i++) {
216
- const files = git(root, ['show', '--name-only', '--format=', range.shas[i]]);
217
- if (!files) continue;
218
- const hit = files.split('\n').map((s) => s.trim()).filter(Boolean).find((f) => isBuildPath(f, lane));
283
+ // range.changed came from the range's own single pass; the per-commit `git show` this replaced
284
+ // ran once per commit PER INITIATIVE, which is why the board took 23 s before it read anything.
285
+ const files = (range.changed && range.changed[i]) || [];
286
+ const hit = files.find((f) => isBuildPath(f, lane));
219
287
  if (!hit) continue;
220
288
  count++;
221
289
  if (first === null) { first = i; sample = hit; }
@@ -290,10 +358,29 @@ function computeWorkflow(id, opts) {
290
358
  const effLane = knownLane ? lane : 'feature'; // unknown lane = strictest, as the bash gate does
291
359
  const range = (opts && opts.range) || commitRange(root);
292
360
  // See ENFORCED_FROM: git decides, the frontmatter only breaks a tie git cannot see.
361
+ //
362
+ // `recIdx === -1` — "the record predates this range" — is the right test for a PR range and a
363
+ // MEANINGLESS one over the whole trunk, where nothing predates the range and every legacy record
364
+ // would suddenly be enforced. So the commit DATE decides whenever the range can supply it, and
365
+ // -1 keeps its meaning for the gate's range. Same question, asked of evidence that survives both.
293
366
  const recIdx = firstAppearance(root, range, recRel);
294
- const legacy = (recIdx === undefined || recIdx === null)
295
- ? String(fm.timestamp || '').slice(0, 10) < ENFORCED_FROM // fallback only — see ENFORCED_FROM
296
- : recIdx === -1;
367
+ const recAt = (range.dates && typeof recIdx === 'number' && recIdx >= 0) ? range.dates[recIdx] : null;
368
+ /*
369
+ * `reconstructed_from` — the merge date of work whose record was written afterwards.
370
+ *
371
+ * A record backfilled today for a PR that merged in June has TODAY's commit date, so the date
372
+ * test would enforce today's rules on work that predates them and report a violation nobody
373
+ * could ever have avoided. The declared merge date decides instead.
374
+ *
375
+ * This is not an escape hatch: it must name a date, that date is checkable against the merge it
376
+ * claims, and declaring one AFTER `ENFORCED_FROM` makes the record non-legacy — so work that
377
+ * genuinely skipped the loop still reports blocked, which is the point of writing it down.
378
+ */
379
+ const recon = String(fm.reconstructed_from || '').trim().slice(0, 10);
380
+ const legacy = recIdx === -1 ? true
381
+ : /^\d{4}-\d{2}-\d{2}$/.test(recon) ? recon < ENFORCED_FROM
382
+ : recAt ? new Date(recAt * 1000).toISOString().slice(0, 10) < ENFORCED_FROM
383
+ : String(fm.timestamp || '').slice(0, 10) < ENFORCED_FROM; // fallback — see ENFORCED_FROM
297
384
  /*
298
385
  * `legacy` answers the ENFORCEMENT question — should this PR fail? It is NOT the visibility
299
386
  * question. Filtering the board by it was a real bug: on `main`, every record is legacy, so the
@@ -304,7 +391,12 @@ function computeWorkflow(id, opts) {
304
391
  * `lastActivity` is the honest fallback for everything else, because "when was this last worked
305
392
  * on" is a fact the repo already holds and nobody has to maintain.
306
393
  */
307
- const inChange = recIdx !== -1 && recIdx !== null && recIdx !== undefined;
394
+ // `● in this change` must keep meaning "the diff under review touches this". Over the whole trunk
395
+ // that is true of everything, which makes the marker noise — so the board passes the GATE's range
396
+ // separately and the marker is answered from that, or dropped when there is no such range.
397
+ const changeRange = (opts && opts.changeRange) || (range.wholeHistory ? null : range);
398
+ const chgIdx = changeRange ? firstAppearance(root, changeRange, recRel) : null;
399
+ const inChange = !!changeRange && chgIdx !== -1 && chgIdx !== null && chgIdx !== undefined;
308
400
  const lastActivity = (() => {
309
401
  const paths = [recRel, fm.prd_path, fm.rubric_path].filter(Boolean);
310
402
  const out = git(root, ['log', '-1', '--format=%ct', '--', ...paths]);
@@ -522,10 +614,14 @@ function computeWorkflow(id, opts) {
522
614
  steps.push(step('spec', 'spec', 'done', `${specNames} — first-appeared at or before the first build commit`));
523
615
  } else {
524
616
  const late = rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? rubric : prd;
525
- steps.push(step('spec', 'spec', 'blocked',
617
+ // `legacy` applies here exactly as it does to O4. It always should have — the rule simply
618
+ // never fired for a legacy record while the board shared the gate's PR range, because an
619
+ // empty range has no build commit to be out of order with. Reading the trunk exposed it:
620
+ // 81 records that predate enforcement were suddenly failing a rule written after them.
621
+ steps.push(step('spec', 'spec', legacy ? 'unknown' : 'blocked',
526
622
  `ORDER VIOLATION — implementation (${build.sample}) was committed BEFORE ${late} first appeared`,
527
623
  'the rubric is the spec (D13). Land the spec artifacts in an earlier commit than the build, or split this PR so the spec lands first',
528
- { blocking: true, rule: rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? 'O2' : 'O1' }));
624
+ { blocking: !legacy, rule: rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? 'O2' : 'O1' }));
529
625
  }
530
626
  }
531
627
  }
@@ -776,6 +872,10 @@ function renderBoard(rows, opts) {
776
872
  out.push('');
777
873
  // Never a silent cap — a truncated list that does not say so reads as "that is everything".
778
874
  if (hidden) out.push(` ${c(33, `+ ${hidden} more without a recorded acceptance, not shown`)} ${c(90, `— node ${opts.self} --all`)}`);
875
+ // The two ranges, said out loud. The board reads the whole trunk so a delivered initiative does
876
+ // not render as unstarted; the gate reads the PR so "spec before build" stays decidable. A rule
877
+ // can therefore read done here and still be evaluated on the PR — intended, and not a discrepancy.
878
+ out.push(` ${c(90, 'derived from the whole trunk history; the CI gate evaluates order within the PR only')}`);
779
879
  out.push(` ${c(90, `one initiative in detail: node ${opts.self} <id>`)}`);
780
880
  out.push('');
781
881
  return out.join('\n');
@@ -871,8 +971,12 @@ function main(argv) {
871
971
  return 0;
872
972
  }
873
973
 
874
- const range = commitRange(root);
875
- const rows = listInitiatives(root).map((i) => computeWorkflow(i, { root, range }));
974
+ // R-7: the BOARD reads the trunk, not the pull request. The gate path above keeps commitRange().
975
+ // The PR range is still computed and passed alongside — it is what `● in this change` means, and
976
+ // it is the one thing the trunk range genuinely cannot answer.
977
+ const range = historyRange(root);
978
+ const changeRange = commitRange(root);
979
+ const rows = listInitiatives(root).map((i) => computeWorkflow(i, { root, range, changeRange }));
876
980
  if (json) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return 0; }
877
981
  if (!rows.length) { process.stdout.write('\n no initiative records found — is this a PMOS repo?\n\n'); return 1; }
878
982
  process.stdout.write(renderBoard(rows, { self, all }));
@@ -882,6 +986,9 @@ function main(argv) {
882
986
  module.exports = {
883
987
  computeWorkflow, renderLadder, renderBoard, listInitiatives, layout, inFlight, BOARD_CAP,
884
988
  parseLeadingFrontmatter, specimenRoute, surfaceRoute, priorsCitation, isBuildPath, commitRange,
989
+ // the board's trunk range, exported so the suite can assert the split: the same record is legacy
990
+ // under BOTH ranges, and `in this change` is false under the trunk one.
991
+ historyRange,
885
992
  ENFORCED_FROM,
886
993
  };
887
994
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.16.1",
9
+ "version": "0.17.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "private": true,
5
- "description": "PMOS Terminal Workspace \u2014 local companion hosting real PTYs beside the library and gates (D62)",
5
+ "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "pmos": "bin/pmos.js"
@@ -29,7 +29,7 @@
29
29
  "playwright-core": "^1.61.1",
30
30
  "ws": "^8.18.0"
31
31
  },
32
- "//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs \u2014 it just stops measuring pixels, and says so.",
32
+ "//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs — it just stops measuring pixels, and says so.",
33
33
  "devDependencies": {
34
34
  "pixelmatch": "^7.1.0",
35
35
  "pngjs": "^7.0.0"
@@ -523,6 +523,10 @@ const ROLE_BY_DIR = {
523
523
  prd: 'PRD', evals: 'eval', design: 'design', research: 'research', briefs: 'brief',
524
524
  okrs: 'OKR', radar: 'radar', gtm: 'gtm', 'out-of-scope': 'rejected', 'harness-refine': 'harness',
525
525
  'design-export': 'design', explainers: 'explainer', fixtures: 'fixture',
526
+ // The initiative RECORD (planning/initiatives/<id>.md) is the canonical anchor+lane carrier and
527
+ // the only artifact that exists for work backfilled from a merge. Before it was a role, a record
528
+ // with no sibling PRD landed in `ungrouped` and the initiative was invisible here.
529
+ initiatives: 'record',
526
530
  };
527
531
  // ── D60 specimens: prototype/<id>/, deliberately OUTSIDE planning/ ───────────────────────────
528
532
  // The planning walk cannot reach them (planningRoots only finds `planning`), so a prototype leg was
@@ -575,17 +579,23 @@ function parseHead(full) {
575
579
  head = buf.slice(0, n).toString('utf8');
576
580
  } catch { return {}; }
577
581
  const id = head.match(/^\s*[-*]?\s*initiative_id\s*:\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*$/m);
582
+ // An initiative RECORD names itself with `id:`, not `initiative_id:`. Only the record role reads
583
+ // this (see docMeta) — a bare `id:` on any other document must not invent an initiative.
584
+ const rid = head.match(/^\s*id\s*:\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*$/m);
578
585
  const h1 = head.match(/^#\s+(.+)$/m);
579
- // status/stage/anchor ride the PRD's identity block — the only initiative signal that lives on
580
- // disk today. RICE/effort/milestones/progress do NOT (net-new state) and are never invented.
586
+ // The anchor and the lane are DECLARED state and are read as such — they say what the work is
587
+ // pointed at, never how far along it is. `status:`/`stage:` are deliberately NOT read: they were
588
+ // hand-typed free text (23 distinct values in this repo, 33 absent, several truncated mid-YAML)
589
+ // funnelled through a regex, so `build complete, PR #138` rendered as `building`. Position is
590
+ // derived from evidence in deliveryIndex/deriveState instead — see the surface explainer.
581
591
  const clean = (m) => m ? m[1].replace(/\*\*/g, '').split(/\s+[—–-]\s|\(/)[0].trim().slice(0, 48) : null;
582
- const status = head.match(/^\s*[-*]?\s*status\s*:\s*(.+?)\s*$/mi);
583
- const stage = head.match(/^\s*[-*]?\s*stage\s*:\s*(.+?)\s*$/mi);
584
592
  const kr = head.match(/^\s*[-*]?\s*parent[_-]?kr\s*:\s*(.+?)\s*$/mi);
593
+ const lane = head.match(/^\s*[-*]?\s*lane\s*:\s*(.+?)\s*$/mi);
585
594
  return {
586
595
  initiative: id ? id[1] : null,
596
+ recordId: rid ? rid[1] : null,
587
597
  title: h1 ? h1[1].replace(/^(PRD|Eval Rubric|Design Brief)\s*:\s*/i, '').trim() : null,
588
- status: clean(status), stage: clean(stage), kr: clean(kr),
598
+ kr: clean(kr), lane: clean(lane),
589
599
  };
590
600
  }
591
601
  function roleAndStem(rel, base) {
@@ -608,8 +618,9 @@ function docMeta(root, full) {
608
618
  const meta = {
609
619
  mtimeMs: st.mtimeMs, path: rel, name: base, role,
610
620
  // frontmatter wins; the filename convention is the fallback, never the override
611
- initiative: head.initiative || null, stem, title: head.title || null,
612
- status: head.status || null, stage: head.stage || null, kr: head.kr || null,
621
+ initiative: head.initiative || (role === 'record' ? head.recordId : null) || null,
622
+ stem, title: head.title || null,
623
+ kr: head.kr || null, lane: head.lane || null,
613
624
  };
614
625
  metaCache.set(full, meta);
615
626
  return meta;
@@ -650,17 +661,17 @@ function initiativeIndex(root) {
650
661
  const k = keyFor(m);
651
662
  const art = { name: m.name, path: m.path, role: m.role, title: m.title };
652
663
  if (!k) { ungrouped.push(art); continue; }
653
- if (!groups.has(k)) groups.set(k, { id: k, title: null, status: null, stage: null, kr: null, artifacts: [] });
664
+ if (!groups.has(k)) groups.set(k, { id: k, title: null, kr: null, lane: null, artifacts: [] });
654
665
  const g = groups.get(k);
655
666
  g.artifacts.push(art);
656
667
  if (m.role === 'PRD' && m.title) g.title = m.title; // the PRD names the initiative
657
668
  else if (!g.title && m.title) g.title = m.title;
658
- // status/stage/anchor: the PRD is authoritative; any artifact fills a gap, never overrides it
659
- if (m.status && (m.role === 'PRD' || !g.status)) g.status = m.status;
660
- if (m.stage && (m.role === 'PRD' || !g.stage)) g.stage = m.stage;
661
- if (m.kr && (m.role === 'PRD' || !g.kr)) g.kr = m.kr;
669
+ // The anchor and lane are the RECORD's to declare (D56/D59 — it is what the gates read); the
670
+ // PRD fills a gap for legacy initiatives that predate repo-native records, never overrides it.
671
+ if (m.kr && (m.role === 'record' || !g.kr)) g.kr = m.kr;
672
+ if (m.lane && (m.role === 'record' || !g.lane)) g.lane = m.lane;
662
673
  }
663
- const ROLE_ORDER = ['PRD', 'rubric', 'eval', 'design brief', 'design', 'reconcile', 'dogfood'];
674
+ const ROLE_ORDER = ['record', 'PRD', 'rubric', 'eval', 'design brief', 'design', 'reconcile', 'dogfood'];
664
675
  const initiatives = [...groups.values()].map((g) => {
665
676
  g.artifacts.sort((a, b) => {
666
677
  const ia = ROLE_ORDER.indexOf(a.role), ib = ROLE_ORDER.indexOf(b.role);
@@ -695,57 +706,207 @@ function library(root) {
695
706
  // client renders it; a surface that can't be produced returns ok:false and the client shows an
696
707
  // honest can't-produce state. RICE/effort/milestones/progress are NET-NEW state → emitted as null
697
708
  // ("— not tracked"), never invented (D14/D40 honesty).
698
- const PHASE_OF = { planning: 'plan', building: 'deliver', blocked: 'deliver', landed: 'report' };
699
- function statusKey(raw) {
700
- const s = String(raw || '').toLowerCase();
701
- if (/block/.test(s)) return 'blocked';
702
- if (/ship|merg|accept|closed|done|live/.test(s)) return 'landed';
703
- if (/build|progress|in build|dogfood/.test(s)) return 'building';
704
- if (/intake|spec|plan|draft|propos/.test(s)) return 'planning';
709
+ const PHASE_OF = { planning: 'plan', building: 'deliver', blocked: 'deliver', delivered: 'report', accepted: 'report', unknown: 'plan' };
710
+
711
+ // ── Delivery evidence (board-delivery-truth R-2/R-3). ──────────────────────────────────────────
712
+ // "Has this shipped?" answered from the trunk's own history rather than from anything anyone typed.
713
+ //
714
+ // ONE `git log` per trunk head, memoised on that sha — ~0.3s and ~800 KB on this repo's 1,214
715
+ // commits, paid once and reused by every surface until the trunk moves. Local only: git reads the
716
+ // object store on disk, so the suite's "no network" guarantee (K. Surfaces) is untouched.
717
+ //
718
+ // TWO signals, because one is not enough. The branch convention (`initiative/<id>`) is precise but
719
+ // incomplete: 31% of this repo's merged PRs shipped on `claude/*` branches, which match no id. So a
720
+ // commit that MENTIONS the id and TOUCHES a build path counts too. The build-path requirement is
721
+ // what keeps the mention signal honest — `feat(planning): add <id> PRD` mentions the id while
722
+ // delivering nothing, and without the path test every specified initiative would read as shipped.
723
+ const BUILD_EXCLUDE = /^(planning|okf|prototype)\//;
724
+ function isDeliveryPath(f) {
725
+ if (!f) return false;
726
+ if (BUILD_EXCLUDE.test(f)) return false;
727
+ return !/^[^/]+\.md$/.test(f); // root governance prose (DECISIONS.md, CLAUDE.md…) is not a build
728
+ }
729
+ const deliveryCache = new Map(); // root -> { head, index }
730
+ function trunkRef(root) {
731
+ for (const c of ['origin/main', 'main', 'origin/master', 'master']) {
732
+ const r = gitCap(root, ['rev-parse', '--verify', '--quiet', c]);
733
+ if (r.ok && r.out.trim()) return { ref: c, head: r.out.trim() };
734
+ }
705
735
  return null;
706
736
  }
707
- function statusFrom(g) {
708
- return statusKey(g.status) || statusKey(g.stage) || 'planning';
737
+ function gitCap(root, args) {
738
+ try {
739
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
740
+ if (r.error || r.status !== 0) return { ok: false, out: '', err: (r.stderr || (r.error && r.error.message) || '').trim() };
741
+ return { ok: true, out: r.stdout || '' };
742
+ } catch (e) { return { ok: false, out: '', err: String((e && e.message) || e) }; }
743
+ }
744
+ /**
745
+ * → { ok, reason?, byBranch:Set<id>, byMention:Set<id> }
746
+ * `ok:false` is a REAL answer and never degrades into "nothing was delivered" — the caller renders
747
+ * it red and marks every initiative's delivery `unknown` (R-3). A board that cannot read the
748
+ * history must say so; silently reporting `planning` for 110 shipped initiatives is the failure
749
+ * this whole initiative exists to remove.
750
+ */
751
+ function deliveryIndex(root, ids) {
752
+ const t = trunkRef(root);
753
+ if (!t) return { ok: false, reason: 'no trunk branch (main/master) is reachable from this repository — delivery cannot be derived', byBranch: new Set(), byMention: new Set() };
754
+ const hit = deliveryCache.get(root);
755
+ if (hit && hit.head === t.head) return hit.index;
756
+
757
+ // %x1e separates commits, %x1f separates the message from --name-only's file list.
758
+ const r = gitCap(root, ['log', '--format=%x1e%s %b%x1f', '--name-only', t.head]);
759
+ if (!r.ok) {
760
+ return { ok: false, reason: `git could not read the history at ${t.ref}${r.err ? ` — ${r.err}` : ''}`, byBranch: new Set(), byMention: new Set() };
761
+ }
762
+ const byBranch = new Set(), byMention = new Set();
763
+ const known = Array.isArray(ids) ? ids : [...ids];
764
+ for (const chunk of r.out.split('\x1e')) {
765
+ if (!chunk.trim()) continue;
766
+ const cut = chunk.indexOf('\x1f');
767
+ const msg = cut < 0 ? chunk : chunk.slice(0, cut);
768
+ const files = cut < 0 ? [] : chunk.slice(cut + 1).split('\n').map((s) => s.trim()).filter(Boolean);
769
+ for (const m of msg.matchAll(/initiative\/([A-Za-z0-9][\w.-]*)/g)) byBranch.add(m[1]);
770
+ if (!files.some(isDeliveryPath)) continue;
771
+ for (const id of known) if (id.length > 3 && msg.includes(id)) byMention.add(id);
772
+ }
773
+ const index = { ok: true, byBranch, byMention };
774
+ deliveryCache.set(root, { head: t.head, index });
775
+ return index;
776
+ }
777
+ /**
778
+ * The five states, in precedence order. Every one of them names the artifact it was read from, so
779
+ * the surface can show its own evidence instead of asking to be trusted.
780
+ *
781
+ * `blocked` deliberately outranks `delivered`: work the PM RETURNED is work that needs them, and
782
+ * hiding that behind a delivery glyph would reproduce, in a new place, the exact failure of a board
783
+ * that reports green over a red underneath. (The PRD's R-1 table lists delivered first; this is the
784
+ * one ordering departure, made because the alternative buries a returned initiative.)
785
+ */
786
+ function deriveState(ev) {
787
+ if (ev.accepted) return { state: 'accepted', why: `accepted by the PM${ev.acceptedAt ? ` on ${String(ev.acceptedAt).slice(0, 10)}` : ''}` };
788
+ if (ev.returned) return { state: 'blocked', why: 'the PM returned this at the Acceptance Gate' };
789
+ if (ev.needs) return { state: 'blocked', why: `${ev.needs} open item${ev.needs > 1 ? 's' : ''} in needs.json` };
790
+ // Not `planning`. An unreadable history means the evidence for the delivery states is missing,
791
+ // and `planning` is itself a claim ("nothing has happened") that the missing evidence cannot
792
+ // support. `unknown` is the only honest answer, and it is visibly not a resting state.
793
+ if (ev.delivery === 'unknown') return { state: 'unknown', why: 'the trunk history could not be read — delivery cannot be derived here' };
794
+ if (ev.deliveredBy) return { state: 'delivered', why: ev.deliveredBy === 'branch' ? 'an initiative/<id> branch merged to the trunk' : 'a commit on the trunk names this initiative and changes build files' };
795
+ if (ev.runs) return { state: 'building', why: `${ev.runs} run${ev.runs > 1 ? 's' : ''} logged, nothing merged yet` };
796
+ return { state: 'planning', why: 'no run, no merge, no acceptance — nothing has happened yet' };
797
+ }
798
+ // BOTH acceptance ledgers, oldest-wins-never: the workspace writes to its state dir, while the
799
+ // ladder and every hand-recorded acceptance in pmos-self live in planning/runs/. Reading only the
800
+ // state dir is why this surface reported 0 accepted against 23 real acceptance rows — the same
801
+ // "one field, several readers, none agreeing" defect the anchor resolver was built to end.
802
+ function acceptanceFilesFor(w) {
803
+ const out = [];
804
+ const d = stateDirFor(w);
805
+ if (d) out.push(path.join(d, 'acceptances.jsonl'));
806
+ if (w && w.root) out.push(path.join(w.root, 'planning', 'runs', 'acceptances.jsonl'));
807
+ return out;
709
808
  }
710
809
  function readAcceptances(w) {
711
- const acc = new Map(); // initiative_id -> last action (accept | approve | return)
712
- const f = acceptancesFileFor(w);
713
- if (!f) return acc;
714
- try {
715
- for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
810
+ const acc = new Map(); // initiative_id -> { action, at }
811
+ for (const f of acceptanceFilesFor(w)) {
812
+ let text = '';
813
+ try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
814
+ for (const line of text.split('\n')) {
716
815
  if (!line.trim()) continue;
717
816
  // Tolerate both shapes: this server's {action} and the kit's {accepted} (D54 adapter).
817
+ // THE BOOLEAN OUTRANKS THE LABEL. `action` is an open vocabulary describing what the PM did
818
+ // — this corpus holds accept, merge, publish, return — while `accepted` is the D12 verdict
819
+ // itself. Reading `action` first (the previous behaviour) meant every row labelled `merge` or
820
+ // `publish` carried `accepted: true` and was still not counted: 7 of 16 real acceptances went
821
+ // missing, and the surface reported those initiatives as merely `building`.
718
822
  try {
719
823
  const r = JSON.parse(line);
720
824
  if (!r.initiative_id) continue;
721
- acc.set(r.initiative_id, r.action || (r.accepted === true ? 'accept' : r.accepted === false ? 'return' : null));
825
+ const label = String(r.action || '').toLowerCase();
826
+ const action = r.accepted === true ? 'accept'
827
+ : r.accepted === false ? 'return'
828
+ : label === 'return' ? 'return'
829
+ : ['accept', 'approve', 'merge', 'publish'].includes(label) ? 'accept'
830
+ : null;
831
+ const at = r.at || r.date || null;
832
+ // Last write wins, but a row with no timestamp never displaces a dated one.
833
+ const prev = acc.get(r.initiative_id);
834
+ if (prev && prev.at && !at) continue;
835
+ acc.set(r.initiative_id, { action, at });
722
836
  } catch {}
723
837
  }
724
- } catch {}
838
+ }
725
839
  return acc;
726
840
  }
841
+ // How many runs each initiative has logged — evidence that work STARTED, which is the only thing
842
+ // separating `building` from `planning`. The file is the same one the ladder reads.
843
+ function readRunCounts(w) {
844
+ const counts = new Map();
845
+ const d = stateDirFor(w);
846
+ const files = [];
847
+ if (d) files.push(path.join(d, 'agent-runs.jsonl'));
848
+ if (w && w.root) files.push(path.join(w.root, 'planning', 'runs', 'agent-runs.jsonl'));
849
+ for (const f of files) {
850
+ let text = '';
851
+ try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
852
+ for (const line of text.split('\n')) {
853
+ if (!line.trim()) continue;
854
+ try {
855
+ const r = JSON.parse(line);
856
+ if (r && r.initiative_id) counts.set(r.initiative_id, (counts.get(r.initiative_id) || 0) + 1);
857
+ } catch {}
858
+ }
859
+ }
860
+ return counts;
861
+ }
727
862
  function surfaceInitiatives(root, w) {
728
863
  const { initiatives } = initiativeIndex(root);
729
864
  const acc = readAcceptances(w);
865
+ const runs = readRunCounts(w);
866
+ const needsRaw = readNeeds(w) || [];
867
+ const needsBy = new Map();
868
+ for (const n of needsRaw) if (n && n.initiative_id) needsBy.set(n.initiative_id, (needsBy.get(n.initiative_id) || 0) + 1);
869
+
870
+ const del = deliveryIndex(root, initiatives.map((g) => g.id));
730
871
  const items = initiatives.map((g) => {
731
- const sk = statusFrom(g);
872
+ const a = acc.get(g.id) || {};
873
+ const deliveredBy = !del.ok ? null : del.byBranch.has(g.id) ? 'branch' : del.byMention.has(g.id) ? 'mention' : null;
874
+ const { state, why } = deriveState({
875
+ accepted: a.action === 'accept' || a.action === 'approve', acceptedAt: a.at,
876
+ returned: a.action === 'return',
877
+ needs: needsBy.get(g.id) || 0,
878
+ delivery: del.ok ? 'known' : 'unknown',
879
+ deliveredBy,
880
+ runs: runs.get(g.id) || 0,
881
+ });
732
882
  return {
733
883
  id: g.id, title: g.title || null,
734
- status: sk, statusText: g.status || g.stage || sk, phase: PHASE_OF[sk] || 'plan',
735
- stage: g.stage || null, kr: g.kr || null,
736
- accepted: acc.get(g.id) === 'accept',
737
- artifacts: g.artifacts.map((a) => ({ role: a.role, name: a.name, path: a.path })),
884
+ status: state, statusText: why, phase: PHASE_OF[state] || 'plan',
885
+ // `stage` was the declared field this surface used to trust. It is gone, not renamed —
886
+ // the client renders `statusText`, which always names the artifact the state was read from.
887
+ stage: null, kr: g.kr || null, lane: g.lane || null,
888
+ accepted: state === 'accepted',
889
+ delivered: state === 'accepted' || state === 'delivered',
890
+ delivery_evidence: deliveredBy,
891
+ artifacts: g.artifacts.map((a2) => ({ role: a2.role, name: a2.name, path: a2.path })),
738
892
  // NET-NEW state — not on disk yet, so null (the surface renders "— not tracked", never a guess)
739
893
  rice: null, effort: null, milestones: null, progress: null, tracked: false,
740
894
  };
741
895
  });
742
896
  const by = (k) => items.filter((i) => i.status === k).length;
743
- const needs = (readNeeds(w) || []).map((n) => ({
897
+ const needs = needsRaw.map((n) => ({
744
898
  t: n.question || '(unstated)', kind: n.kind || 'question', initiative_id: n.initiative_id || null,
745
899
  }));
746
900
  return {
747
901
  initiatives: items,
748
- pulse: { total: items.length, planning: by('planning'), building: by('building'), blocked: by('blocked'), landed: by('landed') },
902
+ pulse: {
903
+ total: items.length, planning: by('planning'), building: by('building'),
904
+ blocked: by('blocked'), delivered: by('delivered'), accepted: by('accepted'),
905
+ unknown: by('unknown'),
906
+ },
907
+ // R-3: a failed read is reported, never absorbed. The client renders this RED above the list
908
+ // rather than letting 110 shipped initiatives quietly read `planning`.
909
+ delivery_read: del.ok ? null : (del.reason || 'the trunk history could not be read'),
749
910
  needs,
750
911
  };
751
912
  }
@@ -755,7 +916,7 @@ function surfaceRoadmap(root, w) {
755
916
  label, dates_tracked: false,
756
917
  items: items.filter((i) => keys.includes(i.status)).map((i) => ({ id: i.id, status: i.status, phase: i.phase, progress: i.progress })),
757
918
  });
758
- const groups = [bucket('In flight', ['building', 'blocked']), bucket('Planned', ['planning']), bucket('Landed', ['landed'])]
919
+ const groups = [bucket('In flight', ['building', 'blocked']), bucket('Planned', ['planning']), bucket('Landed', ['delivered', 'accepted'])]
759
920
  .filter((g) => g.items.length);
760
921
  return { groups, note: 'dates are net-new state — grouped by status until a timeline is tracked' };
761
922
  }
@@ -932,12 +1093,16 @@ function surfaceReport(root, w) {
932
1093
  const s = surfaceInitiatives(root, w);
933
1094
  const line = (i) => `${i.id}${i.title ? ' — ' + i.title : ''}`;
934
1095
  const inflight = s.initiatives.filter((i) => i.status === 'building' || i.status === 'blocked');
935
- const landed = s.initiatives.filter((i) => i.status === 'landed');
1096
+ // Delivered and accepted are reported SEPARATELY, not summed into one "landed" heading. The gap
1097
+ // between them is the D12 Acceptance Gate's backlog — the number this report exists to surface.
1098
+ const delivered = s.initiatives.filter((i) => i.status === 'delivered');
1099
+ const accepted = s.initiatives.filter((i) => i.status === 'accepted');
936
1100
  const sections = [
937
1101
  { heading: 'In flight', items: inflight.map(line) },
938
- { heading: 'Landed', items: landed.map(line) },
1102
+ { heading: 'Delivered — acceptance not recorded', items: delivered.map(line) },
1103
+ { heading: 'Accepted', items: accepted.map(line) },
939
1104
  { heading: 'Needs you', items: s.needs.map((n) => n.t) },
940
- { heading: 'Metrics pulse', items: [`${s.pulse.total} initiatives · ${s.pulse.building} building · ${s.pulse.blocked} blocked · ${s.pulse.landed} landed`] },
1105
+ { heading: 'Metrics pulse', items: [`${s.pulse.total} initiatives · ${s.pulse.building} building · ${s.pulse.blocked} blocked · ${s.pulse.delivered} delivered · ${s.pulse.accepted} accepted`] },
941
1106
  ];
942
1107
  return { cadence: 'weekly', sections };
943
1108
  }
@@ -3608,10 +3773,27 @@ async function handleRequest(req, res) {
3608
3773
  'and useful answer.',
3609
3774
  ].filter((l) => l !== '').join('\n');
3610
3775
 
3776
+ // NOT typed blind into the PTY. An agent CLI opening a folder it has never seen shows a
3777
+ // trust prompt first — and a fresh tenant repo is by definition such a folder, so the case this
3778
+ // feature exists for is the exact case where blind input is swallowed by a security dialog and
3779
+ // the drafting silently never happens. Found by installing the published kit and watching a real
3780
+ // session sit at "Is this a project you trust?" with the prompt already gone.
3781
+ //
3782
+ // Auto-answering that dialog is not on the table: it is the PM's decision, not ours. So the
3783
+ // prompt is PERSISTED and STAGED instead — it cannot be lost, and the PM sends it once the agent
3784
+ // is actually at its input.
3785
+ const promptFile = path.join(adir, 'prompt-' + Date.now() + '.md');
3786
+ try { fs.writeFileSync(promptFile, prompt + '\n'); }
3787
+ catch (e) { warnWriteFail('alignment prompt', e); return json(res, { ok: false, error: 'could not stage the drafting prompt' }, 500); }
3788
+
3611
3789
  const s = spawnSession({ name: 'align: ' + path.basename(rel), agent: 'claude', ws: w.id,
3612
- origin: 'align:' + rel, initialInput: prompt + '\r' });
3790
+ origin: 'align:' + rel });
3613
3791
  const started = !!(s && s.pty);
3792
+ let staged = false;
3793
+ if (started) { try { writeScratchpad(w, s.id, prompt); staged = true; } catch {} }
3614
3794
  return json(res, { ok: started, sessionId: s && s.id, targets, sink: adir,
3795
+ prompt_file: promptFile, staged,
3796
+ next: started ? 'the drafting prompt is staged in this session\'s scratchpad — answer any trust prompt the agent shows, then send it' : undefined,
3615
3797
  error: started ? undefined : ((s && s.note) || 'could not start the session') });
3616
3798
  }
3617
3799
  if (p === '/api/align/drafts') {
@@ -3626,8 +3808,19 @@ async function handleRequest(req, res) {
3626
3808
  let doc;
3627
3809
  try { doc = JSON.parse(fs.readFileSync(path.join(adir, pick), 'utf8')); }
3628
3810
  catch { return json(res, { ok: false, error: `the draft file '${pick}' is unreadable or not valid JSON` }, 500); }
3629
- const drafts = Array.isArray(doc.drafts) ? doc.drafts.filter((d) => d && typeof d.file === 'string') : [];
3630
- return json(res, { ok: true, id: pick.replace(/\.json$/, ''), source: doc.source || null, at: doc.at || null, drafts });
3811
+ // A malformed draft is REFUSED at read, not half-rendered (contract crit 4). Silently dropping a
3812
+ // bad draft would let the agent's mistake reach the PM as a shorter, unexplained list — the same
3813
+ // "looks fine, is wrong" failure the anchored write guard exists to stop. An empty `drafts` array
3814
+ // is valid and calm (crit 3): the agent looked and found nothing to realign.
3815
+ if (!Array.isArray(doc.drafts)) {
3816
+ return json(res, { ok: false, error: `the draft file '${pick}' has no drafts array — the agent did not produce the documented shape` }, 422);
3817
+ }
3818
+ for (const d of doc.drafts) {
3819
+ if (!d || typeof d.file !== 'string' || typeof d.after !== 'string' || (d.before != null && typeof d.before !== 'string')) {
3820
+ return json(res, { ok: false, error: `a draft in '${pick}' is missing a file or an after — refusing to render a partial set (crit 4)` }, 422);
3821
+ }
3822
+ }
3823
+ return json(res, { ok: true, id: pick.replace(/\.json$/, ''), source: doc.source || null, at: doc.at || null, drafts: doc.drafts });
3631
3824
  }
3632
3825
  if (p === '/api/align/apply' && req.method === 'POST') {
3633
3826
  // Apply ONE drafted realignment. Same anchored guard as everything else that writes here: the
@@ -3969,4 +4162,9 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
3969
4162
  // where the anchor resolver is looked for — exported so the suite can assert the INSTALL
3970
4163
  // location is in the list. A dev checkout satisfies entry 1 and would hide the loss of entry 2
3971
4164
  // exactly as it hid the workflow engine's absence for four releases (PKG4).
3972
- ANCHOR_LIB_PATHS };
4165
+ ANCHOR_LIB_PATHS,
4166
+ // the delivery derivation (board-delivery-truth), exported so the suite can assert the three
4167
+ // things that matter independently of a rendered page: that no declared field reaches a status,
4168
+ // that an unreadable history reports rather than degrades, and that a planning-only commit does
4169
+ // not count as delivery.
4170
+ surfaceInitiatives, deliveryIndex, deriveState, isDeliveryPath };