create-pmos 0.47.0 → 0.48.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 (71) hide show
  1. package/package.json +1 -1
  2. package/payload/AGENTS-HUMAN.md +1 -1
  3. package/payload/AGENTS.md +1 -1
  4. package/payload/KIT-VERSION +1 -1
  5. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
  6. package/payload/mods/pmos-cockpit/README.md +4 -2
  7. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
  8. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
  9. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
  10. package/payload/mods/pmos-cockpit/hooks/register.tsx +37 -18
  11. package/payload/planning/evals/eval-calibration.md +1 -1
  12. package/payload/planning/okrs/health-budgets.md +1 -1
  13. package/payload/scripts/pmos-log.sh +18 -8
  14. package/payload/scripts/pmos-model.js +233 -2
  15. package/payload/scripts/status-facts.js +204 -0
  16. package/payload/scripts/workflow-state.js +52 -0
  17. package/payload/state/discoveries.md +1 -1
  18. package/workspace/README.md +2 -2
  19. package/workspace/bin/live-notes.js +133 -0
  20. package/workspace/design-parity.js +328 -21
  21. package/workspace/doc-category-bench.js +165 -0
  22. package/workspace/fixtures/doc-category-labelled.json +167 -0
  23. package/workspace/guide/initiatives-and-next-steps.md +15 -2
  24. package/workspace/guide/knowledge-graph.md +83 -1
  25. package/workspace/guide/layout.md +29 -2
  26. package/workspace/guide/proactive-engine.md +6 -2
  27. package/workspace/guide/validate-with-typesafe.md +16 -1
  28. package/workspace/lib/claims.js +51 -2
  29. package/workspace/lib/doc-category-corrections.js +113 -0
  30. package/workspace/lib/doc-category-writeback.js +110 -0
  31. package/workspace/lib/doc-category.js +225 -0
  32. package/workspace/lib/engine/pmos-model.js +233 -2
  33. package/workspace/lib/engine/status-facts.js +204 -0
  34. package/workspace/lib/engine/workflow-state.js +52 -0
  35. package/workspace/lib/jev-categorize.js +399 -0
  36. package/workspace/lib/jev-claims.js +59 -15
  37. package/workspace/lib/jev-route.js +129 -0
  38. package/workspace/lib/kg-cli.js +3 -1
  39. package/workspace/lib/kg.js +6 -1
  40. package/workspace/lib/learn.js +5 -3
  41. package/workspace/lib/live-notes.js +890 -0
  42. package/workspace/lib/typesafe-settings.js +10 -2
  43. package/workspace/package-lock.json +1 -1
  44. package/workspace/package.json +5 -4
  45. package/workspace/public/pm/app.js +148 -79
  46. package/workspace/public/pm/data.js +58 -0
  47. package/workspace/public/pm/fixture.js +40 -2
  48. package/workspace/public/pm/modes-live.js +216 -18
  49. package/workspace/public/pm/panel-cockpit.js +636 -0
  50. package/workspace/public/pm/panel-detail.js +210 -8
  51. package/workspace/public/pm/panel-initiatives.js +9 -9
  52. package/workspace/public/pm/panel-newsess.js +145 -86
  53. package/workspace/public/pm/panel-repos.js +34 -17
  54. package/workspace/public/pm/panel-tabs.js +145 -27
  55. package/workspace/public/pm/state.js +45 -4
  56. package/workspace/public/pm/tab-doc.js +244 -16
  57. package/workspace/public/pm/tab-term.js +11 -1
  58. package/workspace/public/pm/tokens.css +4 -3
  59. package/workspace/public/pm/view-addrepo.js +207 -0
  60. package/workspace/public/pm/view-files.js +197 -0
  61. package/workspace/public/pm/view-guide.js +141 -0
  62. package/workspace/public/pm/view-knowledge.js +225 -0
  63. package/workspace/public/pm/view-memory.js +122 -0
  64. package/workspace/public/pm/view-portfolio.js +153 -0
  65. package/workspace/public/pm/view-reports.js +190 -0
  66. package/workspace/public/pm/view-scheduled.js +161 -0
  67. package/workspace/public/pm/view-skills.js +216 -0
  68. package/workspace/public/pm/view-today.js +168 -0
  69. package/workspace/public/pm/view-validate.js +171 -0
  70. package/workspace/server.js +290 -7
  71. package/workspace/skills/live-notes.skill +58 -0
@@ -97,16 +97,24 @@ function getRepo(stateDir, root) {
97
97
  // jev-claim-checks (D80): also ask Jev about changed claim pairs after each check. Default OFF, separate from
98
98
  // `consent` and `watch`; a request needs all of key, consent, watch and this.
99
99
  jev: !!(r && r.jev === true),
100
+ // jev-doc-categorizer (D82): ask Jev to categorize documents the local rules leave undetermined. Default OFF, separate
101
+ // from every other switch; a request needs key, consent and this.
102
+ categorize: !!(r && r.categorize === true),
103
+ // status-from-artifacts Run 2 (D83): also send the documents tied to an initiative for two status questions
104
+ // (is it a stub; does a tied note contradict a requirement). Default OFF, and only read when `jev` is on too.
105
+ status_read: !!(r && r.status_read === true),
106
+ // prompt-model-routing: classify prompts with Jev to choose a model tier. Default OFF; a request needs key, consent and this.
107
+ route: !!(r && r.route === true),
100
108
  };
101
109
  }
102
110
 
