create-pmos 0.10.1 → 0.11.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.
@@ -34,6 +34,11 @@
34
34
  <div class="lay-menu" id="layMenu" hidden role="dialog" aria-label="Layouts">
35
35
  <div class="lay-cap">layouts</div>
36
36
  <div id="layList"></div>
37
+ <!-- R12 — every position the pointer drag can reach is also a button here. Without this,
38
+ putting the sidebar on the right or docking the reader at the bottom was pointer-only:
39
+ no stock layout carries either, and the grip's Enter/Space merely opened this menu,
40
+ which had nothing in it that could move a pane. -->
41
+ <div class="lay-pos" id="layPos"></div>
37
42
  <form class="lay-new miniform" id="layNewForm" hidden>
38
43
  <label class="lbl" for="layNewName">name this layout</label>
39
44
  <input id="layNewName" type="text" placeholder="e.g. two agents + prd" autocomplete="off" spellcheck="false" maxlength="40" />
@@ -42,7 +47,7 @@
42
47
  </form>
43
48
  <button class="lay-add" id="layNewBtn">+ new layout from this arrangement</button>
44
49
  <p class="lay-foot">Layouts belong to <b id="layScope">this repo</b>. Drag a pane by its ⠿ grip,
45
- or an edge to resize — whatever you change is kept.</p>
50
+ or an edge to resize — or use the buttons above. Whatever you change is kept.</p>
46
51
  </div>
47
52
  </header>
48
53
 
@@ -52,39 +57,20 @@
52
57
  resize handle. DOM order is fixed; only the grid decides where a pane lands. -->
53
58
  <div class="body" id="bodyGrid">
54
59
 
55
- <nav class="side" id="side" aria-label="Workspaces and running agents">
60
+ <!-- ONE tree (R1): repositories → their working trees, each labelled by the initiative on its
61
+ branch → that tree's sessions. It replaces the old `workspaces` + `running agents` pair,
62
+ which split a parent from its children across two panes and made the PM correlate a live
63
+ count against a separate list. The fold key stays `ws` so an arrangement saved before this
64
+ change still resolves; `sess` is retired and ignored (R13). -->
65
+ <nav class="side" id="side" aria-label="Repositories, initiatives and running agents">
56
66
  <div class="edge" id="sideEdge" data-edge="side" role="separator" aria-label="Resize the sidebar" tabindex="0"></div>
57
67
  <section class="pane" id="paneWs">
58
68
  <div class="cap">
59
69
  <span class="grip" id="sideGrip" data-pane="side" role="button" tabindex="0"
60
70
  aria-label="Move the sidebar" title="Move this column">⠿</span>
61
- <button class="fold" id="wsFold" data-fold="ws" aria-expanded="true" title="Hide the workspaces"><span class="chev" aria-hidden="true">▾</span>workspaces</button>
71
+ <button class="fold" id="wsFold" data-fold="ws" aria-expanded="true" title="Hide the work tree"><span class="chev" aria-hidden="true">▾</span>work</button>
72
+ <span class="ct" id="needCount"></span>
62
73
  <button class="mini" id="addWsBtn" aria-expanded="false" title="Add a repository">+</button></div>
63
- <div class="pane-body">
64
- <div id="workspaces"></div>
65
- <form class="miniform" id="addWsForm" hidden>
66
- <button type="button" class="pick" id="pickFolderBtn">📁 Choose folder…</button>
67
- <label class="lbl" for="addWsPath">or type a path</label>
68
- <input id="addWsPath" type="text" placeholder="/path/to/another/repo" aria-label="Path to a repository" autocomplete="off" spellcheck="false" />
69
- <div class="row">
70
- <button type="submit" class="go">add</button>
71
- <button type="button" class="quiet" id="addWsCancel">cancel</button>
72
- </div>
73
- <p class="hint">or just run <code>pmos</code> inside that repo</p>
74
- </form>
75
- <!-- Worktrees the workspace made are folders the PM has to live with, so the app that
76
- creates them is the app that clears them. Hidden entirely until there is one. -->
77
- <details class="wtbox" id="wtBox" hidden>
78
- <summary id="wtSummary">worktrees</summary>
79
- <div id="wtList"></div>
80
- </details>
81
- </div>
82
- </section>
83
-
84
- <section class="pane" id="paneSess">
85
- <div class="cap">
86
- <button class="fold" id="sessFold" data-fold="sess" aria-expanded="true" title="Hide the running agents"><span class="chev" aria-hidden="true">▾</span>running agents</button>
87
- · <span id="sessCount"></span> <span class="ct" id="needCount"></span></div>
88
74
  <div class="pane-body">
89
75
  <button class="newsess" id="newBtn" aria-expanded="false"><i>+</i> new session — run an agent here</button>
90
76
  <form class="miniform" id="newForm" hidden>
@@ -111,9 +97,27 @@
111
97
  <button type="button" class="quiet" id="newCancel">cancel</button>
112
98
  </div>
113
99
  </form>
