create-pmos 0.38.8 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +1 -1
  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/README.md +29 -1
  7. package/workspace/guide/index.md +38 -0
  8. package/workspace/guide/initiatives-and-next-steps.md +72 -0
  9. package/workspace/guide/layout.md +59 -0
  10. package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
  11. package/workspace/guide/statuses.md +53 -0
  12. package/workspace/guide/validate-with-typesafe.md +47 -0
  13. package/workspace/lib/initiative-files.js +29 -6
  14. package/workspace/lib/judgment.js +65 -0
  15. package/workspace/lib/mobile-nav.js +1 -1
  16. package/workspace/lib/next-step-actions.js +139 -0
  17. package/workspace/lib/next-steps.js +27 -7
  18. package/workspace/lib/schedule-cli.js +5 -2
  19. package/workspace/lib/secret-scan.js +28 -0
  20. package/workspace/lib/session-budget.js +35 -0
  21. package/workspace/lib/status-groups.js +53 -10
  22. package/workspace/lib/typesafe-settings.js +119 -0
  23. package/workspace/lib/typesafe.js +124 -0
  24. package/workspace/lib/validate.js +304 -0
  25. package/workspace/package-lock.json +1 -1
  26. package/workspace/package.json +3 -3
  27. package/workspace/public/pm/app.js +53 -18
  28. package/workspace/public/pm/data.js +18 -3
  29. package/workspace/public/pm/mobile.js +1 -1
  30. package/workspace/public/pm/modes-live.js +152 -0
  31. package/workspace/public/pm/modes.js +25 -0
  32. package/workspace/public/pm/next-step-ui.js +185 -0
  33. package/workspace/public/pm/panel-detail.js +47 -11
  34. package/workspace/public/pm/panel-initiatives.js +26 -4
  35. package/workspace/public/pm/panel-repos.js +2 -2
  36. package/workspace/public/pm/state.js +3 -3
  37. package/workspace/public/pm/status-banner.js +87 -0
  38. package/workspace/public/pm/tab-doc.js +11 -6
  39. package/workspace/server.js +323 -4
@@ -156,6 +156,23 @@ export function portfolioList() {
156
156
  return [el('div', { style: 'padding:2px 0 8px' }, kids)];
157
157
  }
158
158
 
159
+ /* ── the user guide (status-clarity R6): pages that ship with the app, opened read-only in a doc tab ───────────── */
160
+ async function openGuide(pg) {
161
+ const ini = await import('./panel-initiatives.js');
162
+ ini.openTab({ id: 'guide:' + pg.page, kind: 'doc', title: pg.title || pg.page, guide: true, readonly: true, path: pg.page, slug: null, repo: state.product || null, role: 'GUIDE' });
163
+ }
164
+ export function guideList() {
165
+ const r = read('guide', '/api/guide');
166
+ const st = status(r, 'the guide');
167
+ if (st) return [st];
168
+ const pages = (r.data && r.data.pages) || [];
169
+ if (!pages.length) return [el('div', { style: NOTE, 'data-r18': 'guide-empty' }, iv('The guide has no pages in this install.'))];
170
+ return [el('div', { style: 'padding:2px 0 8px', 'data-r18': 'guide-list' },
171
+ el('div', { style: NOTE }, iv('How this workspace works. These pages ship with the app and are updated with it.')),
172
+ pages.map((pg) => el('button', { onClick: () => openGuide(pg), style: ROW, hover: 'background:var(--accent)', 'data-r18': 'guide-page', 'data-page': pg.page },
173
+ el('span', { style: 'font-size:12.5px' }, iv(pg.title)))))];
174
+ }
175
+
159
176
  /* ── D71 — set up the recommended schedules for the selected repository, on a press ───────────── */
160
177
  let seedNote = null;