103
- function setRepo(stateDir, root, { consent, exclude, watch, jev }) {
111
+ function setRepo(stateDir, root, { consent, exclude, watch, jev, categorize, route, status_read: statusRead }) {
104
112
  const cur = read(stateDir);
105
113
  const prev = getRepo(stateDir, root);
106
114
  const ex = Array.isArray(exclude)
107
115
  ? exclude.map((x) => String(x).trim().replace(/^\/+/, '')).filter((x) => x && !x.includes('..') && x.length <= 200).slice(0, MAX_EXCLUDES)
108
116
  : prev.exclude;
109
- const repos = { ...cur.repos, [root]: { consent: typeof consent === 'boolean' ? consent : prev.consent, exclude: ex, watch: typeof watch === 'boolean' ? watch : prev.watch, jev: typeof jev === 'boolean' ? jev : prev.jev } };
117
+ const repos = { ...cur.repos, [root]: { consent: typeof consent === 'boolean' ? consent : prev.consent, exclude: ex, watch: typeof watch === 'boolean' ? watch : prev.watch, jev: typeof jev === 'boolean' ? jev : prev.jev, categorize: typeof categorize === 'boolean' ? categorize : prev.categorize, route: typeof route === 'boolean' ? route : prev.route, status_read: typeof statusRead === 'boolean' ? statusRead : prev.status_read } };
110
118
  return write(stateDir, { key: cur.key, repos });
111
119
  }
112
120
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.47.0",
3
+ "version": "0.48.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",
@@ -10,12 +10,13 @@
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
12
  "test": "node test.js && npm run test:modules",
13
- "test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node jev-claims.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js && node kg.test.js && node proactive-prepare.test.js && node learn.test.js && node session-flag.test.js",
13
+ "test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node status-facts-panel.test.js && node jev-claims.test.js && node jev-categorize.test.js && node jev-status.test.js && node jev-route.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js && node kg.test.js && node doc-category.test.js && node doc-category-corrections.test.js && node proactive-prepare.test.js && node learn.test.js && node session-flag.test.js && node live-notes.test.js",
14
14
  "test:ui": "node ui-test.js",
15
- "test:parity": "node design-parity.js --states",
15
+ "test:parity": "node design-parity.js --board=WsA",
16
+ "test:parity:four-panel": "node design-parity.js --states",
16
17
  "test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
17
18
  "doctor": "node doctor.js",
18
- "test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js && node pm-r18-test.js && node pm-r19-test.js && node pm-r20-test.js && node pm-r21-test.js && node pm-r22-test.js && node pm-r23-test.js && node pm-r24-test.js && node pm-r25-test.js && node pm-r26-test.js",
19
+ "test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js && node pm-r18-test.js && node pm-r19-test.js && node pm-r20-test.js && node pm-r21-test.js && node pm-r22-test.js && node pm-r23-test.js && node pm-r24-test.js && node pm-r25-test.js && node pm-r26-test.js && node pm-doc-category-test.js && node pm-r27-test.js && node pm-r28-test.js && node pm-r29-today-test.js && node pm-r29-portfolio-test.js && node pm-r29-knowledge-test.js && node pm-r29-memory-test.js && node pm-r29-skills-scheduled-test.js && node pm-r29-validate-guide-test.js && node pm-r29-reports-addrepo-test.js && node pm-r29-filespad-test.js && node pm-r29-newsession-alignment-test.js",
19
20
  "test:offline": "node offline-tarball.test.js"
20
21
  },
