create-pmos 0.525.0 → 0.527.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/package.json +51 -56
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/briefing-premium-board-test.js +1 -1
  7. package/workspace/changelog.json +50 -0
  8. package/workspace/ev-everywhere-test.js +2 -2
  9. package/workspace/guide/agent-capabilities.md +28 -2
  10. package/workspace/guide/board.md +14 -1
  11. package/workspace/guide/claude-proposes.md +3 -1
  12. package/workspace/guide/home.md +11 -5
  13. package/workspace/guide/idle-lab.md +2 -2
  14. package/workspace/guide/lifecycle.md +16 -3
  15. package/workspace/guide/reports-set-up.md +1 -0
  16. package/workspace/guide/today.md +11 -3
  17. package/workspace/lib/bg-queue.js +7 -3
  18. package/workspace/lib/board-premium.js +4 -1
  19. package/workspace/lib/briefing-premium-agent.js +9 -2
  20. package/workspace/lib/engine/boards.js +3 -2
  21. package/workspace/lib/headless.js +19 -4
  22. package/workspace/lib/idle-lab-engine.js +531 -0
  23. package/workspace/lib/initiative-chat.js +12 -2
  24. package/workspace/lib/initiative-cli.js +1 -1
  25. package/workspace/lib/initiative-premium.js +11 -3
  26. package/workspace/lib/lab-cli.js +220 -0
  27. package/workspace/lib/lifecycle-stage.js +17 -3
  28. package/workspace/lib/one-stage.js +131 -0
  29. package/workspace/lib/pmos-verbs.js +3 -0
  30. package/workspace/lib/proposals.js +5 -2
  31. package/workspace/lib/report-chat.js +31 -4
  32. package/workspace/lib/report-draft.js +1 -1
  33. package/workspace/lib/route-modules.js +1 -0
  34. package/workspace/lib/routes-board-premium.js +22 -0
  35. package/workspace/lib/routes-boards.js +61 -12
  36. package/workspace/lib/routes-briefing-premium.js +18 -1
  37. package/workspace/lib/routes-notes.js +1 -1
  38. package/workspace/lib/routes-your-move.js +64 -0
  39. package/workspace/lib/status-signal.js +123 -0
  40. package/workspace/lib/workflow-cli.js +3 -1
  41. package/workspace/lib/your-move.js +61 -0
  42. package/workspace/package-lock.json +2 -2
  43. package/workspace/package.json +1 -1
  44. package/workspace/public/pm/bd-acts.js +2 -1
  45. package/workspace/public/pm/bd-cols.js +8 -5
  46. package/workspace/public/pm/bd-prog-panel.js +3 -2
  47. package/workspace/public/pm/bd-span.js +2 -1
  48. package/workspace/public/pm/bd-views.js +8 -6
  49. package/workspace/public/pm/cozy-home.css +3 -0
  50. package/workspace/public/pm/cozy-today.css +151 -0
  51. package/workspace/public/pm/cozy.css +9 -0
  52. package/workspace/public/pm/good-morning.js +327 -0
  53. package/workspace/public/pm/grill-words.js +16 -1
  54. package/workspace/public/pm/home-app.js +187 -18
  55. package/workspace/public/pm/home-chat.js +6 -2
  56. package/workspace/public/pm/home-data.js +45 -13
  57. package/workspace/public/pm/home-day.js +66 -14
  58. package/workspace/public/pm/home-ini-model.js +1 -1
  59. package/workspace/public/pm/home-stage-card.js +7 -0
  60. package/workspace/public/pm/ini-tasks.js +2 -2
  61. package/workspace/public/pm/ini-view-model.js +10 -3
  62. package/workspace/public/pm/ini-view.css +9 -0
  63. package/workspace/public/pm/ini-view.js +19 -5
  64. package/workspace/public/pm/move-rank.js +155 -0
  65. package/workspace/public/pm/premium-board.js +7 -4
  66. package/workspace/public/pm/premium-initiative-head.js +39 -4
  67. package/workspace/public/pm/premium-report-tab.js +15 -3
  68. package/workspace/public/pm/report-flow.js +7 -1
  69. package/workspace/public/pm/stage-chip.js +44 -0
  70. package/workspace/run-tests.js +1 -0
  71. package/workspace/server.js +43 -1
  72. package/workspace/skills/idle-lab-prototype.skill +36 -0
  73. package/workspace/skills/idle-lab-scan.skill +32 -0
  74. package/workspace/test-lists/good-morning.json +9 -0
  75. package/workspace/test-lists/idle-lab-engine.json +9 -0
  76. package/workspace/test-lists/one-stage.json +5 -0
  77. package/workspace/test-lists/status-signal.json +5 -0
  78. package/workspace/test-lists/your-move.json +9 -0
@@ -90,6 +90,8 @@ const rowsLib = require('./initiative-rows.js');
90
90
  const writer = require('./initiative-writer.js');
91
91
  const itemSpans = require('./item-spans.js');
92
92
  const jiraStatus = require('./jira-status.js');
93
+ const SS = require('./status-signal.js');
94
+ const jiraOf = (root, id) => { try { return jiraStatus.readSnapshot(root, id); } catch { return null; } };
93
95
  const routesInit = require('./routes-initiatives.js');
94
96
  const jiraTickets = require('./jira-tickets.js');
95
97
  const { repoScript } = require('./repo-script.js');
@@ -101,6 +103,7 @@ const TS = require('./task-store.js');
101
103
  const holdSpans = require('./hold-spans.js');
102
104
  const ledgers = require('./plan-ledgers.js');
103
105
  const intake = require(repoScript('intake.js'));
106
+ const OS = require('./one-stage.js');
104
107
 
105
108
  const LEDGER = 'boards.jsonl';
106
109
  const SELF_REFUSAL = 'PMOS keeps its derived ladder: its stages come from scripts/workflow-state.js, so a move or a workflow edit is refused here';
@@ -302,14 +305,11 @@ function frontmatter(root, rel) {
302
305
  return out;
303
306
  }
304
307
 
305
- function statusOf(rec, healthLevel, end, day) {
306
- if (rec.accepted || (rec.stopped && rec.stopped.state === 'closed')) return 'done';
307
- if (rec.stopped && rec.stopped.state === 'cancelled') return 'cancelled';
308
- if (rec.stopped && rec.stopped.state === 'on-hold') return 'on hold'; // no health while paused: it never turns late
309
- if (healthLevel === 'off' || (end && end < day)) return 'late';
310
- if (healthLevel === 'at-risk') return 'at risk';
311
- return 'on track';
312
- }
308
+ /* workspace grill W2: the status says only what PMOS knows (lib/status-signal.js decides it; this keeps the inputs). */
309
+ const statusIn = (rec, health, end, day, jira) => ({ accepted: !!rec.accepted, stopped: rec.stopped ? { state: rec.stopped.state } : null,
310
+ reasons: health && Array.isArray(health.reasons) ? health.reasons.map((x) => ({ level: x.level, code: x.code, text: x.text, source: x.source })) : [],
311
+ end: end || null, day, jira: jira ? { fetchedAt: jira.fetchedAt, tickets: [...jira.tickets.values()].map((t) => ({ key: t.key, status: t.status, category: t.category, sprint: t.sprint, flagged: !!t.flagged })) } : null });
312
+ function statusOf(rec, health, end, day, jira) { return SS.statusOf(statusIn(rec, health, end, day, jira)).status; }
313
313
 
