create-pmos 0.16.1 → 0.18.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,66 @@ 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.18.0
337
+
338
+ **Several sessions in one folder now say so.** Run `npx create-pmos@latest workspace`; the version
339
+ bump means an existing install auto-upgrades.
340
+
341
+ - **Starting a session where others are already running tells you what you are sharing.** Sessions
342
+ in one checkout share one branch, one index and one set of files — that was true before and
343
+ nothing said it. The new session's pane now names the sessions already there and points at the
344
+ alternative (a new branch in its own folder). It appears on the ordinary "+ session" path too,
345
+ where no git operation runs at all: the server had already worked out that other sessions would
346
+ be disturbed, and was throwing the answer away unless you happened to pick a branch.
347
+ - **It counts the right thing.** A repository registered at two paths — the project root and a
348
+ subdirectory of it, the ordinary shape when you add a product repo by path — is **one** checkout,
349
+ and its sessions are now told about each other; before, each was told it was alone. Two
350
+ *worktrees* of one repository are **two** checkouts and are deliberately not warned about, because
351
+ those are the sessions that isolation already made safe. A folder with no git says nothing, since
352
+ there is no index to collide over.
353
+ - **One session is the declared writer; the rest read.** The first live session in a checkout holds
354
+ the claim, and you can hand it to another (`POST /api/tree/claim`); `GET /api/tree/peers` reports
355
+ who holds it and who else is there. It moves only when you move it — never on a timer or an exit —
356
+ and when its holder exits the checkout reads *vacant* rather than promoting somebody at random.
357
+ - **Be clear about what that is: a declaration, not a lock.** The workspace hosts your agent's
358
+ terminal; it does not sit between the agent and the files, so nothing here prevents a write, a
359
+ checkout or a commit, and no session is ever refused because another one is there. It makes a
360
+ shared checkout visible. The discipline stays yours.
361
+ - **A folder you are working in can no longer be reclaimed out from under you.** Removing a
362
+ worktree already refused when sessions were running in it — but it counted only sessions in the
363
+ workspace registered at that exact folder, so a session running in a subdirectory of it was
364
+ invisible. It is counted now, and named in the refusal.
365
+
366
+ ## What's new in 0.17.0
367
+
368
+ **Your board stops calling shipped work unstarted.** Run `npx create-pmos@latest workspace`; the
369
+ version bump means an existing install auto-upgrades.
370
+
371
+ - **An initiative's status is now read from what happened, not from what someone typed.** The
372
+ workspace took each initiative's state from a `status:` line written by hand into a PRD and
373
+ matched it against a list of words. In the repo this was built in, that one field held 23
374
+ different values, 33 initiatives had none at all, and several were cut off mid-sentence by a
375
+ stray comma. The plainest value of the lot — `build complete, PR #138` — matched the word
376
+ "build" first and displayed as *in progress*. Status is now derived from your acceptance
377
+ records, your logged runs, your open items, and your repository's own merge history, and each
378
+ row states which of those it was read from. Nothing you type can move it.
379
+ - **`delivered` and `accepted` are no longer the same thing.** Work that merged and work you
380
+ actually signed off are now separate states. That gap is usually the interesting number: in the
381
+ repo this was built in, 62 of 63 delivered initiatives had no acceptance recorded — invisible
382
+ while both wore one label.
383
+ - **The board reads your whole project history, not just the branch you are standing on.** It
384
+ shared a commit range with the CI gate, and that range is *empty* when your branch is level with
385
+ `main` — so every initiative reported "no implementation commits" and fell back to its earliest
386
+ stage. Work finished weeks ago looked identical to work never begun. The gate still reads only
387
+ the pull request, which is correct for judging whether a spec came before its build.
388
+ - **A history it cannot read is now said out loud.** In a shallow clone (some CI checkouts) the
389
+ delivery history genuinely is not there. The board reports that, with the reason, instead of
390
+ quietly showing everything as unstarted — which would look exactly like a project where nothing
391
+ has happened.
392
+
393
+ Unchanged where it matters: **no database, no network at runtime, no telemetry.** The server still
394
+ binds `127.0.0.1` only. The file-mode kit itself is unchanged and still dependency-free.
395
+
336
396
  ## What's new in 0.16.1
337
397
 
338
398
  **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.18.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@83f52ff
2
2
  assembled: 2026-07-31
3
3
  mode: vendor
@@ -155,6 +155,31 @@ the folder and **keeps the branch**; nothing is ever removed automatically. Git
155
155
  between worktrees, so what a folder costs is a checkout of tracked files, not a second copy of the
156
156
  repo.
157
157
 
158
+ ## Several sessions in one tree: you are told, never stopped
159
+
160
+ Sessions in one working tree share **one branch, one index and one set of files**. Starting a
161
+ session in a tree that already has live ones now says so in the pane — naming them, and naming the
162
+ way out (⑂ a new branch in its own folder). It fires on the plain `+ session` path too, where no
163
+ git operation runs at all: the server already knew, and used to say nothing.
164
+
165
+ **The tree is the unit, not the folder you registered.** A repo registered twice — say `~/repo` and
166
+ `~/repo/packages/app`, the ordinary shape for a product repo added by path — is **one** tree, and
167
+ its sessions are told about each other. Two *worktrees* of one repo are **two** trees, and are not
168
+ warned about at all: those are the sessions isolation made safe. A folder with no git has no tree
169
+ and says nothing, because there is no index to collide over.
170
+
171
+ **The writer claim is a declaration, not a lock.** The first live session in a tree holds it; the
172
+ others read. `GET /api/tree/peers?ws=…` reports the tree, its holder and every session on it;
173
+ `POST /api/tree/claim {session}` hands it over. It moves **only** when you move it — never on a
174
+ timer, never on an exit. When the holder exits the tree reads *vacant*, and no survivor is
175
+ promoted, because which pane should be driving is your intention and not something to derive.
176
+
177
+ Be clear about what this buys: **PMOS hosts your agent's terminal, it does not sit between it and
178
+ the files.** Nothing here prevents a write, a checkout or a commit, and no spawn is ever refused
179
+ because someone else is in the tree. It makes a shared tree visible; the discipline is still yours.
180
+ (The role rides on `/api/sessions` rows as `tree` / `tree_role` / `tree_peers`; the tab-bar chip is
181
+ not wired yet.)
182
+
158
183
  A workspace anywhere **inside** a repo knows its branch — a subdirectory (`repo/packages/app`)
159
184
  counts, which matters for a product repo added by path. A folder with **no git** simply says so and
160
185
  starts the session; nothing about git can block work in a repo that doesn't use it.