21
22
  "engines": {
@@ -22,12 +22,17 @@
22
22
  */
23
23
 
24
24
  import { el, clear, setChildren } from './dom.js';
25
- import { state, setState, subscribe, flush, FIXTURE } from './state.js';
25
+ import { state, setState, subscribe, flush, FIXTURE, LEGACY_LAYOUT_FLAG } from './state.js';
26
26
  /* The phone surface (≤760px). It owns its own chrome and decides which single panel is visible;
27
27
  * the shell's only part in it is to keep all four hosts mounted below the breakpoint and to hand
28
28
  * the module a repaint. See pm/mobile.js. */
29
29
  import { isMobile, mountMobile, applyMobile } from './mobile.js';
30
30
 
31
+ /* The LEGACY layout is the three-column shell of column-layout: repositories with the list under them, the detail, the tabs, and
32
+ * a list that a mode (skills, memory …) replaces. A phone still draws it until Run 4 of workspace-cockpit-nav, and `?layout=legacy`
33
+ * draws it on a desktop so the suites of the panels it uses keep guarding them. It is not a setting: nothing offers it. */
34
+ const legacyLayout = () => LEGACY_LAYOUT_FLAG || isMobile();
35
+
31
36
  /* ── derived labels (the prototype's two title maps, lines 1409 / 1699) ─────────────────────── */
32
37
 
33
38
  const MODE_TITLE = {
@@ -35,30 +40,38 @@ const MODE_TITLE = {
35
40
  knowledge: 'knowledge', agent: 'agents', memory: 'memory', connect: 'connections', focus: 'focus',
36
41
  portfolio: 'portfolio', guide: 'guide', validate: 'validate', today: 'today',
37
42
  };
43
+ const modeTitle = () => MODE_TITLE[state.mode] || 'initiatives';
38
44
  const DETAIL_TITLE = {
39
45
  init: 'initiative', flow: 'workflow', sched: 'scheduled task', skill: 'skill', know: 'decision',
40
46
  agent: 'agent', mem: 'memory', prof: 'claude profile', intg: 'connection',
41
47
  addprod: 'add product', new: 'file new',
42
48
  };
43
49
 
44
- const modeTitle = () => MODE_TITLE[state.mode] || 'initiatives';
45
50
  const detailTitle = () => DETAIL_TITLE[(state.detail && state.detail.kind) || ''] || 'detail';
46
51
 
47
52
  /* Panels 1 and 2 are resizable: a drag handle on each right edge. The width is clamped, remembered
48
53
  * (state.js PERSIST) and reset by a double-click. Before this the columns were a fixed 210px / 280px and a
49
54
  * long repository or branch name was simply cut off. */
50
- const W0 = { min: 220, max: 560, def: 280 };
55
+ const W_LEGACY = { min: 220, max: 560, def: 280 };
56
+ /* The cockpit's list is 288px by default (the mockup's) and is resized and clamped the same way. */
57
+ const W_COCKPIT = { min: 220, max: 560, def: 288 };
58
+ const W0 = { get min() { return W_LEGACY.min; }, get max() { return W_LEGACY.max; }, get def() { return legacyLayout() ? W_LEGACY.def : W_COCKPIT.def; } };
51
59
  const clampW = (v, r) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(r.max, Math.max(r.min, Math.round(v))) : r.def);
52
60
  const col0w = () => clampW(state.col0W, W0);
53
61
 
54
- /* THREE columns (column-layout): navigation, detail, tabs. Navigation is the repository list with the list of the
62
+ /* workspace-cockpit-nav: on a desktop the body is FOUR columns — the 64px repository rail (a 65px track: the mockup draws the rail
63
+ * 64px wide with its 1px border outside that width, so the list starts at x=65), the list (288px), the detail (about
64
+ * 470px) and the tabs (the rest) — and the hosts keep the order they had. The rail is always drawn; `col1` folds the list to a 36px
65
+ * strip and `col2` folds the detail. The three-column shell below is what a PHONE still draws until Run 4.
66
+ *
67
+ * THREE columns (column-layout): navigation, detail, tabs. Navigation is the repository list with the list of the
55
68
  * current mode UNDER it (initiatives, or memory, skills, knowledge …). The detail opens in the second column, the
56
69
  * tabs fill the rest. The DOM still holds four hosts in the same order — repos, list, detail, tabs — and the grid
57
70
  * puts the list host under the repositories host (see placeHosts). Folded away, the navigation column is a 36px
58
71
  * rail; the list alone folds to a one-line bar under the repositories. */
59
- const gridCols = () =>
60
- (state.col0 ? col0w() + 'px' : '36px') + ' ' +
61
- (state.col2 ? 'minmax(420px,560px)' : '36px') + ' minmax(0,1fr)';
72
+ const gridCols = (legacy, focus) => focus ? 'minmax(0,1fr) 360px' : legacy
73
+ ? (state.col0 ? col0w() + 'px' : '36px') + ' ' + (state.col2 ? 'minmax(420px,560px)' : '36px') + ' minmax(0,1fr)'
74
+ : '65px ' + (state.col1 ? col0w() + 'px' : '36px') + ' ' + (state.col2 ? 'minmax(420px,470px)' : '36px') + ' minmax(0,1fr)';
62
75
 
63
76
  /** The right-hand 7px of a column is its drag handle. It is a hit-test on the host, not an extra element:
64
77
  * the body grid is exactly four tracks and the panels repaint their own children. `key` is the state
@@ -142,43 +155,58 @@ function runningCount() {
142
155
  */
143
156
  const root = el('div', {
144
157
  'data-pm-theme': state.theme,
145
- style: 'display:grid;grid-template-rows:44px minmax(0,1fr) 26px;height:100vh;overflow:hidden;background:var(--background);color:var(--foreground);font-family:var(--sans);font-size:13px;line-height:1.5',
158
+ style: 'display:grid;grid-template-rows:48px minmax(0,1fr) 28px;height:100vh;overflow:hidden;background:var(--background);color:var(--foreground);font-family:var(--sans);font-size:13px;line-height:1.5',
146
159
  });
147
160
 
161
+ /* The legacy layout has no rail to hang the menu from, so its theme control stays where it was: in the top bar. */
148
162
  const themeBtn = el('button', {
149
163
  onClick: () => setState((s) => ({ theme: s.theme === 'dark' ? 'light' : 'dark' })),
150
- style: 'margin-left:auto;font-size:12px;color:oklch(0.85 0.01 214);padding:5px 12px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%);min-height:30px',
151
- hover: 'border-color:oklch(0.56 0.021 213.5)',
164
+ style: 'font-size:12px;color:var(--foreground);padding:5px 12px;border:1px solid var(--input);min-height:30px',
165
+ hover: 'border-color:var(--muted-foreground)',
152
166
  }, 'theme · dark');
167
+ const crumb = el('span', { 'data-top': 'crumb', style: 'display:flex;align-items:center;gap:8px;color:var(--muted-foreground);min-width:0;overflow:hidden;white-space:nowrap' });
168
+ const jump = el('button', {
169
+ onClick: () => { if (legacyLayout()) focusSearch(); else setState({ paletteOpen: true, menuOpen: false }); },
170
+ 'data-top': 'jump', 'aria-label': 'jump to anything',
171
+ style: 'margin-left:auto;display:flex;align-items:center;gap:8px;box-sizing:content-box;border:1px solid var(--input);border-radius:8px;height:32px;padding:0 12px;min-width:300px;color:var(--muted-foreground);font:13px var(--sans);line-height:1.45',
172
+ hover: 'background:var(--accent)',
173
+ html: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>Jump to anything<span style="margin-left:auto;font:11px var(--mono)">⌘K</span>',
174
+ });
175
+ const liveBadge = el('span', { style: 'font:500 11px var(--sans);border:1px solid transparent;border-radius:999px;padding:0 8px;line-height:18px;white-space:nowrap;background:color-mix(in oklab, var(--success) 16%, transparent);color:var(--success)' }, '● live · local');
153
176
 
154
177
  const topBar = el('div', {
155
- style: 'display:flex;align-items:center;gap:12px;padding:0 12px;background:oklch(0.1 0.004 228.8);color:oklch(0.987 0.002 197.1);border-bottom:1px solid oklch(1 0 0 / 10%)',
178
+ 'data-shell': 'top',
179
+ // its fill is set per layout in render(): none on the cockpit (as on the mockup), --background on the legacy shell
180
+ style: 'display:flex;align-items:center;gap:14px;padding:0 16px;border-bottom:1px solid var(--border);color:var(--foreground);min-width:0;line-height:1.45',
156
181
  },
157
- el('span', { style: 'display:grid;place-items:center;width:24px;height:24px;background:oklch(0.488 0.243 264.376);font-size:14px;line-height:1' }, '◎'),
158
- el('span', { style: 'font-weight:600;letter-spacing:.02em' }, 'PMOS'),
159
- el('span', { style: 'color:oklch(0.723 0.014 214.4);font-size:12px' }, 'PM Operating System · workspace'),
160
- el('span', { style: 'display:flex;gap:6px;align-items:baseline;font-family:var(--mono);font-size:11px;color:oklch(0.76 0.17 152)' },
161
- el('span', { style: 'font-size:9px' }, '●'), 'LIVE · local'),
162
- themeBtn,
182
+ el('span', { style: 'font-weight:600;font-size:15px' }, 'PMOS'),
183
+ crumb,
184
+ jump,
185
+ liveBadge,
163
186
  );
164
187
 
165
- const statusLeft = el('span', null, 'local · persistent server · 0 sessions running');
188
+ const statusLeft = el('span', null, 'local · 0 sessions running');
166
189
  const statusBar = el('div', {
167
- style: 'display:flex;align-items:center;padding:0 12px;background:oklch(0.1 0.004 228.8);color:oklch(0.6 0.02 213);font-size:11px;border-top:1px solid oklch(1 0 0 / 10%)',
190
+ 'data-shell': 'status',
191
+ style: 'display:flex;align-items:center;justify-content:space-between;padding:0 14px;color:var(--muted-foreground);font:11px var(--mono);border-top:1px solid var(--border)',
168
192
  },
169
193
  statusLeft,
170
- el('span', { style: 'margin-left:auto' }, 'sessions survive detach · ⌘K search · Esc closes tab'),
194
+ el('span', null, '⌘K jump · Esc closes tab'),
171
195
  );
196
+ /* The rail's menu is drawn into this host, a sibling of the body so it can float over all four columns. */
197
+ const menuHost = el('div', { 'data-shell': 'menu', style: 'display:contents' });
198
+ const paletteHost = el('div', { id: 'pm-palette', 'data-shell': 'palette', style: 'display:contents' });
172
199
 
173
200
  /* The body grid's own geometry, in one place so the initial build and every repaint below cannot
174
201
  * disagree. The implicit ROW is stated explicitly as `minmax(0,1fr)` for the same reason the root's
175
202
  * middle track is: an auto row is floored at its items' min-content height, and one tall panel
176
203
  * would push the grid taller than the box it was given. */
177
- // Two rows on a desktop (repositories, then the list under them), one on a phone, where the hosts are shown one at a time.
178
- const BODY_GRID_STYLE = (phone) =>
179
- 'display:grid;grid-template-columns:' + gridCols() + ';grid-template-rows:' + (phone ? 'minmax(0,1fr)' : 'auto minmax(0,1fr)') + ';min-height:0;overflow:hidden';
204
+ // A cockpit desktop is one row of four columns; the legacy layout is two rows (repositories, then the list under them) on a desktop
205
+ // and one on a phone, where the hosts are shown one at a time.
206
+ const BODY_GRID_STYLE = (legacy, phone, focus) =>
207
+ 'display:grid;grid-template-columns:' + gridCols(legacy, focus) + ';grid-template-rows:' + (legacy && !phone ? 'auto minmax(0,1fr)' : 'minmax(0,1fr)') + ';min-height:0;overflow:hidden';
180
208
 
181
- const bodyGrid = el('div', { style: BODY_GRID_STYLE(false) });
209
+ const bodyGrid = el('div', { 'data-shell': 'body', style: BODY_GRID_STYLE(false, false) });
182
210
 
183
211
  /** The collapsed 36px strip: a full-height button with a rotated vertical-rl label. */
184
212
  function makeRail(flag) {
@@ -191,9 +219,10 @@ function makeRail(flag) {
191
219
  return { btn, label };
192
220
  }
193
221
 
194
- const rail0 = makeRail('col0');
195
- const rail2 = makeRail('col2');
196
- /* The list under the repositories, folded: one line, not a column. */
222
+ const rail0 = makeRail('col0'); // the phone's folded navigation strip (legacy layout)
223
+ const rail1 = makeRail('col1'); // a desktop's folded list
224
+ const rail2 = makeRail('col2'); // a folded detail
225
+ /* The list under the repositories, folded: one line, not a column (legacy layout). */
197
226
  const listBar = el('button', {
198
227
  onClick: () => setState((s) => ({ col1: !s.col1 })),
199
228
  title: 'show the list',
@@ -202,8 +231,7 @@ const listBar = el('button', {
202
231
  });
203
232
  rail0.label.textContent = 'repositories ⟩';
204
233
  /* overhaul-r1 R4: in live mode the collapsed repositories rail also carries one glyph per repository
205
- * (panel-repos.js#railGlyphs), so a repository is selectable without expanding the panel. The
206
- * toggle keeps its label and stays the rest of the strip. */
234
+ * (panel-repos.js#railGlyphs). Only the phone's legacy layout folds the navigation column now; a desktop's rail never folds. */
207
235
  const rail0Host = el('div', {
208
236
  role: 'button', tabindex: '0', title: 'show the repositories panel',
209
237
  onClick: () => setState((s) => ({ col0: !s.col0 })),
@@ -213,27 +241,29 @@ const rail0Host = el('div', {
213
241
  });
214
242
 
215
243
  /* `min-height:0` on all four: a grid item's automatic minimum is its content, so a panel taller
216
- * than the viewport would otherwise stretch the row it sits in rather than scroll inside it. The
217
- * two that already carry `overflow-y:auto` get it for free under the spec — stated anyway, because
218
- * "this one is safe because of a rule elsewhere in the same line" is how the next edit breaks it. */
219
- const hostRepos = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0;min-height:0;overflow-y:auto' });
220
- const hostInits = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);border-top:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0' });
221
- makeEdgeDrag(hostRepos, 'col0W', W0, col0w);
244
+ * than the viewport would otherwise stretch the row it sits in rather than scroll inside it. */
245
+ const HOST_STYLE = {
246
+ repos: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0;min-height:0;overflow-y:auto',
247
+ inits: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0',
248
+ initsDesk: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;gap:12px;padding:12px;box-sizing:border-box;overflow:hidden;min-width:0;min-height:0',
249
+ };
250
+ const hostRepos = el('div', { id: 'pm-rail', style: HOST_STYLE.repos });
251
+ const hostInits = el('div', { id: 'pm-list', style: HOST_STYLE.initsDesk });
222
252
  makeEdgeDrag(hostInits, 'col0W', W0, col0w);
223
- const hostDetail = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0;display:flex;flex-direction:column' });
224
- const hostTabs = el('div', { style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden' });
253
+ const hostDetail = el('div', { id: 'pm-detail', style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0;display:flex;flex-direction:column' });
254
+ const hostTabs = el('div', { id: 'pm-tabs', style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden' });
225
255
 
226
- setChildren(root, [topBar, bodyGrid, statusBar]);
256
+ setChildren(root, [topBar, bodyGrid, statusBar, menuHost, paletteHost]);
227
257
 
228
258
  /* ── panel modules, loaded at boot and tolerated when absent ────────────────────────────────── */
229
259
 
230
- const mods = { repos: null, inits: null, detail: null, tabs: null, data: null, fixture: null };
260
+ const mods = { repos: null, inits: null, detail: null, tabs: null, data: null, fixture: null, cockpit: null };
231
261
 
232
262
  async function loadModules() {
233
263
  const want = [
234
264
  ['repos', './panel-repos.js'], ['inits', './panel-initiatives.js'],
235
265
  ['detail', './panel-detail.js'], ['tabs', './panel-tabs.js'],
236
- ['data', './data.js'], ['fixture', './fixture.js'],
266
+ ['data', './data.js'], ['fixture', './fixture.js'], ['cockpit', './panel-cockpit.js'],
237
267
  ];
238
268
  await Promise.all(want.map(async ([key, path]) => {
239
269
  try { mods[key] = await import(path); } catch { mods[key] = null; }
@@ -249,10 +279,7 @@ function paint(mod, name, host) {
249
279
 
250
280
  /* ── render ────────────────────────────────────────────────────────────────────────────────── */
251
281
 
252
- /* The rail is still ONE control that re-opens the panel, its first child is still the rotated
253
- * label, and its text is still exactly `repositories ⟩` — the glyphs draw their initials from a data
254
- * attribute (tokens.css) and name themselves through aria-label, and CSS `order` lifts them above
255
- * the label. A glyph click stops at the glyph: it selects, it does not expand. */
282
+ /* The phone's folded navigation strip (legacy layout): one control that re-opens the panel, its first child the rotated label. */
256
283
  function railRepos() {
257
284
  const glyphs = mods.repos && typeof mods.repos.railGlyphs === 'function' ? mods.repos.railGlyphs() : [];
258
285
  if (!glyphs.length) {
@@ -265,15 +292,15 @@ function railRepos() {
265
292
  return rail0Host;
266
293
  }
267
294
 
268
- /* Where each host sits in the grid. Desktop: column 1 holds the repositories (row 1) and the list under them (row 2); the
269
- * detail and the tabs span both rows. The repositories block is capped so a long list of repositories cannot push the
270
- * list off the screen; it scrolls inside its cap. A folded column is a rail spanning both rows. On a phone the placement
271
- * is cleared and mobile.css decides which single host is shown. */
272
- function placeHosts(phone, kids) {
295
+ /* Where each host sits in the LEGACY grid. Column 1 holds the repositories (row 1) and the list under them (row 2); the detail and the
296
+ * tabs span both rows. The repositories block is capped so a long list of repositories cannot push the list off the screen. A folded
297
+ * column is a rail spanning both rows. On a phone the placement is cleared and mobile.css decides which single host is shown. On a
298
+ * cockpit desktop every host has its own column and nothing is placed. */
299
+ function placeHosts(legacy, phone, kids) {
273
300
  const cell = (el0, col, row) => { if (el0) { el0.style.gridColumn = col; el0.style.gridRow = row; } };
274
301
  const clear0 = (el0) => { el0.style.gridColumn = ''; el0.style.gridRow = ''; el0.style.maxHeight = ''; };
275
- for (const h of [hostRepos, hostInits, hostDetail, hostTabs, listBar, rail0.btn, rail0Host, rail2.btn]) clear0(h);
276
- if (phone) return;
302
+ for (const h of [hostRepos, hostInits, hostDetail, hostTabs, listBar, rail0.btn, rail0Host, rail1.btn, rail2.btn]) clear0(h);
303
+ if (!legacy || phone) return;
277
304
  if (state.col0) {
278
305
  cell(hostRepos, '1', '1');
279
306
  hostRepos.style.maxHeight = 'calc((100vh - 70px) * .42)';
@@ -286,44 +313,76 @@ function placeHosts(phone, kids) {
286
313
  cell(hostTabs, '3', '1 / 3');
287
314
  }
288
315
 
316
+ let lastLegacy = null;
289
317
  function render() {
290
318
  root.setAttribute('data-pm-theme', state.theme);
291
- themeBtn.textContent = state.theme === 'dark' ? 'theme · dark' : 'theme · light';
319
+ const phone = isMobile();
320
+ const legacy = legacyLayout();
321
+ // the mockup's body line-height is 1.45; the legacy shell's panels are drawn for 1.5
322
+ root.style.lineHeight = legacy ? '1.5' : '1.45';
323
+ // the cockpit's bars are the mockup's 48px and 28px with their 1px rule outside that height (as its rail is 64px plus its rule);
324
+ // the legacy shell keeps 48px and 28px rule included. A phone's stylesheet replaces the rows altogether.
325
+ root.style.gridTemplateRows = legacy ? '48px minmax(0,1fr) 28px' : '49px minmax(0,1fr) 29px';
326
+ // the cockpit's bars have no fill of their own (the root's --background shows through, as on the mockup); the legacy shell's do
327
+ topBar.style.background = legacy ? 'var(--background)' : '';
328
+ statusBar.style.background = legacy ? 'var(--background)' : '';
329
+ // A cockpit desktop never shows a list mode in the list: it opens as a view tab (panel-cockpit.js)
330
+ if (!legacy && mods.cockpit && typeof mods.cockpit.absorbMode === 'function' && mods.cockpit.absorbMode()) return;
331
+
292
332
  // overhaul-r1 R2.3: the active tab's line of work, when it is not the main tree
293
333
  const actTab = (state.tabs || []).find((t) => t.id === state.active);
294
334
  const br = actTab && mods.tabs && typeof mods.tabs.tabBranch === 'function' ? mods.tabs.tabBranch(actTab, true) : null;
295
- statusLeft.textContent = 'local · persistent server · ' + runningCount() + ' sessions running' + (br ? ' · ⎇ ' + br : '');
335
+ statusLeft.textContent = 'local · ' + runningCount() + ' sessions running' + (br ? ' · ⎇ ' + br : '');
336
+ const parts = mods.cockpit && typeof mods.cockpit.breadcrumb === 'function' ? mods.cockpit.breadcrumb() : [];
337
+ setChildren(crumb, parts);
338
+ themeBtn.textContent = state.theme === 'dark' ? 'theme · dark' : 'theme · light';
339
+ if (legacy && themeBtn.parentNode !== topBar) topBar.appendChild(themeBtn);
340
+ if (!legacy && themeBtn.parentNode === topBar) topBar.removeChild(themeBtn);
296
341
 
297
- // Panel 1's rail is a constant; panels 2 and 3 name whatever is behind them, so a PM who folded
298
- // a panel away can still see what they folded.
342
+ // Folded strips name whatever is behind them, so a PM who folded a panel away can still see what they folded.
299
343
  listBar.textContent = '▸ ' + modeTitle();
344
+ rail1.label.textContent = 'initiatives ⟩';
300
345
  rail2.label.textContent = detailTitle() + ' ⟩';
301
346
 
302
- const phone = isMobile();
303
- bodyGrid.setAttribute('style', BODY_GRID_STYLE(phone));
304
-
305
- // On a phone there are no rails to collapse into and no second column to fold away: the four
306
- // hosts all stay mounted, one is DISPLAYED (pm/mobile.css picks it from `data-mpanel`), and the
307
- // PM's desktop arrangement — col0/col1/col2 — is left exactly as they set it. Mounting all four
308
- // is also what keeps a live terminal alive: a host dropped from this list would be removed from
309
- // the document, and panel 4's xterm does not survive that.
310
- const listShown = phone || (state.col0 && state.col1);
311
- const listHost = phone || state.col1 ? hostInits : listBar;
312
- const kids = phone ? [hostRepos, hostInits, hostDetail, hostTabs] : [
313
- state.col0 ? hostRepos : railRepos(),
314
- // with the navigation column folded away the list goes with it
315
- state.col0 ? listHost : null,
316
- state.col2 ? hostDetail : rail2.btn,
317
- hostTabs,
318
- ].filter(Boolean);
347
+ if (lastLegacy !== legacy) {
348
+ lastLegacy = legacy;
349
+ hostInits.setAttribute('style', legacy ? HOST_STYLE.inits + ';border-top:1px solid var(--border)' : HOST_STYLE.initsDesk);
350
+ // the cockpit's rail is the mockup's centred column; the legacy repositories panel draws its own rows edge to edge
351
+ hostRepos.style.alignItems = legacy ? '' : 'center';
352
+ hostRepos.style.gap = legacy ? '' : '10px';
353
+ hostRepos.style.padding = legacy ? '' : '12px 0';
354
+ }
355
+ const focus = !legacy && !phone && !!state.layoutFocus;
356
+ bodyGrid.setAttribute('style', BODY_GRID_STYLE(legacy, phone, focus));
357
+
358
+ // On a phone there are no rails to collapse into and no second column to fold away: the four hosts all stay mounted, one is
359
+ // DISPLAYED (pm/mobile.css picks it from `data-mpanel`), and the PM's desktop arrangement is left exactly as they set it. Mounting
360
+ // all four keeps a live terminal alive: a host dropped from this list would be removed from the document.
361
+ let kids;
362
+ if (phone) kids = [hostRepos, hostInits, hostDetail, hostTabs];
363
+ else if (legacy) {
364
+ const listHost = state.col1 ? hostInits : listBar;
365
+ kids = [state.col0 ? hostRepos : railRepos(), state.col0 ? listHost : null, state.col2 ? hostDetail : rail2.btn, hostTabs].filter(Boolean);
366
+ } else if (focus) kids = [hostDetail, hostTabs]; // Focus: the tabs and a 360px detail; hostTabs is never moved, only the others come and go
367
+ else kids = [hostRepos, state.col1 ? hostInits : rail1.btn, state.col2 ? hostDetail : rail2.btn, hostTabs];
319
368
  setChildren(bodyGrid, kids);
320
- placeHosts(phone, kids);
321
-
322
- if (phone || state.col0) paint(mods.repos, 'renderRepos', hostRepos);
323
- if (listShown) paint(mods.inits, 'renderInitiatives', hostInits);
324
- if (phone || state.col2) paint(mods.detail, 'renderDetail', hostDetail);
369
+ placeHosts(legacy, phone, kids);
370
+ if (focus) { hostTabs.style.gridColumn = '1'; hostTabs.style.gridRow = '1'; hostDetail.style.gridColumn = '2'; hostDetail.style.gridRow = '1'; }
371
+
372
+ if (legacy) {
373
+ if (phone || state.col0) paint(mods.repos, 'renderRepos', hostRepos);
374
+ if (phone || (state.col0 && state.col1)) paint(mods.inits, 'renderInitiatives', hostInits);
375
+ if (phone || state.col2) paint(mods.detail, 'renderDetail', hostDetail);
376
+ } else if (focus) {
377
+ paint(mods.detail, 'renderDetail', hostDetail);
378
+ } else {
379
+ paint(mods.cockpit, 'renderRail', hostRepos);
380
+ if (state.col1) paint(mods.cockpit, 'renderWorkList', hostInits);
381
+ if (state.col2) paint(mods.detail, 'renderDetail', hostDetail);
382
+ }
325
383
  paint(mods.tabs, 'renderTabs', hostTabs);
326
-
384
+ paint(mods.cockpit, 'renderMenu', menuHost);
385
+ paint(mods.cockpit, 'renderPalette', paletteHost);
327
386
  applyMobile();
328
387
  }
329
388
 
@@ -338,9 +397,19 @@ function focusSearch() {
338
397
  function onKeyDown(e) {
339
398
  if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
340
399
  e.preventDefault();
341
- setState({ col1: true }, focusSearch);
400
+ // the cockpit's ⌘K is the palette; the legacy layout (and a phone) keep focusing the list's filter
401
+ if (legacyLayout()) setState({ col1: true, menuOpen: false }, focusSearch);
402
+ else setState({ paletteOpen: !state.paletteOpen, menuOpen: false });
403
+ return;
404
+ }
405
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !legacyLayout()) {
406
+ e.preventDefault();
407
+ if (mods.cockpit && typeof mods.cockpit.toggleFocus === 'function') mods.cockpit.toggleFocus();
342
408
  return;
343
409
  }
410
+ if (e.key === 'Escape' && state.paletteOpen) { setState({ paletteOpen: false }); return; }
411
+ if (e.key === 'Escape' && state.menuOpen) { setState({ menuOpen: false }); return; }
412
+ if (e.key === 'Escape' && state.layoutFocus) { setState({ layoutFocus: false }); return; }
344
413
  if (e.key === 'Escape' && !/INPUT|TEXTAREA/.test(e.target && e.target.tagName)) {
345
414
  // Verbatim from the prototype: Esc closes the ACTIVE tab, the last remaining tab takes over,
346
415
  // and a split pointing at the closed tab is dropped rather than left dangling.
@@ -336,6 +336,55 @@ function answerFor(row) {
336
336
  };
337
337
  }
338
338
 
339
+ /**
340
+ * The status-from-artifacts lines, derived by the engine (scripts/status-facts.js) and only worded
341
+ * here. Each is a flag beside the ladder with its basis. None is a step: nothing here changes a
342
+ * phase, a dot or a count, and a check that could not run says so instead of staying silent.
343
+ */
344
+ function factsFor(row) {
345
+ if (!row) return [];
346
+ const out = [];
347
+ const sa = row.since_approval;
348
+ if (sa) {
349
+ if (sa.status === 'unknown') out.push({ tone: 'info', label: 'Since approval', text: 'Cannot tell. ' + (sa.reason || '') });
350
+ else {
351
+ for (const a of sa.approvals || []) {
352
+ const what = a.kind === 'contract' ? 'the contract was approved' : 'you accepted it';
353
+ if (a.status === 'unknown') out.push({ tone: 'info', label: 'Since approval', text: 'Cannot tell for ' + what + '. ' + (a.reason || '') });
354
+ else if (!(a.changed || []).length) out.push({ tone: 'ok', label: 'Since approval', text: 'No spec file changed after ' + what + '.' });
355
+ else {
356
+ const files = [...new Set(a.changed.map((x) => x.file.split('/').pop()))].join(', ');
357
+ const last = a.changed[a.changed.length - 1];
358
+ out.push({ tone: 'warn', label: 'Since approval', text: files + ' changed after ' + what + ' (' + a.changed_total + ' commit' + (a.changed_total === 1 ? '' : 's') + ', latest ' + last.sha + (last.date ? ' on ' + last.date : '') + ').' });
359
+ }
360
+ }
361
+ }
362
+ }
363
+ const cv = row.coverage;
364
+ if (cv) {
365
+ if (cv.status === 'unknown') out.push({ tone: 'info', label: 'Coverage', text: 'Cannot tell. ' + (cv.reason || '') });
366
+ else if (!cv.uncited.length && !cv.orphan_citations.length) out.push({ tone: 'ok', label: 'Coverage', text: 'The rubric cites all ' + cv.requirements + ' requirement ids.' });
367
+ else {
368
+ const bits = [];
369
+ if (cv.uncited.length) bits.push(cv.uncited.join(', ') + ' not cited in the rubric');
370
+ if (cv.orphan_citations.length) bits.push(cv.orphan_citations.join(', ') + ' cited but not in the PRD');
371
+ out.push({ tone: 'warn', label: 'Coverage', text: bits.join('; ') + '. A citation is not a grade.' });
372
+ }
373
+ }
374
+ if (row.record_says) out.push({ tone: 'warn', label: 'Record says', text: 'The status line ' + row.record_says.reason + '. The ladder is not changed.' });
375
+ return out;
376
+ }
377
+
378
+ /**
379
+ * D83 — what Jev suggests about the documents tied to this initiative. A suggestion, in words, with the model that
380
+ * gave it. It is never a step: it is listed after the facts and nothing reads it to colour a phase.
381
+ */
382
+ function suggestionLines(sg) {
383
+ if (!sg) return [];
384
+ if (sg.ok === false) return [{ tone: 'info', label: 'Documents suggest', text: 'Cannot tell. ' + (sg.reason || '') }];
385
+ return (sg.items || []).map((x) => ({ tone: 'info', label: 'Documents suggest', text: (x.sentence || (x.doc + ': ' + x.kind)) + ' A suggestion, not a check' + (x.model ? ' (' + x.model + ')' : '') + '.' }));
386
+ }
387
+
339
388
  /** The Review-Gate line, read off the `review` rung — no verdict is not a pass. */
340
389
  function evalFor(ladderSteps) {
341
390
  const r = (ladderSteps || []).find((s) => s.key === 'review');
@@ -405,6 +454,12 @@ function artsFor(payload) {
405
454
  });
406
455
  }
407
456
 
457
+ /** The rung that blocks the initiative, as the card draws it: the engine's label, evidence and next step. Null when none blocks. */
458
+ function blockerOf(row) {
459
+ const s = row && Array.isArray(row.steps) ? row.steps.find((x) => x && x.blocking === true && x.status === 'blocked') : null;
460
+ return s ? { key: s.key, label: s.label || s.key, evidence: s.evidence || '', next: s.next || '' } : null;
461
+ }
462
+
408
463
  /** The full record panel 3 renders, from one `/api/initiative` payload. */
409
464
  function recordFrom(payload) {
410
465
  if (!payload || payload.ok === false) {
@@ -454,11 +509,14 @@ function recordFrom(payload) {
454
509
  arts: artsFor(payload),
455
510
  // R4/R5/R6 — what remains to acceptance, the conversations that belong to it, and the files git says
456
511
  // were changed for it. Each arrives already derived by the server; this layer only carries them.
512
+ // workspace-cockpit-nav R2: the first rung the ladder says is blocking, or null. Nothing is invented — the words are the engine's.
513
+ blocker: blockerOf(row),
457
514
  nextSteps: payload.next_steps || null,
458
515
  lcHistory: payload.lifecycle_history || [],
459
516
  convs: Array.isArray(payload.conversations) ? payload.conversations : [],
460
517
  gitFiles: payload.files || null,
461
518
  eval: evalFor(row && row.steps),
519
+ facts: factsFor(row).concat(suggestionLines(payload.suggestions)),
462
520
  l2: l2For(row),
463
521
  };
464
522
  if (!row) {
@@ -46,7 +46,7 @@ export const TAB_COLORS = {
46
46
  activeText: '#f9fbfb',
47
47
  idleText: 'oklch(0.6 0.02 213)',
48
48
  // dot colour by tab kind; anything that is not term/task (i.e. doc) is blue.
49
- dot: { term: '#47cf79', task: '#b98cff', doc: '#2b7fff' }
49
+ dot: { term: '#47cf79', task: '#b98cff', doc: '#2b7fff', view: '#f2b24a' }
50
50
  };
51
51
  export const TERM_COLORS = { prompt: '#47cf79', dim: '#6f7d81', text: '#c4ccce' };
52
52
  export const SCRATCH_SEND_COLOR = { on: 'oklch(0.85 0.01 214)', off: 'oklch(0.55 0.02 213)' };
@@ -575,7 +575,7 @@ export const allRecords = (userInits = {}) => ({ ...DATA, ...userInits });
575
575
  export const recordFor = (slug, userInits = {}) => allRecords(userInits)[slug];
576
576
 
577
577
  /** The default selection (line 1397). */
578
- export const defaultSlug = product => (product === 'atlas-crm' ? 'crm-import-wizard' : 'workspace-pm-spine');
578
+ export const defaultSlug = product => (COCKPIT_SEED ? (SEED_DEFAULTS[product] || null) : product === 'atlas-crm' ? 'crm-import-wizard' : 'workspace-pm-spine');
579
579
 
580
580
  /** sessionsOf (line 1398) — authored sessions plus any started this session. */
581
581
  export function sessionsOf(slug, { userInits = {}, extraSess = {} } = {}) {
@@ -1000,3 +1000,41 @@ export const FIXTURE = {
1000
1000
  };
1001
1001
 
1002
1002
  export default FIXTURE;
1003
+
1004
+
1005
+ /* ───────────── the cockpit mockup's seed (`?fixture=cockpit`) ─────────────
1006
+ * planning/design/cockpit-mockup/WsA.dc.html draws three repositories and one initiative in detail. The seed is applied by
1007
+ * mutating the exports above in place, once, so every reader (panels, parity script) sees one set of data. It is the
1008
+ * mockup's fake data and nothing else; no value here is read from a repository. */
1009
+ const COCKPIT_SEED = (() => { try { return new URLSearchParams(location.search).get('fixture') === 'cockpit'; } catch { return false; } })();
1010
+ const SEED_DEFAULTS = { microsites: 'ooh-weather-expansion', pmos: 'cockpit-terminal-mod', 'scarlet': 'scarlet-onboarding' };
1011
+ if (COCKPIT_SEED) {
1012
+ const task = (title, status, k) => ({ title, status, instruction: 'Run ' + title + '.', input: 'planning/ + prior phase outputs', expect: 'A reviewable ' + k + ' artifact exists.', evalNote: 'graded against the ' + k + ' rubric', output: status === 'done' ? 'planning/' + k + '/' + k + '.md' : '—' });
1013
+ PRODUCTS.splice(0, PRODUCTS.length, ['microsites', '6', 'github'], ['pmos', '3', 'local'], ['scarlet', '1', 'github']);
1014
+ for (const k of Object.keys(ALLGROUPS)) delete ALLGROUPS[k];
1015
+ ALLGROUPS.microsites = [{ gid: 'O2', idc: C.fg, gname: 'Microsites ship on time', gct: '6', hasKr: true, krid: 'KR1', krname: 'q3-2026', krct: '6',
1016
+ slugs: ['ooh-weather-expansion', 'psle-paywall-revamp', 'smrt-news-feed', 'lightbox-ga-tagging', 'login-metric-tagging', 'shortlink-redirects'] }];
1017
+ ALLGROUPS.pmos = [{ gid: 'health:tech', idc: C.inf, gname: 'health budget', gct: '3', hasKr: false, slugs: ['cockpit-terminal-mod', 'workspace-cockpit-nav', 'kit-publish'] }];
1018
+ ALLGROUPS['scarlet'] = [{ gid: 'O1', idc: C.fg, gname: 'Onboarding', gct: '1', hasKr: false, slugs: ['scarlet-onboarding'] }];
1019
+ for (const k of Object.keys(DATA)) delete DATA[k];
1020
+ const mk = (tag, stage, steps, extra) => ({ tag, stage, flow: 'adlc-feature', pills: [{ t: 'feature', c: C.pur }, { t: 'q3-2026 · O2 · KR1', c: C.mut }], branch: null,
1021
+ answer: { tone: 'info', text: tag }, steps, sessions: [], arts: [], eval: null, l2: [stage, '', C.mut], ...(extra || {}) });
1022
+ const oneStep = (k, s) => [{ k, s, t: '' }];
1023
+ DATA['ooh-weather-expansion'] = mk('OOH weather microsite: add regional forecast units', 'eval', [
1024
+ { k: 'spec', s: 'done', t: '' }, { k: 'build', s: 'done', t: '' }, { k: 'eval', s: 'blocked', t: '' }, { k: 'accepted', s: 'pending', t: '' }], {
1025
+ pills: [{ t: 'feature', c: C.mut }, { t: 'q3-2026 · O2 · KR1', c: C.mut }, { t: '⇄ Jira OOH-212', c: C.mut }],
1026
+ blocker: { label: 'Merge', evidence: 'needs a grant for PR 41', next: '' },
1027
+ tasks: {
1028
+ spec: [task('Write the PRD', 'done', 'spec'), task('Write the rubric', 'done', 'spec')],
1029
+ build: [task('Build the forecast units', 'done', 'build'), task('Wire the regional feed', 'done', 'build'), task('Add the tests', 'done', 'build')],
1030
+ eval: [task('Run independent review', 'blocked', 'eval'), task('Record outcome check', 'todo', 'eval')],
1031
+ accepted: [task('Accept the run', 'todo', 'accepted')],
1032
+ },
1033
+ sessions: [{ name: 'claude · merge PR 41', st: 'working', label: 'working' }, { name: 'review run', st: 'working', label: 'working' }],
1034
+ arts: [{ role: 'PRD', path: 'planning/prd/ooh-weather-expansion-prd.md', dest: null }] });
1035
+ DATA['psle-paywall-revamp'] = mk('PSLE paywall revamp', 'accepted', oneStep('accepted', 'pending'));
1036
+ DATA['smrt-news-feed'] = mk('SMRT news feed', 'build', oneStep('build', 'current'));
1037
+ for (const id of ['lightbox-ga-tagging', 'login-metric-tagging', 'shortlink-redirects']) DATA[id] = mk(id, 'accepted', oneStep('accepted', 'done'));
1038
+ for (const id of ['cockpit-terminal-mod', 'workspace-cockpit-nav', 'scarlet-onboarding']) DATA[id] = mk(id, 'spec', oneStep('spec', 'blocked'));
1039
+ DATA['kit-publish'] = mk('kit-publish', 'accepted', oneStep('accepted', 'pending'));
1040
+ }