161
178
  function seedRow(set, label, hint) {
@@ -508,3 +525,138 @@ export function knowledgeLive() {
508
525
  if (!trees.length && !notes.length) kids.push(el('div', { style: NOTE }, iv('No knowledge bundle in this repository — a successful read of nothing.')));
509
526
  return [el('div', { style: 'padding:2px 0 8px' }, kids)];
510
527
  }
528
+
529
+ /* ── D79 — validate the selected repository with TypeSafe's Jev, on a press ───────────────────────────────────
530
+ * Four things on one page, in the order the PM meets them: the key (never shown again once saved), this
531
+ * repository's consent switch (off until the PM turns it on), the plan (what would be sent, before anything
532
+ * is) and the findings. A finding is advice with a confidence; nothing here edits a file, and a verdict is not
533
+ * an acceptance. The key is typed into a password field and sent once; no route returns it. */
534
+ let vNote = null; // { tone: 'ok'|'bad'|'mut', text }
535
+ let vPlan = null; // the last plan, for the repository it was made for
536
+ let vBusy = false;
537
+ const vSay = (tone, text) => { vNote = { tone, text }; setState({}); };
538
+ const vWs = () => (dataMod && state.product ? dataMod.wsOf(state.product) : null);
539
+ const TONE = { bad: 'var(--destructive)', ok: 'var(--foreground)', mut: 'var(--muted-foreground)' };
540
+
541
+ async function vSaveKey(input) {
542
+ const key = input && input.value ? input.value : '';
543
+ if (!key.trim()) { vSay('bad', 'type or paste the key first'); return; }
544
+ const out = await fetch('/api/typesafe/key', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(readJson).catch((e) => ({ ok: false, error: e.message }));
545
+ if (input) input.value = '';
546
+ reload('typesafe'); reload('vrepo');
547
+ vSay(out && out.ok ? 'ok' : 'bad', out && out.ok ? 'the key is saved on this machine and is not shown again' : ((out && out.error) || 'the key was not saved'));
548
+ }
549
+ async function vClearKey() {
550
+ const out = await fetch('/api/typesafe/key', { method: 'DELETE' }).then(readJson).catch((e) => ({ ok: false, error: e.message }));
551
+ reload('typesafe'); reload('vrepo'); vPlan = null;
552
+ vSay(out && out.ok ? 'ok' : 'bad', out && out.ok ? 'the key is removed; nothing will be sent' : ((out && out.error) || 'the key was not removed'));
553
+ }
554
+ async function vConsent(on) {
555
+ const ws = vWs(); if (!ws) return vSay('bad', 'select a repository first');
556
+ const out = await post('/api/validate/repo', { ws, consent: on });
557
+ reload('vrepo'); vPlan = null;
558
+ vSay(out && out.ok ? 'ok' : 'bad', out && out.ok ? (on ? 'validation is on for this repository' : 'validation is off; nothing from this repository will be sent') : ((out && out.error) || 'not saved'));
559
+ }
560
+ async function vExclude(text) {
561
+ const ws = vWs(); if (!ws) return;
562
+ const exclude = String(text || '').split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
563
+ const out = await post('/api/validate/repo', { ws, exclude });
564
+ reload('vrepo'); vPlan = null;
565
+ vSay(out && out.ok ? 'ok' : 'bad', out && out.ok ? 'excluded paths saved (' + exclude.length + ')' : ((out && out.error) || 'not saved'));
566
+ }
567
+ async function vMakePlan() {
568
+ const ws = vWs(); if (!ws) return vSay('bad', 'select a repository first');
569
+ vBusy = true; vSay('mut', 'working out what would be sent …');
570
+ const out = await post('/api/validate/plan', { ws });
571
+ vBusy = false;
572
+ if (!out || out.ok === false) { vPlan = null; return vSay('bad', (out && out.error) || 'no plan'); }
573
+ vPlan = { ws, plan: out.plan }; vSay('mut', 'review the plan, then confirm to send');
574
+ }
575
+ async function vRun() {
576
+ if (!vPlan) return;
577
+ vBusy = true; vSay('mut', 'validating … this sends text to ' + vPlan.plan.host);
578
+ const out = await post('/api/validate/run', { ws: vPlan.ws, expect_requests: vPlan.plan.requests });
579
+ vBusy = false; vPlan = null; reload('vfind');
580
+ if (!out || out.ok === false) return vSay('bad', (out && out.error) || 'the run failed');
581
+ vSay(out.aborted ? 'bad' : 'ok', 'asked ' + out.asked + ' of ' + out.requests + ' · ' + out.raised + ' raised · ' + out.local_findings + ' local · ' + out.withheld + ' withheld · ' + out.failed + ' failed' + (out.aborted ? ' — ' + out.aborted : ''));
582
+ }
583
+
584
+ export function validateList() {
585
+ const kids = [];
586
+ const k = read('typesafe', '/api/typesafe');
587
+ const ws = vWs();
588
+ const rp = ws ? read('vrepo', '/api/validate/repo?ws=' + encodeURIComponent(ws)) : null;
589
+ const fd = ws ? read('vfind', '/api/validate/findings?ws=' + encodeURIComponent(ws)) : null;
590
+ kids.push(el('div', { style: NOTE, 'data-r19': 'intro' }, iv('Checks this repository\'s documents for reliability, coherence and freshness. Local checks run first and send nothing. Text that needs judgement goes to TypeSafe (Jev), only after you switch it on for this repository and confirm what will be sent. Findings are advice; nothing is edited.')));
591
+
592
+ // 1 · the key
593
+ kids.push(el('div', { style: LABEL }, iv('1 · TypeSafe key')));
594
+ const st = status(k, 'the key status');
595
+ if (st) kids.push(st);
596
+ else {
597
+ const present = k.data && k.data.key && k.data.key.present;
598
+ const src = k.data && k.data.key && k.data.key.source;
599
+ kids.push(el('div', { style: NOTE, 'data-r19': 'key-status' }, iv(present
600
+ ? 'A key is set (' + (src === 'environment' ? 'from the environment' : 'saved on this machine') + '). It is never shown again and is never given to a terminal.'
601
+ : 'No key is set. Without a key nothing is sent.')));
602
+ const input = el('input', { type: 'password', autocomplete: 'off', spellcheck: 'false', placeholder: present ? 'replace the key' : 'paste your TypeSafe key', 'data-r19': 'key-input',
603
+ style: 'flex:1;min-width:0;font-family:var(--mono);font-size:11px;padding:4px 8px;border:1px solid var(--border);background:var(--background);color:var(--foreground);min-height:28px' });
604
+ kids.push(el('div', { style: 'display:flex;gap:4px;padding:0 14px 6px' }, input,
605
+ el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r19': 'key-save', onClick: () => vSaveKey(input) }, 'save'),
606
+ present && src === 'settings' ? el('button', { style: BTN, hover: 'color:var(--destructive)', 'data-r19': 'key-remove', onClick: vClearKey }, 'remove') : null));
607
+ }
608
+
609
+ // 2 · this repository
610
+ kids.push(el('div', { style: LABEL }, iv('2 · this repository' + (state.product ? ' · ' + state.product : ''))));
611
+ if (!ws) kids.push(el('div', { style: NOTE }, iv('Select a repository first.')));
612
+ else {
613
+ const rs = status(rp, 'this repository\'s validation setting');
614
+ if (rs) kids.push(rs);
615
+ else {
616
+ const on = !!(rp.data && rp.data.consent);
617
+ kids.push(el('div', { style: 'display:flex;gap:8px;align-items:center;padding:4px 14px' },
618
+ el('span', { style: 'font-size:12px', 'data-r19': 'consent-state' }, iv(on ? 'validation is ON for this repository' : 'validation is OFF — nothing from this repository is sent')),
619
+ el('button', { style: BTN + ';margin-left:auto', hover: 'color:var(--foreground)', 'data-r19': 'consent-toggle', onClick: () => vConsent(!on) }, on ? 'turn off' : 'turn on')));
620
+ const ta = el('textarea', { rows: 2, placeholder: 'paths never sent, one per line (e.g. private/ or notes/*.md)', 'data-r19': 'exclude-input',
621
+ style: 'width:calc(100% - 28px);margin:4px 14px 0;font-family:var(--mono);font-size:10.5px;padding:4px 8px;border:1px solid var(--border);background:var(--background);color:var(--foreground)' }, (rp.data.exclude || []).join('\n'));
622
+ kids.push(ta, el('div', { style: 'padding:4px 14px 6px' }, el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r19': 'exclude-save', onClick: () => vExclude(ta.value) }, 'save excluded paths')));
623
+ }
624
+ }
625
+
626
+ // 3 · plan and run
627
+ kids.push(el('div', { style: LABEL }, iv('3 · validate')));
628
+ kids.push(el('div', { style: 'padding:4px 14px 6px;display:flex;gap:6px;align-items:center;flex-wrap:wrap' },
629
+ el('button', { style: BTN, hover: 'color:var(--foreground)', disabled: vBusy ? 'true' : null, 'data-r19': 'plan', onClick: vMakePlan }, 'show what would be sent'),
630
+ vPlan && vPlan.ws === ws ? el('button', { style: BTN + ';color:var(--foreground);border-color:var(--foreground)', hover: 'background:var(--accent)', disabled: vBusy ? 'true' : null, 'data-r19': 'run', onClick: vRun }, 'confirm and send ' + vPlan.plan.requests + ' request(s)') : null));
631
+ if (vPlan && vPlan.ws === ws) {
632
+ const p = vPlan.plan;
633
+ kids.push(el('div', { style: NOTE, 'data-r19': 'plan-text' },
634
+ iv(p.requests + ' request(s), about ' + p.characters.toLocaleString() + ' characters, to ' + p.host + '. ' + p.eligible + ' of ' + p.documents + ' documents eligible · ' + p.excluded + ' excluded · ' + p.local_findings + ' local finding(s) that send nothing.')));
635
+ if (p.withheld.length) kids.push(el('div', { style: NOTE + ';color:var(--destructive)', 'data-r19': 'withheld' }, iv('Withheld, matched a credential pattern, will NOT be sent: ' + p.withheld.map((w) => w.file + ' (' + w.rules.join(', ') + ')').join('; '))));
636
+ if (p.too_large.length) kids.push(el('div', { style: NOTE }, iv('Too large to send: ' + p.too_large.join(', '))));
637
+ }
638
+ if (vNote) kids.push(el('div', { 'data-r19': 'note', style: NOTE + ';color:' + (TONE[vNote.tone] || TONE.mut) }, iv(vNote.text)));
639
+
640
+ // 4 · findings
641
+ const fs0 = fd ? status(fd, 'the findings') : null;
642
+ const run = fd && fd.data ? fd.data.run : null;
643
+ kids.push(el('div', { style: LABEL }, iv('4 · findings' + (run ? ' · last run ' + String(run.at).replace('T', ' ').slice(0, 16) + ' · model ' + (run.model || 'none') : ''))));
644
+ if (fs0) kids.push(fs0);
645
+ else if (!run) kids.push(el('div', { style: NOTE, 'data-r19': 'no-run' }, iv('No validation has run for this repository.')));
646
+ else {
647
+ kids.push(el('div', { style: NOTE, 'data-r19': 'coverage' }, iv('Coverage: ' + run.asked + ' of ' + run.requests + ' request(s) answered · ' + run.withheld + ' withheld · ' + run.too_large + ' too large · ' + run.excluded + ' excluded · ' + run.failed + ' failed. A short list is not a clean bill: read this line first.')));
648
+ const list = fd.data.findings || [];
649
+ if (!list.length) kids.push(el('div', { style: NOTE }, iv('The run raised nothing.')));
650
+ for (const f of list) {
651
+ const unsure = f.label === 'for-your-judgement';
652
+ kids.push(el('button', { style: ROW, hover: 'background:var(--accent)', 'data-r19': 'finding', 'data-rule': f.rule, title: f.question || f.detail || '',
653
+ onClick: () => openDocIn(ws, f.file, state.product) },
654
+ el('span', { style: 'display:flex;gap:8px;align-items:baseline' },
655
+ el('span', { style: 'font-family:var(--mono);font-size:10px;color:' + (f.severity === 'high' ? 'var(--destructive)' : 'var(--muted-foreground)') }, iv(f.severity)),
656
+ el('span', { style: 'font-size:12px' }, iv(f.rule.replace(/-/g, ' '))),
657
+ el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(f.kind === 'judged' ? (unsure ? 'for your judgement · ' : '') + 'confidence ' + (f.confidence == null ? '—' : f.confidence.toFixed(2)) : f.sent ? 'sent' : 'local'))),
658
+ el('span', { style: 'display:block;font-size:11px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(f.files.join(' ↔ ') + (f.detail ? ' — ' + f.detail : '')))));
659
+ }
660
+ }
661
+ return [el('div', { style: 'padding:2px 0 8px', 'data-r19': 'validate' }, kids)];
662
+ }
@@ -1044,6 +1044,8 @@ const LISTS = {
1044
1044
  skills: () => (FIXTURE ? skillsList() : LIVE.skillsLive()),
1045
1045
  knowledge: () => (FIXTURE ? knowledgeList() : LIVE.knowledgeLive()),
1046
1046
  portfolio: () => (FIXTURE ? [] : LIVE.portfolioList()),
1047
+ guide: () => (FIXTURE ? [] : LIVE.guideList()),
1048
+ validate: () => (FIXTURE ? [] : LIVE.validateList()),
1047
1049
  agent: agentsList,
1048
1050
  connect: connectList,
1049
1051
  memory: () => (FIXTURE ? memoryList() : LIVE.memoryLive()),
@@ -1060,6 +1062,29 @@ const BODIES = {
1060
1062
  intg: intgBody,
1061
1063
  };
1062
1064
 
1065
+ /* WHERE EACH THING OPENS (column-layout). A list or a directory (memory, skills, knowledge, scheduled tasks, focus,
1066
+ * portfolio, the guide, the initiatives themselves) is drawn in the navigation column, under the repository list. A
1067
+ * detail (an initiative, a skill, a schedule, a memory item) is drawn in the detail column. This table is the rule;
1068
+ * a mode or a detail kind that is not in it is a failing test, so a new one has to say which column it opens in. */
1069
+ export const MODE_PLACE = {
1070
+ lists: { product: 'navigation', focus: 'navigation', scheduled: 'navigation', workflow: 'navigation', skills: 'navigation',
1071
+ knowledge: 'navigation', portfolio: 'navigation', guide: 'navigation', validate: 'navigation', agent: 'navigation', connect: 'navigation', memory: 'navigation' },
1072
+ details: { init: 'detail', flow: 'detail', sched: 'detail', skill: 'detail', know: 'detail', agent: 'detail', mem: 'detail',
1073
+ prof: 'detail', intg: 'detail', addprod: 'detail', bind: 'detail', new: 'detail', none: 'detail' },
1074
+ };
1075
+ /* WHAT A ROW IN EACH LIST OPENS, as the live app does it today. An initiative (from the board, the portfolio or the focus
1076
+ * queue) opens in the detail column. A file-like row (a skill, a knowledge note, a guide page) opens in a TAB, like any
1077
+ * other document, so it can sit beside a terminal. A schedule and a memory entry are acted on inside the list itself. */
1078
+ export const ROW_OPENS = {
1079
+ product: 'detail', portfolio: 'detail', focus: 'detail',
1080
+ skills: 'tab', knowledge: 'tab', guide: 'tab', validate: 'tab',
1081
+ scheduled: 'list', memory: 'list',
1082
+ };
1083
+ /** The column a list mode opens in: always `navigation`. `null` for a mode this table does not know. */
1084
+ export const placeOfMode = (mode) => MODE_PLACE.lists[mode] || null;
1085
+ /** The column a detail kind opens in: always `detail`. `null` for a kind this table does not know. */
1086
+ export const placeOfDetail = (kind) => MODE_PLACE.details[kind] || null;
1087
+
1063
1088
  /** Is `mode` one this module renders? `product` belongs to panel-initiatives.js. */
1064
1089
  export function hasMode(mode) {
1065
1090
  return Object.prototype.hasOwnProperty.call(LISTS, mode);
@@ -0,0 +1,185 @@
1
+ /**
2
+ * next-step-ui.js — next-step-actions. What a next-step row can DO, drawn under the row.
3
+ *
4
+ * The act on each item (`it.act.type`) comes from the server; this module only draws it and carries out the PM's presses:
5
+ * open → the file the step names, in a document tab
6
+ * session → switch to the running session
7
+ * work → PREPARE (a read-only plan), then GO (start exactly that plan)
8
+ * gate → words only: acceptance and the contract approval are the PM's own act, so there is no button
9
+ * lifecycle → reopen
10
+ * After a session started from a step has ended, REVIEW shows what changed and takes the PM's verdict.
11
+ *
12
+ * Nothing here starts a session except the GO press, and GO sends the hash of the plan the PM was shown, so a prompt that
13
+ * changed in the meantime is refused by the server.
14
+ */
15
+ import { el } from './dom.js';
16
+ import { state, setState, FIXTURE } from './state.js';
17
+
18
+ let dataMod = null;
19
+ const dataReady = import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
20
+ let iniMod = null;
21
+ import('./panel-initiatives.js').then((m) => { iniMod = m; }).catch(() => { iniMod = null; });
22
+
23
+ const LAB = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)';
24
+ const BTN = 'font-family:var(--mono);font-size:10px;padding:2px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);cursor:pointer;min-height:24px';
25
+ const GO = 'font-family:var(--mono);font-size:10.5px;padding:3px 10px;background:var(--brand);border:1px solid var(--brand);color:oklch(0.987 0.002 197.1);cursor:pointer;min-height:26px';
26
+ const iv = (t) => el('span', null, t == null ? '' : String(t));
27
+
28
+ /* per-item UI state, keyed by item id: { loading, plan, judgment, profile, note, summary } */
29
+ const ui = Object.create(null);
30
+ let profileList = null;
31
+ const touch = () => setState((s) => ({ nsTick: (s.nsTick || 0) + 1 }));
32
+ const slot = (id) => (ui[id] = ui[id] || { judgment: false, profile: '' });
33
+
34
+ async function prepare(slug, it) {
35
+ const u = slot(it.id);
36
+ u.loading = true; u.note = null; touch();
37
+ await dataReady;
38
+ if (profileList === null) { const r = await dataMod.nextStepProfiles(); profileList = (r && r.profiles) || []; }
39
+ const out = await dataMod.nextStepPost('prepare', { initiative_id: slug, item_id: it.id, agent: 'claude', profile: u.profile || undefined, judgment: u.judgment });
40
+ u.loading = false;
41
+ u.plan = out && out.ok ? out : null;
42
+ if (!out || out.ok === false) u.note = { tone: 'bad', text: (out && out.error) || 'could not prepare' };
43
+ touch();
44
+ }
45
+ async function go(slug, it) {
46
+ const u = slot(it.id); const pl = u.plan;
47
+ if (!pl || !pl.prompt_sha) return;
48
+ u.loading = true; u.note = null; touch();
49
+ const out = await dataMod.nextStepPost('go', { initiative_id: slug, item_id: it.id, agent: 'claude', profile: u.profile || undefined, judgment: u.judgment, confirm: true, prompt_sha: pl.prompt_sha });
50
+ u.loading = false;
51
+ if (!out || out.ok === false) { u.note = { tone: 'bad', text: (out && out.error) || 'nothing was started' }; if (out && out.prompt_sha) u.plan = null; touch(); return; }
52
+ u.plan = null; u.note = { tone: 'ok', text: 'started — ' + (out.notice || out.plan.why || 'the session is running') };
53
+ if (iniMod && out.session) iniMod.openTerm(slug, { id: out.session.id, name: out.session.name, st: 'working' });
54
+ await dataMod.refreshInitiative(slug);
55
+ touch();
56
+ }
57
+ async function review(slug, it, verdict) {
58
+ const u = slot(it.id);
59
+ let note = '';
60
+ if (verdict === 'send-back') {
61
+ note = typeof window !== 'undefined' && window.prompt ? window.prompt('Send “' + (it.key || it.text) + '” back — what should change? (one line)', '') : '';
62
+ if (note === null || !String(note).trim()) return;
63
+ }
64
+ const out = await dataMod.nextStepPost('review', { session_id: it.run.session_id, verdict, note: String(note).trim() });
65
+ u.note = out && out.ok ? { tone: 'ok', text: verdict === 'accept' ? 'step accepted — the initiative is not accepted by this' : 'sent back with your note' } : { tone: 'bad', text: (out && out.error) || 'not recorded' };
66
+ await dataMod.refreshInitiative(slug);
67
+ touch();
68
+ }
69
+ async function loadSummary(it) {
70
+ const u = slot(it.id);
71
+ u.summary = { loading: true }; touch();
72
+ await dataReady;
73
+ const out = await dataMod.nextStepSummary(it.run.session_id);
74
+ u.summary = out && out.ok ? out : { error: (out && out.error) || 'could not read the session' };
75
+ touch();
76
+ }
77
+
78
+ const row = (kids) => el('div', { style: 'display:flex;gap:5px;flex-wrap:wrap;align-items:center;margin-top:3px' }, kids);
79
+ const noteLine = (n) => (n ? el('span', { 'data-r21': 'note', style: 'font-size:11px;color:' + (n.tone === 'bad' ? 'var(--destructive)' : 'var(--muted-foreground)') }, iv(n.text)) : null);
80
+
81
+ function planPanel(slug, it) {
82
+ const u = slot(it.id); const pl = u.plan;
83
+ if (!pl) return null;
84
+ if (pl.refused) {
85
+ return el('div', { 'data-r21': 'plan-refused', style: 'margin-top:4px;padding:6px 8px;border:1px solid var(--destructive);font-size:11.5px;line-height:1.5;color:var(--foreground)' },
86
+ el('span', { style: LAB + ';color:var(--destructive)' }, 'not now'), el('div', null, iv(pl.refused.why)));
87
+ }
88
+ const prof = (profileList || []);
89
+ const opt = (v, t, cur) => el('option', { value: v, selected: v === cur ? 'selected' : null }, t);
90
+ const where = pl.plan.plan === 'new-worktree' ? 'creates branch ' + pl.plan.branch + ' in its own folder' : pl.plan.plan === 'reuse-worktree' ? 'reuses the folder for ' + (pl.plan.branch || 'the initiative branch') : pl.plan.why;
91
+ const kids = [
92
+ el('span', { style: LAB }, 'plan — nothing has been created'),
93
+ el('div', { 'data-r21': 'plan-where', style: 'font-size:11.5px;line-height:1.5' }, iv(where + (pl.plan.dir ? ' · ' + pl.plan.dir : ''))),
94
+ pl.existing_sessions && pl.existing_sessions.length
95
+ ? el('div', { 'data-r21': 'plan-existing', style: 'font-size:11px;color:var(--muted-foreground)' }, iv('already running on this initiative: ' + pl.existing_sessions.map((x) => x.name).join(', ') + ' — go starts another one'))
96
+ : null,
97
+ pl.sources && pl.sources.length
98
+ ? el('div', { 'data-r21': 'plan-sources', style: 'font-size:11px;color:var(--muted-foreground)' }, iv('read-only sources it may name: ' + pl.sources.map((x) => x.label).join(', ')))
99
+ : el('div', { 'data-r21': 'plan-sources', style: 'font-size:11px;color:var(--muted-foreground)' }, iv(u.profile ? 'the profile declares no source that suits this step' : 'no profile chosen, so no outside source is named')),
100
+ el('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:11px' },
101
+ el('label', { style: 'display:flex;gap:4px;align-items:center' }, iv('profile'),
102
+ el('select', { 'data-r21': 'plan-profile', style: 'font-family:var(--mono);font-size:10.5px;background:var(--secondary);color:var(--foreground);border:1px solid var(--border)',
103
+ onChange: (e) => { u.profile = e.target.value; prepare(slug, it); } }, [opt('', 'none', u.profile), ...prof.map((p) => opt(p.id, p.label || p.id, u.profile))])),
104
+ el('label', { style: 'display:flex;gap:4px;align-items:center' },
105
+ el('input', { type: 'checkbox', 'data-r21': 'plan-judgment', checked: u.judgment ? 'checked' : null, onChange: (e) => { u.judgment = !!e.target.checked; prepare(slug, it); } }),
106
+ iv('use the Typesafe skill for categorising, scoring and yes/no'))),
107
+ el('div', { 'data-r21': 'plan-judgment-note', style: 'font-size:11px;color:' + (pl.judgment.on ? 'var(--warning)' : 'var(--muted-foreground)') }, iv(pl.judgment.note)),
108
+ el('span', { style: LAB + ';padding-top:4px' }, 'the exact first input the session will get'),
109
+ el('pre', { 'data-r21': 'plan-prompt', style: 'font-family:var(--mono);font-size:10.5px;line-height:1.5;white-space:pre-wrap;padding:6px 8px;background:var(--secondary);border:1px solid var(--border);margin:0;max-height:240px;overflow:auto' }, iv(pl.prompt)),
110
+ el('div', { 'data-r21': 'plan-budget', style: 'font-size:11px;color:' + (pl.budget.allowed ? 'var(--muted-foreground)' : 'var(--destructive)') },
111
+ iv(pl.budget.allowed ? `${pl.budget.running} of ${pl.budget.limits.concurrent} prepared sessions running · ${pl.budget.startedToday} of ${pl.budget.limits.perDay} started today` : pl.budget.reason)),
112
+ row([
113
+ el('button', { 'data-r21': 'go', style: GO + (pl.budget.allowed && !u.loading ? '' : ';opacity:.5'), disabled: pl.budget.allowed && !u.loading ? null : 'disabled', onClick: () => go(slug, it),
114
+ title: 'starts a session with exactly the input above. It proposes only: it will not push, send, accept or merge.' }, u.loading ? '…' : 'go — start the session'),
115
+ el('button', { 'data-r21': 'cancel', style: BTN, onClick: () => { u.plan = null; touch(); } }, 'cancel'),
116
+ ]),
117
+ ];
118
+ return el('div', { 'data-r21': 'plan', style: 'margin-top:4px;padding:6px 8px;border:1px solid var(--border);display:flex;flex-direction:column;gap:3px' }, kids);
119
+ }
120
+
121
+ function reviewPanel(slug, it) {
122
+ const u = slot(it.id); const s = u.summary; const run = it.run;
123
+ if (!run) return null;
124
+ const done = run.review;
125
+ const head = [];
126
+ // The record was read when the initiative was selected; the live session list is newer. A run the record calls live is
127
+ // ended as soon as the session list says the session has exited.
128
+ const cs = (state.sessions || []).find((x) => x && x.id === run.session_id);
129
+ const ended = run.state === 'ended' || !!(cs && cs.shown === 'exited');
130
+ if (!ended) {
131
+ head.push(el('span', { 'data-r21': 'run-live', style: 'font-size:11px;color:var(--muted-foreground)' }, iv('a session started from this step is running')));
132
+ head.push(el('button', { 'data-r21': 'switch', style: BTN, onClick: () => iniMod && iniMod.openTerm(slug, { id: run.session_id, name: it.key + ' · ' + slug }) }, 'switch to it'));
133
+ } else {
134
+ head.push(el('span', { 'data-r21': 'run-ended', style: 'font-size:11px;color:var(--muted-foreground)' }, iv('the session started from this step has ended')));
135
+ head.push(el('button', { 'data-r21': 'review-open', style: BTN, onClick: () => loadSummary(it) }, s ? 'refresh review' : 'review what it did'));
136
+ }
137
+ if (done) head.push(el('span', { 'data-r21': 'verdict', style: 'font-family:var(--mono);font-size:10px;color:' + (done.verdict === 'accept' ? 'var(--brand)' : 'var(--warning)') }, iv((done.verdict === 'accept' ? 'accepted' : 'sent back') + (done.note ? ' — ' + done.note : ''))));
138
+ const kids = [row(head)];
139
+ if (s && s.loading) kids.push(el('div', { style: 'font-size:11px;color:var(--muted-foreground)' }, 'reading …'));
140
+ if (s && s.error) kids.push(el('div', { 'data-r21': 'review-error', style: 'font-size:11px;color:var(--destructive)' }, iv(s.error)));
141
+ if (s && s.ok) {
142
+ kids.push(el('span', { style: LAB + ';padding-top:3px' }, 'changed on ' + (s.branch || 'its branch') + ' against trunk · ' + s.files.length));
143
+ kids.push(s.files_error ? el('div', { 'data-r21': 'review-files-error', style: 'font-size:11px;color:var(--destructive)' }, iv('could not be read — ' + s.files_error))
144
+ : s.files.length ? el('div', { 'data-r21': 'review-files', style: 'display:flex;flex-direction:column' }, s.files.map((f) => el('span', { style: 'font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground)' }, iv(f.status + ' ' + f.path))))
145
+ : el('div', { 'data-r21': 'review-files', style: 'font-size:11px;color:var(--muted-foreground);font-style:italic' }, 'no file changed on the branch'));
146
+ kids.push(el('span', { style: LAB + ';padding-top:3px' }, 'the end of the conversation'));
147
+ kids.push(el('pre', { 'data-r21': 'review-tail', style: 'font-family:var(--mono);font-size:10.5px;white-space:pre-wrap;padding:6px 8px;background:var(--secondary);border:1px solid var(--border);margin:0;max-height:160px;overflow:auto' }, iv(s.tail || '(empty)')));
148
+ const j = s.judgments || { valid: [], invalid: [] };
149
+ if (j.valid.length || j.invalid.length) {
150
+ kids.push(el('span', { style: LAB + ';padding-top:3px' }, 'judgments the session reported'));
151
+ for (const v of j.valid) kids.push(el('div', { 'data-r21': 'judgment-ok', style: 'font-size:11px' }, iv(v.kind + ' · ' + v.question + ' → ' + (v.kind === 'yesno' ? (v.answer ? 'yes' : 'no') : v.kind === 'score' ? v.score : v.label))));
152
+ for (const v of j.invalid) kids.push(el('div', { 'data-r21': 'judgment-bad', style: 'font-size:11px;color:var(--destructive)' }, iv('rejected (' + v.error + '): ' + v.line)));
153
+ }
154
+ kids.push(row([
155
+ el('button', { 'data-r21': 'verdict-accept', style: GO, onClick: () => review(slug, it, 'accept'), title: 'accepts this step only; the initiative is accepted at the Acceptance Gate' }, 'accept the step'),
156
+ el('button', { 'data-r21': 'verdict-back', style: BTN, onClick: () => review(slug, it, 'send-back') }, 'send it back…'),
157
+ ]));
158
+ }
159
+ return el('div', { 'data-r21': 'review', style: 'margin-top:4px;padding:6px 8px;border:1px solid var(--border);display:flex;flex-direction:column;gap:2px' }, kids);
160
+ }
161
+
162
+ /** The controls and panels for one next-step item; null in fixture mode or when the item has no act. */
163
+ export function stepActions(product, slug, it) {
164
+ if (FIXTURE || !it || !it.act) return null;
165
+ const a = it.act; const u = slot(it.id);
166
+ const btns = [];
167
+ if (a.type === 'work') {
168
+ btns.push(el('button', { 'data-r21': 'prepare', style: BTN, onClick: () => prepare(slug, it), title: 'shows the plan and the exact prompt. Nothing is created or started.' }, u.loading && !u.plan ? '…' : 'prepare'));
169
+ } else if (a.type === 'session' && a.sessionId) {
170
+ btns.push(el('button', { 'data-r21': 'switch', style: BTN, onClick: () => iniMod && iniMod.openTerm(slug, { id: a.sessionId, name: it.text }) }, 'switch to it'));
171
+ } else if (a.type === 'lifecycle') {
172
+ btns.push(el('button', { 'data-r21': 'reopen', style: BTN, onClick: async () => { await dataReady; await dataMod.setLifecycle(product, slug, 'active', ''); await dataMod.refreshInitiative(slug); touch(); } }, 'reopen'));
173
+ } else if (a.type === 'gate') {
174
+ btns.push(el('span', { 'data-r21': 'gate-note', style: 'font-size:11px;color:var(--muted-foreground);font-style:italic' }, iv('yours to do — ' + a.why)));
175
+ }
176
+ if (a.path && (a.type === 'open' || a.type === 'work')) {
177
+ btns.push(el('button', { 'data-r21': 'open', style: BTN, onClick: () => iniMod && iniMod.openDoc(slug, a.path, 'FILE'), title: a.path }, a.type === 'open' ? 'open ' + a.path.split('/').pop() : 'open file'));
178
+ }
179
+ if (a.type === 'open') btns.push(el('span', { 'data-r21': 'gate-note', style: 'font-size:11px;color:var(--muted-foreground);font-style:italic' }, iv('yours to do — ' + a.why)));
180
+ const out = [];
181
+ if (btns.length || u.note) out.push(row([...btns, noteLine(u.note)]));
182
+ const pp = planPanel(slug, it); if (pp) out.push(pp);
183
+ const rp = reviewPanel(slug, it); if (rp) out.push(rp);
184
+ return out.length ? el('div', { 'data-r21': 'actions', 'data-act': a.type }, out) : null;
185
+ }
@@ -55,6 +55,8 @@ import * as FX from './fixture.js';
55
55
  import { openDoc, openTab, openTerm, fileNewModel } from './panel-initiatives.js';
56
56
  import { addProductBody } from './panel-repos.js';
57
57
  import { lifecycleRow } from './lifecycle.js';
58
+ import { statusBanner, lifecycleNotes } from './status-banner.js';
59
+ import { stepActions } from './next-step-ui.js';
58
60
  import { boardOf, records, currentProduct } from './board-read.js';
59
61
 
60
62
  /* ── the prototype's small helpers ─────────────────────────────────────────────────────────── */
@@ -993,11 +995,11 @@ function realSyncPanel(slug, a, key, dest) {
993
995
  * The whole remaining path, in order, from the ladder, the ledgers and the sessions — derived on the server
994
996
  * (lib/next-steps.js), never written by a model. Each item names the fact it was read from; the first is
995
997
  * marked. A stopped initiative says so, and an accepted one says nothing remains. */
996
- function nextStepsBlock(rec) {
998
+ function nextStepsBlock(slug, rec) {
997
999
  const ns = rec && rec.nextSteps;
998
1000
  if (FIXTURE || !ns) return null;
999
1001
  const TONE = { blocked: 'var(--destructive)', current: 'var(--brand)', pending: 'var(--muted-foreground)', unknown: 'var(--destructive)' };
1000
- const rows = (ns.items || []).map((it) => el('div', { 'data-r16': 'next-item', 'data-first': it.first ? '1' : '0', 'data-kind': it.kind,
1002
+ const rowFor = (it) => el('div', { 'data-r16': 'next-item', 'data-first': it.first ? '1' : '0', 'data-kind': it.kind, 'data-owner': it.owner || '', 'data-id': it.id || '',
1001
1003
  style: 'display:grid;grid-template-columns:22px 1fr;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)' + (it.held ? ';opacity:.6' : '') },
1002
1004
  el('span', { style: 'font-family:var(--mono);font-size:11px;color:' + (TONE[it.status] || 'var(--muted-foreground)') }, iv(String(it.n))),
1003
1005
  el('div', { style: 'min-width:0;display:flex;flex-direction:column;gap:2px' },
@@ -1008,7 +1010,21 @@ function nextStepsBlock(rec) {
1008
1010
  it.held ? el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'after reopening') : null),
1009
1011
  el('div', { style: 'font-size:11.5px;line-height:1.5;color:var(--foreground)' }, iv(it.action)),
1010
1012
  it.why && it.why !== it.action ? el('div', { style: 'font-size:11px;line-height:1.5;color:var(--muted-foreground)' }, iv(it.why)) : null,
1011
- el('div', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv('from: ' + it.source)))));
1013
+ el('div', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv('from: ' + it.source)),
1014
+ stepActions(repoOf(slug), slug, it)));
1015
+ // Yours / Mine (status-clarity R3): the same ordered list, split by who acts. The number keeps its place in the whole path.
1016
+ const all = ns.items || [];
1017
+ const SEC = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;padding:6px 0 2px;';
1018
+ const part = (owner, label, tone, empty) => {
1019
+ const mine = all.filter((it) => it.owner === owner);
1020
+ return el('div', { 'data-r18': 'steps-' + owner },
1021
+ el('div', { 'data-r18': 'steps-heading', style: SEC + 'color:' + tone }, label + ' · ' + mine.length),
1022
+ mine.length ? mine.map(rowFor) : el('div', { style: 'font-size:11.5px;color:var(--muted-foreground);font-style:italic;padding:2px 0' }, empty));
1023
+ };
1024
+ const rows = all.length ? [
1025
+ part('pm', 'yours', 'var(--brand)', 'nothing is waiting on you right now'),
1026
+ part('agent', 'mine (the agent’s)', 'var(--muted-foreground)', 'nothing is waiting on the agent'),
1027
+ ] : [];
1012
1028
  return el('div', { 'data-r16': 'next-steps', style: 'padding:8px 14px;border-top:1px solid var(--border)' },
1013
1029
  el('div', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground);padding-bottom:4px' }, 'next steps to done'),
1014
1030
  el('div', { 'data-r16': 'next-summary', style: 'font-size:12px;line-height:1.5;color:var(--foreground);padding-bottom:4px' }, iv(ns.summary || '')),
@@ -1016,8 +1032,8 @@ function nextStepsBlock(rec) {
1016
1032
  }
1017
1033
 
1018
1034
  /* Conversations and git files, listed with the document artifacts (R5, R6). A conversation opens a read-only
1019
- * transcript tab; the row says where it is stored. A file read from git is marked as such and is not a link:
1020
- * it is evidence of what changed, not a document the reader can open here. */
1035
+ * transcript tab; the row says where it is stored. A file read from git is marked as such and opens read-only
1036
+ * (gitFileRow): it is evidence of what changed, so the tab offers no edit or save. */
1021
1037
  function openConversation(slug, c) {
1022
1038
  const ini = iniModLazy;
1023
1039
  if (!ini || typeof ini.openTab !== 'function') return;
@@ -1044,7 +1060,27 @@ function conversationsBlock(slug, rec) {
1044
1060
  cs.length ? el('span', { style: 'font-size:10.5px;color:var(--muted-foreground)' }, 'stored in this machine’s runtime state directory — not in the repository, never sent anywhere') : null);
1045
1061
  }
1046
1062
 
1047
- function gitFilesBlock(rec) {
1063
+ /* A file read from git opens in a read-only doc tab (status-clarity R5). Text, markdown and html open anywhere. A
1064
+ * file that exists only on the initiative's own branch is read through git from that branch, so it opens the branch's
1065
+ * copy and never the trunk's. An image, PDF or workbook opens only from the checked-out tree. Anything else says why it
1066
+ * cannot be previewed instead of failing on click. */
1067
+ const READ_FROM_BRANCH = new Set(['markdown', 'text', 'html']);
1068
+ function gitFileRow(slug, f, it) {
1069
+ const branchOnly = it.where === 'branch';
1070
+ const readable = READ_FROM_BRANCH.has(it.kind) || (!branchOnly && (it.kind === 'image' || it.kind === 'pdf' || it.kind === 'sheet'));
1071
+ const tip = it.commits + ' commit' + (it.commits === 1 ? '' : 's') + ', last ' + it.last + (branchOnly ? ' · only on ' + (f.branch || 'the initiative branch') : '');
1072
+ const STYLE = 'font-family:var(--mono);font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
1073
+ if (!readable) {
1074
+ const why = branchOnly ? 'this kind of file cannot be previewed from a branch' : 'binary or unknown file type — it cannot be previewed here';
1075
+ return el('span', { 'data-r16': 'git-file', 'data-open': '0', title: tip + ' · ' + why, style: STYLE + 'color:var(--muted-foreground)' },
1076
+ iv(it.path + ' — cannot be previewed'));
1077
+ }
1078
+ return el('button', { 'data-r16': 'git-file', 'data-open': '1', 'data-where': it.where || 'head', title: tip + ' · opens read-only',
1079
+ onClick: () => openDoc(slug, it.path, 'FILE', null, { readonly: true, ref: branchOnly ? (f.branch || null) : null }),
1080
+ style: STYLE + 'color:var(--foreground);text-align:left;min-height:22px;padding:1px 4px;margin:0 -4px', hover: 'background:var(--accent)' }, iv(it.path));
1081
+ }
1082
+
1083
+ function gitFilesBlock(slug, rec) {
1048
1084
  if (FIXTURE) return null;
1049
1085
  const f = rec.gitFiles;
1050
1086
  const LAB = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)';
@@ -1061,8 +1097,7 @@ function gitFilesBlock(rec) {
1061
1097
  el('span', { style: LAB + ';padding-top:4px' }, 'files from git · ' + (f.total || 0) + (f.via && f.via.length ? ' · via ' + f.via.join(', ') : '')),
1062
1098
  order.length ? order.map((g) => el('div', { style: 'display:flex;flex-direction:column;gap:1px' },
1063
1099
  el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--info);padding-top:2px' }, g + ' · ' + groups[g].length),
1064
- groups[g].map((it) => el('span', { 'data-r16': 'git-file', title: it.commits + ' commit' + (it.commits === 1 ? '' : 's') + ', last ' + it.last,
1065
- style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(it.path)))))
1100
+ groups[g].map((it) => gitFileRow(slug, f, it))))
1066
1101
  : el('span', { 'data-r16': 'git-empty', style: 'font-size:11.5px;color:var(--muted-foreground);font-style:italic' },
1067
1102
  'none — git shows no commit naming this initiative and no initiative/<id> branch with changes'),
1068
1103
  f.truncated ? el('span', { style: 'font-size:10.5px;color:var(--muted-foreground)' }, 'showing the first 200 of ' + f.total) : null);
@@ -1078,7 +1113,7 @@ function artifactsRow(slug, rec) {
1078
1113
  ? el('span', { style: 'font-size:11.5px;color:var(--muted-foreground);font-style:italic' }, 'none filed')
1079
1114
  : null,
1080
1115
  conversationsBlock(slug, rec),
1081
- gitFilesBlock(rec),
1116
+ gitFilesBlock(slug, rec),
1082
1117
  ),
1083
1118
  );
1084
1119
  }
@@ -1119,7 +1154,8 @@ function initBody(slug) {
1119
1154
  }
1120
1155
 
1121
1156
  out.push(headerBlock(slug, rec.tag || '', rec.pills || [], rec.crumb || null, lanePicker(slug, rec)));
1122
- { const lc = lifecycleRow(repoOf(slug), slug); if (lc) out.push(lc); }
1157
+ { const sb = statusBanner(repoOf(slug), slug, rec); if (sb) out.push(sb); }
1158
+ { const lc = lifecycleRow(repoOf(slug), slug); if (lc) { out.push(lc); const ln = lifecycleNotes(rec); if (ln) out.push(ln); } }
1123
1159
 
1124
1160
  if (rec.noLadder) {
1125
1161
  const ansC = FX.TONE[(rec.answer && rec.answer.tone) || 'info'] || FX.C.mut;
@@ -1131,7 +1167,7 @@ function initBody(slug) {
1131
1167
  const sess = sessionsBlock(slug);
1132
1168
  if (sess) out.push(sess);
1133
1169
 
1134
- const nextB = nextStepsBlock(rec);
1170
+ const nextB = nextStepsBlock(slug, rec);
1135
1171
  if (nextB) out.push(nextB);
1136
1172
 
1137
1173
  const phases = phaseBlock(slug, rec);
@@ -265,15 +265,19 @@ export function openTerm(slug, sess) {
265
265
  });
266
266
  }
267
267
 
268
- export function openDoc(slug, path, role, tree) {
268
+ export function openDoc(slug, path, role, tree, opts) {
269
269
  /* `tree` — the line of work a search hit came from (overhaul-r1 R1.3). A hit on a LINKED worktree
270
270
  * opens a tab that carries that tree's `ws` and `branch`, under its own id, so it is read from and
271
271
  * saved to that tree and can sit beside the main tree's copy of the same path. A main-tree hit
272
272
  * opens exactly the tab it always did. */
273
273
  const linked = !!(tree && tree.ws && tree.linked);
274
+ // `opts.ref` — read the file from that initiative branch through git (it is not in the checked-out tree);
275
+ // `opts.readonly` — a git file is evidence, so the tab offers no edit or save.
276
+ const ref = opts && opts.ref ? opts.ref : null;
274
277
  openTab({
275
- id: 'doc:' + (linked ? tree.ws + ':' : '') + path, kind: 'doc', title: path.split('/').pop(), slug,
278
+ id: 'doc:' + (linked ? tree.ws + ':' : '') + (ref ? 'ref:' + ref + ':' : '') + path, kind: 'doc', title: path.split('/').pop(), slug,
276
279
  ...(linked ? { ws: tree.ws, branch: tree.branch || null } : {}),
280
+ ...(ref ? { ref } : {}), ...(opts && opts.readonly ? { readonly: true } : {}),
277
281
  /* A file opened from the FOLDER search belongs to no initiative, so `slugProduct()` has no
278
282
  * answer for it — and `panel-tabs.visibleTabs` shows a tab whose repo is `undefined` in EVERY
279
283
  * repository, which would hang this workspace's file in another one's tab strip and read its
@@ -850,7 +854,7 @@ export function renderInitiatives(host) {
850
854
  const inits = sg.slugs.map((sl) => rowOf(sl, null));
851
855
  total += inits.length;
852
856
  groups.push({ gid: 'status:' + sg.key, idc: STATUS_TONE[sg.key] || 'var(--muted-foreground)', gname: sg.label, gct: String(inits.length),
853
- hasKr: true, krid: '●', krname: sg.hint, krct: String(inits.length), slugs: sg.slugs, inits, isEmpty: false });
857
+ hasKr: true, krid: '●', krname: sg.meaning || sg.hint, krct: String(inits.length), slugs: sg.slugs, inits, isEmpty: false });
854
858
  }
855
859
  }
856
860
  const CHIP = 'font-family:var(--mono);font-size:10.5px;padding:2px 8px;border:1px solid var(--border);min-height:24px';
@@ -862,9 +866,27 @@ export function renderInitiatives(host) {
862
866
  chip('objective', !byStatus, () => setState({ groupBy: 'objective' }), 'by-objective'),
863
867
  chip('status', byStatus, () => setState({ groupBy: 'status' }), 'by-status'),
864
868
  byStatus ? chip('active only', !!state.activeOnly, () => setState({ activeOnly: !state.activeOnly }), 'active-only') : null,
869
+ SG ? chip('what do these mean?', !!state.legendOpen, () => setState({ legendOpen: !state.legendOpen }), 'legend-toggle') : null,
865
870
  byStatus && statusErr ? el('span', { 'data-r16': 'status-error', style: 'flex-basis:100%;font-size:10.5px;color:var(--destructive)' }, 'status could not be read — ' + statusErr + ' (every initiative is under Unknown)') : null);
866
871
 
867
- const kids = [header, searchBox, groupBar];
872
+ /* THE LEGEND (status-clarity R1). One row per group, read from the same table that files the rows, so what this
873
+ * says and where a row lands cannot disagree: what it means, the fact it is read from, who acts, what moves it,
874
+ * and what the PM can do by hand. */
875
+ const OWNER_WORD = { pm: 'you', agent: 'the agent', system: 'nobody (derived)' };
876
+ const legend = !FIXTURE && SG && state.legendOpen ? el('div', { 'data-r18': 'legend',
877
+ style: 'display:flex;flex-direction:column;gap:8px;margin:0 12px 8px;padding:8px 10px;border:1px solid var(--border);background:var(--secondary)' },
878
+ el('div', { style: 'font-size:11px;color:var(--muted-foreground);line-height:1.5' },
879
+ 'A status is never typed. It is worked out again each time from the ledgers and git. You can only hold, close, cancel or reopen an initiative by hand.'),
880
+ SG.GROUPS.map((g) => el('div', { 'data-r18': 'legend-row', 'data-key': g.key, style: 'display:flex;flex-direction:column;gap:2px;padding-top:6px;border-top:1px solid var(--border)' },
881
+ el('div', { style: 'display:flex;gap:7px;align-items:baseline' },
882
+ el('span', { style: 'font-size:12px;font-weight:600;color:' + (STATUS_TONE[g.key] || 'var(--foreground)') }, g.label),
883
+ el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'acts: ' + (OWNER_WORD[g.owner] || g.owner))),
884
+ el('div', { style: 'font-size:11.5px;line-height:1.5;color:var(--foreground)' }, g.meaning),
885
+ el('div', { style: 'font-size:10.5px;line-height:1.5;color:var(--muted-foreground)' }, 'read from: ' + g.source),
886
+ el('div', { style: 'font-size:10.5px;line-height:1.5;color:var(--muted-foreground)' }, 'moves: ' + g.moves),
887
+ el('div', { style: 'font-size:10.5px;line-height:1.5;color:var(--muted-foreground)' }, 'you can: ' + g.pmCan)))) : null;
888
+
889
+ const kids = [header, searchBox, groupBar, legend];
868
890
  /* THE WORKING SET, pinned above the Objectives.
869
891
  *
870
892
  * It is `groupNode` — the same element, the same collapse key, the same forced-open-on-search
@@ -177,8 +177,8 @@ export const UNBACKED_MODES = ['agent', 'connect']; // mode ids (labels: agent
177
177
  /* overhaul-r4 — the PORTFOLIO mode joins "all repositories" in live mode only (the fixture drawer is the
178
178
  * prototype's, and the design comparison reads it). Its meta lives here, not in fixture.js, for the
179
179
  * same reason. */
180
- const META = Object.assign({}, MODE_META, { portfolio: ['▦', 'portfolio'] });
181
- const LIVE_EXTRA = { 'all repositories': ['portfolio'] };
180
+ const META = Object.assign({}, MODE_META, { portfolio: ['▦', 'portfolio'], guide: ['?', 'guide'], validate: ['◉', 'validate'] });
181
+ const LIVE_EXTRA = { 'all repositories': ['portfolio', 'guide', 'validate'] };
182
182
  const DRAWER_GROUPS = SETTINGS_GROUPS.map((g) => ({
183
183
  label: g.label,
184
184
  items: (FIXTURE ? [] : LIVE_EXTRA[g.label] || []).concat(
@@ -24,7 +24,7 @@ const STORE_KEY = 'pmos.workspace.pm.v1';
24
24
  /** The initial state — the prototype's `state = {…}` block, key for key. */
25
25
  function initial() {
26
26
  return {
27
- theme: 'dark', sel: null, q: '', mode: 'product', collapsedGroups: {}, groupBy: 'objective', activeOnly: false, settingsOpen: false,
27
+ theme: 'dark', sel: null, q: '', mode: 'product', collapsedGroups: {}, groupBy: 'objective', activeOnly: false, legendOpen: false, settingsOpen: false,
28
28
  product: null, intgSynced: {},
29
29
  addSrc: 'github', addRepo: 'github.com/acme/atlas-mobile', addPath: '~/code/notes-cli',
30
30
  addLog: [], addedProducts: [], extraKnow: {},
@@ -176,9 +176,9 @@ const isTabList = (v) =>
176
176
  const PERSIST = {
177
177
  theme: (v) => v === 'dark' || v === 'light',
178
178
  col0: isBool, col1: isBool, col2: isBool,
179
- // the dragged widths of panels 1 and 2 (clamped again by app.js on the way out)
179
+ // the dragged width of the navigation column (clamped again by app.js on the way out). `col1` now means "the list under the
180
+ // repositories is open"; the old second-column width (col1W) is no longer stored and an old value is ignored on load.
180
181
  col0W: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 100 && v <= 1200,
181
- col1W: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 100 && v <= 1200,
182
182
  settingsOpen: isBool,
183
183
  product: isSlug,
184
184
  sel: isSlug,