create-pmos 0.15.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.
- package/README.md +38 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/README.md +44 -12
- package/workspace/design-parity.js +358 -0
- package/workspace/lib/find-browser.js +60 -0
- package/workspace/lib/four-panel.js +345 -0
- package/workspace/lib/git-info.js +28 -1
- package/workspace/lib/mobile-nav.js +32 -2
- package/workspace/package-lock.json +29 -2
- package/workspace/package.json +13 -5
- package/workspace/public/index.html +79 -409
- package/workspace/public/pm/app.js +242 -0
- package/workspace/public/pm/data.js +877 -0
- package/workspace/public/pm/dom.js +116 -0
- package/workspace/public/pm/fixture.js +979 -0
- package/workspace/public/pm/mobile.css +200 -0
- package/workspace/public/pm/mobile.js +403 -0
- package/workspace/public/pm/modes.js +1076 -0
- package/workspace/public/pm/panel-detail.js +648 -0
- package/workspace/public/pm/panel-initiatives.js +426 -0
- package/workspace/public/pm/panel-repos.js +460 -0
- package/workspace/public/pm/panel-tabs.js +487 -0
- package/workspace/public/pm/state.js +164 -0
- package/workspace/public/pm/tab-doc.js +279 -0
- package/workspace/public/pm/tab-task.js +83 -0
- package/workspace/public/pm/tab-term.js +560 -0
- package/workspace/public/pm/tokens.css +73 -0
- package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
- package/workspace/server.js +158 -8
- package/workspace/focus.test.js +0 -650
- package/workspace/handoff.test.js +0 -443
- package/workspace/profiles.test.js +0 -362
- package/workspace/public/app.js +0 -5560
- package/workspace/public/styles.css +0 -2258
- package/workspace/repo-scan.test.js +0 -327
|
@@ -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
|
+
}
|