114
- <div id="sessions"></div>
100
+ <div id="tree"></div>
101
+ <div id="workspaces" hidden></div>
102
+ <form class="miniform" id="addWsForm" hidden>
103
+ <button type="button" class="pick" id="pickFolderBtn">📁 Choose folder…</button>
104
+ <label class="lbl" for="addWsPath">or type a path</label>
105
+ <input id="addWsPath" type="text" placeholder="/path/to/another/repo" aria-label="Path to a repository" autocomplete="off" spellcheck="false" />
106
+ <div class="row">
107
+ <button type="submit" class="go">add</button>
108
+ <button type="button" class="quiet" id="addWsCancel">cancel</button>
109
+ </div>
110
+ <p class="hint">or just run <code>pmos</code> inside that repo</p>
111
+ </form>
112
+ <!-- Worktrees the workspace made are folders the PM has to live with, so the app that
113
+ creates them is the app that clears them. Hidden entirely until there is one. -->
114
+ <details class="wtbox" id="wtBox" hidden>
115
+ <summary id="wtSummary">worktrees</summary>
116
+ <div id="wtList"></div>
117
+ </details>
115
118
  </div>
116
119
  </section>
120
+
117
121
  </nav>
118
122
 
119
123
  <main class="term-col" aria-label="Sessions and terminal">
@@ -187,12 +191,12 @@
187
191
  <span class="grip" id="readGrip" data-pane="reader" role="button" tabindex="0"
188
192
  aria-label="Move the reader" title="Move this pane">⠿</span>
189
193
  <button class="fold" id="libFold" aria-expanded="true" title="Hide the library"><span class="chev" aria-hidden="true">▾</span>library</button>
190
- <span class="lens" role="tablist" aria-label="Library view">
191
- <button role="tab" data-lens="initiatives" aria-selected="true">initiatives</button>
192
- <button role="tab" data-lens="folders" aria-selected="false">folders</button>
193
- <button role="tab" data-lens="scheduled" aria-selected="false">scheduled</button>
194
- <button role="tab" data-lens="history" aria-selected="false">history</button>
195
- </span>
194
+ <!-- R7 — the four standing lenses are gone. What the rail shows follows the SELECTION:
195
+ a repository group gives repo detail (the folder map, its schedules, its history), a
196
+ tree row gives that initiative's dossier. The views below are the sub-views of
197
+ whichever of those two is current, rendered by renderLensTabs(). -->
198
+ <span class="lens" role="tablist" aria-label="Detail view" id="lensTabs"></span>
199
+ <span class="reading" id="libWho" title="the repository these documents come from"></span>
196
200
  <button class="mini librefresh" id="libRefresh" title="Rescan the repository for new files & folders" aria-label="Refresh the library">↻</button>
197
201
  </div>
198
202
  <div class="pane-body">
@@ -258,8 +258,10 @@ button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
258
258
  .cap .fold .chev{font-size:var(--text-2xs);transition:transform var(--duration-fast) var(--ease-emphasized)}
259
259
  .pane[data-fold="off"] .cap .fold .chev{transform:rotate(-90deg)}
260
260
  @media (prefers-reduced-motion:reduce){.cap .fold .chev{transition:none}}
261
- /* the move affordance every relocatable pane carries. Keyboard reaches every position it
262
- offers through the layout menu, so this is an accelerator and never the only path (R12). */
261
+ /* the move affordance every relocatable pane carries. Keyboard reaches every position it offers
262
+ through the layout menu's position controls (`.lay-pos`), so this is an accelerator and never
263
+ the only path (R12). That was asserted here before it was true — the menu had no position
264
+ controls at all, and `side:right` / `reader:bottom` were pointer-only. Review-Gate finding. */
263
265
  .cap .grip{width:22px;min-height:26px;display:grid;place-items:center;border-radius:var(--radius-sm);flex:none;
264
266
  color:var(--muted-foreground);cursor:grab;letter-spacing:-2px;margin-left:-4px;touch-action:none}
265
267
  .cap .grip:hover{background:var(--accent);color:var(--brand)}
@@ -316,6 +318,22 @@ button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
316
318
  border-radius:var(--radius-sm);color:var(--muted-foreground);font-size:var(--text-xs);flex:none}
317
319
  .lay-act:hover{background:var(--secondary);color:var(--brand)}
318
320
  .lay-act.del:hover{color:var(--destructive)}