314
314
  /** The board-independent part of every work row of a repository, cached by the read signature. */
315
315
  /* A READ PASS (ctx.boardsPass, a Map): a page that asks several of these routes for one answer (GET /api/board-premium asks ten)
@@ -333,6 +333,7 @@ function baseRowsRead(w) {
333
333
  // every lifecycle line, not only the latest: a hold is a gap on the timeline (feedback batch 9 item 8)
334
334
  let lcFile = null;
335
335
  try { lcFile = path.join(ledgers.runsDir(w.root), 'lifecycle.jsonl'); const st = fs.statSync(lcFile); sig.lifecycle = st.size + ':' + st.mtimeMs; } catch { sig.lifecycle = 'none'; }
336
+ try { const jd = jiraTickets.jiraDir(w.root); sig.jira = fs.readdirSync(jd).filter((n) => n.endsWith('.md')).map((n) => { const st = fs.statSync(path.join(jd, n)); return n + ':' + st.mtimeMs; }).join(','); } catch { sig.jira = 'none'; }
336
337
  const cacheKey = JSON.stringify(sig) + day;
337
338
  const hit = rowCache.get(w.root);
338
339
  if (hit && hit.key === cacheKey) return { ok: true, rows: hit.rows };
@@ -365,7 +366,8 @@ function baseRowsRead(w) {
365
366
  id, title: r.title, lane: r.lane || null,
366
367
  owner: fm.owner ? String(fm.owner) : null,
367
368
  anchor: r.anchor || null, rank: Number.isInteger(r.rank) ? r.rank : null, dormant: !!r.dormant,
368
- status: statusOf(rec, r.health && r.health.level, end, day),
369
+ status: statusOf(rec, r.health, end, day, jiraOf(w.root, id)),
370
+ statusIn: statusIn(rec, r.health, end, day, jiraOf(w.root, id)),
369
371
  health: r.health ? r.health.level : null,
370
372
  start: span ? span.start || null : null, end,
371
373
  progress: r.progress && r.progress.total ? Math.round((r.progress.done / r.progress.total) * 100) / 100 : null, // a fraction 0..1, as the PRD row reads
@@ -377,6 +379,7 @@ function baseRowsRead(w) {
377
379
  hasSpec, record: relRec || null, domainFolder: dm ? dm[1] : null,
378
380
  recordEpic: jiraTickets.anchorOf(fm),
379
381
  holds: holds.get(id) || [], workSpans: work.map((x) => ({ start: x.start, due: x.due, stage: x.stage || null })),
382
+ accepted: !!rec.accepted, root: w.root, // W1: what lib/one-stage.js reads besides the row (the sign-off, the Jira snapshot's repository)
380
383
  });
381
384
  }
382
385
  rowCache.set(w.root, { key: cacheKey, rows });
@@ -499,7 +502,33 @@ function closedOf(row, wx) {
499
502
  // the last stage, not counting the ones after it that only end work
500
503
  return wf.length > 1 && wf.slice(i + 1).every((x) => END_RE.test(String(x && x.name || '')));
501
504
  }
505
+ /** W1: the one stage of a row (lib/one-stage.js): the PM's word, then Jira, then dated plan items, then the files (base.reading).
506
+ * A Board move is the PM's word while it is newer than the last Jira read. → the stageOf answer, with `moveDropped` (a mapped move older than the Jira read). */
507
+ function oneStageOf(board, base, folded, epics) {
508
+ const k = B.key(board.id, base.id);
509
+ const movedName = folded && folded.stages ? folded.stages.get(k) : null;
510
+ let move = null;
511
+ if (movedName && !(board && board.self)) {
512
+ const last = [...((folded && folded.moves) || [])].reverse().find((m) => m.board === board.id && m.id === base.id);
513
+ const pf = WF.resolve(libOf(folded), null);
514
+ const st = WF.stageByRef(pf, movedName) || mergedStage(pf, movedName, null);
515
+ if (st) move = { key: st.key, at: last ? last.at : null };
516
+ }
517
+ const jira = base.root ? OS.factsOf(base.root, base.id, epics) : null;
518
+ const one = OS.stageOf({ files: base.reading, life: base.lifecycle ? { ...(base.stopped || {}), state: base.lifecycle } : null, move, jira, plan: { dated: (base.workSpans || []).length }, accepted: !!base.accepted });
519
+ const under = one.under || one;
520
+ // a move to a board stage the eight steps do not name (Execute, Doing) stays the board's: only a mapped move older than Jira's read gives way
521
+ return { ...one, moveDropped: !!(move && under.setBy !== 'pm') };
522
+ }
502
523
  function boardRow(board, base, folded, lib) {
524
+ const ep = epicsOf(base, folded);
525
+ const one = oneStageOf(board, base, folded, ep.epics);
526
+ // the board's stage name follows the one stage: a move the one stage did not keep (an older move, Jira read since) no longer places it
527
+ if (one.moveDropped && folded && folded.stages && folded.stages.has(B.key(board.id, base.id))) {
528
+ const own = folded.iniWf && folded.iniWf.get(B.key(board.id, base.id));
529
+ if (!own) { const st = new Map(folded.stages); st.delete(B.key(board.id, base.id)); folded = Object.assign(Object.create(Object.getPrototypeOf(folded)), folded, { stages: st }); }
530
+ }
531
+ if (one.setBy !== 'files' || !folded || !folded.stages || !folded.stages.has(B.key(board.id, base.id)) || one.moveDropped) base = { ...base, reading: one.step };
503
532
  const s = B.stageOf(board, base.id, base.reading, folded);
504
533
  const ts = taskSpanOf(folded, base.id); // the bar covers the breakdown items and the tasks
505
534
  const wf = B.workflowOf(board, base.id, folded);
@@ -513,15 +542,28 @@ function boardRow(board, base, folded, lib) {
513
542
  // item 13); stageSpans: where the dated work of each stage runs, so a skipped stage between two of them is marked » (XgapAGrill)
514
543
  holds: base.holds || [], ...gapFields((base.workSpans || []).concat(taskSpansOf(folded, base.id))),
515
544
  template: B.templateOf(board, base.id, folded), workflow: wf.map((x) => ({ name: x.name, cat: x.cat })),
516
- ...epicsOf(base, folded),
545
+ ...ep,
546
+ // W1: the one stage every surface draws (public/pm/stage-chip.js), and what set it
547
+ oneStage: { stage: one.stage, step: one.step, label: one.label, setBy: one.setBy, source: one.source, readAt: one.readAt },
517
548
  };
518
549
  const wx = workflowRowOf(board, base, folded, lib || libOf(folded), s.stage);
519
550
  Object.assign(row, wfRowFields(wx));
551
+ // a board whose columns merge or leave out a step ("Discover" holds Triage) shows the step its column names: the chip says the same
552
+ if (!['shipped', 'on-hold', 'cancelled'].includes(one.stage) && row.wfStage && OS.STEPS.includes(row.wfStage.key) && row.wfStage.key !== one.step && !wx.own) {
553
+ row.oneStage = { ...row.oneStage, stage: row.wfStage.key, step: row.wfStage.key, label: OS.labelOf(row.wfStage.key) };
554
+ }
520
555
  if (wx.own) { row.stage = (wx.stages.find((x) => x.key === wx.stage) || {}).name || row.stage; row.cat = null; row.stageSource = 'move'; }
521
556
  // batch 26 item 5: an initiative in its workflow's terminal stage is finished, so it is never late or at risk. Every surface (the Board's
522
557
  // tone, dot, late line and edge tabs, Home, Kanban, the program rollups) reads the status this leaves and row.closed.
558
+ // workspace grill W2: the status from what PMOS read, a passed date, or a risk the PM confirmed on a program milestone; else No signal.
559
+ // W1's one stage (row.oneStage, lib/one-stage.js stageOf) is read when it is there: a stage the PM set to shipped is done, a Jira-set stage a read
560
+ if (base.statusIn) {
561
+ const sig = SS.statusOf({ ...base.statusIn, stage: row.oneStage || null, confirmed: (folded && folded.risks && folded.risks.get(base.id)) || null });
562
+ row.status = sig.status; row.signal = { from: sig.from, why: sig.why, source: sig.source, readAt: sig.readAt };
563
+ }
523
564
  row.closed = closedOf(row, wx);
524
- if (row.closed && (row.status === 'late' || row.status === 'at risk')) row.status = 'on track';
565
+ // a finished initiative is never late, at risk or without a signal: its last stage is the PM's own move (batch 26 item 5)
566
+ if (row.closed && ['late', 'at risk', 'no signal'].includes(row.status)) { row.status = 'on track'; row.signal = { from: 'pm', why: 'In its last stage', source: 'the workflow', readAt: null }; }
525
567
  const w = [];
526
568
  if (s.source === 'move') {
527
569
  const last = [...folded.moves].reverse().find((m) => m.board === board.id && m.id === base.id);
@@ -539,10 +581,17 @@ function frameRead(ctx, w) {
539
581
  const boards = boardsOf(ctx, w, folded);
540
582
  const d = ctx.stateDirFor ? ctx.stateDirFor(w) : null;
541
583
  folded.wfLib = WS.library(d); // D99: the workflow library rides on the fold, so every boardRow call reads one copy
584
+ folded.risks = risksOf(ctx, w); // workspace grill W2: the risks the PM confirmed on a program milestone
542
585
  return { w, folded, boards };
543
586
  }
544
587
  /** A fresh fold with the workflow library on it (frameFor's, without the boards). Inside a read pass, the pass's own fold. */
545
- function foldOf(ctx, w) { if (ctx && ctx.boardsPass instanceof Map) return frameFor(ctx, w).folded; const f = B.fold(readLedger(ctx, w)); const d = ctx.stateDirFor ? ctx.stateDirFor(w) : null; f.wfLib = WS.library(d); return f; }
588
+ function foldOf(ctx, w) { if (ctx && ctx.boardsPass instanceof Map) return frameFor(ctx, w).folded; const f = B.fold(readLedger(ctx, w)); const d = ctx.stateDirFor ? ctx.stateDirFor(w) : null; f.wfLib = WS.library(d); f.risks = risksOf(ctx, w); return f; }
589
+ /** The initiatives of workspace w that a program milestone the PM kept "at risk" waits on (lib/status-signal.js confirmedRisks). Every
590
+ * program of every workspace is read once per read pass; a program ledger that cannot be read confirms nothing. */
591
+ function risksOf(ctx, w) {
592
+ if (!ctx || !ctx.workspaceList) return new Map();
593
+ try { return SS.confirmedRisks(inPass(ctx, 'programs', () => require('./routes-programs.js').all(ctx)), w.id); } catch { return new Map(); }
594
+ }
546
595
  const libOf = (folded) => (folded && folded.wfLib) || WS.library(null);
547
596
  /** A workflow by id for workspace w: its own library first, then the home workspace's, then any other open workspace's (not archived there).
548
597
  * → { wf, ws: null } | { wf, ws: <the workspace it came from> } | null */
@@ -83,6 +83,23 @@ function iniInfoOf(ctx, key, wsId) {
83
83
  return null;
84
84
  }
85
85
 
86
+ /** The set-up report open beside this chat, for lib/report-chat.js wrap: { id, name, draft } | null. Wawan, 2026-11-04: feedback in the chat,
87
+ * in any words, is about the draft beside it. The page names the report (pm/premium-report-tab.js reportBeside); it counts only while it is set
88
+ * up, read in the chat's own workspace, then the others. */
89
+ function reportInfoOf(ctx, wsId, rid) {
90
+ const id = String(rid || ''); if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(id)) return null;
91
+ const RS = require('./report-setups.js');
92
+ const list = ctx.workspaceList ? ctx.workspaceList() : [];
93
+ const first = wsId && ctx.getWs ? ctx.getWs(wsId) : null;
94
+ for (const w of [first, ...list.filter((x) => !first || x.id !== first.id)].filter((x) => x && x.root)) {
95
+ let st; try { st = RS.stateOf(w.root); } catch { continue; }
96
+ const s = st.setups.get(id); if (!s) continue;
97
+ const last = (s.drafted || []).filter((d) => d && d.path).slice(-1)[0];
98
+ return { id, name: s.name || id, draft: last ? last.path : null };
99
+ }
100
+ return null;
101
+ }
102
+
86
103
  async function post(req, res, url, ctx) {
87
104
  const act = url.pathname.slice('/api/briefing-premium/'.length);
88
105
  const got = await ctx.readJson(req);
@@ -125,7 +142,7 @@ async function post(req, res, url, ctx) {
125
142
  if (!att.ok) { ctx.json(res, { ok: false, error: att.error }, att.status); return true; }
126
143
  // batch 20 item 13: the PM can start a grill any time by asking for it in an initiative's chat; it also runs by itself
127
144
  const g2 = grillAsk(ctx, b.key, b.text, wsId);
128
- const a = (ctx.chatSessions ? Ag.chat : Ag.ask)({ runtimeDir, key: b.key, text: Files.withFiles(g2 ? g2.text : b.text, att.paths), context: b.context, sessions: ctx.chatSessions, model: /^work:/.test(String(b.key || '')) ? WP.peekModel() : /^board:/.test(String(b.key || '')) ? BoardP.peekModel() : /^ins:/.test(String(b.key || '')) ? InsP.peekModel() : /^(init|doc):/.test(String(b.key || '')) ? InitP.peekModel() : sk || (/^src:/.test(String(b.key || '')) && !(b.key === 'src:this-page' && url.searchParams.get('ws'))) ? Prod.peekModel() : BP.peekModel(root), spawnSession: ctx.spawnSession, wsId, iniInfo: () => iniInfoOf(ctx, b.key, wsId) });
145
+ const a = (ctx.chatSessions ? Ag.chat : Ag.ask)({ runtimeDir, key: b.key, text: Files.withFiles(g2 ? g2.text : b.text, att.paths), context: b.context, sessions: ctx.chatSessions, reportInfo: () => reportInfoOf(ctx, wsId, b.report), model: /^work:/.test(String(b.key || '')) ? WP.peekModel() : /^board:/.test(String(b.key || '')) ? BoardP.peekModel() : /^ins:/.test(String(b.key || '')) ? InsP.peekModel() : /^(init|doc):/.test(String(b.key || '')) ? InitP.peekModel() : sk || (/^src:/.test(String(b.key || '')) && !(b.key === 'src:this-page' && url.searchParams.get('ws'))) ? Prod.peekModel() : BP.peekModel(root), spawnSession: ctx.spawnSession, wsId, iniInfo: () => iniInfoOf(ctx, b.key, wsId) });
129
146
  ctx.json(res, a.body, a.status);
130
147
  return true;
131
148
  }
@@ -373,7 +373,7 @@ function openInitiativesOf(w) {
373
373
  if (rec && (rec.stopped || rec.accepted)) return;
374
374
  const isTop = !dormant && top < PICKER_TOP;
375
375
  if (isTop) top++;
376
- ranked.push({ id: r.id, title: r.title, anchor: r.anchor, intent: '', stage: lifecycle.labelOf(rec), dormant, top: isTop });
376
+ ranked.push({ id: r.id, title: r.title, anchor: r.anchor, intent: '', stage: lifecycle.labelOf(rec, w.root), dormant, top: isTop });
377
377
  };
378
378
  for (const r of rm.rows) add(r, false);
379
379
  for (const r of rm.dormant) add(r, true);
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+ /* routes-your-move.js — what Your move reads from a product beside its Board rows (workspace grill W3, frame WgW3Move).
3
+ *
4
+ * GET /api/your-move?ws=<id> → { ok, ws, today, verdicts: [lib/your-move.js verdict + board], kr: lib/your-move.js krOf | null }
5
+ *
6
+ * verdicts: the outcome checks past their date with no verdict (lib/outcomes.js scan, less Intelligence's verdicts in
7
+ * lib/insights-records.js), each with Claude's draft from the board's Jira tickets and the board it is on (the ✓ records it there through
8
+ * POST /api/insights-records/verdict). kr: the first key result of the OKR file that waits on a source (lib/okr-goals.js, the kr-metrics records
9
+ * as its readers). Read only: nothing is written. A part that cannot be read is left out, never guessed. */
10
+ const YM = require('./your-move.js');
11
+
12
+ const localDay = (ms = Date.now()) => { const d = new Date(ms); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
13
+
14
+ /** The board items of every board of the workspace, and the board each initiative is on. */
15
+ function itemsOf(ctx, w) {
16
+ const RB = require('./routes-boards.js'), TLR = require('./routes-task-links.js');
17
+ const items = [], boardOf = new Map();
18
+ let fr = null;
19
+ try { fr = RB.frameFor(ctx, w); } catch { fr = null; }
20
+ for (const b of (fr && fr.boards) || []) {
21
+ let f = null;
22
+ try { f = TLR.frame(ctx, w, b); } catch { f = null; }
23
+ if (!f) continue;
24
+ for (const r of f.rows || []) if (r && r.id && !boardOf.has(r.id)) boardOf.set(r.id, b.id);
25
+ items.push(...(f.items || []));
26
+ }
27
+ return { items, boardOf };
28
+ }
29
+
30
+ function verdicts(ctx, w, today) {
31
+ const S = require('./outcomes.js').scan(w.root, { today });
32
+ if (!S.due.length) return [];
33
+ const have = new Set();
34
+ try { const V = require('./insights-records.js').read(w.root).verdicts || {}; for (const b of Object.values(V)) for (const id of Object.keys(b || {})) have.add(id); } catch { /* none recorded */ }
35
+ const { items, boardOf } = itemsOf(ctx, w);
36
+ return YM.verdictsOf({ due: S.due, verdicts: have, items, today }).map((v) => ({ ...v, board: boardOf.get(v.ini) || null }));
37
+ }
38
+
39
+ function kr(ctx, w, today) {
40
+ const OG = require('./okr-goals.js');
41
+ const okr = OG.read(w.root);
42
+ if (!okr.ok) return null;
43
+ let recs = [];
44
+ try { recs = require(require('./repo-script.js').repoScript('records/index.js')).list(require('./records-dir.js').recordsDirFor(ctx, w.id), 'kr-metrics').filter((k) => k.retired !== true); } catch { recs = []; }
45
+ const goals = recs.map((k) => ({ id: k.rid, title: k.title || '', kr: k.kr || null, readings: Array.isArray(k.readings) ? k.readings.length : 0, recipe: k.recipe || null }));
46
+ return YM.krOf(OG.build({ okr, goals, initiatives: [], records: {}, today }));
47
+ }
48
+
49
+ module.exports = {
50
+ prefixes: ['/api/your-move'],
51
+ handle: async (req, res, url, ctx) => {
52
+ if (url.pathname !== '/api/your-move') return false;
53
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
54
+ let w = null;
55
+ try { w = ctx.getWs(url.searchParams.get('ws') || null); } catch { w = null; }
56
+ if (!w || !w.root) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
57
+ const today = localDay();
58
+ let V = [], K = null;
59
+ try { V = verdicts(ctx, w, today); } catch { V = []; }
60
+ try { K = kr(ctx, w, today); } catch { K = null; }
61
+ ctx.json(res, { ok: true, ws: w.id, today, verdicts: V, kr: K });
62
+ return true;
63
+ },
64
+ };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+ /* status-signal.js — an initiative's status says only what PMOS knows (workspace grill W2, "Status says what it knows"; canvas In
3
+ * development, frame WgW2Status). THE ONE place a row's status is decided. Every surface (the Board's rows and header, Home's product row and
4
+ * card, the weekly update, the program rollups) reads the status this decides; none decides one of its own.
5
+ *
6
+ * statusOf(x) → { status, from, why, source, readAt }
7
+ * wordsOf(c) → '3 late · 1 at risk · 5 no signal' the pill's words from THE count (scripts/boards.js counts, whose
8
+ * byStatus holds 'no signal' apart: it is never counted as on track); ''
9
+ * when nothing is open
10
+ * confirmedRisks(programs, ws) → Map initiative → { why, program, milestone, name } the PM's ✓ on a "Mark … at risk" ✦
11
+ * openRisks(programs, ws) → [{ key, program, title, milestone, name, why, src, inis }] the same ✦ before the PM's ✓
12
+ * READ_DAYS · STATUSES
13
+ *
14
+ * THE RULES (the agreed W2 frame):
15
+ * done · cancelled · on hold the PM's word on the record (accepted, closed, cancelled, on-hold in lifecycle.jsonl); a stage the PM set to
16
+ * shipped (W1's stageOf, setBy 'pm') reads on track: it stays open for Monitor and Evaluate
17
+ * late only from a date that passed: the planned finish of the dated plan items, or an overdue milestone
18
+ * at risk only from a read (an overdue plan item, an idle initiative under way, a passed check-by date, a failed factory item, an
19
+ * open Jira ticket past its due date or its sprint's end, a ticket flagged blocked) or from a risk the PM confirmed (✓ on Claude's "Mark … at risk")
20
+ * on track only from a read: a Jira reading of the initiative's tickets in the last READ_DAYS with none past its due date, or dated
21
+ * plan items whose finish is still ahead
22
+ * no signal everything else: grey, and never counted as on track anywhere
23
+ * A health reason the spec ladder gives (a PM step owed: files, not a read) and an `unknown` reason (a source PMOS could not read) say
24
+ * nothing about status. A stale Jira reading (older than READ_DAYS) is not a read.
25
+ *
26
+ * x = { accepted, stopped: { state }, reasons: [{ level, code, text, source }] (lib/health.js), end ('YYYY-MM-DD', the planned finish),
27
+ * day (today, 'YYYY-MM-DD'), now (ms), jira: { fetchedAt, tickets: Map|[] of { key, status, category, due } } | null,
28
+ * stage: W1's { stage, setBy, source, readAt } | null, confirmed: { why, name } | null }
29
+ * Pure: no I/O, no clock beyond x.now.
30
+ */
31
+ const STATUSES = Object.freeze(['on track', 'at risk', 'late', 'no signal', 'on hold', 'done', 'cancelled']);
32
+ const READ_DAYS = 14;
33
+ const DAY = 864e5;
34
+ const DATE_CODES = new Set(['milestone-overdue']); // a passed date: late
35
+ const NOT_READS = new Set(['pm-step-owed']); // the spec ladder (files): not a read
36
+ const DONE_CAT = /^done$/i;
37
+
38
+ const ticketsOf = (j) => (!j || !j.tickets ? [] : typeof j.tickets.values === 'function' && !Array.isArray(j.tickets) ? [...j.tickets.values()] : Array.isArray(j.tickets) ? j.tickets : []);
39
+ const doneTicket = (t) => DONE_CAT.test(String(t.category || '')) || /^(done|closed|resolved|won't do)$/i.test(String(t.status || ''));
40
+ /** When an open ticket was meant to be done: its own due date, else the end of the sprint it sits in (a closed sprint, or the active one). */
41
+ const dueOf = (t) => {
42
+ const sp = t.sprint && (t.sprint.state === 'active' || t.sprint.state === 'closed') ? t.sprint.end : null;
43
+ const m = /^\d{4}-\d{2}-\d{2}/.exec(String(t.due || t.duedate || sp || ''));
44
+ return m ? m[0] : null;
45
+ };
46
+
47
+ /** The Jira reading, when it is a read: fresh, with tickets. → { at, open, late: [ticket] } | null */
48
+ function jiraRead(j, now) {
49
+ if (!j || !j.fetchedAt) return null;
50
+ const at = Date.parse(j.fetchedAt);
51
+ if (!Number.isFinite(at) || now - at > READ_DAYS * DAY) return null;
52
+ const T = ticketsOf(j);
53
+ if (!T.length) return null;
54
+ const day = new Date(now).toISOString().slice(0, 10);
55
+ const open = T.filter((t) => !doneTicket(t));
56
+ return { at: j.fetchedAt, n: T.length, open: open.length, late: open.filter((t) => { const d = dueOf(t); return d && d < day; }), blocked: open.filter((t) => t.flagged) };
57
+ }
58
+
59
+ function statusOf(x = {}) {
60
+ const now = typeof x.now === 'number' ? x.now : Date.now();
61
+ const day = x.day || new Date(now).toISOString().slice(0, 10);
62
+ const out = (status, from, why, source, readAt) => ({ status, from, why: why || '', source: source || '', readAt: readAt || null });
63
+ const st = x.stopped && x.stopped.state;
64
+ const S = x.stage || null;
65
+ // the PM's word
66
+ if (x.accepted || st === 'closed') return out('done', 'pm', '', 'the record');
67
+ if (st === 'cancelled') return out('cancelled', 'pm', '', 'lifecycle.jsonl');
68
+ if (st === 'on-hold') return out('on hold', 'pm', '', 'lifecycle.jsonl');
69
+ // shipped by the PM's word: never late or at risk, but still open on the Board (Monitor and Evaluate follow), so on track, not done
70
+ if (S && S.setBy === 'pm' && S.stage === 'shipped') return out('on track', 'pm', 'Shipped', S.source || 'lifecycle.jsonl', S.readAt);
71
+ const reasons = (Array.isArray(x.reasons) ? x.reasons : []).filter((r) => r && (r.level === 'off' || r.level === 'at-risk') && !NOT_READS.has(r.code));
72
+ // a passed date
73
+ if (x.end && x.end < day) return out('late', 'date', 'Planned finish ' + x.end + ' passed', 'planning/runs/plan-items.jsonl');
74
+ const dated = reasons.find((r) => DATE_CODES.has(r.code));
75
+ if (dated) return out('late', 'date', dated.text, dated.source);
76
+ // a risk the PM confirmed
77
+ if (x.confirmed) return out('at risk', 'confirmed', x.confirmed.why || 'You marked it at risk', x.confirmed.name ? 'Program milestone ' + x.confirmed.name : 'the program charter');
78
+ // a read
79
+ if (reasons.length) return out('at risk', 'read', reasons[0].text, reasons[0].source);
80
+ const J = jiraRead(x.jira, now) || (S && S.setBy === 'jira' && S.readAt && now - Date.parse(S.readAt) <= READ_DAYS * DAY ? { at: S.readAt, n: 0, open: 0, late: [], stage: S } : null);
81
+ if (J && J.late.length) return out('at risk', 'read', J.late.length === 1 ? (J.late[0].key || 'A ticket') + ' is past its due date' : J.late.length + ' tickets are past their due dates', 'Jira', J.at);
82
+ if (J && J.blocked && J.blocked.length) return out('at risk', 'read', (J.blocked[0].key || 'A ticket') + ' is blocked in Jira' + (J.blocked.length > 1 ? ', and ' + (J.blocked.length - 1) + ' more' : ''), 'Jira', J.at);
83
+ if (J) return out('on track', 'read', J.n ? (J.n - J.open) + ' of ' + J.n + ' tickets done' : (J.stage.source || 'Jira'), 'Jira', J.at);
84
+ if (x.end && x.end >= day) return out('on track', 'read', 'Plan items on dates, finish ' + x.end, 'planning/runs/plan-items.jsonl');
85
+ return out('no signal', null, 'Nothing read says how it is going', '');
86
+ }
87
+
88
+ function wordsOf(c) {
89
+ if (!c || !c.open) return '';
90
+ const p = [c.late && c.late + ' late', c.risk && c.risk + ' at risk', c.on && c.on + ' on track', c.none && c.none + ' no signal', c.hold && c.hold + ' on hold'].filter(Boolean);
91
+ return p.join(' · ');
92
+ }
93
+
94
+ /* ── the PM-confirmed risks: a program milestone the PM kept "at risk", on the initiatives it waits on ── */
95
+ const memberOf = (p, ws, ini) => (p.members || []).some((m) => m && m.initiative === ini && (!ws || !m.ws || m.ws === ws));
96
+ function confirmedRisks(programs, ws) {
97
+ const out = new Map();
98
+ for (const p of programs || []) {
99
+ if (!p || p.archived) continue;
100
+ for (const m of p.milestones || []) {
101
+ if (!m || !m.risk || m.done || m.fixed) continue;
102
+ for (const ini of m.waits || []) if (memberOf(p, ws, ini) && !out.has(ini)) out.set(ini, { why: String(m.risk), program: p.id, milestone: m.id, name: m.name || '' });
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+ /** Claude's "Mark … at risk" ✦ the PM has not answered, for the initiatives of workspace `ws`. */
108
+ function openRisks(programs, ws) {
109
+ const out = [];
110
+ for (const p of programs || []) {
111
+ if (!p || p.archived) continue;
112
+ for (const i of (p.review && p.review.open) || []) {
113
+ if (!i || i.op !== 'risk') continue;
114
+ const m = (p.milestones || []).find((x) => x.id === i.milestone) || {};
115
+ const inis = (m.waits || []).filter((ini) => memberOf(p, ws, ini));
116
+ if (ws && !inis.length && !(p.members || []).some((x) => x && x.ws === ws)) continue;
117
+ out.push({ key: i.key, program: p.id, programTitle: p.title || p.id, title: 'Mark ' + (m.name || 'a milestone') + ' at risk', milestone: i.milestone, name: m.name || '', why: i.why, src: i.src || null, at: i.at || '', inis });
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ module.exports = { STATUSES, READ_DAYS, statusOf, wordsOf, confirmedRisks, openRisks, jiraRead };
@@ -233,11 +233,13 @@ function ofLines(r) {
233
233
  const row = r.row, st = row.wfStage || {};
234
234
  const out = [`${row.title} (${row.id}) runs ${row.wf.name} (${row.wf.id}) on the board ${r.board.name}.`];
235
235
  out.push(row.ended ? `It ended: ${row.ended.as === 'wontdo' ? "Won't do" : 'Archived'}.` : `Stage ${st.n} of ${st.of}: ${st.name} [${st.key}]`);
236
+ const one = row.oneStage; // W1: the one stage the pages show, and what set it
237
+ if (one && one.label) out.push(`The pages show: ${one.label} · set by ${one.source}${one.readAt ? ' · read ' + one.readAt : ''}`);
236
238
  (row.wfStages || []).forEach((s, i) => out.push(` ${MARK[s.status] || ' '} ${String(i + 1).padStart(2)}. ${s.milestone ? '◆ ' : ''}${s.name} [${s.key}]${s.status === 'current' ? ' · current' : s.status === 'skipped' ? ' · skipped' : ''}`));
237
239
  out.push(`Move it: pmos workflow move ${row.id} --stage <key>. Moving the initiative changes no task; a task's group (pmos task set --stage) is its place in the breakdown, not its state.`);
238
240
  return out;
239
241
  }
240
- const ofJson = (r) => ({ initiative: { id: r.row.id, title: r.row.title }, board: { id: r.board.id, name: r.board.name }, wf: r.row.wf, stage: r.row.wfStage, stages: r.row.wfStages, ended: r.row.ended });
242
+ const ofJson = (r) => ({ initiative: { id: r.row.id, title: r.row.title }, board: { id: r.board.id, name: r.board.name }, wf: r.row.wf, stage: r.row.wfStage, stages: r.row.wfStages, ended: r.row.ended, oneStage: r.row.oneStage || null });
241
243
 
242
244
  function onInitiative(verb, { pos, flags, json, say, fail, root, stateDir, actor, others }) {
243
245
  const id = pos[0];
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+ /* your-move.js — what Your move needs from a product that its Board rows do not carry (workspace grill W3, frame WgW3Move; brief
3
+ * /mnt/project-files/workspace-grill/brief-W1-W4.md). Pure: lib/routes-your-move.js reads the files and passes them in.
4
+ *
5
+ * verdictsOf({ due, verdicts, items, today }) → [verdict] the outcome checks past their date with no verdict yet, oldest first
6
+ * krOf(okrFile) → { waiting, first: { token, title, need } } | null the first key result with no reading
7
+ *
8
+ * A VERDICT ROW. An outcome check is due (lib/outcomes.js scan: `due` = a metric, a check date on or before today, no disposition in
9
+ * outcome_checks.jsonl) and Intelligence holds no verdict for it (lib/insights-records.js). Claude drafts Keep, Iterate or Stop only from what
10
+ * it read, and the PM's ✓ records it (POST /api/insights-records/verdict, drafted: true). What it reads today is the initiative's Jira tickets on
11
+ * the board (each item carries the time Jira was read):
12
+ * - a ticket of type Bug still open → Iterate, naming the bugs
13
+ * - tickets read and no Bug open → Keep, and it says the metric itself was not read (the mood is never happier than the data)
14
+ * - no ticket read → no draft: "I could not read enough to judge", and ✎ opens the full verdict on Intelligence
15
+ * Stop is never drafted: it is the PM's call. Each draft carries its sources.
16
+ *
17
+ * A KEY RESULT ROW. lib/okr-goals.js build() lists the OKR file's key results with what each needs when nothing reads it. The row names the
18
+ * first one that waits on a source and how many wait; a file where every key result has a reading draws nothing (zero is silence). */
19
+
20
+ const DAY = 864e5;
21
+ const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
22
+ const dn = (iso) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || '')); return m ? Math.round(Date.UTC(+m[1], +m[2] - 1, +m[3]) / DAY) : null; };
23
+ const md = (iso, today) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || '')); if (!m) return ''; const y = String(today || '').slice(0, 4); return +m[3] + ' ' + MON[+m[2] - 1] + (y && m[1] !== y ? ' ' + m[1] : ''); };
24
+ const plural = (n, a, b) => n + ' ' + (n === 1 ? a : b);
25
+ const list = (a) => (a.length < 2 ? a.join('') : a.slice(0, -1).join(', ') + ' and ' + a[a.length - 1]);
26
+ const OPEN = (s) => !/^(done|closed|resolved|released|won't do|archived)$/i.test(String(s || ''));
27
+
28
+ /** The drafted verdict for one initiative from its Jira tickets on the board, or null when nothing was read. */
29
+ function draftOf(ini, items, today) {
30
+ const tk = (items || []).filter((x) => x && x.src === 'jira' && x.ini === ini && x.kind !== 'Epic' && x.readAt);
31
+ if (!tk.length) return null;
32
+ const at = tk.map((x) => String(x.readAt).slice(0, 10)).sort().pop();
33
+ const src = [{ label: 'Jira, read ' + md(at, today), ref: tk.map((x) => x.key).slice(0, 6).join(', ') }];
34
+ const bugs = tk.filter((x) => x.kind === 'Bug' && OPEN(x.status));
35
+ if (bugs.length) {
36
+ const keys = bugs.map((x) => x.key);
37
+ return { verdict: 'Iterate', text: plural(bugs.length, 'bug is', 'bugs are') + ' still open in Jira: ' + list(keys.slice(0, 3)) + (keys.length > 3 ? ' and ' + (keys.length - 3) + ' more' : '') + '.', sources: src };
38
+ }
39
+ return { verdict: 'Keep', text: 'No bug is open among its ' + plural(tk.length, 'Jira ticket', 'Jira tickets') + ' (read ' + md(at, today) + '). I could not read the metric itself.', sources: src };
40
+ }
41
+
42
+ /** The checks past their date with no verdict: [{ ini, title, metric, horizon, days, draft }] oldest first. PURE.
43
+ * due: lib/outcomes.js scan().due; verdicts: Set of initiative ids Intelligence holds a verdict for; items: the board's work items. */
44
+ function verdictsOf({ due, verdicts, items, today }) {
45
+ const t = dn(today), have = verdicts instanceof Set ? verdicts : new Set(verdicts || []);
46
+ return (due || []).filter((it) => it && it.id && it.horizon && !have.has(it.id)).map((it) => ({
47
+ ini: it.id, title: it.title || it.id, metric: it.metric || '', horizon: it.horizon, days: t != null && dn(it.horizon) != null ? Math.max(0, t - dn(it.horizon)) : 0,
48
+ draft: draftOf(it.id, items, today),
49
+ })).sort((a, b) => b.days - a.days || a.ini.localeCompare(b.ini));
50
+ }
51
+
52
+ /** The first key result that waits on a source, and how many wait. PURE. okrFile: lib/okr-goals.js build(). */
53
+ function krOf(F) {
54
+ if (!F || !Array.isArray(F.objectives)) return null;
55
+ const wait = F.objectives.flatMap((o) => o.krs || []).filter((k) => k && !k.read && k.need && !k.proposed);
56
+ if (!wait.length) return null;
57
+ const k = wait[0];
58
+ return { waiting: wait.length, total: F.total || wait.length, read: F.read || 0, first: { token: k.token, title: k.title, need: k.need, goal: k.goal || null } };
59
+ }
60
+
61
+ module.exports = { verdictsOf, krOf, draftOf };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.525.0",
3
+ "version": "0.527.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.525.0",
9
+ "version": "0.527.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.525.0",
3
+ "version": "0.527.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -26,6 +26,7 @@ import * as Asks from './bd-asks.js';
26
26
  import * as WF from './bd-wf.js';
27
27
  import * as Pins from './bd-pin-card.js';
28
28
  import { holdSpans, holdWords, holdRange, md } from './bd-span.js';
29
+ import { stageName } from './stage-chip.js'; // W1: the one stage
29
30
 
30
31
  const SV = (d) => `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${d}"/></svg>`;
31
32
  const I = {
@@ -174,7 +175,7 @@ function findList() {
174
175
  const h = findHits(findQ), all = [...h.ini.map((r) => ({ r })), ...h.tk];
175
176
  findAt = Math.max(0, Math.min(findAt, all.length - 1));
176
177
  let i = -1;
177
- const ini = h.ini.map((r) => { i++; const s = V.statusLine(r); return `<button type="button" class="bd-mi${i === findAt ? ' hl' : ''}" data-findini="${esc(r.key)}"><i class="cz-dot ${s.tone}"></i><span class="tx"><span>${mark(r.title, findQ)}</span><span class="bd-amut">${esc([(r.track && r.track.label) || r.stage, s.text].filter(Boolean).join(' · '))}</span></span>${i === findAt ? '<span class="k">Enter</span>' : ''}</button>`; }).join('');
178
+ const ini = h.ini.map((r) => { i++; const s = V.statusLine(r); return `<button type="button" class="bd-mi${i === findAt ? ' hl' : ''}" data-findini="${esc(r.key)}"><i class="cz-dot ${s.tone}"></i><span class="tx"><span>${mark(r.title, findQ)}</span><span class="bd-amut">${esc([stageName(r) || r.stage, s.text].filter(Boolean).join(' · '))}</span></span>${i === findAt ? '<span class="k">Enter</span>' : ''}</button>`; }).join('');
178
179
  const tk = h.tk.map(({ k, r }) => { i++; return `<button type="button" class="bd-mi${i === findAt ? ' hl' : ''}" data-findtk="${esc(k.key)}" data-ini="${esc(r.key)}"><span class="bd-pill">${mark(k.key, findQ)}</span><span class="tx"><span>${mark(k.title, findQ)}</span><span class="bd-amut">${esc([k.blocked ? 'Blocked' : k.status, k.assignee].filter(Boolean).join(' · '))}</span></span>${i === findAt ? '<span class="k">Enter</span>' : ''}</button>`; }).join('');
179
180
  return (ini ? '<p class="bd-mlbl">Initiatives</p>' + ini : '') + (tk ? '<p class="bd-mlbl">Work-items in Jira</p>' + tk : '') + (findQ.trim() && !ini && !tk ? `<p class="bd-amut bd-mnote">Nothing on this Board matches “${esc(findQ.trim())}”.</p>` : '');
180
181
  }
@@ -13,6 +13,7 @@ import { esc, store } from './premium-shell.js';
13
13
  import { endStage } from './bd-pins.js';
14
14
  import { stageOfRow, wfOf, wfIcon, catOfRow, catLabel, CAT_ORDER } from './bd-wf.js';
15
15
  import { offTone, md, dy } from './bd-span.js';
16
+ import { stageName as oneStageName } from './stage-chip.js';
16
17
 
17
18
  /** Every column in its place, left to right: key, title, width in px. Initiative takes what the others leave of its 250 px. */
18
19
  export const COLS = [
@@ -73,15 +74,17 @@ export function nextMs(r, now = new Date()) {
73
74
  // a closed initiative (batch 26 item 5) is never late: its next milestone keeps its date and drops the days late
74
75
  return r.closed ? { ...x, late: false, over: 0 } : { ...x, late: over > 0, over: Math.max(0, over) };
75
76
  }
76
- const STATUS = { late: 'Late', risk: 'At risk', q: 'On hold' };
77
+ const STATUS = { late: 'Late', risk: 'At risk', q: 'On hold', none: 'No signal' };
78
+ /* workspace grill W2: a row with nothing read says No signal, grey; on track stays empty (zero is silence) */
79
+ const stTone = (r, now) => offTone(r, now) || (r.status === 'no signal' ? 'none' : null);
77
80
  const nextText = (r) => { const t = r.track || {}; if (typeof t.next === 'string') return t.next; if (t.next && t.next.text) return t.next.text; const l = (t.line || [])[0]; return l ? l.text || '' : ''; };
78
- const stageName = (r) => { const s = stageOfRow(r); return (s && s.name) || (r.track && r.track.label) || r.stage || ''; };
81
+ const stageName = (r) => oneStageName(r) || ((s) => (s && s.name) || '')(stageOfRow(r)); // W1: the one stage
79
82
 
80
83
  /** One cell's markup (never a <b>: the name is the row's only one). o: { now, avatar(name) } */
81
84
  function cell(r, k, o) {
82
85
  const now = o.now;
83
86
  if (k === 'stage') return `<span class="tg-c">${esc(stageName(r))}</span>`;
84
- if (k === 'status') { const t = offTone(r, now); return t ? `<span class="tg-c tg-cst ${t}"><i class="cz-dot ${t}" aria-hidden="true"></i>${STATUS[t]}</span>` : '<span class="tg-c"></span>'; }
87
+ if (k === 'status') { const t = stTone(r, now); return t ? `<span class="tg-c tg-cst ${t === 'none' ? 'q' : t}"><i class="cz-dot ${t === 'none' ? 'q' : t}" aria-hidden="true"></i>${STATUS[t]}</span>` : '<span class="tg-c"></span>'; }
85
88
  if (k === 'start') { const d = day(r.start); return `<span class="tg-c">${d ? esc(dy(d, now)) : ''}</span>`; }
86
89
  if (k === 'ms') {
87
90
  const m = nextMs(r, now);
@@ -99,7 +102,7 @@ function cell(r, k, o) {
99
102
  function words(r, keys, now) {
100
103
  return keys.map((k) => {
101
104
  if (k === 'stage') return stageName(r);
102
- if (k === 'status') { const t = offTone(r, now); return t ? STATUS[t] : ''; }
105
+ if (k === 'status') { const t = stTone(r, now); return t ? STATUS[t] : ''; }
103
106
  if (k === 'start') return day(r.start) ? 'starts ' + md(day(r.start)) : '';
104
107
  if (k === 'ms') { const m = nextMs(r, now); return m ? 'next ' + m.name + ' ' + md(m.d) + (m.late ? ', ' + m.over + (m.over === 1 ? ' day' : ' days') + ' late' : '') : ''; }
105
108
  if (k === 'wf') return wfOf(r).name;
@@ -111,7 +114,7 @@ function words(r, keys, now) {
111
114
  }).filter(Boolean).join(', ');
112
115
  }
113
116
 
114
- const tone = (r, now) => ({ late: 0, risk: 1, q: 2 })[offTone(r, now)] ?? 3;
117
+ const tone = (r, now) => ({ late: 0, risk: 1, q: 2, none: 3 })[stTone(r, now)] ?? 4;
115
118
  const str = (v) => String(v || '~');
116
119
  const SORTS = {
117
120
  title: (a, b) => String(a.title).localeCompare(String(b.title)),
@@ -39,6 +39,7 @@ import { openCharter as charterForm, renamed as charterRenamed } from './program
39
39
  import * as MSG from './ms-suggest.js';
40
40
  import * as MD from './metric-drafts.js'; // the goal Claude drafts when the charter has none (batch 15 item 1, canvas Iq9Program)
41
41
  import * as DB from './dash-view.js'; // the page model's parts: the reports and the proposals (batch 17 item 1)
42
+ import { stageName } from './stage-chip.js'; // W1: the one stage
42
43
  import * as PV from './prog-page.js'; // the one program page: the top and four tabs (batch 21 item 2, canvas RdProgL–RdProgP)
43
44
 
44
45
  const SV = (d, w) => `<svg viewBox="0 0 24 24" width="${w || 18}" height="${w || 18}" aria-hidden="true"><path d="${d}"/></svg>`;
@@ -225,7 +226,7 @@ const TREE_TASKS = 3; // an initiative shows its first 3 open tasks in the tre
225
226
  function iniNode(p, st, i, tasks) {
226
227
  const k = 'i:' + p.id + ':' + i.id, r = i.row, t = r.track || {}, here = rowsOf(i.ws) && rowsOf(i.ws).here;
227
228
  const name = here ? `<button type="button" class="x pp-open" data-pp="ini" data-ini="${esc(i.id)}" aria-haspopup="dialog">${esc(i.title)}</button>` : `<a class="x pp-open" href="${esc(boardHref(i.ws, i.id))}" title="Opens on its product's board">${esc(i.title)}</a>`;
228
- const stg = t.label || (STEPS[i.at] && STEPS[i.at].label) || i.stage;
229
+ const stg = stageName(r) || t.label || (STEPS[i.at] && STEPS[i.at].label) || i.stage; // W1: the one stage
229
230
  let kids = '';
230
231
  if (!fold.has(k)) {
231
232
  const you = youOf(i);
@@ -448,7 +449,7 @@ function textOf(p) {
448
449
  const st = programState(p, rowsOf), s = summaryOf(p, st);
449
450
  const lines = [p.title + (p.goal ? ' · ' + p.goal : ''), [s.line, s.meta].filter(Boolean).join(' · ')];
450
451
  for (const m of st.milestones) lines.push('Milestone: ' + m.name + ' · ' + m.day + (m.done ? ' · done' : ''));
451
- for (const i of st.inis) lines.push('- ' + i.title + ' · ' + ((i.row.track && i.row.track.label) || i.stage || ''));
452
+ for (const i of st.inis) lines.push('- ' + i.title + ' · ' + (stageName(i.row) || (i.row.track && i.row.track.label) || i.stage || ''));
452
453
  return lines.join('\n');
453
454
  }
454
455
  const titleOf = (p) => p.title + (p.goal ? ' · ' + p.goal : '');
@@ -47,6 +47,7 @@ import { esc } from './premium-shell.js';
47
47
  import { mergeClose } from './program-model.js';
48
48
  import { stagesOfRow } from './bd-wf.js';
49
49
  import { shownMs, endStage } from './bd-pins.js';
50
+ import { stageName } from './stage-chip.js'; // W1: the one stage
50
51
 
51
52
  const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
52
53
  const day = (v) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v || '')); return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null; };
@@ -566,7 +567,7 @@ function scaled(rows, M, o) {
566
567
  }
567
568
  const head = `<div class="tg-r tg-h"><div class="tg-n">${C ? C.head(back) : `<b>Initiative</b>${back}`}</div><div class="tg-p">${ticks}${pill}</div></div>`;
568
569
  // one line per initiative (PgTimeline): its status dot and its name; the owner and the step stay in the row's title for a pointer and a reader
569
- const one = (r, cls) => { const s = o.status(r), sub = [r.owner, r.track ? r.track.label : r.stage, s.text].filter(Boolean).join(' · ');
570
+ const one = (r, cls) => { const s = o.status(r), sub = [r.owner, stageName(r), s.text].filter(Boolean).join(' · ');
570
571
  // the dot keeps its place while it is silent, so every name starts at the same line (XcalmA1Desk)
571
572
  const dot = dotHtml(r, now) || '<i class="tg-nodot" aria-hidden="true"></i>', nm = `<b>${r.inProgram && o.memberIcon ? o.memberIcon(r.inProgram) : ''}${esc(r.title)}</b>`;
572
573
  // with columns, the Status cell carries the dot and the name has none (say it once)