create-pmos 0.13.0 → 0.15.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.
@@ -0,0 +1,967 @@
1
+ 'use strict';
2
+ /*
3
+ * focus.js — the Focus queue (workspace-focus R2–R10).
4
+ *
5
+ * ONE question, asked across every registered tree: **what does only a human unblock?**
6
+ *
7
+ * The workspace already computes everything Focus shows — a board per tree, a needs-you queue per
8
+ * tree, a spine per tree, one session list for all of them. What it has never done is the join, and
9
+ * the join is the whole product: the PM currently opens a product, reads a ladder, decides whether
10
+ * that blocked step is his or an agent's, switches product, repeats. This module is that decision,
11
+ * written down once so it is checkable instead of re-made by hand every morning.
12
+ *
13
+ * ── THE MEMBERSHIP RULE IS THE SPEC (PRD "The membership rule") ─────────────────────────────────
14
+ * An item enters `queue` ONLY if closing it is an act no agent can perform. Everything else — the
15
+ * agent that is mid-run, the step a skill can fix by writing a file, the board another team owns —
16
+ * belongs in `delegated` or `handoffs`.
17
+ *
18
+ * The rule is ENUMERATED, not heuristic, and the classifiers below are TOTAL over their domains.
19
+ * That distinction is the point: a filter list ("exclude route, prototype, understand, …") admits
20
+ * the thirteenth step key the day someone adds it to the engine, silently, and the queue grows by
21
+ * one item nobody chose. `classifyStep` answers for every (key, status) pair including the ones
22
+ * that do not exist yet, and its answer for those is NO with a stated reason.
23
+ *
24
+ * The failure that looks like success is a FULL queue. A long list containing every blocked ladder
25
+ * step and every running session is correct-looking, comprehensive, photographs well, and is
26
+ * worthless within a week — the one item that was actually the PM's is now item nine of thirty.
27
+ * The value is entirely in what this refuses.
28
+ *
29
+ * ── PURE BY CONSTRUCTION ────────────────────────────────────────────────────────────────────────
30
+ * `buildFocus` does NO I/O: no filesystem, no git, no clock, no DOM. It takes reads that the caller
31
+ * already performed and returns a shape. Three consequences, all deliberate:
32
+ *
33
+ * 1. It is testable over its whole domain rather than sampled against a live machine.
34
+ * 2. It cannot accidentally call `consoleState.workflow()` on the request path — the ~30 s
35
+ * synchronous derivation that freezes every PTY socket (board-cache.js:5–12, PRD R12). Boards
36
+ * arrive here already read, through `boardCache.board()`, or they do not arrive at all.
37
+ * 3. Anything it cannot be told, it cannot claim. The on-disk skill index (R8) and the health
38
+ * budget registry (R10) are INPUTS; when they are not supplied the answer is a stated
39
+ * "not read", never a guess and never an offer PMOS could not honour.
40
+ *
41
+ * ── HONEST STATES, RESTATED WHERE THIS FILE CAN VIOLATE THEM ────────────────────────────────────
42
+ * · a failed read is RED and carries the reader's own string, never a paraphrase;
43
+ * · a successful read of nothing is CALM — an empty queue is the good outcome;
44
+ * · an ABSENCE is stated in its own words (handoffs, memory), which are not "empty"'s words;
45
+ * · DERIVING is a third state — a `pending` board is neither empty nor broken;
46
+ * · no verdict recorded is not a pass.
47
+ *
48
+ * `count` is `queue.length`, computed at the end of every call. There is no cached total and no
49
+ * second code path — R5's rule exists because a stored counter is how a badge starts disagreeing
50
+ * with the list under it. And a BARE NUMBER is forbidden whenever a contributing read failed, is
51
+ * still deriving, or was never supplied: render `badge.text`, never `count`.
52
+ */
53
+
54
+ const sessionState = require('./session-state');
55
+
56
+ // ── Tiers ───────────────────────────────────────────────────────────────────────────────────────
57
+ // The handoff's four tiers, plus 2b. `order` sorts; `tier` is the label the PM sees, and it keeps
58
+ // the "2b" wording so the surface and the PRD name the same thing.
59
+ //
60
+ // Tier 3 is DECLARED AND ALWAYS EMPTY in this build. Nothing in this repository can read another
61
+ // team's board, so no item can ever be built for it. It is not dropped — `handoffs` renders the
62
+ // absence in words (R9) — because a tier that quietly vanishes reads as "there is nothing of that
63
+ // kind", which is a claim about someone else's work derived from no connection at all.
64
+ const TIERS = [
65
+ { id: 'integrity', order: 1, tier: '1', label: 'board integrity' },
66
+ { id: 'gate', order: 2, tier: '2', label: 'contract / gate' },
67
+ { id: 'session', order: 3, tier: '2b', label: 'a session is waiting on you' },
68
+ { id: 'handoff', order: 4, tier: '3', label: 'handoff blockers' },
69
+ { id: 'intake', order: 5, tier: '4', label: 'intake' },
70
+ ];
71
+ const TIER = {};
72
+ for (const t of TIERS) TIER[t.id] = t;
73
+
74
+ // ── The ladder's step vocabulary ────────────────────────────────────────────────────────────────
75
+ // Emission order from `computeWorkflow` (scripts/workflow-state.js). Listed here so a test can walk
76
+ // the domain; NOT used as a filter — see `classifyStep`, which answers for keys absent from this
77
+ // list too.
78
+ const STEP_KEYS = ['route', 'anchor', 'prototype', 'understand', 'spec', 'priors',
79
+ 'contract', 'build', 'quality', 'review', 'explain', 'accept'];
80
+ const STEP_STATUSES = ['done', 'current', 'blocked', 'pending', 'na', 'unknown'];
81
+
82
+ /*
83
+ * The three ladder positions the engine itself says are the PM's, quoted from the engine.
84
+ *
85
+ * There are exactly three, and each one is a sentence in `scripts/workflow-state.js` naming the PM
86
+ * as the actor. No fourth position is inferred: the moment this table is extended by judgement
87
+ * rather than by a quotation, the queue starts holding work an agent could have done.
88
+ */
89
+ const PM_STEPS = {
90
+ contract: {
91
+ blocked: {
92
+ tier: TIER.gate,
93
+ why: 'the pre-run contract needs numbered done_criteria + test_method agreed WITH THE PM — runs are logged against this initiative with no PM_approved line',
94
+ reason: 'workflow-state.js:600 — "agree numbered done_criteria + test_method with the PM"',
95
+ },
96
+ },
97
+ accept: {
98
+ pending: {
99
+ tier: TIER.gate,
100
+ why: 'the PM Acceptance Gate is open and non-delegable (D12) — no agent can close it',
101
+ reason: 'workflow-state.js:670 — "PM Acceptance Gate open — non-delegable (D12)"',
102
+ },
103
+ },
104
+ anchor: {
105
+ blocked: {
106
+ tier: TIER.intake,
107
+ why: 'this initiative is unanchored or names something the OKR documents do not declare — adding the KR, or a budget, is a PM act',
108
+ reason: 'workflow-state.js:359/:371 — "add the KR (a PM act)" / "only the PM adds a budget"',
109
+ },
110
+ },
111
+ };
112
+
113
+ // Why each REFUSAL is a refusal. Every one of these is a sentence the engine or the PRD already
114
+ // says; they are carried so a PM auditing an absence gets the reason rather than silence.
115
+ const STEP_REFUSALS = {
116
+ 'accept/blocked': 'the Acceptance Gate does not open over an unfinished ladder (workflow-state.js:669) — admitting these would put every in-flight initiative in the queue and destroy it',
117
+ 'contract/pending': 'no run is logged for this initiative yet — nothing has been built without a contract, so nothing is owed',
118
+ 'build/*': 'build is a fact about commits, not a request to the PM',
119
+ 'quality/*': 'quality is always unknown by design — the engine never claims a test result it did not run (workflow-state.js:620)',
120
+ };
121
+ // Every blocked step outside PM_STEPS whose `next` names an agent act. These are `delegated`'s raw
122
+ // material (R8), never the queue's.
123
+ const AGENT_ACT_STEPS = ['route', 'prototype', 'understand', 'spec', 'priors', 'review', 'explain'];
124
+
125
+ /**
126
+ * TOTAL over (step key × status), including keys and statuses that do not exist.
127
+ *
128
+ * → { qualifies, tier, why, reason }
129
+ * `tier` is a TIERS entry when it qualifies, otherwise null.
130
+ * `reason` is always populated — it states why the answer is what it is, for admits AND refusals.
131
+ *
132
+ * The default arm is the load-bearing one: an unrecognised key defaults OUT. A step the engine
133
+ * grows next quarter cannot appear in the PM's queue until someone quotes the engine saying it is
134
+ * the PM's.
135
+ */
136
+ function classifyStep(key, status) {
137
+ const k = key == null ? '' : String(key);
138
+ const s = status == null ? '' : String(status);
139
+ const rule = PM_STEPS[k] && PM_STEPS[k][s];
140
+ if (rule) return { qualifies: true, tier: rule.tier, why: rule.why, reason: rule.reason };
141
+
142
+ let reason = STEP_REFUSALS[`${k}/${s}`] || STEP_REFUSALS[`${k}/*`] || null;
143
+ if (!reason) {
144
+ if (!STEP_KEYS.includes(k)) {
145
+ reason = `'${k || '(unnamed)'}' is not one of the engine's step keys — a step key added later defaults OUT of the queue, never in`;
146
+ } else if (!STEP_STATUSES.includes(s)) {
147
+ reason = `'${s || '(unnamed)'}' is not one of the engine's step statuses — an unrecognised status defaults OUT of the queue`;
148
+ } else if (AGENT_ACT_STEPS.includes(k)) {
149
+ reason = s === 'blocked'
150
+ ? `the \`next\` for a blocked ${k} names an agent act — it belongs in delegated, not the queue`
151
+ : `${k} is ${s}; nothing is being asked of the PM`;
152
+ } else {
153
+ reason = `${k} is ${s}; the engine asks nothing of the PM here`;
154
+ }
155
+ }
156
+ return { qualifies: false, tier: null, why: null, reason };
157
+ }
158
+
159
+ /**
160
+ * TOTAL over the presented session states (lib/session-state's PRESENTED), plus anything else.
161
+ *
162
+ * Only `needs` admits, and it admits because `presented()` reaches it ONLY from quiet + turn 'user'
163
+ * — the turn ended and nothing is moving. That is the most literal instance of "you are the
164
+ * blocker" this system holds, and it is already derived honestly by a module built for the claim.
165
+ *
166
+ * `blocked` is the judgement call and it is refused: session-state.js:57 is explicit that the
167
+ * evidence cannot separate a permission prompt from a hung tool from a corpse. A queue whose intro
168
+ * promises "you are the blocker" may not be filled with maybes. It reaches `delegated` instead,
169
+ * carrying the module's own hedged word verbatim.
170
+ */
171
+ function classifySession(shown) {
172
+ const p = shown == null ? '' : String(shown);
173
+ if (p === 'needs') {
174
+ return {
175
+ qualifies: true,
176
+ tier: TIER.session,
177
+ why: 'the turn ended and nothing is moving — this session is waiting on you',
178
+ reason: "session-state.js:100 — `needs` is reachable only from quiet + turn 'user'",
179
+ };
180
+ }
181
+ const refusals = {
182
+ working: 'an agent is producing bytes — the most common thing on screen and the most tempting thing to count',
183
+ quiet: 'quiet carries no claim on the PM',
184
+ exited: 'an exited session carries no claim on the PM',
185
+ blocked: "the evidence cannot separate a permission prompt from a hung tool from a corpse — it appears in delegated carrying its own 'maybe blocked' hedge",
186
+ };
187
+ return {
188
+ qualifies: false,
189
+ tier: null,
190
+ why: null,
191
+ reason: refusals[p] || `'${p || '(unnamed)'}' is not a presented session state — an unrecognised state defaults OUT of the queue`,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * TOTAL over needs-queue items. Three admit:
197
+ * · kind 'accept' — an eval with no acceptance for its run_id (deriveNeeds, server.js:1855)
198
+ * · kind 'outcome' — a declared outcome past its horizon, never disposed (D57)
199
+ * · derived !== true — the harness filed it against the PM by name, whatever it called it
200
+ * Anything else the derivation grows later defaults out.
201
+ */
202
+ function classifyNeed(kind, derived) {
203
+ const k = kind == null ? '' : String(kind);
204
+ if (derived !== true) {
205
+ return {
206
+ qualifies: true, tier: TIER.gate,
207
+ why: 'the harness filed this against you by name — it is not derived, someone asked',
208
+ reason: 'needs.json entry with derived !== true',
209
+ };
210
+ }
211
+ if (k === 'accept') {
212
+ return {
213
+ qualifies: true, tier: TIER.gate,
214
+ why: 'an eval has been recorded and no acceptance names its run — the Acceptance Gate is open (D12)',
215
+ reason: 'deriveNeeds (server.js:1855) — evals.jsonl run_id with no row in acceptances.jsonl',
216
+ };
217
+ }
218
+ if (k === 'outcome') {
219
+ return {
220
+ qualifies: true, tier: TIER.gate,
221
+ why: 'a declared outcome is past its horizon and has never been disposed — the D57 check is due',
222
+ reason: 'deriveNeeds (server.js:1866) — outcome_horizon in the past, no row in outcome_checks.jsonl',
223
+ };
224
+ }
225
+ return {
226
+ qualifies: false, tier: null, why: null,
227
+ reason: `a derived need of kind '${k || '(unnamed)'}' is not one of the two enumerated derivations — it defaults OUT of the queue`,
228
+ };
229
+ }
230
+
231
+ // ── Normalising the inputs ──────────────────────────────────────────────────────────────────────
232
+ // Every reader this module joins has its own shape and its own failure mode. Each `read*` below
233
+ // turns one of them into a single vocabulary — state ∈ ok | empty | deriving | error | not-read —
234
+ // so the join never has to ask "which of these four shapes is a failure again?".
235
+ //
236
+ // `not-read` is deliberately NOT `error`. A read that FAILED produces a reader's own reason and is
237
+ // the PM's problem; a read that never HAPPENED is a plumbing gap in the caller, and reporting it as
238
+ // a broken repository sends the PM looking for a fault they do not have. Both suppress the bare
239
+ // count, in different words.
240
+
241
+ const arr = (v) => (Array.isArray(v) ? v : []);
242
+ const str = (v) => (v == null ? '' : String(v));
243
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
244
+
245
+ /**
246
+ * `boardCache.board(root)` → one of:
247
+ * { pending:true, refreshing, as_of:null, stale:false } never derived yet
248
+ * { ok:false, error, as_of, stale } the derivation failed
249
+ * { ok:true, empty:true, source, as_of, stale } a repo with no records
250
+ * { ok:true, empty:false, data:{ live, hidden, total, unreadable, … }, as_of, stale, refreshing }
251
+ */
252
+ function readBoard(board) {
253
+ const base = { rows: [], unreadable: [], shown: 0, hidden: 0, total: 0, as_of: null, stale: false, refreshing: false, error: null };
254
+ if (board == null) {
255
+ return Object.assign(base, { state: 'not-read', error: 'no board read was supplied for this product — nothing about its ladders can be claimed here' });
256
+ }
257
+ if (!isObj(board)) {
258
+ return Object.assign(base, { state: 'error', error: 'the board read returned something that is not an object — the source is broken, not empty' });
259
+ }
260
+ const env = { as_of: board.as_of == null ? null : str(board.as_of), stale: !!board.stale, refreshing: !!board.refreshing };
261
+ if (board.pending === true) return Object.assign(base, env, { state: 'deriving' });
262
+ if (board.ok === false) {
263
+ return Object.assign(base, env, { state: 'error', error: str(board.error) || 'the board derivation failed and gave no reason' });
264
+ }
265
+ if (board.ok !== true) {
266
+ return Object.assign(base, env, { state: 'error', error: 'the board read returned neither a result, a failure nor a pending derivation — an unrecognised shape' });
267
+ }
268
+ if (board.empty === true || !isObj(board.data)) {
269
+ // A repo with no initiative records at all is a successful read of nothing — calm, not red.
270
+ return Object.assign(base, env, { state: 'empty' });
271
+ }
272
+ const d = board.data;
273
+ const rows = arr(d.live).filter(isObj);
274
+ return Object.assign(base, env, {
275
+ state: 'ok',
276
+ rows,
277
+ unreadable: arr(d.unreadable).filter(isObj),
278
+ shown: rows.length,
279
+ hidden: Number.isFinite(d.hidden) ? d.hidden : 0,
280
+ total: Number.isFinite(d.total) ? d.total : rows.length,
281
+ });
282
+ }
283
+
284
+ /**
285
+ * `needsResult(w)` → { ok:false, error } | { ok:true, empty:true } | { ok:true, empty:false, data:{items} }
286
+ */
287
+ function readNeeds(needs) {
288
+ if (needs == null) return { state: 'not-read', items: [], error: 'no needs-you read was supplied for this product' };
289
+ if (!isObj(needs)) return { state: 'error', items: [], error: 'the needs-you read returned something that is not an object — corrupt state, not empty' };
290
+ if (needs.ok === false) return { state: 'error', items: [], error: str(needs.error) || 'the needs-you queue could not be read and gave no reason' };
291
+ if (needs.ok !== true) return { state: 'error', items: [], error: 'the needs-you read returned neither a result nor a failure — an unrecognised shape' };
292
+ if (needs.empty === true || !isObj(needs.data)) return { state: 'empty', items: [], error: null };
293
+ return { state: 'ok', items: arr(needs.data.items).filter(isObj), error: null };
294
+ }
295
+
296
+ /**
297
+ * `surfaceSpine(root)` → { objectives, budgets, unanchored, unreadable, budgets_readable }
298
+ * `unreadable` is a STRING when the anchor resolver or the OKR documents could not be read.
299
+ */
300
+ function readSpine(spine) {
301
+ const base = { objectives: [], budgets: [], unanchored: [], budgets_readable: null, error: null };
302
+ if (spine == null) return Object.assign(base, { state: 'not-read', error: 'no spine read was supplied for this product' });
303
+ if (!isObj(spine)) return Object.assign(base, { state: 'error', error: 'the spine read returned something that is not an object' });
304
+ if (spine.unreadable) return Object.assign(base, { state: 'error', error: str(spine.unreadable) });
305
+ if (!Array.isArray(spine.objectives) || !Array.isArray(spine.unanchored)) {
306
+ return Object.assign(base, { state: 'error', error: 'the spine read returned no objectives/unanchored lists — the source is malformed, not empty' });
307
+ }
308
+ return {
309
+ state: 'ok',
310
+ objectives: spine.objectives.filter(isObj),
311
+ budgets: arr(spine.budgets).filter(isObj),
312
+ unanchored: spine.unanchored.filter(isObj),
313
+ budgets_readable: spine.budgets_readable == null ? null : !!spine.budgets_readable,
314
+ error: null,
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Extra per-initiative ladders, from `consoleState.ladder({root,id})`. Accepts an array of rows, an
320
+ * array of `{ok,row}` envelopes, or a map keyed by id. Anything unrecognised is ignored rather than
321
+ * guessed at.
322
+ *
323
+ * Where an id appears in BOTH the board and here, this wins: the board is served from a cache up to
324
+ * ten minutes old (board-cache MAX_AGE_MS), while a single-initiative ladder is derived on demand
325
+ * and therefore never the older of the two.
326
+ */
327
+ function readLadders(ladders) {
328
+ const rows = [];
329
+ const failed = [];
330
+ const take = (v, keyHint) => {
331
+ if (!isObj(v)) return;
332
+ if (v.ok === false) {
333
+ failed.push({ id: str(v.id || keyHint) || '(unnamed)', error: str(v.error) || 'this ladder could not be derived and gave no reason' });
334
+ return;
335
+ }
336
+ const row = isObj(v.row) ? v.row : v;
337
+ if (v.empty === true && !isObj(v.row)) return; // a successful read of "no such record"
338
+ if (Array.isArray(row.steps)) rows.push(row);
339
+ };
340
+ if (Array.isArray(ladders)) for (const v of ladders) take(v, null);
341
+ else if (isObj(ladders)) for (const k of Object.keys(ladders)) take(ladders[k], k);
342
+ return { rows, failed };
343
+ }
344
+
345
+ // ── Products ────────────────────────────────────────────────────────────────────────────────────
346
+ /*
347
+ * Focus is cross-product by definition, so `products` is the real input and the top-level
348
+ * `board`/`needs`/`spine`/`ladders` are the ONE-product convenience form.
349
+ *
350
+ * The fallback is deliberately refused when more than one product is present. A single top-level
351
+ * board shared across several trees would attribute one repository's ladders to all of them —
352
+ * quietly, plausibly, and wrongly. Sessions are the exception and are always partitioned from the
353
+ * top-level list, because `sessionList()` rows carry `ws` and can therefore be split without a
354
+ * guess.
355
+ */
356
+ function normalizeProducts(inp) {
357
+ const declared = arr(inp.products).filter(isObj);
358
+ const list = declared.length ? declared : [{}];
359
+ const single = list.length === 1;
360
+ const allSessions = arr(inp.sessions).filter(isObj);
361
+ return list.map((p, i) => {
362
+ const id = p.id == null ? (declared.length ? null : (inp.product == null ? null : str(inp.product))) : str(p.id);
363
+ const name = str(p.name || p.label || '') || (id || '');
364
+ const has = (k) => Object.prototype.hasOwnProperty.call(p, k) && p[k] != null;
365
+ const sessions = has('sessions')
366
+ ? arr(p.sessions).filter(isObj)
367
+ : (id ? allSessions.filter((s) => str(s.ws) === id) : allSessions);
368
+ return {
369
+ id,
370
+ name,
371
+ // sortKey keeps the order stable between renders (product name, then id, then position), so a
372
+ // rank number means something across two consecutive looks at the same screen. The separator
373
+ // is NUL because nothing in a product name or an id can contain it, so two fields can never
374
+ // run together into a false ordering — but it is written as the ESCAPE `\x00`, never as a
375
+ // literal byte. A raw NUL makes the file binary to git diff, grep and ripgrep, and ripgrep
376
+ // then skips it silently: the largest module in this batch was invisible to every source
377
+ // search anyone ran over it. Same string at runtime, same file readable by the tools.
378
+ sortKey: `${name.toLowerCase()}\x00${(id || '').toLowerCase()}\x00${String(i).padStart(4, '0')}`,
379
+ board: readBoard(has('board') ? p.board : (single ? inp.board : null)),
380
+ needs: readNeeds(has('needs') ? p.needs : (single ? inp.needs : null)),
381
+ spine: readSpine(has('spine') ? p.spine : (single ? inp.spine : null)),
382
+ ladders: readLadders(has('ladders') ? p.ladders : (single ? inp.ladders : null)),
383
+ sessions,
384
+ };
385
+ });
386
+ }
387
+
388
+ // ── `read:` lines ───────────────────────────────────────────────────────────────────────────────
389
+ // R4: every queue row carries one mono line naming WHAT WAS ACTUALLY READ. It is not decoration —
390
+ // it is what lets the PM check the queue instead of trusting it, so it names real files and real
391
+ // fields and never a summary of them. The `read:` label itself belongs to the caller; these strings
392
+ // are the content after it.
393
+ function boardRead(b) {
394
+ if (b.state === 'not-read') return 'board — not read';
395
+ if (b.state === 'deriving') return 'boardCache.board() — deriving, never yet returned a result';
396
+ const at = b.as_of ? `board as_of ${b.as_of}` : 'board (no as_of recorded)';
397
+ return b.stale ? `${at} (stale, refresh running)` : at;
398
+ }
399
+ function stepRead(b, row, step) {
400
+ const parts = [boardRead(b)];
401
+ if (row.record) parts.push(str(row.record));
402
+ parts.push(`ladder step ${step.key} (${step.status})`);
403
+ const ev = str(step.evidence).trim();
404
+ return ev ? `${parts.join(' · ')} — ${ev}` : parts.join(' · ');
405
+ }
406
+
407
+ // ── The queue ───────────────────────────────────────────────────────────────────────────────────
408
+ function item(product, tier, fields) {
409
+ return Object.assign({
410
+ tier: tier.tier, tierId: tier.id, tierOrder: tier.order, tierLabel: tier.label,
411
+ product: product.id, productName: product.name || product.id || null,
412
+ title: null, detail: null, action: null,
413
+ // internal join/sort keys, stripped once the queue is ranked
414
+ dedup: null, sortKey: product.sortKey,
415
+ }, fields);
416
+ }
417
+
418
+ /**
419
+ * TIER 1 · board integrity — the board itself, or a record under it, could not be read.
420
+ * Nothing below a failed read can be trusted, so these lead the queue however few they are.
421
+ */
422
+ function integrityItems(p) {
423
+ const out = [];
424
+ if (p.board.state === 'error') {
425
+ out.push(item(p, TIER.integrity, {
426
+ kind: 'board-unreadable', id: `board:${p.id || 'workspace'}`, subject: p.id || 'workspace',
427
+ dedup: `board:${p.id || 'workspace'}`,
428
+ why: 'the board itself could not be derived — nothing below it can be trusted',
429
+ detail: p.board.error,
430
+ read: `${boardRead(p.board)} — ok:false · ${p.board.error}`,
431
+ target: { kind: 'product', product: p.id, id: null },
432
+ }));
433
+ }
434
+ // Reported over ALL records, not just the capped 12 (console-state.js:214) — so an unparseable
435
+ // record cannot hide behind the board's in-flight cap.
436
+ for (const u of p.board.unreadable) {
437
+ const id = str(u.id) || '(unnamed record)';
438
+ out.push(item(p, TIER.integrity, {
439
+ kind: 'record-unreadable', id: `record:${p.id || ''}:${id}`, subject: id, dedup: `record:${id}`,
440
+ why: 'this initiative record will not parse — only its author\'s intent can fix it',
441
+ detail: str(u.error) || null,
442
+ read: `${boardRead(p.board)} · unreadable[] row ${id}${u.error ? ` — ${str(u.error)}` : ''}`,
443
+ target: { kind: 'initiative', product: p.id, id },
444
+ }));
445
+ }
446
+ for (const f of p.ladders.failed) {
447
+ out.push(item(p, TIER.integrity, {
448
+ kind: 'record-unreadable', id: `record:${p.id || ''}:${f.id}`, subject: f.id, dedup: `record:${f.id}`,
449
+ why: 'this initiative record will not parse — only its author\'s intent can fix it',
450
+ detail: f.error,
451
+ read: `consoleState.ladder({id:'${f.id}'}) — ok:false · ${f.error}`,
452
+ target: { kind: 'initiative', product: p.id, id: f.id },
453
+ }));
454
+ }
455
+ if (p.needs.state === 'error') {
456
+ out.push(item(p, TIER.integrity, {
457
+ kind: 'needs-unreadable', id: `needs:${p.id || 'workspace'}`, subject: p.id || 'workspace',
458
+ dedup: `needs:${p.id || 'workspace'}`,
459
+ why: 'the needs-you queue is corrupt or unreadable — a broken honesty surface must read red, never zero',
460
+ detail: p.needs.error,
461
+ read: `needsResult() over needs.json + evals.jsonl/acceptances.jsonl — ok:false · ${p.needs.error}`,
462
+ target: { kind: 'product', product: p.id, id: null },
463
+ }));
464
+ }
465
+ if (p.spine.state === 'error') {
466
+ out.push(item(p, TIER.integrity, {
467
+ kind: 'spine-unreadable', id: `spine:${p.id || 'workspace'}`, subject: p.id || 'workspace',
468
+ dedup: `spine:${p.id || 'workspace'}`,
469
+ why: 'the OKR documents or the anchor resolver could not be read — every anchor below them is unverifiable',
470
+ detail: p.spine.error,
471
+ read: `surfaceSpine() — unreadable · ${p.spine.error}`,
472
+ target: { kind: 'product', product: p.id, id: null },
473
+ }));
474
+ }
475
+ return out;
476
+ }
477
+
478
+ /** The ladder rows Focus reasons over: the board's in-flight rows, overridden by any on-demand
479
+ * ladder for the same id (fresher — see `readLadders`). */
480
+ function ladderRows(p) {
481
+ const byId = new Map();
482
+ for (const r of p.board.rows) if (r && r.id != null && Array.isArray(r.steps)) byId.set(str(r.id), r);
483
+ for (const r of p.ladders.rows) if (r && r.id != null) byId.set(str(r.id), r);
484
+ return [...byId.values()];
485
+ }
486
+
487
+ /** TIERS 2 and 4 from the ladder — the three PM steps and nothing else. */
488
+ function ladderItems(p) {
489
+ const out = [];
490
+ for (const row of ladderRows(p)) {
491
+ const id = str(row.id);
492
+ for (const step of arr(row.steps)) {
493
+ if (!isObj(step)) continue;
494
+ const c = classifyStep(step.key, step.status);
495
+ if (!c.qualifies) continue;
496
+ out.push(item(p, c.tier, {
497
+ kind: str(step.key), id: `${c.tier.id}:${p.id || ''}:${id}:${str(step.key)}`, subject: id,
498
+ dedup: step.key === 'anchor' ? `anchor:${id}` : `${str(step.key)}:${id}`,
499
+ title: row.title == null ? null : str(row.title),
500
+ why: c.why,
501
+ detail: step.evidence == null ? null : str(step.evidence),
502
+ action: step.next == null ? null : str(step.next),
503
+ read: stepRead(p.board, row, step),
504
+ target: { kind: 'initiative', product: p.id, id },
505
+ }));
506
+ }
507
+ }
508
+ return out;
509
+ }
510
+
511
+ /** TIER 2 from the needs-you queue. */
512
+ function needsItems(p) {
513
+ const out = [];
514
+ for (const n of p.needs.items) {
515
+ const c = classifyNeed(n.kind, n.derived);
516
+ if (!c.qualifies) continue;
517
+ const runId = str(n.run_id) || '(no run_id)';
518
+ const initId = n.initiative_id == null ? null : str(n.initiative_id);
519
+ const kind = str(n.kind) || 'question';
520
+ let read;
521
+ if (n.derived !== true) read = `needs.json — entry filed by the harness (kind ${kind}, run_id ${runId})`;
522
+ else if (kind === 'accept') read = `evals.jsonl run_id ${runId} · acceptances.jsonl — no acceptance row for that run_id`;
523
+ else read = `state initiatives/${initId || '?'}.md outcome_metric + outcome_horizon · outcome_checks.jsonl — no disposal recorded`;
524
+ out.push(item(p, c.tier, {
525
+ kind: `need-${kind}`, id: `need:${p.id || ''}:${runId}`, subject: initId || runId,
526
+ dedup: `need:${runId}`,
527
+ why: c.why,
528
+ detail: n.question == null ? null : str(n.question),
529
+ read,
530
+ target: { kind: initId ? 'initiative' : 'product', product: p.id, id: initId },
531
+ }));
532
+ }
533
+ return out;
534
+ }
535
+
536
+ /** TIER 2b · a session whose turn ended with nothing moving. */
537
+ function sessionItems(p) {
538
+ const out = [];
539
+ for (const s of p.sessions) {
540
+ const c = classifySession(s.shown);
541
+ if (!c.qualifies) continue;
542
+ const id = str(s.id);
543
+ out.push(item(p, c.tier, {
544
+ kind: 'session-needs', id: `session:${id}`, subject: id, dedup: `session:${id}`,
545
+ title: s.name == null ? null : str(s.name),
546
+ why: c.why,
547
+ detail: s.lastWords == null ? null : str(s.lastWords),
548
+ read: `sessionList() session ${id}${s.agent ? ` (${str(s.agent)})` : ''} · session-state.presented() → shown '${str(s.shown)}'`
549
+ + (Number.isFinite(s.quietMs) ? ` · quiet ${s.quietMs}ms` : ''),
550
+ target: { kind: 'session', product: p.id, id },
551
+ }));
552
+ }
553
+ return out;
554
+ }
555
+
556
+ /**
557
+ * TIER 4 · intake, from the spine. `surfaceSpine().unanchored[]` is UNCAPPED, which is exactly why
558
+ * the intake tier reads it: anchor faults escape the board's in-flight cap of 12 through this list.
559
+ */
560
+ function unanchoredItems(p) {
561
+ const out = [];
562
+ for (const u of p.spine.unanchored) {
563
+ const id = str(u.id) || '(unnamed record)';
564
+ out.push(item(p, TIER.intake, {
565
+ kind: 'unanchored', id: `unanchored:${p.id || ''}:${id}`, subject: id, dedup: `anchor:${id}`,
566
+ title: u.title == null ? null : str(u.title),
567
+ why: 'this initiative is unanchored or names something the OKR documents do not declare — adding the KR, or a budget, is a PM act',
568
+ detail: u.reason == null ? null : str(u.reason),
569
+ read: `surfaceSpine().unanchored[] ${id}${u.file ? ` (${str(u.file)})` : ''} — parent_kr '${str(u.raw)}' · status ${str(u.status) || 'unresolved'}`,
570
+ target: { kind: 'initiative', product: p.id, id },
571
+ }));
572
+ }
573
+ return out;
574
+ }
575
+
576
+ /*
577
+ * The ladder's `anchor` step and the spine's `unanchored[]` row are TWO VIEWS OF ONE FACT, not two
578
+ * facts. Listing both would inflate the queue with duplicates — the exact way a queue stops being
579
+ * read. The first-ranked view wins (the ladder's, which carries the engine's `next`), and the
580
+ * second is folded into its `read:` line so no source is silently dropped.
581
+ */
582
+ function dedupe(items) {
583
+ const seen = new Map();
584
+ const out = [];
585
+ for (const it of items) {
586
+ const key = `${it.tierOrder}|${it.product == null ? '' : it.product}|${it.dedup}`;
587
+ const prior = seen.get(key);
588
+ if (prior) { prior.read = `${prior.read} · also ${it.read}`; continue; }
589
+ seen.set(key, it);
590
+ out.push(it);
591
+ }
592
+ return out;
593
+ }
594
+
595
+ // ── Sections that are not the queue ─────────────────────────────────────────────────────────────
596
+
597
+ /**
598
+ * R7 — one row per product, its top objective. Four distinguishable states, because there are four
599
+ * distinguishable facts:
600
+ * objective a top objective was read
601
+ * none a SUCCESSFUL read of a repo that declares none — calm
602
+ * error the read FAILED — RED, carrying surfaceSpine's own string (and it also files a
603
+ * tier-1 queue item; one fact, two views)
604
+ * not-read no spine was supplied to this call — nothing happened, so nothing is claimed
605
+ */
606
+ function objectiveRows(products) {
607
+ return products.map((p) => {
608
+ const base = { product: p.id, productName: p.name || p.id || null };
609
+ if (p.spine.state === 'error') {
610
+ return Object.assign(base, { state: 'error', id: null, name: null, error: p.spine.error });
611
+ }
612
+ if (p.spine.state === 'not-read') {
613
+ return Object.assign(base, {
614
+ state: 'not-read', id: null, name: null,
615
+ error: 'no spine read was supplied for this product — its objectives were not read, which is not the same as none being declared',
616
+ });
617
+ }
618
+ const top = p.spine.objectives[0];
619
+ if (!top) {
620
+ return Object.assign(base, { state: 'none', id: null, name: null, error: null, note: 'this repository declares no objectives' });
621
+ }
622
+ return Object.assign(base, {
623
+ state: 'objective', id: str(top.id) || null, name: str(top.name) || null, error: null,
624
+ });
625
+ });
626
+ }
627
+
628
+ /*
629
+ * R9 — handoffs. The requirement the prototype most directly contradicts, and the one this build is
630
+ * most likely to regress by copying it.
631
+ *
632
+ * There is no credential, no MCP client and no sync path in this repository, so a story count or a
633
+ * sync time would be fabricated. The worse failure is the quiet one: a calm "nothing owed by you"
634
+ * over an absent connection is a claim about another team's board derived from NOTHING, and it
635
+ * looks identical to a working, connected, all-clear integration. That is the watermelon the
636
+ * honest-state contract exists to prevent.
637
+ *
638
+ * `count` is null, never 0. Zero is an answer; this section does not have one.
639
+ */
640
+ function handoffsSection() {
641
+ return {
642
+ connected: false,
643
+ state: 'not-connected',
644
+ heading: 'handoffs · waiting on other teams',
645
+ body: 'not connected. No external tracker is configured, so PMOS cannot say what another team owes. '
646
+ + 'Connecting one is a PM decision about credentials and an MCP server.',
647
+ items: [],
648
+ count: null,
649
+ tier: TIER.handoff.tier,
650
+ };
651
+ }
652
+
653
+ // A skill path named inside an engine `next` string. `skills/…` and `build-skills/…` are the only
654
+ // two roots that carry them, and the `.skill` suffix is what makes the reference resolvable rather
655
+ // than prose about a skill.
656
+ const SKILL_PATH = /(?:build-)?skills\/[A-Za-z0-9._\-\/]+\.skill/g;
657
+ /** Every skill path a `next` string names, in order, without duplicates. */
658
+ function skillPathsIn(text) {
659
+ const found = str(text).match(SKILL_PATH);
660
+ return found ? [...new Set(found)] : [];
661
+ }
662
+
663
+ /*
664
+ * R8 — delegated. Two parts, and the second is where an over-eager surface does damage.
665
+ *
666
+ * RUNNING is every non-exited session, carrying `session-state`'s own WORD verbatim — including
667
+ * 'maybe blocked' with its hedge intact. Focus may not upgrade or soften a session state; the
668
+ * module owns those words and both sides already share it.
669
+ *
670
+ * STARTS offers `▶ delegate` ONLY where the engine's own `next` names a skill file that EXISTS ON
671
+ * DISK. Focus is pure, so it cannot stat anything: the caller supplies the on-disk skill index. No
672
+ * index supplied ⇒ NO offers at all and a stated reason, because an offer PMOS cannot honour is
673
+ * worse than no offer. Focus never invents an agent name, a capability claim or a recommendation.
674
+ */
675
+ function delegatedSection(products, skills) {
676
+ const index = skills == null ? null
677
+ : new Set((skills instanceof Set ? [...skills] : arr(skills)).map((s) => str(s)));
678
+
679
+ const running = [];
680
+ for (const p of products) {
681
+ for (const s of p.sessions) {
682
+ const shown = str(s.shown);
683
+ if (shown === 'exited') continue;
684
+ running.push({
685
+ id: str(s.id), name: s.name == null ? null : str(s.name),
686
+ product: p.id, productName: p.name || p.id || null,
687
+ agent: s.agent == null ? null : str(s.agent),
688
+ shown,
689
+ // Verbatim from lib/session-state's WORD table — one source of truth for the word beside a
690
+ // session, so this surface and the sidebar can never hold different opinions.
691
+ word: sessionState.WORD[shown] || shown,
692
+ glyph: sessionState.GLYPH[shown] || null,
693
+ hedged: shown === 'blocked',
694
+ quietMs: Number.isFinite(s.quietMs) ? s.quietMs : null,
695
+ in_queue: classifySession(shown).qualifies,
696
+ });
697
+ }
698
+ }
699
+ running.sort((a, b) => (a.product || '').localeCompare(b.product || '') || a.id.localeCompare(b.id));
700
+
701
+ const starts = [];
702
+ const seen = new Set();
703
+ for (const p of products) {
704
+ for (const row of ladderRows(p)) {
705
+ const id = str(row.id);
706
+ for (const step of arr(row.steps)) {
707
+ if (!isObj(step)) continue;
708
+ // Belt and braces: a position the PM owns never gets a delegate button, whatever its `next`
709
+ // happens to contain. The three PM steps name no skill file today; this makes that a rule
710
+ // rather than a coincidence of the engine's current wording.
711
+ if (classifyStep(step.key, step.status).qualifies) continue;
712
+ for (const skill of skillPathsIn(step.next)) {
713
+ if (index && !index.has(skill)) continue;
714
+ const key = `${p.id || ''}:${id}:${skill}`;
715
+ if (seen.has(key)) continue;
716
+ seen.add(key);
717
+ starts.push({
718
+ initiative: id, title: row.title == null ? null : str(row.title),
719
+ product: p.id, productName: p.name || p.id || null,
720
+ step: str(step.key), status: str(step.status),
721
+ skill,
722
+ action: str(step.next),
723
+ initialInput: `Run ${skill} for initiative ${id}. The workflow engine says: ${str(step.next)}`,
724
+ offered: !!index,
725
+ });
726
+ }
727
+ }
728
+ }
729
+ }
730
+ starts.sort((a, b) => (a.product || '').localeCompare(b.product || '')
731
+ || a.initiative.localeCompare(b.initiative) || a.skill.localeCompare(b.skill));
732
+
733
+ return {
734
+ running,
735
+ starts: index ? starts : [],
736
+ starts_state: index ? 'resolved' : 'not-resolved',
737
+ starts_note: index
738
+ ? 'a ▶ delegate action is offered only where the engine\'s own `next` names a skill file present in the supplied on-disk index'
739
+ : 'no on-disk skill index was supplied to this call, so no ▶ delegate action is offered — an offer PMOS cannot honour is worse than no offer',
740
+ // What WOULD be offered if the index resolved them. Kept separate so a caller can never render
741
+ // it as a button, and so the "no index" case is diagnosable rather than mysteriously empty.
742
+ candidates: starts.map((s) => ({ initiative: s.initiative, product: s.product, step: s.step, skill: s.skill })),
743
+ };
744
+ }
745
+
746
+ /*
747
+ * R10 — upkeep says only what it can read.
748
+ *
749
+ * MEMORY is a stated absence, not a count. `memory` mode is `data: 'absent'` by construction; there
750
+ * is no store, so there are no unpinned entries at compaction risk. The row says the mechanism is
751
+ * not built.
752
+ *
753
+ * HEALTH BUDGETS carry a caveat instead of a verdict. The caps are shares of A QUARTER's
754
+ * initiatives; no initiative record carries a period and `surfaceSpine` reads none. So the counts
755
+ * below are over ALL anchored initiatives and are NOT the cap test — and no percentage is printed,
756
+ * because a percentage implies a test that has not been run.
757
+ */
758
+ const BUDGET_CAVEAT = 'The caps are shares of a QUARTER\'s initiatives. No initiative record carries a period '
759
+ + 'and the spine reads none, so the counts here are over ALL anchored initiatives and are NOT the cap test.';
760
+
761
+ function upkeepSection(products, registry) {
762
+ const memory = {
763
+ id: 'memory',
764
+ state: 'absent',
765
+ heading: 'memory grooming',
766
+ body: 'not built. There is no memory store in this workspace, so there are no entries to groom and none at compaction risk. '
767
+ + 'This row states the absence of the mechanism; it is not a count of zero.',
768
+ count: null,
769
+ };
770
+
771
+ // The registry (planning/okrs/health-budgets.md) is a FILE, and this function reads nothing. It
772
+ // is an input; absent, the caps are reported as not read rather than guessed.
773
+ let caps = null;
774
+ let capsError = null;
775
+ if (Array.isArray(registry)) caps = registry.filter(isObj);
776
+ else if (isObj(registry)) {
777
+ if (registry.ok === false) capsError = str(registry.error) || 'the health-budget registry could not be read';
778
+ else if (Array.isArray(registry.budgets)) caps = registry.budgets.filter(isObj);
779
+ else capsError = 'the health-budget registry read returned no budget list — malformed, not empty';
780
+ }
781
+
782
+ const rows = new Map();
783
+ const row = (id) => {
784
+ if (!rows.has(id)) rows.set(id, { id, cap: null, covers: null, cap_state: 'not-read', anchored: [], total: 0 });
785
+ return rows.get(id);
786
+ };
787
+ for (const b of arr(caps)) {
788
+ const id = str(b.id) || '';
789
+ if (!id) continue;
790
+ const r = row(id.startsWith('health:') ? id : `health:${id}`);
791
+ r.cap = b.cap == null ? null : str(b.cap);
792
+ r.covers = b.covers == null ? null : str(b.covers);
793
+ r.cap_state = r.cap ? 'declared' : 'declared-without-cap';
794
+ }
795
+ let spineUnread = 0;
796
+ let budgetsUnreadable = 0;
797
+ for (const p of products) {
798
+ if (p.spine.state !== 'ok') { spineUnread++; continue; }
799
+ if (p.spine.budgets_readable === false) budgetsUnreadable++;
800
+ for (const b of p.spine.budgets) {
801
+ const id = str(b.id) || '';
802
+ if (!id) continue;
803
+ const r = row(id);
804
+ const n = arr(b.initiatives).length;
805
+ r.anchored.push({ product: p.id, productName: p.name || p.id || null, count: n });
806
+ r.total += n;
807
+ }
808
+ }
809
+
810
+ return {
811
+ memory,
812
+ budgets: {
813
+ state: capsError ? 'error' : (caps ? 'read' : 'not-read'),
814
+ error: capsError,
815
+ note: capsError ? null : (caps
816
+ ? 'caps as declared in planning/okrs/health-budgets.md'
817
+ : 'the caps live in planning/okrs/health-budgets.md and were not supplied to this call — no cap is shown for any budget'),
818
+ caveat: BUDGET_CAVEAT,
819
+ // Deliberately no percentage, share or ratio field anywhere in this row. Printing one would
820
+ // imply the cap has been evaluated against a quarter it was never measured over.
821
+ rows: [...rows.values()].sort((a, b) => a.id.localeCompare(b.id)),
822
+ spine_not_read: spineUnread,
823
+ registry_unreadable_in: budgetsUnreadable,
824
+ },
825
+ };
826
+ }
827
+
828
+ /*
829
+ * R6 — coverage is stated, because it is genuinely partial. BOARD_CAP is 12
830
+ * (workflow-state.js:787), so the ladder-derived tiers see only the top 12 in-flight initiatives
831
+ * per product. The cap sorts unreadable first, then blocked, so it is UNLIKELY to hide a qualifying
832
+ * item — and unlikely is not cannot, which is why this line exists rather than a reassurance.
833
+ */
834
+ function coverageRows(products) {
835
+ return products.map((p) => {
836
+ const b = p.board;
837
+ const base = {
838
+ product: p.id, productName: p.name || p.id || null,
839
+ state: b.state, shown: b.shown, total: b.total, hidden: b.hidden,
840
+ stale: b.stale, as_of: b.as_of, error: b.error, line: null,
841
+ };
842
+ if (b.state === 'error') { base.line = `this board could not be derived — ${b.error}`; return base; }
843
+ if (b.state === 'deriving') { base.line = 'this board is still deriving — it is neither empty nor broken'; return base; }
844
+ if (b.state === 'not-read') { base.line = 'no board read was supplied for this product'; return base; }
845
+ if (b.state === 'empty') { base.line = 'this repository holds no initiative records'; return base; }
846
+ base.line = b.hidden > 0
847
+ ? `${b.shown} of ${b.total} scanned · ${b.hidden} outside the board's in-flight cap.`
848
+ : `${b.shown} of ${b.total} scanned · none outside the board's in-flight cap.`;
849
+ if (b.stale) base.line += ` This board was derived at ${b.as_of} and a refresh is running.`;
850
+ return base;
851
+ });
852
+ }
853
+
854
+ /*
855
+ * R5 — the badge. The count is derived from the queue that was just built, in the same call that
856
+ * renders the list, and a BARE NUMBER IS FORBIDDEN whenever a contributing read failed, is still
857
+ * deriving, or never happened. The third quiet failure the PRD names is a badge that sums the
858
+ * readable products and ignores the broken one: right about what it counted, wrong about what it
859
+ * claimed, with nothing on screen saying which.
860
+ */
861
+ function badgeFor(n, reads) {
862
+ const failed = reads.filter((r) => r.state === 'error');
863
+ const deriving = reads.filter((r) => r.state === 'deriving');
864
+ const notRead = reads.filter((r) => r.state === 'not-read');
865
+ const reasons = failed.map((r) => `${r.product || 'this workspace'}: ${r.error}`);
866
+
867
+ if (failed.length) {
868
+ // The shape app.js:795 already uses. No partial sum, because a partial sum is a claim about a
869
+ // total nobody computed.
870
+ return { n, text: 'needs unreadable', exact: false, unreadable: true, failed: failed.length, deriving: deriving.length, not_read: notRead.length, reasons };
871
+ }
872
+ const notes = [];
873
+ if (deriving.length) notes.push(`${deriving.length} product${deriving.length === 1 ? '' : 's'} still deriving`);
874
+ if (notRead.length) notes.push(`${notRead.length} product${notRead.length === 1 ? '' : 's'} not read`);
875
+ const head = n === 0 && !notes.length ? 'nothing needs you' : `${n} need you`;
876
+ return {
877
+ n, text: notes.length ? `${head} · ${notes.join(' · ')}` : head,
878
+ exact: !notes.length, unreadable: false,
879
+ failed: 0, deriving: deriving.length, not_read: notRead.length,
880
+ reasons: notes,
881
+ };
882
+ }
883
+
884
+ /** Every contributing read, with its state — the provenance the badge is derived from. */
885
+ function readLog(products) {
886
+ const out = [];
887
+ for (const p of products) {
888
+ const tag = { product: p.id, productName: p.name || p.id || null };
889
+ out.push(Object.assign({ source: 'board' }, tag, { state: p.board.state, error: p.board.error }));
890
+ out.push(Object.assign({ source: 'needs' }, tag, { state: p.needs.state, error: p.needs.error }));
891
+ out.push(Object.assign({ source: 'spine' }, tag, { state: p.spine.state, error: p.spine.error }));
892
+ }
893
+ return out;
894
+ }
895
+
896
+ /**
897
+ * buildFocus(input) — the whole surface, derived.
898
+ *
899
+ * INPUT (every field optional; nothing is read from disk, a clock or the network):
900
+ * products [] — [{ id, name, board, needs, spine, sessions, ladders }]. The real input.
901
+ * board — one `boardCache.board(root)` result. Used only when `products` names at
902
+ * needs — most one product; a top-level read shared across several trees would
903
+ * spine — attribute one repository's state to all of them.
904
+ * ladders — extra `consoleState.ladder()` results (array, envelope array, or id map).
905
+ * sessions [] — one `sessionList()`, partitioned per product by each row's `ws`.
906
+ * skills — array/Set of repo-relative skill paths that EXIST ON DISK (R8). Omit and no
907
+ * ▶ delegate action is offered at all.
908
+ * budgetRegistry — the declared health budgets, parsed from planning/okrs/health-budgets.md
909
+ * (array of {id, cap, covers}, or {ok:false,error}). Omit and no cap is shown.
910
+ *
911
+ * RETURNS:
912
+ * { objectives[], queue[], handoffs, delegated, upkeep, count, badge, coverage, reads, tiers }
913
+ *
914
+ * Render `badge.text`, NEVER `count`. `count` is a true statement about the queue's length; it is
915
+ * not a true statement about the world whenever `badge.unreadable` is set.
916
+ */
917
+ function buildFocus(input) {
918
+ const inp = isObj(input) ? input : {};
919
+ const products = normalizeProducts(inp);
920
+
921
+ const raw = [];
922
+ for (const p of products) {
923
+ raw.push(...integrityItems(p), ...ladderItems(p), ...needsItems(p), ...sessionItems(p), ...unanchoredItems(p));
924
+ }
925
+
926
+ // Rank exactly as the PRD states: board-integrity → contract/gate → a session is waiting →
927
+ // handoff (structurally empty) → intake. Within a tier, product name then id — so the order is
928
+ // stable between renders and a rank number therefore means something.
929
+ raw.sort((a, b) => a.tierOrder - b.tierOrder
930
+ || String(a.sortKey).localeCompare(String(b.sortKey))
931
+ || String(a.subject).localeCompare(String(b.subject))
932
+ || String(a.id).localeCompare(String(b.id)));
933
+
934
+ const queue = dedupe(raw);
935
+ for (let i = 0; i < queue.length; i++) {
936
+ queue[i].rank = i + 1;
937
+ // internal join/sort keys — not fields the UI owes anything to
938
+ delete queue[i].dedup;
939
+ delete queue[i].sortKey;
940
+ }
941
+
942
+ const reads = readLog(products);
943
+ const count = queue.length; // derived here, every call. Never stored.
944
+ return {
945
+ intro: 'This list holds only items where you are the blocker. Everything else is running on an agent or waiting on another team.',
946
+ objectives: objectiveRows(products),
947
+ queue,
948
+ handoffs: handoffsSection(),
949
+ delegated: delegatedSection(products, inp.skills),
950
+ upkeep: upkeepSection(products, inp.budgetRegistry),
951
+ count,
952
+ badge: badgeFor(count, reads),
953
+ coverage: coverageRows(products),
954
+ reads,
955
+ tiers: TIERS,
956
+ empty_note: 'nothing is waiting on you', // the CALM state — only honest when badge.exact
957
+ };
958
+ }
959
+
960
+ module.exports = {
961
+ buildFocus,
962
+ // The classifiers are exported so the suite can walk their whole domains rather than sample them
963
+ // — the property that separates a total classifier from a filter list.
964
+ classifyStep, classifySession, classifyNeed, skillPathsIn,
965
+ readBoard, readNeeds, readSpine, readLadders,
966
+ STEP_KEYS, STEP_STATUSES, AGENT_ACT_STEPS, PM_STEPS, TIERS, BUDGET_CAVEAT,
967
+ };