321
+
322
+ /* R12 — the position controls. Every value the pointer drag can produce is a focusable button
323
+ here, which is what makes "the layout control can always bring a pane back" a fact about the
324
+ DOM rather than an argument about the grid template. */
325
+ .lay-pos{margin-top:6px;padding-top:6px;border-top:1px solid var(--border);display:grid;gap:4px}
326
+ .pos-row{display:flex;align-items:center;gap:3px}
327
+ .pos-lbl{font-size:var(--text-2xs);color:var(--muted-foreground);width:52px;flex:none}
328
+ .pos-btn{flex:1;min-height:34px;display:flex;flex-direction:column;align-items:center;justify-content:center;
329
+ gap:1px;border-radius:var(--radius-sm);border:1px solid transparent;color:var(--muted-foreground);
330
+ font-size:var(--text-2xs);line-height:1}
331
+ .pos-btn i{font-style:normal;font-size:var(--text-sm)}
332
+ .pos-btn:hover{background:var(--accent);color:var(--accent-foreground)}
333
+ .pos-btn:focus-visible{outline:2px solid var(--ring);outline-offset:1px}
334
+ .pos-btn[aria-pressed="true"]{background:var(--accent);color:var(--brand);border-color:var(--ring);font-weight:500}
335
+ /* a coarse pointer gets the 44px target R12 asks for; a mouse does not need the extra height */
336
+ @media (pointer:coarse){.pos-btn{min-height:44px}.lay-act{min-width:44px;min-height:44px}.lay-row{padding:11px 8px}}
319
337
  .lay-add{display:block;width:100%;margin-top:4px;padding:9px;border:1px dashed var(--border);
320
338
  border-radius:var(--radius-md);font-size:var(--text-sm);color:var(--muted-foreground);
321
339
  transition:color var(--duration-fast) var(--ease-emphasized),
@@ -1313,3 +1331,104 @@ line-height:1.35;cursor:pointer}
1313
1331
  border-top:1px solid var(--border);border-bottom:1px solid var(--border);padding:6px 0}
1314
1332
  .al-err{margin-top:9px;padding:8px 11px;border-radius:var(--radius-md);border:1px solid var(--destructive);
1315
1333
  background:rgba(242,101,95,.08);font-size:var(--text-sm);line-height:1.5;color:var(--foreground)}
