create-pmos 0.38.7 → 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 (47) 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 +64 -1
  7. package/workspace/bin/pmos.js +10 -1
  8. package/workspace/guide/index.md +38 -0
  9. package/workspace/guide/initiatives-and-next-steps.md +72 -0
  10. package/workspace/guide/layout.md +59 -0
  11. package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
  12. package/workspace/guide/statuses.md +53 -0
  13. package/workspace/guide/validate-with-typesafe.md +47 -0
  14. package/workspace/lib/initiative-files.js +134 -0
  15. package/workspace/lib/judgment.js +65 -0
  16. package/workspace/lib/mobile-nav.js +1 -1
  17. package/workspace/lib/next-step-actions.js +139 -0
  18. package/workspace/lib/next-steps.js +133 -0
  19. package/workspace/lib/prompt-input.js +18 -0
  20. package/workspace/lib/schedule-cli.js +259 -0
  21. package/workspace/lib/schedule-logic.js +23 -1
  22. package/workspace/lib/secret-scan.js +28 -0
  23. package/workspace/lib/session-budget.js +35 -0
  24. package/workspace/lib/skill-store.js +11 -3
  25. package/workspace/lib/status-groups.js +109 -0
  26. package/workspace/lib/transcripts.js +73 -0
  27. package/workspace/lib/typesafe-settings.js +119 -0
  28. package/workspace/lib/typesafe.js +124 -0
  29. package/workspace/lib/validate.js +304 -0
  30. package/workspace/package-lock.json +1 -1
  31. package/workspace/package.json +3 -3
  32. package/workspace/public/index.html +2 -0
  33. package/workspace/public/pm/app.js +53 -18
  34. package/workspace/public/pm/data.js +41 -5
  35. package/workspace/public/pm/mobile.js +1 -1
  36. package/workspace/public/pm/modes-live.js +171 -0
  37. package/workspace/public/pm/modes.js +25 -0
  38. package/workspace/public/pm/next-step-ui.js +185 -0
  39. package/workspace/public/pm/panel-detail.js +121 -1
  40. package/workspace/public/pm/panel-initiatives.js +61 -3
  41. package/workspace/public/pm/panel-repos.js +2 -2
  42. package/workspace/public/pm/panel-tabs.js +38 -2
  43. package/workspace/public/pm/panel-trees.js +9 -2
  44. package/workspace/public/pm/state.js +9 -4
  45. package/workspace/public/pm/status-banner.js +87 -0
  46. package/workspace/public/pm/tab-doc.js +11 -6
  47. package/workspace/server.js +434 -8
