create-pmos 0.10.0 → 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.
- package/README.md +60 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/lib/board-cache.js +116 -0
- package/workspace/lib/console-state.js +20 -0
- package/workspace/lib/layout.js +44 -9
- package/workspace/package.json +7 -3
- package/workspace/public/app.js +570 -80
- package/workspace/public/index.html +39 -35
- package/workspace/public/styles.css +121 -2
- package/workspace/server.js +216 -23
|
@@ -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 —
|
|
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
|
-
|
|
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
|
|
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="
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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}
|
package/workspace/server.js
CHANGED
|
@@ -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,14 +218,47 @@ 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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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']);
|
|
259
|
+
// The directories stateDirFor() resolves to. Their contents are machine-written runtime and curated
|
|
260
|
+
// records — never documents alignment should offer to edit.
|
|
261
|
+
const STATE_DIR_NAMES = new Set(['state', '.pmos']);
|
|
228
262
|
const PLAIN_MAX_DEPTH = 3; // the curated (knowledge) view stays shallow
|
|
229
263
|
const TREE_MAX_DEPTH = 6; // the folder lens is a real explorer, but bounded on huge/slow repos
|
|
230
264
|
const TREE_MAX_NODES = 4000; // …and node-budgeted so a giant repo (or Google Drive) never hangs
|
|
@@ -317,6 +351,11 @@ function markdownDocs(root, limit = 800) {
|
|
|
317
351
|
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
|
|
318
352
|
const full = path.join(dir, e.name);
|
|
319
353
|
const r = rel ? rel + '/' + e.name : e.name;
|
|
354
|
+
// Machine-written state is not a document the PM maintains, and alignment must never propose
|
|
355
|
+
// realigning it. change-context.md in particular QUOTES every change, so it matches the
|
|
356
|
+
// quoted-text rule on the first save in any repository and would top the candidate list —
|
|
357
|
+
// a generated file suggesting itself, found by installing the published artifact and using it.
|
|
358
|
+
if (STATE_DIR_NAMES.has(e.name)) continue;
|
|
320
359
|
if (e.isDirectory()) walk(full, r, depth + 1);
|
|
321
360
|
else if (preview.previewKind(e.name) === 'markdown') out.push(r);
|
|
322
361
|
}
|
|
@@ -367,8 +406,11 @@ function readTextDoc(root, rel) {
|
|
|
367
406
|
// filesystem, useless for "show me everything for pmos-jira-sync". This projection joins
|
|
368
407
|
// them on the initiative_id the artifacts already declare in their own identity block,
|
|
369
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.
|
|
370
411
|
const ROLE_SUFFIXES = [
|
|
371
412
|
['-eval-rubric', 'rubric'], ['-design-brief', 'design brief'], ['-design-reference', 'design'],
|
|
413
|
+
['-copy-brief', 'copy brief'], ['-user-stories', 'user stories'], ['-copy', 'copy'],
|
|
372
414
|
['-dogfood', 'dogfood'], ['-reconcile', 'reconcile'], ['-findings', 'findings'],
|
|
373
415
|
['-scoping', 'scoping'], ['-intake', 'intake'], ['-brief', 'brief'], ['-prd', 'PRD'],
|
|
374
416
|
];
|
|
@@ -377,6 +419,43 @@ const ROLE_BY_DIR = {
|
|
|
377
419
|
okrs: 'OKR', radar: 'radar', gtm: 'gtm', 'out-of-scope': 'rejected', 'harness-refine': 'harness',
|
|
378
420
|
'design-export': 'design', explainers: 'explainer', fixtures: 'fixture',
|
|
379
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
|
+
|
|
380
459
|
/** full path -> {mtimeMs, initiative, role, title} — re-read only what actually changed */
|
|
381
460
|
const metaCache = new Map();
|
|
382
461
|
|
|
@@ -443,8 +522,10 @@ function walkMd(dir, acc) {
|
|
|
443
522
|
}
|
|
444
523
|
function initiativeIndex(root) {
|
|
445
524
|
const proots = planningRoots(root); // planning/ at the root OR nested (e.g. IDPF/pmos/planning)
|
|
446
|
-
|
|
447
|
-
|
|
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);
|
|
448
529
|
// Pass 1: every id an artifact explicitly declares. Only these are real initiatives —
|
|
449
530
|
// a filename stem is promoted to a group only when it matches a declared id or when
|
|
450
531
|
// several artifacts share it, so a lone research note never invents an initiative.
|
|
@@ -573,22 +654,58 @@ function surfaceRoadmap(root, w) {
|
|
|
573
654
|
.filter((g) => g.items.length);
|
|
574
655
|
return { groups, note: 'dates are net-new state — grouped by status until a timeline is tracked' };
|
|
575
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
|
+
}
|
|
576
666
|
function surfaceOkr(root) {
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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 });
|
|
588
700
|
}
|
|
589
701
|
}
|
|
590
702
|
}
|
|
591
|
-
return {
|
|
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
|
+
};
|
|
592
709
|
}
|
|
593
710
|
function surfaceReport(root, w) {
|
|
594
711
|
const s = surfaceInitiatives(root, w);
|
|
@@ -1510,11 +1627,17 @@ const LAYOUT_MAX = 40_000;
|
|
|
1510
1627
|
function layoutPath(w) { return w ? path.join(LAYOUT_DIR, w.id + '.json') : null; }
|
|
1511
1628
|
// Always returns a usable document: an absent, empty, truncated or hand-mangled file yields the
|
|
1512
1629
|
// four stock layouts rather than an error, because the alternative is a window the PM cannot use.
|
|
1630
|
+
// `stored` is the client's ONLY safe signal that R8's one-time migration should run. sanitize()
|
|
1631
|
+
// always yields the four stock layouts, so the response body alone cannot distinguish "this
|
|
1632
|
+
// workspace has never stored anything" from "I could not read what it stored" — and the client
|
|
1633
|
+
// answers those two cases very differently (one writes, one must not). A file that exists but is
|
|
1634
|
+
// corrupt still counts as stored: this workspace has been through the layout system, so replaying
|
|
1635
|
+
// the pre-feature localStorage over it would be a second loss, not a recovery.
|
|
1513
1636
|
function readLayouts(w) {
|
|
1514
1637
|
const f = layoutPath(w);
|
|
1515
1638
|
let raw = null;
|
|
1516
1639
|
if (f) { try { raw = fs.readFileSync(f, 'utf8'); } catch { raw = null; } }
|
|
1517
|
-
return layoutModel.sanitize(raw);
|
|
1640
|
+
return Object.assign(layoutModel.sanitize(raw), { stored: raw !== null });
|
|
1518
1641
|
}
|
|
1519
1642
|
function writeLayouts(w, doc) {
|
|
1520
1643
|
const f = layoutPath(w);
|
|
@@ -1694,6 +1817,59 @@ function needsResult(w) {
|
|
|
1694
1817
|
if (!items.length) return { ok: true, empty: true };
|
|
1695
1818
|
return { ok: true, empty: false, data: { items } };
|
|
1696
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(); }
|
|
1697
1873
|
function buildConsole(name, w, root) {
|
|
1698
1874
|
const stateDir = stateDirFor(w);
|
|
1699
1875
|
const snapshotDir = latestSnapshotDir(root);
|
|
@@ -1702,7 +1878,12 @@ function buildConsole(name, w, root) {
|
|
|
1702
1878
|
if (name === 'radar') return consoleState.radar({ stateDir, snapshotDir });
|
|
1703
1879
|
// The workflow ladder reads the OPENED workspace's repo (records + git), not the state dir — an
|
|
1704
1880
|
// initiative's position is a property of the repo it lives in, snapshot or no snapshot.
|
|
1705
|
-
|
|
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);
|
|
1706
1887
|
return { ok: false, error: 'unknown console surface: ' + name };
|
|
1707
1888
|
}
|
|
1708
1889
|
|
|
@@ -2122,7 +2303,7 @@ async function handleRequest(req, res) {
|
|
|
2122
2303
|
const w = getWs(url.searchParams.get('ws'));
|
|
2123
2304
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
2124
2305
|
const doc = readLayouts(w);
|
|
2125
|
-
return json(res, { ok: true, layouts: doc.layouts, currentId: doc.currentId });
|
|
2306
|
+
return json(res, { ok: true, layouts: doc.layouts, currentId: doc.currentId, stored: doc.stored });
|
|
2126
2307
|
}
|
|
2127
2308
|
if (p === '/api/doc') {
|
|
2128
2309
|
const w = getWs(url.searchParams.get('ws'));
|
|
@@ -2153,7 +2334,18 @@ async function handleRequest(req, res) {
|
|
|
2153
2334
|
res.end(buf);
|
|
2154
2335
|
});
|
|
2155
2336
|
}
|
|
2156
|
-
|
|
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
|
+
}
|
|
2157
2349
|
if (p === '/api/console') {
|
|
2158
2350
|
// Read-only console surfaces (needs-you · radar · metrics) over file-mode state (D64 Track B).
|
|
2159
2351
|
// Discriminated result: {ok:true,empty} genuinely nothing · {ok:false,error} a broken read the
|
|
@@ -2180,6 +2372,7 @@ async function handleRequest(req, res) {
|
|
|
2180
2372
|
if (p === '/api/gate' && req.method === 'POST') {
|
|
2181
2373
|
// The PMOS Acceptance/Review vocabulary in file mode (R5). NEVER automatic — this is
|
|
2182
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
|
|
2183
2376
|
let body = {};
|
|
2184
2377
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
2185
2378
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|