1334
+
1335
+ /* ── The sidebar tree (workspace-initiative-nav-build, R1–R6) ─────────────────────────────────
1336
+ Repository → its working trees, each labelled by the initiative on its branch → sessions.
1337
+ Replaces the old `workspaces` + `running agents` pair. Session-row styles are reused verbatim
1338
+ from that pane — only the nesting is new. Colours go through the D38 semantic tokens: a hex
1339
+ fallback here would be a second source of truth for a value the token layer owns, which is the
1340
+ drift `token-hygiene` exists to catch. */
1341
+ .repo-group{margin-bottom:6px}
1342
+ .repo-head{display:flex;align-items:center;gap:6px;width:100%;padding:4px 8px;background:none;
1343
+ border:0;color:var(--foreground);font:inherit;font-weight:600;font-size:var(--text-xs);
1344
+ cursor:default;text-align:left}
1345
+ .repo-head .cue{color:var(--muted-foreground);font-size:var(--text-2xs)}
1346
+ .repo-head small{margin-left:auto;color:var(--muted-foreground);font-family:var(--mono);font-size:var(--text-2xs)}
1347
+
1348
+ .treerow{border-left:2px solid transparent}
1349
+ .treerow[data-open="true"]{border-left-color:var(--border)}
1350
+ .tr-head{display:flex;align-items:flex-start}
1351
+ .tr-toggle{background:none;border:0;color:var(--muted-foreground);cursor:pointer;
1352
+ padding:6px 2px 6px 8px;font:inherit;line-height:1.2}
1353
+ .tr-toggle .cue{font-size:var(--text-2xs)}
1354
+ .tr{flex:1;min-width:0;display:block;text-align:left;background:none;border:0;color:inherit;
1355
+ font:inherit;padding:5px 6px 6px 0;cursor:pointer}
1356
+ .tr:hover,.tr-head:hover .tr{background:var(--accent)}
1357
+ .tr[aria-current="true"]{background:var(--accent);font-weight:500}
1358
+ .tr .r1{display:flex;align-items:baseline;gap:6px}
1359
+ .tr .r1 .g{font-size:var(--text-2xs);width:9px;flex:none}
1360
+ .treerow[data-st="working"] .r1 .g{color:var(--success)}
1361
+ .treerow[data-st="quiet"] .r1 .g,
1362
+ .treerow[data-st="idle"] .r1 .g,
1363
+ .treerow[data-st="exited"] .r1 .g{color:var(--muted-foreground)}
1364
+ .treerow[data-st="blocked"] .r1 .g{color:var(--destructive)}
1365
+ .tr .r1 .nm{font-size:var(--text-xs);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1366
+ .tr .r1 .dir{margin-left:auto;flex:none;font-family:var(--mono);font-size:var(--text-2xs);
1367
+ color:var(--muted-foreground)}
1368
+ .tr .r2{display:flex;flex-wrap:wrap;gap:6px;align-items:baseline;padding-left:15px;margin-top:2px;
1369
+ font-family:var(--mono);font-size:var(--text-2xs);color:var(--muted-foreground)}
1370
+ .tr .wf{display:inline-flex;gap:6px;align-items:baseline}
1371
+ .tr .wf .lane{color:var(--muted-foreground)}
1372
+ .tr .wf .stg{color:var(--brand)}
1373
+ .tr .wf .nx{color:var(--muted-foreground)}
1374
+ .tr .wf .bl,.tr .r2 .bl{color:var(--destructive)}
1375
+ .tr .wf .drv{color:var(--muted-foreground);font-style:italic}
1376
+ .tr .needs-n{color:var(--warning)}
1377
+ .tr .rollup{color:var(--muted-foreground)}
1378
+ /* R5 — the branch a ladder was derived from, and when. Never optional when a stage is shown. */
1379
+ .tr .attrib{display:block;padding-left:15px;margin-top:2px;font-family:var(--mono);
1380
+ font-size:var(--text-2xs);color:var(--muted-foreground)}
1381
+ .tr .attrib b{color:var(--foreground);font-weight:500}
1382
+ .tr-sessions{padding-left:14px}
1383
+ .tree-new{display:block;width:calc(100% - 8px);margin:2px 0 6px 4px;padding:4px 8px;background:none;
1384
+ border:1px dashed var(--border);border-radius:var(--radius-sm);color:var(--muted-foreground);
1385
+ font:inherit;font-size:var(--text-2xs);cursor:pointer;text-align:left}
1386
+ .tree-new:hover{color:var(--foreground);border-color:var(--input)}
1387
+ .cap .ct[data-bad="1"]{color:var(--destructive)}
1388
+
1389
+ /* ── The rail's initiative dossier (R7·R8·R9) ────────────────────────────────────────────────── */
1390
+ .reading{font-family:var(--mono);font-size:var(--text-2xs);color:var(--muted-foreground);
1391
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:12ch}
1392
+ .reading[data-away="1"]{color:var(--warning);max-width:none}
1393
+ .dz-head{padding:10px 12px 8px;border-bottom:1px solid var(--border)}
1394
+ .dz-id{font-size:var(--text-sm);font-weight:600;letter-spacing:-.01em}
1395
+ .dz-t{font-size:var(--text-xs);color:var(--muted-foreground);margin-top:2px;line-height:1.4}
1396
+ .dz-meta{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px}
1397
+ .dz-meta .pill{font-family:var(--mono);font-size:var(--text-2xs);padding:2px 5px;
1398
+ border-radius:var(--radius-sm);background:var(--secondary);color:var(--muted-foreground);
1399
+ border:1px solid var(--border)}
1400
+ .dz-meta .pill.lane{color:var(--purple)}
1401
+ /* R5 — the branch a ladder came from, never optional beside a stage */
1402
+ .dz-attrib{margin-top:7px;font-family:var(--mono);font-size:var(--text-2xs);
1403
+ color:var(--muted-foreground);line-height:1.5}
1404
+ .dz-attrib b{color:var(--foreground);font-weight:500}
1405
+ .dz-attrib .sub{display:block;opacity:.85}
1406
+ .dz-next{margin:9px 12px;padding:8px 10px;border-radius:var(--radius-md);background:var(--success-wash);
1407
+ border:1px solid var(--border);font-size:var(--text-xs);color:var(--foreground);line-height:1.5}
1408
+ .dz-next b{display:block;font-family:var(--mono);font-size:var(--text-2xs);letter-spacing:.12em;
1409
+ text-transform:uppercase;color:var(--success);margin-bottom:3px;font-weight:500}
1410
+ /* accepted AND owing, together — a green badge alone is the watermelon */
1411
+ .dz-next.owed{background:var(--warning-wash)}
1412
+ .dz-next.owed b{color:var(--warning)}
1413
+ .dz-next.done b{color:var(--muted-foreground)}
1414
+ .dz-wait{margin:9px 12px;font-size:var(--text-xs);color:var(--muted-foreground);line-height:1.5}
1415
+ .dz-wait .sub{display:block;font-size:var(--text-2xs);margin-top:3px;opacity:.85}
1416
+ .dz-none{margin:9px 12px;font-size:var(--text-xs);color:var(--muted-foreground);line-height:1.55}
1417
+ .dz-arts{padding:0 12px 14px}
1418
+ .dz-sec{padding-top:10px}
1419
+ .dz-sec+.dz-sec{border-top:1px solid var(--border);margin-top:8px}
1420
+ .dz-sec h5{display:flex;gap:7px;align-items:baseline;font-family:var(--mono);
1421
+ font-size:var(--text-2xs);letter-spacing:.13em;text-transform:uppercase;
1422
+ color:var(--muted-foreground);font-weight:500;margin-bottom:5px}
1423
+ .dz-sec h5 .c{opacity:.8;letter-spacing:0}
1424
+ .dz-art{display:flex;gap:7px;align-items:baseline;width:100%;text-align:left;padding:4px 7px;
1425
+ margin-bottom:3px;border-radius:var(--radius-sm);background:var(--card);border:1px solid var(--border);
1426
+ color:inherit;font:inherit;cursor:pointer}
1427
+ .dz-art:hover{border-color:var(--input);background:var(--accent)}
1428
+ .dz-art .role{font-family:var(--mono);font-size:var(--text-2xs);letter-spacing:.1em;
1429
+ text-transform:uppercase;color:var(--info);width:74px;flex:none}
1430
+ .dz-art .pth{font-family:var(--mono);font-size:var(--text-2xs);color:var(--muted-foreground);
1431
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1432
+ /* `n/a for this lane` must never look like `missing` — that difference is the whole of R8 */
1433
+ .dz-na{font-size:var(--text-2xs);color:var(--muted-foreground);font-style:italic;line-height:1.5;
1434
+ padding:2px 0 4px}
@@ -68,6 +68,7 @@ const skillStore = require('./lib/skill-store');
68
68
  const remoteAccess = require('./lib/remote-access'); // tailnet bind + token guard (D62) + origin/host allowlist (D64)
69
69
  const rateLimit = require('./lib/rate-limit'); // per-connection token bucket for spawn/input (D64 A3)
70
70
  const consoleState = require('./lib/console-state'); // read-only console surfaces over file-mode state (D64 B)
71
+ const boardCache = require('./lib/board-cache'); // the workflow board, cached + derived off the event loop (R4)
71
72
  const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
72
73
  const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
73
74
  const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
@@ -217,12 +218,42 @@ function homeWorkspace() {
217
218
  return HOME_ENABLED ? workspaces.get(wsId(SELF_ROOT)) : [...workspaces.values()][0];
218
219
  }
219
220
  function getWs(id) { return (id && workspaces.get(id)) || homeWorkspace(); }
221
+ // R2/R3 — what the sidebar tree needs and cannot derive for itself: which REPOSITORY a registered
222
+ // working tree belongs to, and which branch it is on. `git worktree list` names the main worktree
223
+ // first from any of a repo's trees (git-info.worktreesAt), so index 0 is the group key: it is what
224
+ // turns `pmos` and `pmos ⑂ initiative/foo` from two peer rows into a parent and its child.
225
+ // Both shell git, so both are memoised — wsList() runs on every workspace push.
226
+ const REPO_KEY_TTL_MS = 15000;
227
+ const repoKeyCache = new Map(); // root -> { at, key, name, branch }
228
+ function repoIdentity(root) {
229
+ const hit = repoKeyCache.get(root);
230
+ const now = Date.now();
231
+ if (hit && now - hit.at < REPO_KEY_TTL_MS) return hit;
232
+ let key = root, name = path.basename(root), branch = null, branchError = null;
233
+ try {
234
+ const trees = gitInfo.worktrees(root);
235
+ if (trees.length && trees[0].dir) { key = path.resolve(trees[0].dir); name = path.basename(key); }
236
+ } catch { /* not a repo, or git absent — the tree is its own group, which is the truth */ }
237
+ try {
238
+ const bi = gitInfo.branchInfo(root);
239
+ if (bi.ok) branch = bi.detached ? null : bi.current;
240
+ else branchError = bi.error || 'branch could not be read';
241
+ } catch { branchError = 'branch could not be read'; }
242
+ const val = { at: now, key, name, branch, branchError };
243
+ repoKeyCache.set(root, val);
244
+ return val;
245
+ }
220
246
  function wsList() {
221
- return [...workspaces.values()].map((w) => ({
222
- id: w.id, name: w.name, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
223
- shape: wsShape(w.root), addedAt: w.addedAt,
224
- sessions: [...sessions.values()].filter((s) => s.ws === w.id).length,
225
- }));
247
+ return [...workspaces.values()].map((w) => {
248
+ const rid = repoIdentity(w.root);
249
+ return {
250
+ id: w.id, name: w.name, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
251
+ shape: wsShape(w.root), addedAt: w.addedAt,
252
+ sessions: [...sessions.values()].filter((s) => s.ws === w.id).length,
253
+ // the repository this working tree belongs to, and the branch it is on
254
+ repo_key: rid.key, repo_name: rid.name, branch: rid.branch, branch_error: rid.branchError,
255
+ };
256
+ });
226
257
  }
227
258
  const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'vendor', 'target', 'coverage', '.git', 'tmp', 'out']);