@@ -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) {
@@ -392,6 +409,25 @@ async function openSkillTab(sk) {
392
409
  const ini = await import('./panel-initiatives.js');
393
410
  ini.openTab({ id: 'skill:' + sk.id, kind: 'skill', title: sk.label || sk.skill || sk.id, skillId: sk.id, repo: state.product || null, slug: null, role: 'SKILL' });
394
411
  }
412
+ /** The body of a conversation tab (panel 4): one recorded conversation, read-only. The text is the live session's, or
413
+ * the saved record once the session has ended; the line above it says where it is kept. */
414
+ export function mountTranscriptTab(host, tab) {
415
+ const r = read('conv:' + tab.convId, '/api/transcript?id=' + encodeURIComponent(tab.convId || ''));
416
+ const st = status(r, 'this conversation');
417
+ const kids = [];
418
+ if (st) kids.push(st);
419
+ else {
420
+ const d = r.data;
421
+ kids.push(el('div', { 'data-r16': 'transcript-head', style: 'padding:12px 14px 4px;font-size:11.5px;color:var(--muted-foreground)' },
422
+ iv((d.live ? 'live — ' : 'ended — ') + (d.name || d.id) + (d.initiative ? ' · initiative ' + d.initiative : '') + (d.cut ? ' · the start was cut (long session)' : '') + ' · ' + (d.stored || ''))));
423
+ kids.push(el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r16': 'transcript-refresh', onClick: () => { reload('conv:' + tab.convId); setState({}); } }, 'refresh'));
424
+ kids.push(el('pre', { 'data-r16': 'transcript-text', style: 'margin:0;padding:10px 14px 18px;font-family:var(--mono);font-size:11.5px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--foreground)' },
425
+ iv(d.text && d.text.trim() ? d.text : '(this conversation recorded no text)')));
426
+ }
427
+ setChildren(host, [el('div', { style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground)' }, kids)]);
428
+ return host;
429
+ }
430
+
395
431
  /** The body of a skill tab (panel 4). Rebuilt on each paint, like the task tab; the draft lives in `skillDraft`. */
396
432
  export function mountSkillTab(host, tab) {
397
433
  const body = el('div', { style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground)' }, skillDetailLive({ kind: 'skill', id: tab.skillId, tab: true }));
@@ -489,3 +525,138 @@ export function knowledgeLive() {
489
525
  if (!trees.length && !notes.length) kids.push(el('div', { style: NOTE }, iv('No knowledge bundle in this repository — a successful read of nothing.')));
490
526
  return [el('div', { style: 'padding:2px 0 8px' }, kids)];
491
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 ─────────────────────────────────────────────────────────── */
@@ -989,6 +991,118 @@ function realSyncPanel(slug, a, key, dest) {
989
991
  );
990
992
  }
991
993
 
994
+ /* ── NEXT STEPS TO DONE (initiative-surface-batch R4) ─────────────────────────────────────────
995
+ * The whole remaining path, in order, from the ladder, the ledgers and the sessions — derived on the server
996
+ * (lib/next-steps.js), never written by a model. Each item names the fact it was read from; the first is
997
+ * marked. A stopped initiative says so, and an accepted one says nothing remains. */
998
+ function nextStepsBlock(slug, rec) {
999
+ const ns = rec && rec.nextSteps;
1000
+ if (FIXTURE || !ns) return null;
1001
+ const TONE = { blocked: 'var(--destructive)', current: 'var(--brand)', pending: 'var(--muted-foreground)', unknown: 'var(--destructive)' };
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 || '',
1003
+ style: 'display:grid;grid-template-columns:22px 1fr;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)' + (it.held ? ';opacity:.6' : '') },
1004
+ el('span', { style: 'font-family:var(--mono);font-size:11px;color:' + (TONE[it.status] || 'var(--muted-foreground)') }, iv(String(it.n))),
1005
+ el('div', { style: 'min-width:0;display:flex;flex-direction:column;gap:2px' },
1006
+ el('div', { style: 'display:flex;gap:7px;align-items:baseline;flex-wrap:wrap' },
1007
+ el('span', { style: 'font-size:12.5px;color:var(--foreground)' }, iv(it.text)),
1008
+ it.first ? el('span', { 'data-r16': 'next-first', style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--brand)' }, 'next') : null,
1009
+ it.status === 'blocked' ? el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--destructive)' }, 'blocked') : null,
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),
1011
+ el('div', { style: 'font-size:11.5px;line-height:1.5;color:var(--foreground)' }, iv(it.action)),
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,
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
+ ] : [];
1028
+ return el('div', { 'data-r16': 'next-steps', style: 'padding:8px 14px;border-top:1px solid var(--border)' },
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'),
1030
+ el('div', { 'data-r16': 'next-summary', style: 'font-size:12px;line-height:1.5;color:var(--foreground);padding-bottom:4px' }, iv(ns.summary || '')),
1031
+ rows);
1032
+ }
1033
+
1034
+ /* Conversations and git files, listed with the document artifacts (R5, R6). A conversation opens a read-only
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. */
1037
+ function openConversation(slug, c) {
1038
+ const ini = iniModLazy;
1039
+ if (!ini || typeof ini.openTab !== 'function') return;
1040
+ ini.openTab({ id: 'conv:' + c.id, kind: 'transcript', title: c.name || c.id, convId: c.id, repo: repoOf(slug) || null, slug: slug || null, role: 'CONVERSATION' });
1041
+ }
1042
+ let iniModLazy = null;
1043
+ import('./panel-initiatives.js').then((m) => { iniModLazy = m; }).catch(() => { iniModLazy = null; });
1044
+
1045
+ function conversationsBlock(slug, rec) {
1046
+ if (FIXTURE) return null;
1047
+ const cs = rec.convs || [];
1048
+ const LAB = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)';
1049
+ const kids = cs.map((c) => el('button', { onClick: () => openConversation(slug, c), 'data-r16': 'conv-row', 'data-id': c.id,
1050
+ style: 'display:flex;gap:8px;align-items:center;width:100%;padding:4px 6px;margin:0 -6px;min-width:0;min-height:30px', hover: 'background:var(--accent)',
1051
+ title: 'a recorded conversation — read-only; kept in the runtime state directory on this machine, not in the repository' },
1052
+ el('span', { style: 'flex:none;width:52px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;color:var(--info)' }, 'talk'),
1053
+ el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(c.name || c.id)),
1054
+ el('span', { style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' },
1055
+ iv((c.live ? 'live' : c.state === 'exited' ? 'ended' : 'saved') + (c.saved ? ' · ' + String(c.saved).slice(0, 10) : '')))));
1056
+ return el('div', { 'data-r16': 'conversations', style: 'display:flex;flex-direction:column;gap:2px' },
1057
+ el('span', { style: LAB + ';padding-top:4px' }, 'conversations · ' + cs.length),
1058
+ cs.length ? kids : el('span', { 'data-r16': 'conv-empty', style: 'font-size:11.5px;color:var(--muted-foreground);font-style:italic' },
1059
+ 'none — no session is bound to this initiative. A session on its branch, or one you bind, is recorded here and kept after it ends.'),
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);
1061
+ }
1062
+
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) {
1084
+ if (FIXTURE) return null;
1085
+ const f = rec.gitFiles;
1086
+ const LAB = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)';
1087
+ if (!f) return null;
1088
+ if (f.ok === false) {
1089
+ return el('div', { 'data-r16': 'git-files', style: 'display:flex;flex-direction:column;gap:2px' },
1090
+ el('span', { style: LAB + ';padding-top:4px' }, 'files from git'),
1091
+ el('span', { 'data-r16': 'git-error', style: 'font-size:11.5px;color:var(--destructive)' }, 'could not be read — ' + (f.reason || 'git did not answer')));
1092
+ }
1093
+ const groups = {};
1094
+ for (const it of f.items || []) (groups[it.group] = groups[it.group] || []).push(it);
1095
+ const order = ['code', 'tests', 'documents', 'other'].filter((g) => groups[g]);
1096
+ return el('div', { 'data-r16': 'git-files', style: 'display:flex;flex-direction:column;gap:2px' },
1097
+ el('span', { style: LAB + ';padding-top:4px' }, 'files from git · ' + (f.total || 0) + (f.via && f.via.length ? ' · via ' + f.via.join(', ') : '')),
1098
+ order.length ? order.map((g) => el('div', { style: 'display:flex;flex-direction:column;gap:1px' },
1099
+ el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--info);padding-top:2px' }, g + ' · ' + groups[g].length),
1100
+ groups[g].map((it) => gitFileRow(slug, f, it))))
1101
+ : el('span', { 'data-r16': 'git-empty', style: 'font-size:11.5px;color:var(--muted-foreground);font-style:italic' },
1102
+ 'none — git shows no commit naming this initiative and no initiative/<id> branch with changes'),
1103
+ f.truncated ? el('span', { style: 'font-size:10.5px;color:var(--muted-foreground)' }, 'showing the first 200 of ' + f.total) : null);
1104
+ }
1105
+
992
1106
  function artifactsRow(slug, rec) {
993
1107
  const arts = rec.arts || [];
994
1108
  return el('div', { style: 'display:grid;grid-template-columns:76px 1fr;gap:10px;padding:8px 14px' },
@@ -998,6 +1112,8 @@ function artifactsRow(slug, rec) {
998
1112
  arts.length === 0
999
1113
  ? el('span', { style: 'font-size:11.5px;color:var(--muted-foreground);font-style:italic' }, 'none filed')
1000
1114
  : null,
1115
+ conversationsBlock(slug, rec),
1116
+ gitFilesBlock(slug, rec),
1001
1117
  ),
1002
1118
  );
1003
1119
  }
@@ -1038,7 +1154,8 @@ function initBody(slug) {
1038
1154
  }
1039
1155
 
1040
1156
  out.push(headerBlock(slug, rec.tag || '', rec.pills || [], rec.crumb || null, lanePicker(slug, rec)));
1041
- { 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); } }
1042
1159
 
1043
1160
  if (rec.noLadder) {
1044
1161
  const ansC = FX.TONE[(rec.answer && rec.answer.tone) || 'info'] || FX.C.mut;
@@ -1050,6 +1167,9 @@ function initBody(slug) {
1050
1167
  const sess = sessionsBlock(slug);
1051
1168
  if (sess) out.push(sess);
1052
1169
 
1170
+ const nextB = nextStepsBlock(slug, rec);
1171
+ if (nextB) out.push(nextB);
1172
+
1053
1173
  const phases = phaseBlock(slug, rec);
1054
1174
  if (phases) out.push(phases);
1055
1175