@@ -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
 
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+ // Who shares a working tree with whom, and who is DECLARED to be driving it.
3
+ //
4
+ // The premise, stated once so nothing below has to hedge: PMOS hosts real PTYs and the agent CLI
5
+ // inside one is untouched (D62). There is nothing between an agent and the filesystem, so this
6
+ // module cannot prevent a write, a checkout or a commit — and never claims to. What it can do is
7
+ // compute the right SET of sessions that would collide, and carry one declaration about which of
8
+ // them the PM says is driving. Advisory, and every sentence it produces says so.
9
+ //
10
+ // The one idea worth holding: **three keys look like "which repo is this" and only one of them is
11
+ // the collision unit.**
12
+ // • the workspace id — a registered DIRECTORY. Too narrow: one repo can be registered twice
13
+ // (`~/repo` and `~/repo/packages/app`, the ordinary shape for a tenant repo added by path),
14
+ // and those two ids share one HEAD, one index, one set of files.
15
+ // • `repo_key` — the repository, i.e. its MAIN worktree. Too wide: it deliberately groups
16
+ // a repo's linked worktrees, which are exactly the sessions that are SAFELY isolated. Warning
17
+ // on those is how a warning gets trained away.
18
+ // • the working-tree top (`git rev-parse --show-toplevel`) — one HEAD, one index, one
19
+ // `index.lock`. This is the one. A folder with no git has no tree key, and therefore no
20
+ // co-tenants: there is no index to corrupt, so there is nothing to say.
21
+ //
22
+ // Pure functions over plain rows, on purpose: the server owns the git shell-outs and the session
23
+ // registry, and the rules are the part worth testing without either.
24
+
25
+ /** Live = a session the PM could still be driving. `exited` is a corpse and collides with nobody. */
26
+ function isLive(s) { return !!s && s.state !== 'exited'; }
27
+
28
+ /**
29
+ * The live sessions sharing `tree`, excluding one id (normally the session asking).
30
+ * `treeOfWs` maps a workspace id → its working-tree top (null for a folder with no git).
31
+ * A null/empty `tree` matches nothing — "not in a repo" is never a group.
32
+ */
33
+ function coTenants(sessions, treeOfWs, tree, exclude) {
34
+ if (!tree) return [];
35
+ const out = [];
36
+ for (const s of sessions || []) {
37
+ if (!isLive(s) || s.id === exclude) continue;
38
+ if (treeOfWs.get(s.ws) === tree) out.push(s);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * Who holds the writer claim on `tree` — or null when nobody does.
45
+ *
46
+ * The claim is stored, but it is only BELIEVED while its holder is live: a claim is a statement
47
+ * about a running session, and a dead session cannot be driving anything. Deriving the answer this
48
+ * way is what makes "the holder exited" read as **vacant** rather than as a stale name, without a
49
+ * cleanup pass having to be correct for the UI to be honest.
50
+ *
51
+ * Note what this deliberately does NOT do: promote the oldest survivor. Which pane should drive is
52
+ * the PM's intention, not an observable, and a derived answer must never impersonate a decision.
53
+ */
54
+ function writerOf(claims, treeOfWs, sessions, tree) {
55
+ if (!tree) return null;
56
+ const held = claims.get(tree);
57
+ if (!held) return null;
58
+ const s = (sessions || []).find((x) => x && x.id === held);
59
+ if (!isLive(s) || treeOfWs.get(s.ws) !== tree) return null;
60
+ return held;
61
+ }
62
+
63
+ /** 'writer' | 'reader' for a session in a tree; null when it is not in one (no git → no role). */
64
+ function roleOf(sessionId, tree, writerId) {
65
+ if (!tree) return null;
66
+ return sessionId === writerId ? 'writer' : 'reader';
67
+ }
68
+
69
+ /** Name a handful of panes without letting a 12-session tree produce a paragraph. */
70
+ function nameList(rows, cap = 3) {
71
+ const names = (rows || []).map((s) => `“${s.name}”`);
72
+ if (names.length <= cap) return names.join(', ');
73
+ return `${names.slice(0, cap).join(', ')} and ${names.length - cap} more`;
74
+ }
75
+
76
+ /**
77
+ * The sentence a session gets when it starts in a tree somebody is already working in.
78
+ *
79
+ * It names the panes (a bare count tells the PM nothing they can act on), states the ONE fact that
80
+ * makes this matter — a shared branch, index and file set — and names the way out that already
81
+ * exists, because a warning with no alternative is just a nag. Returns null when there is nobody
82
+ * there, so the caller never has to test for emptiness.
83
+ */
84
+ function coTenancyNotice(mates, opts) {
85
+ const rows = mates || [];
86
+ if (!rows.length) return null;
87
+ const n = rows.length;
88
+ const role = opts && opts.role === 'writer' ? 'writer' : 'reader';
89
+ const held = opts && opts.writerName;
90
+ const who = role === 'writer'
91
+ // Vacant tree: this session took the claim. Say so — the PM should know a declaration was made
92
+ // on their behalf, not discover it later in a list.
93
+ ? 'nobody had claimed it, so this session now holds the writer claim'
94
+ : held
95
+ ? `“${held}” holds the writer claim`
96
+ : 'no session holds the writer claim';
97
+ return `${n} session${n === 1 ? '' : 's'} already running in this working tree (${nameList(rows)}) — `
98
+ + `you share one branch, one index and one set of files with ${n === 1 ? 'it' : 'them'}. ${who}. `
99
+ + 'Claims are advisory: PMOS hosts your agent’s terminal, it does not sit between it and the '
100
+ + 'files. To work in parallel for real, start the session on a new branch with its own folder (⑂).';
101
+ }
102
+
103
+ /**
104
+ * The same fact, written into the pane's own scrollback at spawn.
105
+ *
106
+ * The notice frame is a transient line in whichever client was listening; this one is part of the
107
+ * session's record, so re-opening the tab tomorrow still shows what it was told. Dim, one line,
108
+ * and it never claims anything was prevented.
109
+ */
110
+ function paneAdvisory(mates, opts) {
111
+ const line = coTenancyNotice(mates, opts);
112
+ if (!line) return null;
113
+ return `\x1b[38;5;244m⑂ ${line}\x1b[0m\r\n`;
114
+ }
115
+
116
+ /**
117
+ * Carry two non-fatal truths on a channel that has only ever carried one.
118
+ *
119
+ * `spawnWithBranch` returns a single `notice` string and, until now, at most one of its facts could
120
+ * be set at a time. A branch notice and a co-tenancy notice can both be true of the same spawn, and
121
+ * the second must not evict the first — dropping one would make the wire format quietly lossy in
122
+ * exactly the situation the PM most needs both.
123
+ */
124
+ function joinNotices(...parts) {
125
+ const kept = parts.filter((p) => p != null && String(p).trim() !== '');
126
+ return kept.length ? kept.join(' · ') : null;
127
+ }
128
+
129
+ module.exports = { isLive, coTenants, writerOf, roleOf, coTenancyNotice, paneAdvisory, joinNotices, nameList };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.16.1",
3
+ "version": "0.18.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.18.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.18.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"
@@ -71,6 +71,7 @@ const consoleState = require('./lib/console-state'); // read-only console surf
71
71
  const boardCache = require('./lib/board-cache'); // the workflow board, cached + derived off the event loop (R4)
72
72
  const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
73
73
  const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
74
+ const treeShare = require('./lib/tree-share'); // who shares a working tree, and who is DECLARED to be driving it
74
75
  const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
75
76
  const relatedDocs = require('./lib/related-docs'); // which documents a change puts out of step (R5/R6)
76
77
  const applyChange = require('./lib/apply-change'); // anchored + whole-document saves, refuse-on-stale
@@ -312,11 +313,18 @@ function repoIdentity(root) {
312
313
  const now = Date.now();
313
314
  if (hit && now - hit.at < REPO_KEY_TTL_MS) return hit;
314
315
  let key = root, name = path.basename(root), branch = null, branchError = null, branchCode = null;
315
- let remote = null;
316
+ let remote = null, tree = null;
316
317
  try {
317
318
  const trees = gitInfo.worktrees(root);
318
319
  if (trees.length && trees[0].dir) { key = path.resolve(trees[0].dir); name = path.basename(key); }
319
320
  } catch { /* not a repo, or git absent — the tree is its own group, which is the truth */ }
321
+ // The THIRD key, and the only one that answers "would these two sessions collide": the working
322
+ // tree this workspace sits in — one HEAD, one index, one set of files. `key` above is the
323
+ // REPOSITORY (its main worktree), which deliberately groups a repo's linked worktrees; those are
324
+ // exactly the sessions that are safely isolated, so it is the wrong key for collisions. Null for
325
+ // a folder with no git, and null is meaningful: no index, nothing to collide over. Memoised in
326
+ // this same entry because `wsList()` runs on every workspace push and this is a third shell-out.
327
+ try { tree = gitInfo.repoRoot(root); } catch { tree = null; }
320
328
  // Where this tree came FROM. Memoised with the rest of the identity (same 15s TTL) because it is
321
329
  // another `git` shell-out on a call that runs for every workspace on every push. A failure is not
322
330
  // a fault: a folder registered by path legitimately has no origin, and null is the honest answer.
@@ -333,10 +341,62 @@ function repoIdentity(root) {
333
341
  // one drifts.
334
342
  else { branchError = bi.error || 'branch could not be read'; branchCode = bi.code || 'git-error'; }
335
343
  } catch { branchError = 'branch could not be read'; branchCode = 'git-error'; }
336
- const val = { at: now, key, name, branch, branchError, branchCode, remote };
344
+ const val = { at: now, key, name, branch, branchError, branchCode, remote, tree };
337
345
  repoKeyCache.set(root, val);
338
346
  return val;
339
347
  }
348
+
349
+ // ── Shared-tree co-tenancy (workspace-session-concurrency) ────────────────────────────────────
350
+ // The rules live in lib/tree-share.js, pure and unit-tested; what lives here is the two things the
351
+ // server owns — the git answer (via the memoised identity above) and the session registry.
352
+ //
353
+ // Held claims, tree top → session id. Only ever BELIEVED while its holder is live (treeShare.
354
+ // writerOf re-checks), so an exited writer leaves the tree vacant rather than leaving a stale name
355
+ // on it. Nothing is promoted automatically: which pane drives is the PM's intention, not ours.
356
+ const treeClaims = new Map();
357
+ /** workspace id → its working-tree top (null when the folder is not in a repo). */
358
+ function treeOfWsMap() {
359
+ const m = new Map();
360
+ for (const w of workspaces.values()) m.set(w.id, repoIdentity(w.root).tree || null);
361
+ return m;
362
+ }
363
+ function treeForWs(wsIdent) {
364
+ const w = wsIdent && workspaces.get(wsIdent);
365
+ return w ? (repoIdentity(w.root).tree || null) : null;
366
+ }
367
+ /** Live sessions sharing a tree, excluding one id. Rows are the registry's own objects. */
368
+ function treeTenants(tree, excludeId) {
369
+ return treeShare.coTenants([...sessions.values()], treeOfWsMap(), tree, excludeId);
370
+ }
371
+ function treeWriter(tree) {
372
+ return treeShare.writerOf(treeClaims, treeOfWsMap(), [...sessions.values()], tree);
373
+ }
374
+ /**
375
+ * A freshly-live session meets its tree: take the claim if nobody holds one, and write what it
376
+ * found into its own scrollback. The pane line is deliberately part of the session's record rather
377
+ * than only a transient notice frame — re-open the tab tomorrow and it still says what it was told.
378
+ * Returns the co-tenants so the caller can put the same fact on the wire.
379
+ */
380
+ function announceTree(s) {
381
+ const tree = treeForWs(s.ws);
382
+ s.tree = tree || null;
383
+ if (!tree) return [];
384
+ const mates = treeTenants(tree, s.id);
385
+ const holder = treeWriter(tree);
386
+ if (!holder) treeClaims.set(tree, s.id); // vacant → this session declares itself
387
+ if (!mates.length) return []; // alone in the tree: nothing to say
388
+ const held = holder ? (sessions.get(holder) || {}).name : null;
389
+ const line = treeShare.paneAdvisory(mates, { role: holder ? 'reader' : 'writer', writerName: held });
390
+ if (line) appendBuf(s, line);
391
+ return mates;
392
+ }
393
+ /** The sentence for the spawn's notice frame — same facts as the pane line, same source. */
394
+ function treeNoticeFor(s, mates) {
395
+ if (!mates || !mates.length) return null;
396
+ const holder = treeWriter(s.tree);
397
+ const held = holder && holder !== s.id ? (sessions.get(holder) || {}).name : null;
398
+ return treeShare.coTenancyNotice(mates, { role: holder === s.id ? 'writer' : 'reader', writerName: held });
399
+ }
340
400
  function wsList() {
341
401
  return [...workspaces.values()].map((w) => {
342
402
  const rid = repoIdentity(w.root);
@@ -523,6 +583,10 @@ const ROLE_BY_DIR = {
523
583
  prd: 'PRD', evals: 'eval', design: 'design', research: 'research', briefs: 'brief',
524
584
  okrs: 'OKR', radar: 'radar', gtm: 'gtm', 'out-of-scope': 'rejected', 'harness-refine': 'harness',
525
585
  'design-export': 'design', explainers: 'explainer', fixtures: 'fixture',
586
+ // The initiative RECORD (planning/initiatives/<id>.md) is the canonical anchor+lane carrier and
587
+ // the only artifact that exists for work backfilled from a merge. Before it was a role, a record
588
+ // with no sibling PRD landed in `ungrouped` and the initiative was invisible here.
589
+ initiatives: 'record',
526
590
  };
527
591
  // ── D60 specimens: prototype/<id>/, deliberately OUTSIDE planning/ ───────────────────────────
528
592
  // The planning walk cannot reach them (planningRoots only finds `planning`), so a prototype leg was
@@ -575,17 +639,23 @@ function parseHead(full) {
575
639
  head = buf.slice(0, n).toString('utf8');
576
640
  } catch { return {}; }
577
641
  const id = head.match(/^\s*[-*]?\s*initiative_id\s*:\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*$/m);
642
+ // An initiative RECORD names itself with `id:`, not `initiative_id:`. Only the record role reads
643
+ // this (see docMeta) — a bare `id:` on any other document must not invent an initiative.
644
+ const rid = head.match(/^\s*id\s*:\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*$/m);
578
645
  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.
646
+ // The anchor and the lane are DECLARED state and are read as such — they say what the work is
647
+ // pointed at, never how far along it is. `status:`/`stage:` are deliberately NOT read: they were
648
+ // hand-typed free text (23 distinct values in this repo, 33 absent, several truncated mid-YAML)
649
+ // funnelled through a regex, so `build complete, PR #138` rendered as `building`. Position is
650
+ // derived from evidence in deliveryIndex/deriveState instead — see the surface explainer.
581
651
  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
652
  const kr = head.match(/^\s*[-*]?\s*parent[_-]?kr\s*:\s*(.+?)\s*$/mi);
653
+ const lane = head.match(/^\s*[-*]?\s*lane\s*:\s*(.+?)\s*$/mi);
585
654
  return {
586
655
  initiative: id ? id[1] : null,
656
+ recordId: rid ? rid[1] : null,
587
657
  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),
658
+ kr: clean(kr), lane: clean(lane),
589
659
  };
590
660
  }
591
661
  function roleAndStem(rel, base) {
@@ -608,8 +678,9 @@ function docMeta(root, full) {
608
678
  const meta = {
609
679
  mtimeMs: st.mtimeMs, path: rel, name: base, role,
610
680
  // 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,
681
+ initiative: head.initiative || (role === 'record' ? head.recordId : null) || null,
682
+ stem, title: head.title || null,
683
+ kr: head.kr || null, lane: head.lane || null,
613
684
  };
614
685
  metaCache.set(full, meta);
615
686
  return meta;
@@ -650,17 +721,17 @@ function initiativeIndex(root) {
650
721
  const k = keyFor(m);
651
722
  const art = { name: m.name, path: m.path, role: m.role, title: m.title };
652
723
  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: [] });
724
+ if (!groups.has(k)) groups.set(k, { id: k, title: null, kr: null, lane: null, artifacts: [] });
654
725
  const g = groups.get(k);
655
726
  g.artifacts.push(art);
656
727
  if (m.role === 'PRD' && m.title) g.title = m.title; // the PRD names the initiative
657
728
  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;
729
+ // The anchor and lane are the RECORD's to declare (D56/D59 — it is what the gates read); the
730
+ // PRD fills a gap for legacy initiatives that predate repo-native records, never overrides it.
731
+ if (m.kr && (m.role === 'record' || !g.kr)) g.kr = m.kr;
732
+ if (m.lane && (m.role === 'record' || !g.lane)) g.lane = m.lane;
662
733
  }
663
- const ROLE_ORDER = ['PRD', 'rubric', 'eval', 'design brief', 'design', 'reconcile', 'dogfood'];
734
+ const ROLE_ORDER = ['record', 'PRD', 'rubric', 'eval', 'design brief', 'design', 'reconcile', 'dogfood'];
664
735
  const initiatives = [...groups.values()].map((g) => {
665
736
  g.artifacts.sort((a, b) => {
666
737
  const ia = ROLE_ORDER.indexOf(a.role), ib = ROLE_ORDER.indexOf(b.role);
@@ -695,57 +766,207 @@ function library(root) {
695
766
  // client renders it; a surface that can't be produced returns ok:false and the client shows an
696
767
  // honest can't-produce state. RICE/effort/milestones/progress are NET-NEW state → emitted as null
697
768
  // ("— 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';
769
+ const PHASE_OF = { planning: 'plan', building: 'deliver', blocked: 'deliver', delivered: 'report', accepted: 'report', unknown: 'plan' };
770
+
771
+ // ── Delivery evidence (board-delivery-truth R-2/R-3). ──────────────────────────────────────────
772
+ // "Has this shipped?" answered from the trunk's own history rather than from anything anyone typed.
773
+ //
774
+ // ONE `git log` per trunk head, memoised on that sha — ~0.3s and ~800 KB on this repo's 1,214
775
+ // commits, paid once and reused by every surface until the trunk moves. Local only: git reads the
776
+ // object store on disk, so the suite's "no network" guarantee (K. Surfaces) is untouched.
777
+ //
778
+ // TWO signals, because one is not enough. The branch convention (`initiative/<id>`) is precise but
779
+ // incomplete: 31% of this repo's merged PRs shipped on `claude/*` branches, which match no id. So a
780
+ // commit that MENTIONS the id and TOUCHES a build path counts too. The build-path requirement is
781
+ // what keeps the mention signal honest — `feat(planning): add <id> PRD` mentions the id while
782
+ // delivering nothing, and without the path test every specified initiative would read as shipped.
783
+ const BUILD_EXCLUDE = /^(planning|okf|prototype)\//;
784
+ function isDeliveryPath(f) {
785
+ if (!f) return false;
786
+ if (BUILD_EXCLUDE.test(f)) return false;
787
+ return !/^[^/]+\.md$/.test(f); // root governance prose (DECISIONS.md, CLAUDE.md…) is not a build
788
+ }
789
+ const deliveryCache = new Map(); // root -> { head, index }
790
+ function trunkRef(root) {
791
+ for (const c of ['origin/main', 'main', 'origin/master', 'master']) {
792
+ const r = gitCap(root, ['rev-parse', '--verify', '--quiet', c]);
793
+ if (r.ok && r.out.trim()) return { ref: c, head: r.out.trim() };
794
+ }
705
795
  return null;
706
796
  }
707
- function statusFrom(g) {
708
- return statusKey(g.status) || statusKey(g.stage) || 'planning';
797
+ function gitCap(root, args) {
798
+ try {
799
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
800
+ if (r.error || r.status !== 0) return { ok: false, out: '', err: (r.stderr || (r.error && r.error.message) || '').trim() };
801
+ return { ok: true, out: r.stdout || '' };
802
+ } catch (e) { return { ok: false, out: '', err: String((e && e.message) || e) }; }
803
+ }
804
+ /**
805
+ * → { ok, reason?, byBranch:Set<id>, byMention:Set<id> }
806
+ * `ok:false` is a REAL answer and never degrades into "nothing was delivered" — the caller renders
807
+ * it red and marks every initiative's delivery `unknown` (R-3). A board that cannot read the
808
+ * history must say so; silently reporting `planning` for 110 shipped initiatives is the failure
809
+ * this whole initiative exists to remove.
810
+ */
811
+ function deliveryIndex(root, ids) {
812
+ const t = trunkRef(root);
813
+ 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() };
814
+ const hit = deliveryCache.get(root);
815
+ if (hit && hit.head === t.head) return hit.index;
816
+
817
+ // %x1e separates commits, %x1f separates the message from --name-only's file list.
818
+ const r = gitCap(root, ['log', '--format=%x1e%s %b%x1f', '--name-only', t.head]);
819
+ if (!r.ok) {
820
+ return { ok: false, reason: `git could not read the history at ${t.ref}${r.err ? ` — ${r.err}` : ''}`, byBranch: new Set(), byMention: new Set() };
821
+ }
822
+ const byBranch = new Set(), byMention = new Set();
823
+ const known = Array.isArray(ids) ? ids : [...ids];
824
+ for (const chunk of r.out.split('\x1e')) {
825
+ if (!chunk.trim()) continue;
826
+ const cut = chunk.indexOf('\x1f');
827
+ const msg = cut < 0 ? chunk : chunk.slice(0, cut);
828
+ const files = cut < 0 ? [] : chunk.slice(cut + 1).split('\n').map((s) => s.trim()).filter(Boolean);
829
+ for (const m of msg.matchAll(/initiative\/([A-Za-z0-9][\w.-]*)/g)) byBranch.add(m[1]);
830
+ if (!files.some(isDeliveryPath)) continue;
831
+ for (const id of known) if (id.length > 3 && msg.includes(id)) byMention.add(id);
832
+ }
833
+ const index = { ok: true, byBranch, byMention };
834
+ deliveryCache.set(root, { head: t.head, index });
835
+ return index;
836
+ }
837
+ /**
838
+ * The five states, in precedence order. Every one of them names the artifact it was read from, so
839
+ * the surface can show its own evidence instead of asking to be trusted.
840
+ *
841
+ * `blocked` deliberately outranks `delivered`: work the PM RETURNED is work that needs them, and
842
+ * hiding that behind a delivery glyph would reproduce, in a new place, the exact failure of a board
843
+ * that reports green over a red underneath. (The PRD's R-1 table lists delivered first; this is the
844
+ * one ordering departure, made because the alternative buries a returned initiative.)
845
+ */
846
+ function deriveState(ev) {
847
+ if (ev.accepted) return { state: 'accepted', why: `accepted by the PM${ev.acceptedAt ? ` on ${String(ev.acceptedAt).slice(0, 10)}` : ''}` };
848
+ if (ev.returned) return { state: 'blocked', why: 'the PM returned this at the Acceptance Gate' };
849
+ if (ev.needs) return { state: 'blocked', why: `${ev.needs} open item${ev.needs > 1 ? 's' : ''} in needs.json` };
850
+ // Not `planning`. An unreadable history means the evidence for the delivery states is missing,
851
+ // and `planning` is itself a claim ("nothing has happened") that the missing evidence cannot
852
+ // support. `unknown` is the only honest answer, and it is visibly not a resting state.
853
+ if (ev.delivery === 'unknown') return { state: 'unknown', why: 'the trunk history could not be read — delivery cannot be derived here' };
854
+ 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' };
855
+ if (ev.runs) return { state: 'building', why: `${ev.runs} run${ev.runs > 1 ? 's' : ''} logged, nothing merged yet` };
856
+ return { state: 'planning', why: 'no run, no merge, no acceptance — nothing has happened yet' };
857
+ }
858
+ // BOTH acceptance ledgers, oldest-wins-never: the workspace writes to its state dir, while the
859
+ // ladder and every hand-recorded acceptance in pmos-self live in planning/runs/. Reading only the
860
+ // state dir is why this surface reported 0 accepted against 23 real acceptance rows — the same
861
+ // "one field, several readers, none agreeing" defect the anchor resolver was built to end.
862
+ function acceptanceFilesFor(w) {
863
+ const out = [];
864
+ const d = stateDirFor(w);
865
+ if (d) out.push(path.join(d, 'acceptances.jsonl'));
866
+ if (w && w.root) out.push(path.join(w.root, 'planning', 'runs', 'acceptances.jsonl'));
867
+ return out;
709
868
  }
710
869
  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')) {
870
+ const acc = new Map(); // initiative_id -> { action, at }
871
+ for (const f of acceptanceFilesFor(w)) {
872
+ let text = '';
873
+ try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
874
+ for (const line of text.split('\n')) {
716
875
  if (!line.trim()) continue;
717
876
  // Tolerate both shapes: this server's {action} and the kit's {accepted} (D54 adapter).
877
+ // THE BOOLEAN OUTRANKS THE LABEL. `action` is an open vocabulary describing what the PM did
878
+ // — this corpus holds accept, merge, publish, return — while `accepted` is the D12 verdict
879
+ // itself. Reading `action` first (the previous behaviour) meant every row labelled `merge` or
880
+ // `publish` carried `accepted: true` and was still not counted: 7 of 16 real acceptances went
881
+ // missing, and the surface reported those initiatives as merely `building`.
718
882
  try {
719
883
  const r = JSON.parse(line);
720
884
  if (!r.initiative_id) continue;
721
- acc.set(r.initiative_id, r.action || (r.accepted === true ? 'accept' : r.accepted === false ? 'return' : null));
885
+ const label = String(r.action || '').toLowerCase();
886
+ const action = r.accepted === true ? 'accept'
887
+ : r.accepted === false ? 'return'
888
+ : label === 'return' ? 'return'
889
+ : ['accept', 'approve', 'merge', 'publish'].includes(label) ? 'accept'
890
+ : null;
891
+ const at = r.at || r.date || null;
892
+ // Last write wins, but a row with no timestamp never displaces a dated one.
893
+ const prev = acc.get(r.initiative_id);
894
+ if (prev && prev.at && !at) continue;
895
+ acc.set(r.initiative_id, { action, at });
722
896
  } catch {}
723
897
  }
724
- } catch {}
898
+ }
725
899
  return acc;
726
900
  }
901
+ // How many runs each initiative has logged — evidence that work STARTED, which is the only thing
902
+ // separating `building` from `planning`. The file is the same one the ladder reads.
903
+ function readRunCounts(w) {
904
+ const counts = new Map();
905
+ const d = stateDirFor(w);
906
+ const files = [];
907
+ if (d) files.push(path.join(d, 'agent-runs.jsonl'));
908
+ if (w && w.root) files.push(path.join(w.root, 'planning', 'runs', 'agent-runs.jsonl'));
909
+ for (const f of files) {
910
+ let text = '';
911
+ try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
912
+ for (const line of text.split('\n')) {
913
+ if (!line.trim()) continue;
914
+ try {
915
+ const r = JSON.parse(line);
916
+ if (r && r.initiative_id) counts.set(r.initiative_id, (counts.get(r.initiative_id) || 0) + 1);
917
+ } catch {}
918
+ }
919
+ }
920
+ return counts;
921
+ }
727
922
  function surfaceInitiatives(root, w) {
728
923
  const { initiatives } = initiativeIndex(root);
729
924
  const acc = readAcceptances(w);
925
+ const runs = readRunCounts(w);
926
+ const needsRaw = readNeeds(w) || [];
927
+ const needsBy = new Map();
928
+ for (const n of needsRaw) if (n && n.initiative_id) needsBy.set(n.initiative_id, (needsBy.get(n.initiative_id) || 0) + 1);
929
+
930
+ const del = deliveryIndex(root, initiatives.map((g) => g.id));
730
931
  const items = initiatives.map((g) => {
731
- const sk = statusFrom(g);
932
+ const a = acc.get(g.id) || {};
933
+ const deliveredBy = !del.ok ? null : del.byBranch.has(g.id) ? 'branch' : del.byMention.has(g.id) ? 'mention' : null;
934
+ const { state, why } = deriveState({
935
+ accepted: a.action === 'accept' || a.action === 'approve', acceptedAt: a.at,
936
+ returned: a.action === 'return',
937
+ needs: needsBy.get(g.id) || 0,
938
+ delivery: del.ok ? 'known' : 'unknown',
939
+ deliveredBy,
940
+ runs: runs.get(g.id) || 0,
941
+ });
732
942
  return {
733
943
  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 })),
944
+ status: state, statusText: why, phase: PHASE_OF[state] || 'plan',
945
+ // `stage` was the declared field this surface used to trust. It is gone, not renamed —
946
+ // the client renders `statusText`, which always names the artifact the state was read from.
947
+ stage: null, kr: g.kr || null, lane: g.lane || null,
948
+ accepted: state === 'accepted',
949
+ delivered: state === 'accepted' || state === 'delivered',
950
+ delivery_evidence: deliveredBy,
951
+ artifacts: g.artifacts.map((a2) => ({ role: a2.role, name: a2.name, path: a2.path })),
738
952
  // NET-NEW state — not on disk yet, so null (the surface renders "— not tracked", never a guess)
739
953
  rice: null, effort: null, milestones: null, progress: null, tracked: false,
740
954
  };
741
955
  });
742
956
  const by = (k) => items.filter((i) => i.status === k).length;
743
- const needs = (readNeeds(w) || []).map((n) => ({
957
+ const needs = needsRaw.map((n) => ({
744
958
  t: n.question || '(unstated)', kind: n.kind || 'question', initiative_id: n.initiative_id || null,
745
959
  }));
746
960
  return {
747
961
  initiatives: items,
748
- pulse: { total: items.length, planning: by('planning'), building: by('building'), blocked: by('blocked'), landed: by('landed') },
962
+ pulse: {
963
+ total: items.length, planning: by('planning'), building: by('building'),
964
+ blocked: by('blocked'), delivered: by('delivered'), accepted: by('accepted'),
965
+ unknown: by('unknown'),
966
+ },
967
+ // R-3: a failed read is reported, never absorbed. The client renders this RED above the list
968
+ // rather than letting 110 shipped initiatives quietly read `planning`.
969
+ delivery_read: del.ok ? null : (del.reason || 'the trunk history could not be read'),
749
970
  needs,
750
971
  };
751
972
  }
@@ -755,7 +976,7 @@ function surfaceRoadmap(root, w) {
755
976
  label, dates_tracked: false,
756
977
  items: items.filter((i) => keys.includes(i.status)).map((i) => ({ id: i.id, status: i.status, phase: i.phase, progress: i.progress })),
757
978
  });
758
- const groups = [bucket('In flight', ['building', 'blocked']), bucket('Planned', ['planning']), bucket('Landed', ['landed'])]
979
+ const groups = [bucket('In flight', ['building', 'blocked']), bucket('Planned', ['planning']), bucket('Landed', ['delivered', 'accepted'])]
759
980
  .filter((g) => g.items.length);
760
981
  return { groups, note: 'dates are net-new state — grouped by status until a timeline is tracked' };
761
982
  }
@@ -932,12 +1153,16 @@ function surfaceReport(root, w) {
932
1153
  const s = surfaceInitiatives(root, w);
933
1154
  const line = (i) => `${i.id}${i.title ? ' — ' + i.title : ''}`;
934
1155
  const inflight = s.initiatives.filter((i) => i.status === 'building' || i.status === 'blocked');
935
- const landed = s.initiatives.filter((i) => i.status === 'landed');
1156
+ // Delivered and accepted are reported SEPARATELY, not summed into one "landed" heading. The gap
1157
+ // between them is the D12 Acceptance Gate's backlog — the number this report exists to surface.
1158
+ const delivered = s.initiatives.filter((i) => i.status === 'delivered');
1159
+ const accepted = s.initiatives.filter((i) => i.status === 'accepted');
936
1160
  const sections = [
937
1161
  { heading: 'In flight', items: inflight.map(line) },
938
- { heading: 'Landed', items: landed.map(line) },
1162
+ { heading: 'Delivered — acceptance not recorded', items: delivered.map(line) },
1163
+ { heading: 'Accepted', items: accepted.map(line) },
939
1164
  { 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`] },
1165
+ { 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
1166
  ];
942
1167
  return { cadence: 'weekly', sections };
943
1168
  }
@@ -1307,7 +1532,16 @@ function sessionList() {
1307
1532
  // failure to avoid claiming "no profiles are configured on this machine" when PMOS never looked.
1308
1533
  const pread = readProfilesNow();
1309
1534
  const byId = new Map((pread.profiles || []).map((p) => [p.id, p]));
1310
- return [...sessions.values()].map((s) => {
1535
+ // ONE tree map and ONE writer lookup per broadcast, for the same reason profiles are read once:
1536
+ // the heartbeat runs every 2s and a per-row `git rev-parse` would be a shell-out per pane per
1537
+ // tick. `repoIdentity` is memoised, but the map is still built once and reused.
1538
+ const rowsAll = [...sessions.values()];
1539
+ const treeOfWs = treeOfWsMap();
1540
+ const writerByTree = new Map();
1541
+ for (const t of new Set([...treeOfWs.values()].filter(Boolean))) {
1542
+ writerByTree.set(t, treeShare.writerOf(treeClaims, treeOfWs, rowsAll, t));
1543
+ }
1544
+ return rowsAll.map((s) => {
1311
1545
  const d = harness.deriveState(s.state, s.lastOutputAt, now);
1312
1546
  const h = harnessOf(s);
1313
1547
  const hx = transcriptHarness(s);
@@ -1339,6 +1573,18 @@ function sessionList() {
1339
1573
  // BARE ID — the module rejects a bare string on purpose ("the recorded value is not a
1340
1574
  // profile descriptor"), which is the honest answer. Resolving it to `null` instead would
1341
1575
  // print "none was chosen" over a session that did choose one (R9).
1576
+ // ── the shared working tree, and the DECLARED writer on it ──
1577
+ // `tree` is the collision unit (one HEAD, one index) — null for a folder with no git, and
1578
+ // null is the honest answer there rather than a group of one. `tree_role` is 'writer' for the
1579
+ // one session that holds the claim, 'reader' for every other session in that tree, and null
1580
+ // where there is no tree. `tree_peers` counts the OTHER live sessions sharing it. All three
1581
+ // are advisory: PMOS hosts the terminal, it does not sit between the agent and the files.
1582
+ tree: treeOfWs.get(s.ws) || null,
1583
+ // an exited pane holds no role: a corpse is neither writing nor waiting to.
1584
+ tree_role: treeShare.isLive(s)
1585
+ ? treeShare.roleOf(s.id, treeOfWs.get(s.ws) || null, writerByTree.get(treeOfWs.get(s.ws)))
1586
+ : null,
1587
+ tree_peers: treeShare.coTenants(rowsAll, treeOfWs, treeOfWs.get(s.ws) || null, s.id).length,
1342
1588
  profile_id: 'profile' in s ? (s.profile == null ? null : s.profile) : null,
1343
1589
  profile_line: profilesLib.profileLine(
1344
1590
  !('profile' in s) ? undefined
@@ -1757,6 +2003,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
1757
2003
  s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
1758
2004
  persist(s); pushSessions(); pushWorkspaces(); return s;
1759
2005
  }
2006
+ announceTree(s); // before attach, so the advisory is the first line of the pane's record
1760
2007
  attachPty(s);
1761
2008
  feed(s);
1762
2009
  persist(s); pushSessions(); pushWorkspaces(); return s;
@@ -1775,6 +2022,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
1775
2022
  pty: p, tmux: null, note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO(),
1776
2023
  lastOutputAt: Date.now() };
1777
2024
  sessions.set(id, s);
2025
+ announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
1778
2026
  p.onData((d) => {
1779
2027
  appendBuf(s, d);
1780
2028
  for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id, data: d }));
@@ -1852,7 +2100,16 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
1852
2100
  }
1853
2101
  }
1854
2102
  const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id });
1855
- return { ok: true, session: s, notice };
2103
+ // The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
2104
+ // block above. The isolate default already computes that other sessions would be disturbed — and
2105
+ // then the whole git block is skipped when no branch was picked, which is the common path and the
2106
+ // one the PM has been living in. It said nothing. Now it says what it already knew.
2107
+ //
2108
+ // Both facts travel: `notice` is a single string on the wire and a branch notice and a co-tenancy
2109
+ // notice can both be true of one spawn. joinNotices keeps them both rather than letting the
2110
+ // second evict the first exactly when the PM most needs both.
2111
+ const shared = treeNoticeFor(s, treeTenants(s.tree, s.id));
2112
+ return { ok: true, session: s, notice: treeShare.joinNotices(notice, shared) };
1856
2113
  }
1857
2114
 
1858
2115
  // ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
@@ -1868,7 +2125,11 @@ function worktreeReport(w) {
1868
2125
  const rows = trees.map((t, i) => {
1869
2126
  const main = i === 0;
1870
2127
  const owner = [...workspaces.values()].find((x) => path.resolve(x.root) === path.resolve(t.dir));
1871
- const live = owner ? liveSessionCount(owner.id) : 0;
2128
+ // Counted by TREE, not by workspace id. A session running in a workspace registered at a
2129
+ // SUBDIRECTORY of this tree (`repo/packages/app` — the ordinary tenant-repo shape) is a session
2130
+ // whose files this removal would pull out from under it, and the old workspace-id count could
2131
+ // not see it: two ids, one checkout. `owner` is still the row's workspace for the client.
2132
+ const live = treeTenants(path.resolve(t.dir), null).length;
1872
2133
  // the main worktree is the repo itself — never removable from here, and saying so beats an
1873
2134
  // absent button the PM has to reason about
1874
2135
  const gone = !main && !isDir(t.dir);
@@ -1972,6 +2233,10 @@ function removeSession(id) {
1972
2233
  if (!s || s.pty) return false;
1973
2234
  if (s.tmux) tmuxKillSession(s.tmux); // best-effort: never orphan a detached tmux session
1974
2235
  forgetScratchpad(s); // the tab's staging text goes with the tab
2236
+ // Drop a claim this session held. Correctness does not depend on this — treeShare.writerOf only
2237
+ // believes a claim whose holder is still live, so a stale entry already reads as vacant — but a
2238
+ // map that only grows is a leak waiting to be found by someone else.
2239
+ if (s.tree && treeClaims.get(s.tree) === id) treeClaims.delete(s.tree);
1975
2240
  sessions.delete(id); unpersist(id); pushSessions(); pushWorkspaces();
1976
2241
  return true;
1977
2242
  }
@@ -2782,6 +3047,25 @@ async function handleRequest(req, res) {
2782
3047
  return serveFile(res, file, null, true);
2783
3048
  }
2784
3049
 
3050
+ if (p === '/api/tree/claim' && req.method === 'POST') {
3051
+ // Hand the writer claim to a named session. The ONLY way a claim moves: never on a timer, never
3052
+ // on an exit, never on a heuristic. Which pane should be driving is the PM's intention, and a
3053
+ // derived answer must not impersonate a decision — so an exited holder leaves the tree vacant
3054
+ // and this endpoint is how it stops being vacant.
3055
+ let body = {};
3056
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
3057
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
3058
+ const s = body.session && sessions.get(String(body.session));
3059
+ if (!s) return json(res, { ok: false, error: 'no such session' }, 400);
3060
+ if (!treeShare.isLive(s)) return json(res, { ok: false, error: 'that session has exited — an exited pane cannot hold a claim' }, 400);
3061
+ const tree = treeForWs(s.ws);
3062
+ if (!tree) return json(res, { ok: false, error: 'that session is not in a git working tree — there is nothing to claim' }, 400);
3063
+ const prev = treeWriter(tree);
3064
+ treeClaims.set(tree, s.id);
3065
+ pushSessions();
3066
+ return json(res, { ok: true, tree, writer: s.id, previous: prev || null, advisory: true,
3067
+ note: 'a declaration, not a guarantee — nothing about either process changed' });
3068
+ }
2785
3069
  if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
2786
3070
  if (p === '/api/workspaces' && req.method === 'POST') {
2787
3071
  // How `pmos` in another repo becomes another workspace (R1). Local-only, like every
@@ -3228,6 +3512,27 @@ async function handleRequest(req, res) {
3228
3512
  if (!w) return json(res, { ok: false, error: 'no workspace' });
3229
3513
  return json(res, worktreeReport(w));
3230
3514
  }
3515
+ if (p === '/api/tree/peers') {
3516
+ // Who this workspace's sessions actually share mutable git state with, and who is declared to
3517
+ // be driving. `advisory` is a FIELD, not a footnote: a caller that renders this must not be
3518
+ // able to read it as enforcement without having read the word.
3519
+ const w = getWs(url.searchParams.get('ws'));
3520
+ if (!w) return json(res, { ok: false, error: 'no workspace' });
3521
+ const tree = repoIdentity(w.root).tree || null;
3522
+ if (!tree) {
3523
+ return json(res, { ok: true, tree: null, writer: null, sessions: [], advisory: true,
3524
+ note: 'this workspace is not inside a git working tree — there is no shared index to collide over' });
3525
+ }
3526
+ const writer = treeWriter(tree);
3527
+ const rows = treeTenants(tree, null).map((s) => ({
3528
+ id: s.id, name: s.name, ws: s.ws, agent: s.agent, state: s.state,
3529
+ role: treeShare.roleOf(s.id, tree, writer),
3530
+ }));
3531
+ return json(res, { ok: true, tree, writer, sessions: rows, advisory: true,
3532
+ note: writer
3533
+ ? 'the writer claim is a declaration, not a guarantee — PMOS hosts the terminal, it does not sit between the agent and the files'
3534
+ : 'no session holds the writer claim on this tree' });
3535
+ }
3231
3536
  // The pad belongs to a SESSION when one is named — and the workspace then comes from that
3232
3537
  // session, not from the caller, so a stale `ws` can never file one tab's text under another
3233
3538
  // repo. `session` omitted still addresses the workspace pad (a window with no tabs yet).
@@ -3608,10 +3913,27 @@ async function handleRequest(req, res) {
3608
3913
  'and useful answer.',
3609
3914
  ].filter((l) => l !== '').join('\n');
3610
3915
 
3916
+ // NOT typed blind into the PTY. An agent CLI opening a folder it has never seen shows a
3917
+ // trust prompt first — and a fresh tenant repo is by definition such a folder, so the case this
3918
+ // feature exists for is the exact case where blind input is swallowed by a security dialog and
3919
+ // the drafting silently never happens. Found by installing the published kit and watching a real
3920
+ // session sit at "Is this a project you trust?" with the prompt already gone.
3921
+ //
3922
+ // Auto-answering that dialog is not on the table: it is the PM's decision, not ours. So the
3923
+ // prompt is PERSISTED and STAGED instead — it cannot be lost, and the PM sends it once the agent
3924
+ // is actually at its input.
3925
+ const promptFile = path.join(adir, 'prompt-' + Date.now() + '.md');
3926
+ try { fs.writeFileSync(promptFile, prompt + '\n'); }
3927
+ catch (e) { warnWriteFail('alignment prompt', e); return json(res, { ok: false, error: 'could not stage the drafting prompt' }, 500); }
3928
+
3611
3929
  const s = spawnSession({ name: 'align: ' + path.basename(rel), agent: 'claude', ws: w.id,
3612
- origin: 'align:' + rel, initialInput: prompt + '\r' });
3930
+ origin: 'align:' + rel });
3613
3931
  const started = !!(s && s.pty);
3932
+ let staged = false;
3933
+ if (started) { try { writeScratchpad(w, s.id, prompt); staged = true; } catch {} }
3614
3934
  return json(res, { ok: started, sessionId: s && s.id, targets, sink: adir,
3935
+ prompt_file: promptFile, staged,
3936
+ next: started ? 'the drafting prompt is staged in this session\'s scratchpad — answer any trust prompt the agent shows, then send it' : undefined,
3615
3937
  error: started ? undefined : ((s && s.note) || 'could not start the session') });
3616
3938
  }
3617
3939
  if (p === '/api/align/drafts') {
@@ -3626,8 +3948,19 @@ async function handleRequest(req, res) {
3626
3948
  let doc;
3627
3949
  try { doc = JSON.parse(fs.readFileSync(path.join(adir, pick), 'utf8')); }
3628
3950
  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 });
3951
+ // A malformed draft is REFUSED at read, not half-rendered (contract crit 4). Silently dropping a
3952
+ // bad draft would let the agent's mistake reach the PM as a shorter, unexplained list — the same
3953
+ // "looks fine, is wrong" failure the anchored write guard exists to stop. An empty `drafts` array
3954
+ // is valid and calm (crit 3): the agent looked and found nothing to realign.
3955
+ if (!Array.isArray(doc.drafts)) {
3956
+ return json(res, { ok: false, error: `the draft file '${pick}' has no drafts array — the agent did not produce the documented shape` }, 422);
3957
+ }
3958
+ for (const d of doc.drafts) {
3959
+ if (!d || typeof d.file !== 'string' || typeof d.after !== 'string' || (d.before != null && typeof d.before !== 'string')) {
3960
+ 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);
3961
+ }
3962
+ }
3963
+ return json(res, { ok: true, id: pick.replace(/\.json$/, ''), source: doc.source || null, at: doc.at || null, drafts: doc.drafts });
3631
3964
  }
3632
3965
  if (p === '/api/align/apply' && req.method === 'POST') {
3633
3966
  // Apply ONE drafted realignment. Same anchored guard as everything else that writes here: the
@@ -3954,6 +4287,10 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
3954
4287
  // non-repo folder from a real git fault, and the whole legibility defect was that distinction
3955
4288
  // being dropped one layer above it.
3956
4289
  repoIdentity, wsList,
4290
+ // the shared-tree co-tenancy layer — exported so the suite can assert the KEY in both directions
4291
+ // (one repo registered twice is one tree; two worktrees of one repo are two) without driving
4292
+ // every case through a spawn.
4293
+ treeForWs, treeTenants, treeWriter, treeClaims,
3957
4294
  // exported so the suite can assert the D08 invariant end-to-end: the harness parsers consume
3958
4295
  // scrubbed text, so a secret printed into scrollback must not survive into a rendered value.
3959
4296
  scrub, harnessOf,
@@ -3969,4 +4306,9 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
3969
4306
  // where the anchor resolver is looked for — exported so the suite can assert the INSTALL
3970
4307
  // location is in the list. A dev checkout satisfies entry 1 and would hide the loss of entry 2
3971
4308
  // exactly as it hid the workflow engine's absence for four releases (PKG4).
3972
- ANCHOR_LIB_PATHS };
4309
+ ANCHOR_LIB_PATHS,
4310
+ // the delivery derivation (board-delivery-truth), exported so the suite can assert the three
4311
+ // things that matter independently of a rendered page: that no declared field reaches a status,
4312
+ // that an unreadable history reports rather than degrades, and that a planning-only commit does
4313
+ // not count as delivery.
4314
+ surfaceInitiatives, deliveryIndex, deriveState, isDeliveryPath };