228
259
  // The directories stateDirFor() resolves to. Their contents are machine-written runtime and curated
@@ -375,8 +406,11 @@ function readTextDoc(root, rel) {
375
406
  // filesystem, useless for "show me everything for pmos-jira-sync". This projection joins
376
407
  // them on the initiative_id the artifacts already declare in their own identity block,
377
408
  // falling back to the filename convention when an artifact declares nothing.
409
+ // Ordered LONGEST-DISCRIMINATOR-FIRST: `-copy-brief` must be tested before `-brief`, and
410
+ // `-eval-rubric` before any `-rubric`, because the first match wins and slices the stem.
378
411
  const ROLE_SUFFIXES = [
379
412
  ['-eval-rubric', 'rubric'], ['-design-brief', 'design brief'], ['-design-reference', 'design'],
413
+ ['-copy-brief', 'copy brief'], ['-user-stories', 'user stories'], ['-copy', 'copy'],
380
414
  ['-dogfood', 'dogfood'], ['-reconcile', 'reconcile'], ['-findings', 'findings'],
381
415
  ['-scoping', 'scoping'], ['-intake', 'intake'], ['-brief', 'brief'], ['-prd', 'PRD'],
382
416
  ];
@@ -385,6 +419,43 @@ const ROLE_BY_DIR = {
385
419
  okrs: 'OKR', radar: 'radar', gtm: 'gtm', 'out-of-scope': 'rejected', 'harness-refine': 'harness',
386
420
  'design-export': 'design', explainers: 'explainer', fixtures: 'fixture',
387
421
  };
422
+ // ── D60 specimens: prototype/<id>/, deliberately OUTSIDE planning/ ───────────────────────────
423
+ // The planning walk cannot reach them (planningRoots only finds `planning`), so a prototype leg was
424
+ // invisible on the initiative that produced it — and D60 makes the specimen a PRECONDITION of the
425
+ // PRD, so the missing artifact was the one that gates the rest.
426
+ //
427
+ // Grouping here is by DIRECTORY NAME, not by a parsed declaration: `prototype/<id>/` is the lane's
428
+ // own convention, so attribution comes from where a file lives. That is deliberately STRICTER than
429
+ // the planning path, whose parseHead() matches `initiative_id:` anywhere in the first 4 KB — body
430
+ // text can therefore claim a document there, and widening the scan must not widen that laxity.
431
+ // HTML counts here (it does nowhere else in the index): a lookable specimen is the artifact.
432
+ function specimenRoots(root) { return findDirsNamed(root, 'prototype', BUNDLE_MAX_DEPTH); }
433
+ const SPECIMEN_ROLE = { 'brief.md': 'brief', 'grilling.md': 'grilling', 'readme.md': 'doc' };
434
+ function specimenMetas(root) {
435
+ const out = [];
436
+ for (const proot of specimenRoots(root)) {
437
+ let dirs = [];
438
+ try { dirs = fs.readdirSync(proot, { withFileTypes: true }); } catch { continue; }
439
+ for (const d of dirs) {
440
+ if (!d.isDirectory() || d.name.startsWith('.') || SKIP_DIRS.has(d.name)) continue;
441
+ let files = [];
442
+ try { files = fs.readdirSync(path.join(proot, d.name), { withFileTypes: true }); } catch { continue; }
443
+ for (const f of files) {
444
+ if (!f.isFile() || f.name.startsWith('.')) continue;
445
+ const html = /\.html?$/i.test(f.name);
446
+ if (!html && !f.name.endsWith('.md')) continue;
447
+ const full = path.join(proot, d.name, f.name);
448
+ out.push({
449
+ path: path.relative(root, full), name: f.name,
450
+ role: SPECIMEN_ROLE[f.name.toLowerCase()] || (html ? 'specimen' : 'doc'),
451
+ initiative: d.name, stem: d.name, title: null, status: null, stage: null, kr: null,
452
+ });
453
+ }
454
+ }
455
+ }
456
+ return out;
457
+ }
458
+
388
459
  /** full path -> {mtimeMs, initiative, role, title} — re-read only what actually changed */
389
460
  const metaCache = new Map();
390
461
 
@@ -451,8 +522,10 @@ function walkMd(dir, acc) {
451
522
  }
452
523
  function initiativeIndex(root) {
453
524
  const proots = planningRoots(root); // planning/ at the root OR nested (e.g. IDPF/pmos/planning)
454
- if (!proots.length) return { initiatives: [], ungrouped: [] };
455
- const metas = proots.flatMap((p) => walkMd(p, [])).map((f) => docMeta(root, f)).filter(Boolean);
525
+ const specimens = specimenMetas(root); // prototype/<id>/ — outside planning/, grouped by dir
526
+ if (!proots.length && !specimens.length) return { initiatives: [], ungrouped: [] };
527
+ const metas = proots.flatMap((p) => walkMd(p, [])).map((f) => docMeta(root, f))
528
+ .filter(Boolean).concat(specimens);
456
529
  // Pass 1: every id an artifact explicitly declares. Only these are real initiatives —
457
530
  // a filename stem is promoted to a group only when it matches a declared id or when
458
531
  // several artifacts share it, so a lone research note never invents an initiative.
@@ -581,22 +654,58 @@ function surfaceRoadmap(root, w) {
581
654
  .filter((g) => g.items.length);
582
655
  return { groups, note: 'dates are net-new state — grouped by status until a timeline is tracked' };
583
656
  }
657
+ // The OKR surface reads through the SAME resolver as the ladder (spec R-6 / contract C-7). It used
658
+ // to carry its own parser, and that parser matched objectives with /^#{1,3}\s+(O\d+)/ and KRs with
659
+ // (KR\d+) — so `## O-INTEGRITY` and `KR-C` were invisible here while the ladder happily accepted
660
+ // anchors naming them. One field with two opinions is the defect this initiative removes; a second
661
+ // parser would have been the same bug wearing a different regex.
662
+ function anchorLib() {
663
+ try { return require(path.join(__dirname, '..', 'scripts', 'anchor-resolve.js')); }
664
+ catch { return null; }
665
+ }
584
666
  function surfaceOkr(root) {
585
- const objectives = [];
586
- for (const dir of findDirsNamed(root, 'okrs', BUNDLE_MAX_DEPTH)) {
587
- for (const f of walkMd(dir, [])) {
588
- let text = '';
589
- try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
590
- let cur = null;
591
- for (const line of text.split('\n')) {
592
- const o = line.match(/^#{1,3}\s+(O\d+)\s*[—–:-]?\s*(.+)$/);
593
- if (o) { cur = { id: o[1], name: o[2].replace(/\*\*/g, '').trim().slice(0, 120), krs: [] }; objectives.push(cur); continue; }
594
- const k = line.match(/^\s*[-*]\s*\*\*\s*(KR\d+)\s*[—–:-]?\s*(.+?)\*\*/);
595
- if (k && cur) cur.krs.push({ id: k[1], name: k[2].trim().slice(0, 140), current: null, target: null, levers: null });
667
+ const A = anchorLib();
668
+ if (!A) {
669
+ return { objectives: [], unreadable: 'the anchor resolver (scripts/anchor-resolve.js) is not present in this install — the OKR surface cannot be derived here', note: null };
670
+ }
671
+ const index = A.parseOkrIndex(root);
672
+ // A read that FAILED is not a read that found nothing. It renders RED (D64 Track B); the calm
673
+ // empty state is reserved for a successful read of genuinely nothing.
674
+ if (!index.ok) return { objectives: [], unreadable: index.error || 'the OKR documents could not be read', note: null };
675
+
676
+ const objectives = index.objectives.map((o) => ({
677
+ id: o.token, key: o.id, name: (o.name || '').slice(0, 120), file: o.file,
678
+ krs: o.krs.map((k) => ({ id: k.token, key: k.id, name: (k.name || '').slice(0, 140), current: null, target: null, levers: null })),
679
+ }));
680
+
681
+ // Anchor health, NOT the initiative↔KR join — the join is an explicit non-goal of this spec. What
682
+ // is reported is only whether each anchor names something these documents declare, because an
683
+ // anchor pointing at nothing is precisely what went unseen for two days.
684
+ const budgets = A.readBudgets(root);
685
+ const unresolvable = [];
686
+ let checked = 0, empty = 0;
687
+ for (const dir of findDirsNamed(root, 'initiatives', BUNDLE_MAX_DEPTH)) {
688
+ let files = [];
689
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')); } catch { continue; }
690
+ for (const f of files) {
691
+ let head = '';
692
+ try { head = fs.readFileSync(path.join(dir, f), 'utf8').slice(0, 4096); } catch { continue; }
693
+ const m = /^parent_kr:[ \t]*(.*)$/m.exec(head);
694
+ if (!m) continue;
695
+ const r = A.resolveAnchor(m[1], index, budgets);
696
+ if (r.kind === 'empty') { empty++; continue; }
697
+ checked++;
698
+ if (r.status === 'unresolvable' || r.status === 'unknown') {
699
+ unresolvable.push({ initiative: f.replace(/\.md$/, ''), raw: String(m[1]).trim().slice(0, 80), reason: r.reason, status: r.status });
596
700
  }
597
701
  }
598
702
  }
599
- return { objectives, note: 'KR current/target and the lever tree are net-new state — shown as "— not tracked"' };
703
+ return {
704
+ objectives,
705
+ unreadable: null,
706
+ anchor_health: { checked, empty, unresolvable },
707
+ note: 'KR current/target and the lever tree are net-new state — shown as "— not tracked"',
708
+ };
600
709
  }
601
710
  function surfaceReport(root, w) {
602
711
  const s = surfaceInitiatives(root, w);
@@ -1708,6 +1817,59 @@ function needsResult(w) {
1708
1817
  if (!items.length) return { ok: true, empty: true };
1709
1818
  return { ok: true, empty: false, data: { items } };
1710
1819
  }
1820
+ // R12 — the per-tree aggregate the collapsed sidebar rows need, and R10's honesty rule applied to
1821
+ // the count itself. It deliberately routes through needsResult(), the STRICT reader: readNeeds()
1822
+ // swallows a corrupt needs.json in a bare `catch {}`, so a broken queue rendered as a calm "nothing
1823
+ // pending" on the one chip the PM uses to decide whether to look. A row that cannot read its state
1824
+ // says so; it never reports zero. `error` and `count` are mutually exclusive by construction.
1825
+ function needsCountFor(w) {
1826
+ // One unreadable tree must never take down the aggregate — a registered workspace whose root has
1827
+ // been moved or deleted is ordinary, and a 500 here would blank the count for every OTHER tree.
1828
+ // Caught defensively rather than trusted: needsResult reaches the filesystem through several
1829
+ // helpers (needsFileFor → stateDirFor → deriveNeeds) and only some of them guard themselves.
1830
+ let r;
1831
+ try { r = needsResult(w); }
1832
+ catch (e) { return { count: null, error: 'needs state unreadable: ' + String((e && e.code) || (e && e.message) || 'error').slice(0, 60) }; }
1833
+ if (!r.ok) return { count: null, error: r.error };
1834
+ return { count: r.empty ? 0 : r.data.items.length, error: null };
1835
+ }
1836
+ // The aggregate covers EVERY registered tree, and deriveNeeds() re-reads that tree's whole
1837
+ // initiative set (110 records on this repo) plus its jsonl state. Computing it per request, per
1838
+ // workspace, made /api/needs cost O(trees × records) in filesystem reads — enough to hang the
1839
+ // request and reset the connection. It is memoised with a short TTL, and the TTL is the honest
1840
+ // bound: this is the glance number on a COLLAPSED row, refreshed on the cadence the sidebar
1841
+ // repaints. The SELECTED tree's queue (`needs`) is never cached — the list the PM acts on stays
1842
+ // authoritative and is read fresh on every call.
1843
+ const NEEDS_AGG_TTL_MS = 4000;
1844
+ const needsAgg = new Map(); // ws id -> { at, sig, value }
1845
+ // The queue file's identity, so a change to it is never served from cache. A TTL alone was wrong in
1846
+ // exactly the case R10 exists for: corrupt the queue and the memo keeps answering with the count it
1847
+ // computed a moment earlier — a calm number over broken state, which is the failure this whole
1848
+ // requirement is about. `none` and `unreadable` are distinct signatures on purpose; a file that
1849
+ // appears, vanishes or becomes unstattable each invalidate.
1850
+ function needsSig(w) {
1851
+ const f = needsFileFor(w);
1852
+ if (!f) return 'none';
1853
+ try { const st = fs.statSync(f); return `${st.mtimeMs}:${st.size}`; }
1854
+ catch { return 'unreadable'; }
1855
+ }
1856
+ function needsCountCached(w) {
1857
+ const hit = needsAgg.get(w.id);
1858
+ const now = Date.now();
1859
+ let sig;
1860
+ try { sig = needsSig(w); } catch { sig = 'unreadable'; }
1861
+ if (hit && hit.sig === sig && now - hit.at < NEEDS_AGG_TTL_MS) return hit.value;
1862
+ const value = needsCountFor(w);
1863
+ needsAgg.set(w.id, { at: now, sig, value });
1864
+ return value;
1865
+ }
1866
+ function needsByWorkspace() {
1867
+ const out = {};
1868
+ for (const w of workspaces.values()) out[w.id] = needsCountCached(w);
1869
+ return out;
1870
+ }
1871
+ // Anything that resolves or files a need makes the memo stale; drop it rather than wait out the TTL.
1872
+ function invalidateNeedsAgg(id) { if (id) needsAgg.delete(id); else needsAgg.clear(); }
1711
1873
  function buildConsole(name, w, root) {
1712
1874
  const stateDir = stateDirFor(w);
1713
1875
  const snapshotDir = latestSnapshotDir(root);
@@ -1716,7 +1878,12 @@ function buildConsole(name, w, root) {
1716
1878
  if (name === 'radar') return consoleState.radar({ stateDir, snapshotDir });
1717
1879
  // The workflow ladder reads the OPENED workspace's repo (records + git), not the state dir — an
1718
1880
  // initiative's position is a property of the repo it lives in, snapshot or no snapshot.
1719
- if (name === 'workflow') return consoleState.workflow({ root: w ? w.root : root });
1881
+ // Through the cache, never the raw reader. The derivation shells git once per initiative —
1882
+ // ~30s on this repo — and it is synchronous, so calling it here froze the server and every PTY
1883
+ // socket with it for the whole half minute. boardCache returns immediately and derives in a
1884
+ // child; the payload carries `as_of` / `stale` / `pending` so the client can say how old the
1885
+ // answer is instead of implying it is current.
1886
+ if (name === 'workflow') return boardCache.board(w ? w.root : root);
1720
1887
  return { ok: false, error: 'unknown console surface: ' + name };
1721
1888
  }
1722
1889
 
@@ -2167,7 +2334,18 @@ async function handleRequest(req, res) {
2167
2334
  res.end(buf);
2168
2335
  });
2169
2336
  }
2170
- if (p === '/api/needs') return json(res, { needs: readNeeds(getWs(url.searchParams.get('ws'))) });
2337
+ // `needs` stays the queue the strip renders. `state` is R10: whether that list is trustworthy or
2338
+ // merely quiet — the strip must not draw a calm empty over a corrupt file. `by_ws` is R12: the
2339
+ // per-tree counts a COLLAPSED sidebar row shows, which is the whole reason a row can be collapsed.
2340
+ if (p === '/api/needs') {
2341
+ const w = getWs(url.searchParams.get('ws'));
2342
+ const strict = needsResult(w);
2343
+ return json(res, {
2344
+ needs: readNeeds(w),
2345
+ state: strict.ok ? { ok: true } : { ok: false, error: strict.error },
2346
+ by_ws: needsByWorkspace(),
2347
+ });
2348
+ }
2171
2349
  if (p === '/api/console') {
2172
2350
  // Read-only console surfaces (needs-you · radar · metrics) over file-mode state (D64 Track B).
2173
2351
  // Discriminated result: {ok:true,empty} genuinely nothing · {ok:false,error} a broken read the
@@ -2194,6 +2372,7 @@ async function handleRequest(req, res) {
2194
2372
  if (p === '/api/gate' && req.method === 'POST') {
2195
2373
  // The PMOS Acceptance/Review vocabulary in file mode (R5). NEVER automatic — this is
2196
2374
  // only reached by an explicit PM click, and it just records the decision.
2375
+ invalidateNeedsAgg(); // a recorded decision changes the counts; do not wait out the TTL
2197
2376
  let body = {};
2198
2377
  try { body = JSON.parse((await readBody(req)) || '{}'); }
2199
2378
  catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }