create-pmos 0.14.0 → 0.16.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.
Files changed (39) hide show
  1. package/README.md +57 -0
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +2 -2
  4. package/workspace/README.md +44 -12
  5. package/workspace/design-parity.js +358 -0
  6. package/workspace/lib/console-state.js +48 -1
  7. package/workspace/lib/engine/workflow-state.js +13 -3
  8. package/workspace/lib/find-browser.js +60 -0
  9. package/workspace/lib/focus.js +967 -0
  10. package/workspace/lib/four-panel.js +345 -0
  11. package/workspace/lib/git-info.js +28 -1
  12. package/workspace/lib/handoff.js +280 -0
  13. package/workspace/lib/layout.js +68 -46
  14. package/workspace/lib/mobile-nav.js +157 -0
  15. package/workspace/lib/profiles.js +285 -0
  16. package/workspace/lib/repo-scan.js +469 -0
  17. package/workspace/package-lock.json +29 -2
  18. package/workspace/package.json +14 -4
  19. package/workspace/public/index.html +79 -279
  20. package/workspace/public/pm/app.js +242 -0
  21. package/workspace/public/pm/data.js +877 -0
  22. package/workspace/public/pm/dom.js +116 -0
  23. package/workspace/public/pm/fixture.js +979 -0
  24. package/workspace/public/pm/mobile.css +200 -0
  25. package/workspace/public/pm/mobile.js +403 -0
  26. package/workspace/public/pm/modes.js +1076 -0
  27. package/workspace/public/pm/panel-detail.js +648 -0
  28. package/workspace/public/pm/panel-initiatives.js +426 -0
  29. package/workspace/public/pm/panel-repos.js +460 -0
  30. package/workspace/public/pm/panel-tabs.js +487 -0
  31. package/workspace/public/pm/state.js +164 -0
  32. package/workspace/public/pm/tab-doc.js +279 -0
  33. package/workspace/public/pm/tab-task.js +83 -0
  34. package/workspace/public/pm/tab-term.js +560 -0
  35. package/workspace/public/pm/tokens.css +73 -0
  36. package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
  37. package/workspace/server.js +1072 -31
  38. package/workspace/public/app.js +0 -3450
  39. package/workspace/public/styles.css +0 -1444
@@ -0,0 +1,877 @@
1
+ /* pm/data.js — the real-state adapter for the four-panel workspace.
2
+ *
3
+ * Every panel module renders from `state`. This module is the ONLY thing that puts real repository
4
+ * state into it. It reads exclusively from this server's own origin over relative URLs — there is
5
+ * no other host, no client library, no dependency — and in FIXTURE mode it does not read at all.
6
+ *
7
+ * ── The failure it exists to prevent ────────────────────────────────────────────────────────
8
+ * The PRD names it "the beautiful shell": seed data rendering in both modes, so the comparator's
9
+ * diff is clean and nothing is connected. Two rules keep that impossible here:
10
+ * 1. `FIXTURE` short-circuits every function below before any fetch. Fixture mode makes NO
11
+ * network calls, so a fixture render is provably not a real read wearing a costume.
12
+ * 2. Nothing in this file falls back to `fixture.js` seed data. The only thing imported from it
13
+ * is `C` — the semantic colour tokens — because a second copy of `var(--warning)` in this file
14
+ * is exactly the kind of duplicate that drifts.
15
+ *
16
+ * ── The honesty contract ────────────────────────────────────────────────────────────────────
17
+ * The server's own contract is `{ok:true,empty:true}` (a successful read of nothing → CALM) ·
18
+ * `{ok:true,…}` (data) · `{ok:false,error}` (a failed read → RED). This module preserves the
19
+ * distinction rather than collapsing it:
20
+ * · a failed read of an initiative becomes `{readError}` on the record, which panel 3 renders as
21
+ * its red block carrying the server's own message. It never becomes an empty tree.
22
+ * · a failed read of the board leaves the board absent, which panel 2 renders as its own empty
23
+ * state — and the failure is reported on the record of whatever the PM then selects.
24
+ * · a field with no source is `null`. Not zero, not an empty string standing in for a value, and
25
+ * never a plausible-looking guess. `— not tracked` is the surface's job, not this file's.
26
+ *
27
+ * ── What is derived here, and by what rule ──────────────────────────────────────────────────
28
+ * PMOS has no task entity, so a phase's tasks are DERIVED (PRD, "Tasks"):
29
+ * · the phase SET comes from the initiative's lane;
30
+ * · each phase folds a fixed set of the workflow engine's twelve rungs, so every rung's evidence
31
+ * lands in exactly one phase and none is dropped;
32
+ * · a task's status comes from that phase's rungs — `done` where they passed, `blocked` where
33
+ * one is blocking, `doing` where one is current, else `todo`. A phase whose rungs carry no
34
+ * evidence at all reads `todo`, NEVER `done`;
35
+ * · an `## Tasks` section authored in the initiative record overrides the derivation entirely.
36
+ * Nothing is invented per initiative: every task's instruction, expectation and evaluation is the
37
+ * engine's own text for the rung it belongs to.
38
+ *
39
+ * ── The one thing this file refuses to do ───────────────────────────────────────────────────
40
+ * Linking an artifact records a DECLARATION (`{artifact_path, kind, target}` appended to
41
+ * `artifact-links.jsonl`). It makes no outbound request, because PMOS holds no credential. The
42
+ * sync panel therefore reports an unconfigured connector rather than a difference summary it never
43
+ * read — `state.connectors` is published empty, which is what makes panel 3's two approval buttons
44
+ * inert. A "2 sections differ" line over a destination nobody contacted is the watermelon this
45
+ * repo has a word for.
46
+ */
47
+
48
+ import { state, setState, subscribe, FIXTURE } from './state.js';
49
+ import { C } from './fixture.js';
50
+
51
+ /* ── transport ──────────────────────────────────────────────────────────────────────────────
52
+ * Relative URLs only. The server's origin/host allowlist 403s a page served from anywhere else,
53
+ * so a relative path is both the simplest form and the only one that can work.
54
+ */
55
+
56
+ const q = (params) => Object.entries(params)
57
+ .filter(([, v]) => v !== null && v !== undefined && v !== '')
58
+ .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
59
+
60
+ /** GET JSON. Resolves to `{ok:false,error}` on any transport failure — never throws at the caller. */
61
+ async function getJson(path, params) {
62
+ const url = params ? path + '?' + q(params) : path;
63
+ let res;
64
+ try { res = await fetch(url); }
65
+ catch (e) { return { ok: false, error: 'could not reach the workspace server — ' + msg(e) }; }
66
+ let body;
67
+ try { body = await res.json(); }
68
+ catch { return { ok: false, error: url + ' returned a body that is not JSON (HTTP ' + res.status + ')' }; }
69
+ // The console family returns HTTP 200 even for {ok:false} — the body carries the reason. A
70
+ // non-2xx with a JSON body is still that body's failure to report.
71
+ if (!res.ok && body && body.ok !== false) return { ok: false, error: 'HTTP ' + res.status + ' from ' + url };
72
+ return body;
73
+ }
74
+
75
+ async function postJson(path, body) {
76
+ let res;
77
+ try {
78
+ res = await fetch(path, {
79
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
80
+ });
81
+ } catch (e) { return { ok: false, error: 'could not reach the workspace server — ' + msg(e) }; }
82
+ try { return await res.json(); }
83
+ catch { return { ok: false, error: path + ' returned a body that is not JSON (HTTP ' + res.status + ')' }; }
84
+ }
85
+
86
+ const msg = (e) => (e && e.message ? e.message : String(e));
87
+
88
+ /* ── the lane → phase model (PRD, "Tasks") ──────────────────────────────────────────────────
89
+ * PHASES is the phase SET per lane, verbatim from the PRD. RUNGS maps each phase to the engine
90
+ * rungs it folds. Every lane's mapping covers all twelve rungs exactly once — that is the property
91
+ * that makes the fold lossless: no rung's evidence is dropped, and none is counted twice.
92
+ */
93
+
94
+ const PHASES = {
95
+ feature: ['intake', 'spec', 'contract', 'build', 'eval', 'accepted'],
96
+ discovery: ['intake', 'explore', 'synthesize', 'decide'],
97
+ chore: ['intake', 'fix', 'verify'],
98
+ incident: ['intake', 'contain', 'record', 'verify'],
99
+ content: ['intake', 'draft', 'review', 'publish'],
100
+ prototype: ['intake', 'specimens', 'grill', 'dispose'],
101
+ };
102
+
103
+ const RUNGS = {
104
+ feature: {
105
+ intake: ['route', 'anchor', 'prototype', 'understand', 'priors'],
106
+ spec: ['spec'], contract: ['contract'], build: ['build', 'quality'],
107
+ eval: ['review', 'explain'], accepted: ['accept'],
108
+ },
109
+ discovery: {
110
+ intake: ['route', 'anchor', 'prototype', 'understand', 'priors', 'spec', 'contract'],
111
+ explore: ['build', 'quality'], synthesize: ['review'], decide: ['explain', 'accept'],
112
+ },
113
+ chore: {
114
+ intake: ['route', 'anchor', 'prototype', 'understand', 'priors', 'spec', 'contract'],
115
+ fix: ['build', 'quality'], verify: ['review', 'explain', 'accept'],
116
+ },
117
+ incident: {
118
+ intake: ['route', 'anchor', 'prototype', 'understand', 'priors'],
119
+ contain: ['build', 'quality'], record: ['spec', 'contract', 'review'], verify: ['explain', 'accept'],
120
+ },
121
+ content: {
122
+ intake: ['route', 'anchor', 'prototype', 'understand', 'priors'],
123
+ draft: ['spec', 'contract', 'build', 'quality'], review: ['review'], publish: ['explain', 'accept'],
124
+ },
125
+ prototype: {
126
+ intake: ['route', 'anchor', 'prototype', 'understand', 'priors', 'spec', 'contract'],
127
+ specimens: ['build', 'quality'], grill: ['review', 'explain'], dispose: ['accept'],
128
+ },
129
+ };
130
+
131
+ const laneOf = (lane) => (PHASES[lane] ? lane : 'feature');
132
+
133
+ /**
134
+ * The status a phase inherits from the rungs it folds.
135
+ *
136
+ * `na` and `unknown` are DECIDED BY NOBODY and are excluded from the roll-up rather than counted
137
+ * as failures. `quality` is permanently `unknown` by the engine's own design — it never claims a
138
+ * test result it did not run — so counting it as not-done would mean the build phase could never
139
+ * read done on any initiative, forever. The engine's own `stage` derivation ignores it for exactly
140
+ * this reason.
141
+ *
142
+ * A phase whose rungs carry NO evidence is `pending`, checked before anything else: a rung the
143
+ * engine found nothing for must never roll up to `done`.
144
+ */
145
+ function phaseRungStatus(rungs) {
146
+ if (!rungs.length) return 'pending';
147
+ if (rungs.every((r) => !r.evidence)) return 'pending';
148
+ if (rungs.some((r) => r.status === 'blocked')) return 'blocked';
149
+ if (rungs.some((r) => r.status === 'current')) return 'current';
150
+ const decided = rungs.filter((r) => r.status !== 'na' && r.status !== 'unknown');
151
+ if (decided.length && decided.every((r) => r.status === 'done')) return 'done';
152
+ return 'pending';
153
+ }
154
+
155
+ /** The rung that explains the phase: the blocker, else the current one, else the last with evidence. */
156
+ function decidingRung(rungs) {
157
+ return rungs.find((r) => r.status === 'blocked')
158
+ || rungs.find((r) => r.status === 'current')
159
+ || [...rungs].reverse().find((r) => r.evidence)
160
+ || rungs[rungs.length - 1] || null;
161
+ }
162
+
163
+ /** Task statuses per phase status — the PRD's rule, spread over the three derived tasks. */
164
+ const TASK_STATUS = {
165
+ done: ['done', 'done', 'done'],
166
+ current: ['done', 'doing', 'todo'],
167
+ blocked: ['blocked', 'todo', 'todo'],
168
+ pending: ['todo', 'todo', 'todo'],
169
+ };
170
+
171
+ /**
172
+ * The three derived tasks for one phase. Each field is the engine's own text for the rungs this
173
+ * phase folds — the instruction is what the engine says to DO, the expectation is what it says it
174
+ * LOOKED FOR, the evaluation names the rule that decides. A field with no source is null and the
175
+ * task pane renders it empty; it is never filled with a sentence that sounds like evidence.
176
+ */
177
+ function derivedTasks(phase, rungs, ctx) {
178
+ const s = phaseRungStatus(rungs);
179
+ const lead = decidingRung(rungs) || {};
180
+ const titles = s === 'blocked'
181
+ ? ['resolve the ' + phase + ' blocker', 'produce the ' + phase + ' output', 'record the result']
182
+ : ['read the inputs for ' + phase, 'produce the ' + phase + ' output', 'record the result'];
183
+ const evidence = rungs.filter((r) => r.evidence).map((r) => r.key + ': ' + r.evidence).join(' · ') || null;
184
+ const inputs = [ctx.record, ctx.prd, ctx.rubric].filter(Boolean).join(' · ') || null;
185
+ const rules = rungs.map((r) => r.rule).filter(Boolean);
186
+ return titles.map((title, i) => ({
187
+ title,
188
+ status: (TASK_STATUS[s] || TASK_STATUS.pending)[i],
189
+ // The engine's own action for the phase — except on the first task, whose job IS the reading,
190
+ // so it names the files rather than repeating the action. Neither is authored prose: one is the
191
+ // engine's sentence, the other is a list of paths the record declares.
192
+ instruction: (i === 0 && inputs ? 'Read ' + inputs : null) || lead.next || lead.evidence || null,
193
+ input: inputs,
194
+ expect: evidence,
195
+ evalNote: rules.length
196
+ ? 'workflow rule ' + rules.join(', ') + ' decides this phase'
197
+ : 'the workflow engine derives this phase from ' + rungs.map((r) => r.key).join(', '),
198
+ // The produced artifact, when the engine named a path in its own evidence. Never guessed:
199
+ // an artifact the engine did not name is "no artifact yet", which the task pane says in words.
200
+ output: i === 1 ? (pathIn(lead.evidence) || pathIn(lead.next) || null) : null,
201
+ }));
202
+ }
203
+
204
+ /** The first repo-relative path inside an engine message, or null. Used only for a task's `output`. */
205
+ function pathIn(text) {
206
+ const m = String(text || '').match(/\b(?:planning|prototype|workspace|okf|skills|build-skills)\/[\w./-]+\.\w+/);
207
+ return m ? m[0] : null;
208
+ }
209
+
210
+ /** The `steps[]` panel 3 reads: one entry per lane phase, carrying the folded rungs' evidence. */
211
+ function phaseSteps(lane, ladderSteps) {
212
+ const L = laneOf(lane);
213
+ const by = {};
214
+ for (const s of ladderSteps || []) by[s.key] = s;
215
+ return PHASES[L].map((phase) => {
216
+ const rungs = (RUNGS[L][phase] || []).map((k) => by[k]).filter(Boolean);
217
+ const lead = decidingRung(rungs) || {};
218
+ return {
219
+ k: phase,
220
+ s: phaseRungStatus(rungs),
221
+ t: lead.evidence || '',
222
+ action: lead.next || null,
223
+ cmd: pathIn(lead.next) || pathIn(lead.evidence) || null,
224
+ };
225
+ });
226
+ }
227
+
228
+ /* ── record derivation ──────────────────────────────────────────────────────────────────────── */
229
+
230
+ /** The `pills` row: lane, then the resolved anchor or an honest rejection, then a declared flow. */
231
+ function pillsFor({ lane, anchor, flow }) {
232
+ const out = [];
233
+ if (lane) out.push({ t: lane, c: C.pur });
234
+ if (anchor && anchor.canonical) out.push({ t: anchor.canonical, c: C.mut });
235
+ else if (anchor && anchor.status === 'unknown') out.push({ t: 'anchor unread', c: C.wrn });
236
+ else out.push({ t: 'unanchored', c: C.wrn });
237
+ // The `flow` pill ships only when the record declares one. No record in this repository does, and
238
+ // inventing `adlc-feature` to fill the slot would be stating a workflow that was never chosen.
239
+ if (flow) out.push({ t: 'flow ' + flow, c: C.inf });
240
+ return out;
241
+ }
242
+
243
+ /**
244
+ * The footer tuple `[stage, note, colour]` and the dot beside every row in panel 2. `stage` is the
245
+ * engine's, never a `status:` string — there is no stage field to read (D64/the record README) and
246
+ * a status nobody maintains is a board that lies.
247
+ */
248
+ function l2For(row) {
249
+ if (!row) return ['—', 'ladder not derived yet', C.mut];
250
+ const blockers = (row.blockers || []).length;
251
+ if (row.accepted) return [row.stage || 'accepted', 'accepted', C.suc];
252
+ if (blockers) return [row.stage || '—', '✗ ' + blockers + ' blocked', C.des];
253
+ if (row.next) return [row.stage || '—', '→ ' + row.next.step + ' owed', C.wrn];
254
+ return [row.stage || '—', 'no next step recorded', C.mut];
255
+ }
256
+
257
+ /** The `answer` banner — the engine's own `next`, with a tone derived from the blockers. */
258
+ function answerFor(row) {
259
+ if (!row) return null;
260
+ if (!row.next) {
261
+ return row.accepted
262
+ ? { tone: 'ok', text: 'accepted — the ladder is complete.' }
263
+ : { tone: 'info', text: 'the engine reports no next step for this initiative.' };
264
+ }
265
+ return {
266
+ tone: (row.blockers || []).length ? 'bad' : 'warn',
267
+ text: row.next.action || row.next.why || '',
268
+ };
269
+ }
270
+
271
+ /** The Review-Gate line, read off the `review` rung — no verdict is not a pass. */
272
+ function evalFor(ladderSteps) {
273
+ const r = (ladderSteps || []).find((s) => s.key === 'review');
274
+ if (!r || r.status !== 'done' || !r.evidence) return null;
275
+ return { pass: /^PASS/i.test(r.evidence), text: r.evidence };
276
+ }
277
+
278
+ /** A live session, in the shape panel 2 and panel 4 read. `shown` is the server's own derived state. */
279
+ function sessionRow(s) {
280
+ const label = s.shown === 'quiet' && Number.isFinite(s.quietMs)
281
+ ? 'quiet ' + Math.max(1, Math.round(s.quietMs / 60000)) + 'm'
282
+ : (s.shown === 'needs' ? 'needs you' : s.shown);
283
+ return { name: s.name, st: s.shown, label, profile: s.profile_id || null, id: s.id };
284
+ }
285
+
286
+ /**
287
+ * External references. Two sources, both declarations and neither a reading: the record's own
288
+ * `handoff_*` frontmatter (parsed by lib/handoff.js, whose `connected` is the literal false on
289
+ * every path it has), and the rows the PM added by hand into `initiative-links.jsonl`.
290
+ */
291
+ function linksFor(payload) {
292
+ const out = [];
293
+ const h = payload.links && payload.links.links;
294
+ for (const l of h || []) {
295
+ out.push({ kind: l.kind, label: l.declared, path: l.href || l.declared, st: l.state_text || l.state });
296
+ }
297
+ for (const r of (payload.declarations && payload.declarations.links) || []) {
298
+ out.push({ kind: r.kind, label: r.label, path: r.target, st: 'declared · not connected' });
299
+ }
300
+ return out;
301
+ }
302
+
303
+ /** Artifacts, with any declared external destination attached. */
304
+ function artsFor(payload) {
305
+ const dest = new Map();
306
+ for (const r of (payload.declarations && payload.declarations.artifacts) || []) {
307
+ dest.set(r.artifact_path, { kind: r.kind, target: r.target }); // a later row supersedes an earlier
308
+ }
309
+ return (payload.artifacts || []).map((a) => {
310
+ const d = dest.get(a.path);
311
+ return d ? { role: a.role, path: a.path, dest: d } : { role: a.role, path: a.path };
312
+ });
313
+ }
314
+
315
+ /** The full record panel 3 renders, from one `/api/initiative` payload. */
316
+ function recordFrom(payload) {
317
+ if (!payload || payload.ok === false) {
318
+ const error = (payload && payload.error) || 'the initiative could not be read';
319
+ return { readError: error, l2: ['read failed', error.slice(0, 60), C.des] };
320
+ }
321
+ const fm = payload.frontmatter || {};
322
+ const row = (payload.ladder && payload.ladder.ok && payload.ladder.row) || null;
323
+ const scope = (payload.ladder && payload.ladder.scope) || null;
324
+ const lane = fm.lane || (row && row.lane) || null;
325
+ const steps = row ? phaseSteps(lane, row.steps) : [];
326
+ const ctx = { record: payload.record, prd: fm.prd_path || null, rubric: fm.rubric_path || null };
327
+ // Authored tasks win over derivation, per phase (PRD, "Tasks"). Their absence is not a defect.
328
+ const authored = payload.tasks || {};
329
+ const tasks = {};
330
+ const byKey = {};
331
+ for (const s of (row && row.steps) || []) byKey[s.key] = s;
332
+ for (const st of steps) {
333
+ const rungs = (RUNGS[laneOf(lane)][st.k] || []).map((k) => byKey[k]).filter(Boolean);
334
+ tasks[st.k] = Array.isArray(authored[st.k]) && authored[st.k].length
335
+ ? authored[st.k]
336
+ : derivedTasks(st.k, rungs, ctx);
337
+ }
338
+ const rec = {
339
+ tag: fm.title || (row && row.title) || '',
340
+ stage: row ? row.stage : null,
341
+ flow: fm.flow || null,
342
+ pills: pillsFor({ lane, anchor: payload.anchor, flow: fm.flow || null }),
343
+ // What the ladder was DERIVED ON, which is exactly what the footer sentence claims.
344
+ branch: scope ? scope.branch : null,
345
+ answer: answerFor(row),
346
+ steps,
347
+ tasks,
348
+ sessions: (payload.sessions || []).map(sessionRow),
349
+ links: linksFor(payload),
350
+ arts: artsFor(payload),
351
+ eval: evalFor(row && row.steps),
352
+ l2: l2For(row),
353
+ };
354
+ if (!row) {
355
+ rec.noLadder = payload.absent
356
+ ? 'No initiative record on this board — the artifacts below are what the library can see; no loop position is claimed.'
357
+ : 'No loop position could be derived for this initiative.';
358
+ if (!rec.answer) {
359
+ rec.answer = { tone: 'warn', text: 'File an initiative record — artifacts exist, but no loop position is claimed.' };
360
+ }
361
+ rec.pills = payload.absent ? [{ t: 'no record', c: C.wrn }] : rec.pills;
362
+ rec.l2 = payload.absent ? ['—', 'no record', C.wrn] : rec.l2;
363
+ }
364
+ if (!rec.sessions.length) {
365
+ rec.noSess = (payload.session_trees || []).length
366
+ ? 'No sessions on this initiative — normal, not an error.'
367
+ : 'No working tree is on initiative/' + payload.id + ' — a normal state.';
368
+ }
369
+ return rec;
370
+ }
371
+
372
+ /* ── the board (panel 2), from the spine + the library ──────────────────────────────────────── */
373
+
374
+ /** A LIGHT record: everything the board can show without deriving a ladder per initiative. */
375
+ function lightRecord(rec, artifacts) {
376
+ return {
377
+ tag: rec.title || '',
378
+ stage: null,
379
+ flow: null,
380
+ pills: pillsFor({ lane: rec.lane, anchor: rec.anchorHint || null, flow: null }),
381
+ branch: null,
382
+ answer: null,
383
+ steps: [],
384
+ sessions: [],
385
+ links: [],
386
+ arts: (artifacts || []).map((a) => ({ role: a.role, path: a.path })),
387
+ eval: null,
388
+ // Not "todo" and not "done": the ladder for this initiative has not been derived. Deriving all
389
+ // of them costs one `git log` per record — ~30 s on this repository — and a board that froze
390
+ // the PM's terminals to colour its dots would be a worse answer than a grey one.
391
+ l2: ['—', 'ladder not derived yet — open it to derive', C.mut],
392
+ lite: true,
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Fold `/api/console?name=spine` into the board's groups.
398
+ *
399
+ * A group is one Objective+KR pair, which is the prototype's own model (one group, one optional KR
400
+ * sub-row). Its `gid` therefore carries BOTH ids: the collapse key in panel 2 is `product|gid`, so
401
+ * three groups all calling themselves `O1` would fold and unfold as one.
402
+ */
403
+ function groupsFromSpine(spine) {
404
+ const groups = [];
405
+ for (const o of spine.objectives || []) {
406
+ const total = (o.krs || []).reduce((n, k) => n + k.initiatives.length, 0) + ((o.direct || []).length);
407
+ for (const kr of o.krs || []) {
408
+ if (!kr.initiatives.length) continue;
409
+ groups.push({
410
+ gid: o.id + '/' + kr.id, idc: C.fg, gname: o.name, gct: String(total),
411
+ hasKr: true, krid: kr.id, krname: kr.name,
412
+ krct: String(kr.initiatives.length),
413
+ slugs: kr.initiatives.map((i) => i.id),
414
+ });
415
+ }
416
+ if ((o.direct || []).length) {
417
+ groups.push({
418
+ gid: o.id, idc: C.fg, gname: o.name, gct: String(total), hasKr: false,
419
+ slugs: o.direct.map((i) => i.id),
420
+ });
421
+ }
422
+ }
423
+ for (const b of spine.budgets || []) {
424
+ groups.push({
425
+ gid: b.id, idc: C.inf, gname: 'health budget — upkeep, not a bet',
426
+ gct: String(b.initiatives.length), hasKr: false, slugs: b.initiatives.map((i) => i.id),
427
+ });
428
+ }
429
+ // D59: rejected work is FILED, never hidden. It is the last group and it says what it is.
430
+ groups.push({
431
+ gid: 'unanchored', dyn: true, idc: C.wrn,
432
+ gname: 'no resolvable anchor — rejected work under D59',
433
+ gct: String((spine.unanchored || []).length), hasKr: false,
434
+ slugs: (spine.unanchored || []).map((i) => i.id),
435
+ });
436
+ return groups;
437
+ }
438
+
439
+ /* ── the workspace registry ─────────────────────────────────────────────────────────────────── */
440
+
441
+ const products = new Map(); // product name → {name, ws, root, count, board, records}
442
+ let productOrder = [];
443
+
444
+ /** Group registered working trees into products: several worktrees of one repo share a `repo_key`. */
445
+ function foldWorkspaces(rows) {
446
+ const byRepo = new Map();
447
+ for (const w of rows || []) {
448
+ const key = w.repo_key || w.root;
449
+ if (!byRepo.has(key)) {
450
+ byRepo.set(key, {
451
+ name: w.repo_name || w.name, ws: w.id, root: w.root, trees: [],
452
+ // The server derived this from the tree's `origin` remote (git-info.originUrl). It is read
453
+ // here and never re-derived: a second opinion about what a repository IS would be a second
454
+ // thing to keep true, and this one is the one that did the read.
455
+ kind: w.kind === 'github' ? 'github' : 'local', remote: w.remote || null,
456
+ });
457
+ }
458
+ const p = byRepo.get(key);
459
+ p.trees.push(w);
460
+ // The main worktree is the product's identity; prefer its id for reads scoped to `ws` — and its
461
+ // remote, since a linked worktree of the same repo answers with the same origin anyway.
462
+ if (w.root === key) {
463
+ p.ws = w.id;
464
+ p.kind = w.kind === 'github' ? 'github' : 'local';
465
+ p.remote = w.remote || null;
466
+ }
467
+ }
468
+ // Two distinct repositories can share a basename. Disambiguating with the parent directory keeps
469
+ // both rows on the board rather than silently merging one into the other.
470
+ const seen = new Map();
471
+ const out = [];
472
+ for (const p of byRepo.values()) {
473
+ let name = p.name;
474
+ if (seen.has(name)) {
475
+ const parent = String(p.root).split('/').slice(-2, -1)[0] || '';
476
+ name = parent ? parent + '/' + name : name + ' (' + p.ws + ')';
477
+ }
478
+ seen.set(p.name, true);
479
+ out.push({ ...p, name });
480
+ }
481
+ return out;
482
+ }
483
+
484
+ /** Load one product's board: the spine (structure) joined with the library (artifacts). */
485
+ async function loadBoard(prod) {
486
+ const [spine, lib] = await Promise.all([
487
+ getJson('/api/console', { name: 'spine', ws: prod.ws }),
488
+ getJson('/api/library', { ws: prod.ws }),
489
+ ]);
490
+ if (spine.ok === false) {
491
+ prod.error = spine.error || 'the initiative spine could not be read';
492
+ prod.board = [];
493
+ prod.records = {};
494
+ prod.count = null;
495
+ return prod;
496
+ }
497
+ const artsById = new Map();
498
+ for (const i of (lib && lib.initiatives) || []) artsById.set(i.id, i.artifacts || []);
499
+
500
+ const groups = groupsFromSpine(spine);
501
+ const records = {};
502
+ const note = (rec, anchorHint) => {
503
+ records[rec.id] = lightRecord({ ...rec, anchorHint }, artsById.get(rec.id));
504
+ };
505
+ for (const o of spine.objectives || []) {
506
+ for (const kr of o.krs || []) for (const r of kr.initiatives) note(r, { canonical: o.key + '/' + kr.key });
507
+ for (const r of o.direct || []) note(r, { canonical: o.key });
508
+ }
509
+ for (const b of spine.budgets || []) for (const r of b.initiatives) note(r, { canonical: b.id });
510
+ for (const r of spine.unanchored || []) note(r, null);
511
+
512
+ // The library can see initiatives the SPINE has no record for: artifacts that group into a unit
513
+ // with no `planning/initiatives/<id>.md` behind them. They get their own group rather than being
514
+ // folded into `unanchored`, because the two are different facts and the panel says different
515
+ // things about them — "this record declares no resolvable anchor" (rejected under D59) is not
516
+ // "there is no record at all" (real files, no loop position claimed). Dropping them was never an
517
+ // option: they are documents that exist.
518
+ // Counted BEFORE the orphans are merged in — see `prod.count` below.
519
+ const spineCount = Object.keys(records).length;
520
+ const known = new Set(Object.keys(records));
521
+ const orphans = [...artsById.keys()].filter((id) => !known.has(id)).sort();
522
+ if (orphans.length) {
523
+ groups.push({
524
+ gid: 'no record', dyn: true, idc: C.wrn,
525
+ gname: 'artifacts with no initiative record — no loop position is claimed',
526
+ gct: String(orphans.length), hasKr: false, slugs: orphans,
527
+ });
528
+ for (const id of orphans) {
529
+ records[id] = {
530
+ ...lightRecord({ id, title: '', lane: null }, artsById.get(id)),
531
+ pills: [{ t: 'no record', c: C.wrn }],
532
+ l2: ['—', 'no record', C.wrn],
533
+ };
534
+ }
535
+ }
536
+
537
+ prod.error = null;
538
+ prod.board = groups;
539
+ prod.records = records;
540
+ // THE COUNT ON PANEL 1 IS THE SPINE'S, NOT THE RECORD MAP'S. `records` deliberately holds two
541
+ // different populations — the initiatives the spine placed, and the orphan groups above, which
542
+ // the very next panel labels "artifacts with no initiative record — no loop position is claimed".
543
+ // Sizing the row off the merged map made panel 1 read `176 initiatives` on a repository holding
544
+ // 138 placed records and 38 orphans: two numbers on one screen disagreeing, with the headline one
545
+ // inflated by 27% with items the app itself refuses to call initiatives. Panel 2 still shows the
546
+ // orphans, under their own honest heading; the row counts what it says it counts.
547
+ prod.count = spineCount;
548
+ prod.unrecorded = orphans.length;
549
+ return prod;
550
+ }
551
+
552
+ /** Publish every loaded product's board and records into state, in one patch. */
553
+ function publishBoards() {
554
+ const board = {};
555
+ let records = {};
556
+ for (const name of productOrder) {
557
+ const p = products.get(name);
558
+ if (!p || !p.board) continue;
559
+ board[name] = p.board;
560
+ records = { ...records, ...p.records };
561
+ }
562
+ setState({ board, records: { ...records, ...(hydrated) } });
563
+ }
564
+
565
+ /* Hydrated (fully derived) records live apart from the light ones so that a board reload cannot
566
+ * silently downgrade the initiative the PM is looking at back to its light form. */
567
+ const hydrated = Object.create(null);
568
+
569
+ /* ── hydration: one initiative, fully derived ───────────────────────────────────────────────── */
570
+
571
+ const inFlight = new Set();
572
+
573
+ /* THE READ CLOCK, and why it lives here rather than in the renderer.
574
+ *
575
+ * Panel 3's footer says "derived on <branch> · as of <time>". That line's whole job is provenance:
576
+ * WHEN was what you are looking at read. It was first shipped as the design's literal `12:41:03` —
577
+ * a time at which nothing ever happened — and then fixed by calling the clock inside `footer()`,
578
+ * which measured the RENDER instead. The panel re-renders on any state change (a session poll, a
579
+ * theme toggle, a tab opening), while `hydrate` runs once per slug per page life — so the line
580
+ * advanced, untouched data underneath it, and read `as of 17:11:57` over a payload fetched twenty
581
+ * seconds earlier. A true measurement of the wrong event is still a false claim.
582
+ *
583
+ * So the stamp is taken HERE, at the instant the payload lands, and carried on the record. The
584
+ * renderer prints what it is given and owns no clock. */
585
+ function readStamp() {
586
+ const d = new Date();
587
+ const p = (n) => String(n).padStart(2, '0');
588
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
589
+ }
590
+
591
+ async function hydrate(slug) {
592
+ if (FIXTURE || !slug || inFlight.has(slug)) return null;
593
+ const prod = products.get(currentProductName());
594
+ const ws = prod ? prod.ws : null;
595
+ inFlight.add(slug);
596
+ try {
597
+ const payload = await getJson('/api/initiative', { id: slug, ws });
598
+ // Stamped after the await, on the line that follows the read — a failed read is still a read,
599
+ // and its footer ("read failed · as of …") is entitled to say when it was attempted.
600
+ const asOf = readStamp();
601
+ hydrated[slug] = { ...recordFrom(payload), asOf };
602
+ setState((s) => ({ records: { ...s.records, [slug]: hydrated[slug] } }));
603
+ return payload;
604
+ } finally { inFlight.delete(slug); }
605
+ }
606
+
607
+ function currentProductName() {
608
+ if (state.product && products.has(state.product)) return state.product;
609
+ return productOrder[0] || null;
610
+ }
611
+
612
+ /* ── sessions ───────────────────────────────────────────────────────────────────────────────
613
+ * Polled rather than pushed. The WebSocket that carries `sessions` frames belongs to panel 4's
614
+ * terminal module, which owns the attach/input protocol; opening a SECOND socket here to read a
615
+ * list would put two connections on the same server for one fact. A 4 s poll of a local endpoint
616
+ * is cheaper than the duplicate protocol and cannot disagree with itself.
617
+ */
618
+
619
+ let pollTimer = null;
620
+
621
+ async function pollSessions() {
622
+ if (FIXTURE) return;
623
+ const out = await getJson('/api/sessions');
624
+ if (out && Array.isArray(out.sessions)) {
625
+ setState({ sessions: out.sessions, runningCount: liveCount(out.sessions) });
626
+ }
627
+ schedulePoll();
628
+ }
629
+
630
+ function liveCount(rows) {
631
+ let n = 0;
632
+ for (const s of rows || []) if (s && (s.shown === 'working' || s.shown === 'needs')) n++;
633
+ for (const k in state.extraSess || {}) {
634
+ for (const s of state.extraSess[k] || []) {
635
+ // Started in this window and not yet visible in the server's list — counted once, by name.
636
+ if (s && (s.st === 'working' || s.st === 'needs') && !(rows || []).some((r) => r && r.name === s.name)) n++;
637
+ }
638
+ }
639
+ return n;
640
+ }
641
+
642
+ function schedulePoll() {
643
+ if (pollTimer) clearTimeout(pollTimer);
644
+ pollTimer = setTimeout(() => {
645
+ if (typeof document !== 'undefined' && document.hidden) { schedulePoll(); return; }
646
+ pollSessions();
647
+ }, 4000);
648
+ }
649
+
650
+ /* ══════════════════════════════════════════════════════════════════════════════════════════════
651
+ * The exported surface. Every one of these is called by a panel module BY NAME — the signatures
652
+ * are the panels', not this file's, and none of them may change without changing the caller.
653
+ * ════════════════════════════════════════════════════════════════════════════════════════════ */
654
+
655
+ /**
656
+ * panel-repos.js — `loadRepos()`. The repository list, with a real initiative count per row.
657
+ * Each product's board is loaded here rather than lazily, because the count on the row IS the
658
+ * board's size: a row that said "— initiatives" until it was clicked would be a row that has not
659
+ * read anything.
660
+ *
661
+ * `kind` is the SERVER's answer, not a guess made here: /api/workspaces reports `remote` (the tree's
662
+ * `origin` URL, or null) and the `github`/`local` label derived from it, so a row says `github` only
663
+ * where a GitHub remote was actually read. It used to be hardcoded `local` on every row, which was
664
+ * honest but blind; inferring it from the presence of a `.git` folder — the other tempting fix —
665
+ * would put a provenance label on a row that no read supports.
666
+ */
667
+ export async function loadRepos() {
668
+ if (FIXTURE) return [];
669
+ const out = await getJson('/api/workspaces');
670
+ const rows = foldWorkspaces(out && out.workspaces);
671
+ productOrder = rows.map((r) => r.name);
672
+ for (const r of rows) products.set(r.name, Object.assign(products.get(r.name) || {}, r));
673
+ await Promise.all(rows.map((r) => loadBoard(products.get(r.name))));
674
+ publishBoards();
675
+ const first = currentProductName();
676
+ const p = first ? products.get(first) : null;
677
+ // The selection is set explicitly rather than left to panel 2's fallback. The fallback picks the
678
+ // same row, but it picks it WITHOUT setting `sel` — so the row is not highlighted, and the
679
+ // hydration watcher below (which fires on a change to `sel`) never sees the initiative that is
680
+ // actually on screen. A restored selection from the PM's last session always wins.
681
+ const sel = state.sel || (p ? firstSlugOf(p.name) : null);
682
+ setState({
683
+ wsId: p ? p.ws : null,
684
+ connectors: [], // no connector is configured — panel 3's approvals stay inert
685
+ product: state.product && products.has(state.product) ? state.product : first,
686
+ sel,
687
+ });
688
+ if (sel) hydrate(sel);
689
+ schedulePoll();
690
+ pollSessions();
691
+ return productOrder.map((name) => {
692
+ const prod = products.get(name);
693
+ return {
694
+ name,
695
+ kind: prod && prod.kind === 'github' ? 'github' : 'local',
696
+ count: prod && prod.count !== null ? prod.count : null,
697
+ };
698
+ });
699
+ }
700
+
701
+ /**
702
+ * panel-repos.js — `firstSlugOf(id)`, synchronous. The initiative selected when a repository is
703
+ * picked. A repository whose board is loaded and genuinely empty returns null, and panel 3 says so.
704
+ */
705
+ export function firstSlugOf(id) {
706
+ const p = products.get(id);
707
+ if (!p || !p.board) return null;
708
+ for (const g of p.board) if (g.slugs && g.slugs.length) return g.slugs[0];
709
+ return null;
710
+ }
711
+
712
+ /**
713
+ * panel-repos.js — `loadInitiatives(id)`. The board for one repository, loaded if it is not
714
+ * already. Returns `{groups}` because that is the shape the caller's `firstSlugIn` reads.
715
+ */
716
+ export async function loadInitiatives(id) {
717
+ if (FIXTURE) return { groups: [] };
718
+ let p = products.get(id);
719
+ if (!p) {
720
+ await loadRepos();
721
+ p = products.get(id);
722
+ if (!p) return { ok: false, error: 'no repository named ' + id + ' is registered', groups: [] };
723
+ }
724
+ if (!p.board) await loadBoard(p);
725
+ publishBoards();
726
+ if (p.error) return { ok: false, error: p.error, groups: [] };
727
+ return { ok: true, groups: p.board, records: p.records };
728
+ }
729
+
730
+ /**
731
+ * panel-repos.js — `addRepo({source, kind})`. Registering a local folder is a one-call write.
732
+ *
733
+ * A GitHub repository goes through the server's repo INGEST instead: the server clones it off the
734
+ * request path (the one sanctioned egress in this process, git's and not the browser's), scans it
735
+ * and proposes anchors that a human confirms one at a time. This function starts that and reports
736
+ * where it got to; it does NOT add a board row, because a row for a repository whose anchors have
737
+ * not been confirmed would be a board claiming work PMOS has not accepted.
738
+ */
739
+ export async function addRepo({ source, kind }) {
740
+ if (FIXTURE) return { log: [] };
741
+ if (kind === 'local') {
742
+ const res = await postJson('/api/workspaces', { root: source, focus: false });
743
+ if (!res || res.ok === false) {
744
+ return { log: [{ t: 'refused — ' + ((res && res.error) || 'the folder could not be registered'), c: 'var(--destructive)' }] };
745
+ }
746
+ const w = res.workspace || {};
747
+ const rows = foldWorkspaces([{ ...w, repo_key: w.root, repo_name: w.name }]);
748
+ const p = rows[0];
749
+ products.set(p.name, Object.assign(products.get(p.name) || {}, p));
750
+ if (!productOrder.includes(p.name)) productOrder.push(p.name);
751
+ await loadBoard(products.get(p.name));
752
+ publishBoards();
753
+ const prod = products.get(p.name);
754
+ return {
755
+ repo: { name: p.name, kind: 'local', count: prod.count },
756
+ log: [
757
+ { t: 'registered ' + w.root, c: 'var(--muted-foreground)' },
758
+ prod.error
759
+ ? { t: 'the initiative spine could not be read — ' + prod.error, c: 'var(--destructive)' }
760
+ : { t: 'read planning/ … ' + (prod.count || 0) + ' initiative record(s)', c: 'var(--muted-foreground)' },
761
+ { t: 'done — ' + p.name + ' added', c: 'var(--success)' },
762
+ ],
763
+ };
764
+ }
765
+ const res = await postJson('/api/ingest', { source: 'github', ref: source });
766
+ if (!res || res.ok === false) {
767
+ return { log: [{ t: 'refused — ' + ((res && res.error) || 'the ingest could not be started'), c: 'var(--destructive)' }] };
768
+ }
769
+ return {
770
+ log: [
771
+ { t: 'ingest ' + res.id + ' started — clone and scan run off the request path', c: 'var(--muted-foreground)' },
772
+ { t: 'anchors are PROPOSED by the scan and confirmed one at a time — nothing is on the board yet', c: 'var(--warning)' },
773
+ ],
774
+ };
775
+ }
776
+
777
+ /**
778
+ * panel-tabs.js — `startSession(slug, sess)`. Deliberately NOT a spawn.
779
+ *
780
+ * panel 4's terminal module owns session creation: it holds the WebSocket, and it spawns a PTY for
781
+ * exactly the sessions that were started in this window. Spawning one here as well would start two
782
+ * agents for one click and give the PM a tab attached to whichever won. What this hook does is
783
+ * refresh the session list soon enough that the new PTY appears in `state.sessions` — the join the
784
+ * board and the status bar read.
785
+ */
786
+ export function startSession(slug, sess) {
787
+ if (FIXTURE) return;
788
+ void slug; void sess;
789
+ setTimeout(pollSessions, 800);
790
+ }
791
+
792
+ /** tab-doc.js — `readDoc(path)`. The raw document body, or a rejected promise carrying the reason. */
793
+ export async function readDoc(path) {
794
+ if (FIXTURE) return '';
795
+ const p = products.get(currentProductName());
796
+ const url = '/api/doc?' + q({ ws: state.wsId || (p ? p.ws : ''), path });
797
+ const res = await fetch(url);
798
+ if (!res.ok) throw new Error(res.status === 404 ? 'not a readable text document in this workspace' : 'HTTP ' + res.status);
799
+ return res.text();
800
+ }
801
+
802
+ /**
803
+ * tab-doc.js — `saveDoc({ws, path, base, text})`. Refuse-on-stale: the server compares `base`
804
+ * against the file on disk and REFUSES rather than overwriting a file that changed underneath.
805
+ * The refusal is returned as `{ok:false,error}` and the caller keeps the buffer.
806
+ */
807
+ export async function saveDoc({ ws, path, base, text }) {
808
+ if (FIXTURE) return { ok: true };
809
+ const p = products.get(currentProductName());
810
+ return postJson('/api/doc/save', { ws: ws || state.wsId || (p ? p.ws : ''), path, base, text, pm: true });
811
+ }
812
+
813
+ /**
814
+ * panel-detail.js — `recordArtifactLink({artifact_path, slug, kind, target})`.
815
+ * Appends ONE declaration to `artifact-links.jsonl`. It contacts nothing: PMOS holds no credential,
816
+ * so this records what the PM says the mapping is and says nothing about the destination.
817
+ */
818
+ export async function recordArtifactLink({ artifact_path, slug, kind, target }) {
819
+ if (FIXTURE) return { ok: true };
820
+ const p = products.get(currentProductName());
821
+ const out = await postJson('/api/initiative/link', {
822
+ store: 'artifact', ws: state.wsId || (p ? p.ws : ''),
823
+ initiative_id: slug, artifact_path,
824
+ // The surface labels a Google destination `google doc`; the store's vocabulary is `google`.
825
+ kind: String(kind || '').startsWith('google') ? 'google' : kind,
826
+ target,
827
+ });
828
+ if (out && out.ok === false) console.warn('[pm] the artifact link was NOT recorded: ' + out.error);
829
+ return out;
830
+ }
831
+
832
+ /** panel-detail.js — `recordInitiativeLink({slug, kind, label, path, st})` → `initiative-links.jsonl`. */
833
+ export async function recordInitiativeLink({ slug, kind, label, path }) {
834
+ if (FIXTURE) return { ok: true };
835
+ const p = products.get(currentProductName());
836
+ const out = await postJson('/api/initiative/link', {
837
+ store: 'initiative', ws: state.wsId || (p ? p.ws : ''),
838
+ initiative_id: slug, kind, label, target: path,
839
+ });
840
+ if (out && out.ok === false) console.warn('[pm] the reference link was NOT recorded: ' + out.error);
841
+ return out;
842
+ }
843
+
844
+ /**
845
+ * app.js — `runningCount(state)`. The status bar's number: live PTYs the server reports as working
846
+ * or needing the PM, plus any started in this window the server has not listed yet.
847
+ */
848
+ export function runningCount(s) {
849
+ // DECLINE in fixture mode rather than answering 0. Returning a number here is an answer, and
850
+ // app.js quite correctly takes the first answer it gets — so `0` shadowed the fixture's own
851
+ // count and the status bar read "0 sessions running" against a seed board with three. It cost a
852
+ // parity failure on a bar that is otherwise pixel-identical. Null means "not mine to answer".
853
+ if (FIXTURE) return null;
854
+ return liveCount((s && s.sessions) || state.sessions || []);
855
+ }
856
+
857
+ /* ── keeping the selection hydrated ──────────────────────────────────────────────────────────
858
+ * The PM selects an initiative in panel 2 and panel 3 must show its ladder. Rather than making
859
+ * every panel call this module, the selection is watched here: one subscription, one fetch per
860
+ * newly selected initiative, and a repaint when it lands.
861
+ */
862
+ if (!FIXTURE) {
863
+ let lastSel = null, lastProduct = null;
864
+ subscribe(() => {
865
+ if (state.product !== lastProduct) {
866
+ lastProduct = state.product;
867
+ const p = products.get(state.product);
868
+ if (p && p.ws && state.wsId !== p.ws) setState({ wsId: p.ws });
869
+ if (p && !p.board) loadInitiatives(state.product);
870
+ }
871
+ const sel = state.sel;
872
+ if (sel && sel !== lastSel) {
873
+ lastSel = sel;
874
+ if (!hydrated[sel]) hydrate(sel);
875
+ }
876
+ });
877
+ }