create-pmos 0.38.8 → 0.40.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 (49) 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 +37 -1
  7. package/workspace/bin/pmos.js +8 -1
  8. package/workspace/guide/index.md +40 -0
  9. package/workspace/guide/initiatives-and-next-steps.md +72 -0
  10. package/workspace/guide/layout.md +73 -0
  11. package/workspace/guide/proactive-engine.md +75 -0
  12. package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
  13. package/workspace/guide/statuses.md +53 -0
  14. package/workspace/guide/validate-with-typesafe.md +80 -0
  15. package/workspace/lib/brief.js +113 -0
  16. package/workspace/lib/claims.js +605 -0
  17. package/workspace/lib/doc-profiles.js +74 -0
  18. package/workspace/lib/initiative-files.js +37 -6
  19. package/workspace/lib/judgment.js +65 -0
  20. package/workspace/lib/mobile-nav.js +1 -1
  21. package/workspace/lib/next-step-actions.js +139 -0
  22. package/workspace/lib/next-steps.js +27 -7
  23. package/workspace/lib/preview-kind.js +17 -2
  24. package/workspace/lib/proactive-cli.js +137 -0
  25. package/workspace/lib/proactive.js +208 -0
  26. package/workspace/lib/repo-watch.js +120 -0
  27. package/workspace/lib/schedule-cli.js +5 -2
  28. package/workspace/lib/secret-scan.js +28 -0
  29. package/workspace/lib/session-budget.js +35 -0
  30. package/workspace/lib/status-groups.js +53 -10
  31. package/workspace/lib/typesafe-settings.js +122 -0
  32. package/workspace/lib/typesafe.js +124 -0
  33. package/workspace/lib/validate.js +304 -0
  34. package/workspace/package-lock.json +1 -1
  35. package/workspace/package.json +3 -3
  36. package/workspace/public/pm/app.js +53 -18
  37. package/workspace/public/pm/data.js +18 -3
  38. package/workspace/public/pm/mobile.js +1 -1
  39. package/workspace/public/pm/modes-live.js +373 -0
  40. package/workspace/public/pm/modes.js +26 -0
  41. package/workspace/public/pm/next-step-ui.js +185 -0
  42. package/workspace/public/pm/panel-detail.js +47 -11
  43. package/workspace/public/pm/panel-initiatives.js +26 -4
  44. package/workspace/public/pm/panel-repos.js +2 -2
  45. package/workspace/public/pm/state.js +3 -3
  46. package/workspace/public/pm/status-banner.js +87 -0
  47. package/workspace/public/pm/tab-doc.js +66 -8
  48. package/workspace/server.js +562 -18
  49. package/workspace/skills/morning-brief.skill +43 -0
@@ -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'], today: ['☀', 'today'] });
181
+ const LIVE_EXTRA = { 'all repositories': ['today', '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,
@@ -0,0 +1,87 @@
1
+ /**
2
+ * status-banner.js — status-clarity R2/R3/R4. The block at the top of an initiative that says where it stands.
3
+ *
4
+ * It states, from facts the server already derived: the group, why (the evidence behind it), who acts, the one
5
+ * thing to do next, how the status will change, and, for "needs you", each open item by its own words. It adds
6
+ * no fact of its own: the group text comes from lib/status-groups.js, the evidence from deriveState, the steps
7
+ * from lib/next-steps.js. A failed read says so and shows no group.
8
+ *
9
+ * The lifecycle control (hold, close, cancel, reopen) sits beside it with one sentence on what it does and the
10
+ * history of every change. Acceptance is the PM's and is recorded in a session, not by a button here (D12), so the
11
+ * banner says how and offers the list of what is waiting.
12
+ */
13
+ import { el } from './dom.js';
14
+ import { state, setState, FIXTURE } from './state.js';
15
+
16
+ const SG = () => (typeof window !== 'undefined' ? window.PMOSStatusGroups : null);
17
+ const LAB = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)';
18
+ const TONE = { 'needs-you': 'var(--destructive)', 'in-progress': 'var(--brand)', 'awaiting-acceptance': 'var(--foreground)', unknown: 'var(--destructive)' };
19
+ const WHO = { pm: 'you', agent: 'the agent', system: 'nobody — it is derived' };
20
+ const iv = (t) => el('span', null, t == null ? '' : String(t));
21
+
22
+ function line(label, value, attr) {
23
+ return el('div', { style: 'display:grid;grid-template-columns:78px 1fr;gap:8px;padding:2px 0' },
24
+ el('span', { style: LAB + ';padding-top:2px' }, label),
25
+ el('span', { 'data-r18': attr, style: 'font-size:12px;line-height:1.5;color:var(--foreground);overflow-wrap:anywhere' }, iv(value)));
26
+ }
27
+
28
+ /** @returns {HTMLElement|null} null in fixture mode (the prototype has no such block). */
29
+ export function statusBanner(product, slug, rec) {
30
+ const sg = SG();
31
+ if (FIXTURE || !sg || !slug) return null;
32
+ const err = state.statusErrors && state.statusErrors[product];
33
+ const row = state.statuses && state.statuses[product] && state.statuses[product][slug];
34
+ if (err || !row) {
35
+ return el('div', { 'data-r18': 'status-banner', 'data-key': 'unreadable', style: 'padding:8px 14px;border-bottom:1px solid var(--border)' },
36
+ el('span', { style: LAB }, 'status'),
37
+ el('div', { 'data-r18': 'status-error', style: 'font-size:12px;color:var(--destructive);line-height:1.5' },
38
+ 'The status could not be read — ' + (err || 'the server returned no status for this initiative') + '. No group is shown because none can be derived.'));
39
+ }
40
+ const key = sg.keyOf(row);
41
+ const g = sg.BY_KEY[key];
42
+ const items = (rec && rec.nextSteps && rec.nextSteps.items) || [];
43
+ const waiting = items.filter((i) => i.kind === 'needs' || i.kind === 'returned');
44
+ // The one thing to do next: the engine's first item, unless it is not yours and something is waiting on you.
45
+ const mine = items.filter((i) => i.owner === 'pm' && !i.held);
46
+ const first = items.find((i) => i.first) || items[0] || null;
47
+ const next = g.owner === 'pm' ? (mine[0] || first) : first;
48
+ const nextText = next ? next.text + ' — ' + next.action : (g.key === 'accepted' ? 'Nothing remains.' : 'No step is outstanding.');
49
+
50
+ const kids = [
51
+ el('div', { style: 'display:flex;gap:8px;align-items:baseline;flex-wrap:wrap' },
52
+ el('span', { style: LAB }, 'status'),
53
+ el('span', { 'data-r18': 'status-group', style: 'font-size:13px;font-weight:600;color:' + (TONE[key] || 'var(--foreground)') }, g.label),
54
+ el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'derived, not typed')),
55
+ line('why', row.statusText ? row.statusText + '. ' + g.meaning : g.meaning, 'status-why'),
56
+ line('acts now', WHO[g.owner] || g.owner, 'status-owner'),
57
+ line('next', nextText, 'status-next'),
58
+ line('changes when', g.moves, 'status-moves'),
59
+ ];
60
+ if (waiting.length) {
61
+ kids.push(el('div', { 'data-r18': 'needs-items', style: 'display:flex;flex-direction:column;gap:2px;padding-top:4px' },
62
+ el('span', { style: LAB }, 'waiting on you · ' + waiting.length),
63
+ waiting.map((w) => el('div', { 'data-r18': 'needs-item', style: 'font-size:12px;line-height:1.5;color:var(--foreground);padding-left:10px;border-left:2px solid var(--destructive)' },
64
+ iv(w.text + (w.action ? ' — ' + w.action : ''))))));
65
+ }
66
+ if (key === 'awaiting-acceptance' || key === 'needs-you') {
67
+ kids.push(el('div', { 'data-r18': 'acceptance-note', style: 'font-size:11px;line-height:1.5;color:var(--muted-foreground);padding-top:4px' },
68
+ 'Acceptance is yours and cannot be delegated (D12). Tell the session that holds this initiative to record it, or run scripts/record-acceptance.js. The workspace never records one for you.'),
69
+ el('button', { 'data-r18': 'see-waiting', onClick: () => setState({ mode: 'focus', settingsOpen: false, col1: true }),
70
+ style: 'align-self:flex-start;font-family:var(--mono);font-size:10px;padding:2px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);margin-top:4px' },
71
+ 'see everything waiting on you'));
72
+ }
73
+ return el('div', { 'data-r18': 'status-banner', 'data-key': key, style: 'display:flex;flex-direction:column;gap:2px;padding:8px 14px;border-bottom:1px solid var(--border)' }, kids);
74
+ }
75
+
76
+ /** What the lifecycle control does, and every change made with it. Sits under the control row. */
77
+ export function lifecycleNotes(rec) {
78
+ if (FIXTURE) return null;
79
+ const hist = (rec && rec.lcHistory) || [];
80
+ return el('div', { 'data-r18': 'lifecycle-notes', style: 'display:flex;flex-direction:column;gap:2px;padding:4px 14px 6px;border-bottom:1px solid var(--border)' },
81
+ el('span', { 'data-r18': 'lifecycle-explain', style: 'font-size:10.5px;line-height:1.5;color:var(--muted-foreground)' },
82
+ 'Hold, close and cancel take this initiative off the board and stop it gating; reopen puts it back. They are the only statuses you set by hand. Every other status is read from git and the ledgers, so it cannot be edited here.'),
83
+ hist.length ? el('div', { 'data-r18': 'lifecycle-history', style: 'display:flex;flex-direction:column;gap:1px' },
84
+ el('span', { style: LAB + ';padding-top:2px' }, 'lifecycle history · ' + hist.length),
85
+ hist.map((h) => el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' },
86
+ iv(String(h.at || '').slice(0, 10) + ' · ' + h.state + (h.reason ? ' — ' + h.reason : ''))))) : null);
87
+ }
@@ -60,7 +60,7 @@ const wsId = () => state.wsId || state.ws || state.product || '';
60
60
  * `ws`, and is read from and saved to THAT tree. Two trees can hold the same path with different
61
61
  * content, so every per-document cache keys on tree + path; a main-tree tab keys on path alone,
62
62
  * exactly as before. */
63
- const docKey = (tab) => (tab && tab.ws ? tab.ws + '\u241f' : '') + (tab ? tab.path : '');
63
+ const docKey = (tab) => (tab && tab.guide ? 'guide\u241f' : '') + (tab && tab.ws ? tab.ws + '\u241f' : '') + (tab && tab.ref ? tab.ref + '\u241f' : '') + (tab ? tab.path : '');
64
64
  const wsOf = (tab) => (tab && tab.ws) || wsId();
65
65
 
66
66
  /* A REFERENCE is not a file. Panel 3's links section files `jira://ATL-501`, `confluence://PM/x`,
@@ -97,9 +97,12 @@ function requestDoc(tab) {
97
97
  if (pending.has(path)) return;
98
98
  pending.add(path);
99
99
  const done = (text) => { pending.delete(path); disk.set(path, text); setState({}); };
100
- const load = (dataMod && typeof dataMod.readDoc === 'function')
101
- ? dataMod.readDoc(tab.path, tab.ws)
102
- : fetch('/api/doc?ws=' + encodeURIComponent(wsOf(tab)) + '&path=' + encodeURIComponent(tab.path))
100
+ // A guide page ships with the app and is read from it, not from the repository (status-clarity R6).
101
+ const load = tab.guide
102
+ ? fetch('/api/guide?page=' + encodeURIComponent(tab.path)).then((r) => (r.ok ? r.text() : Promise.reject(new Error('no such guide page'))))
103
+ : (dataMod && typeof dataMod.readDoc === 'function')
104
+ ? dataMod.readDoc(tab.path, tab.ws, tab.ref)
105
+ : fetch('/api/doc?ws=' + encodeURIComponent(wsOf(tab)) + '&path=' + encodeURIComponent(tab.path) + (tab.ref ? '&ref=' + encodeURIComponent(tab.ref) : ''))
103
106
  .then((r) => (r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status))));
104
107
  Promise.resolve(load).then(
105
108
  (text) => { unread.delete(path); done(typeof text === 'string' ? text : String(text == null ? '' : text)); },
@@ -473,16 +476,71 @@ function sheetBody(tab) {
473
476
  ]);
474
477
  }
475
478
 
479
+ /* ── file-viewer-any-text — a document that is not markdown, a page or a grid is SOURCE ──────────────────
480
+ * `previewKind` decides what counts as markdown or HTML, in the one place the server also trusts. Anything else
481
+ * the reader serves is shown as numbered monospace lines, never through the markdown renderer: JSON pretty-printed,
482
+ * JSONL as one record per line. These tabs are read-only, because /api/doc/save refuses everything but markdown and
483
+ * an edit or save button that cannot work is worse than none. If the classifier did not load, nothing here applies
484
+ * and the pane behaves as before. */
485
+ function isSourcePath(p) {
486
+ if (isReference(p)) return false; // an external reference has no file to show
487
+ const PK = typeof window !== 'undefined' ? window.PreviewKind : null;
488
+ if (!PK || typeof PK.previewKind !== 'function') return false;
489
+ const kind = PK.previewKind(String(p || ''));
490
+ if (kind === 'markdown' || kind === 'html') return false;
491
+ const e = extOfPath(p);
492
+ return !(e === 'csv' || e === 'tsv' || e === 'pdf' || e === 'xlsx');
493
+ }
494
+ const SRC_CAP = 5000; // lines drawn; the cap is STATED when it applies
495
+ const JSONL_EXT = new Set(['jsonl', 'ndjson', 'jsonlines', 'ldjson']);
496
+ function sourceBody(tab, text) {
497
+ const ext = extOfPath(tab.path);
498
+ return scrollPane(tab, 'view', 'src\u0000' + text, RAW_ST, () => {
499
+ const head = (t, bad) => el('div', {
500
+ 'data-r21': 'source-note',
501
+ style: 'padding:2px 0 8px;font-family:var(--mono);font-size:10.5px;color:' + (bad ? 'var(--destructive)' : 'var(--muted-foreground)'),
502
+ }, iv(t));
503
+ const row = (n, t, bad) => el('div', { style: 'display:flex;gap:10px;min-height:1.2em' },
504
+ el('span', { style: 'flex:none;min-width:4ch;text-align:right;color:var(--muted-foreground);user-select:none' }, iv(String(n))),
505
+ el('div', { style: 'flex:1;min-width:0;white-space:pre-wrap;overflow-wrap:anywhere' + (bad ? ';color:var(--destructive)' : '') }, iv(t)));
506
+ const lines = (arr) => arr.slice(0, SRC_CAP).map((t, i) => row(i + 1, t, false));
507
+ const capNote = (n) => (n > SRC_CAP ? [head('Showing the first ' + SRC_CAP + ' of ' + n + ' lines. The rest is not drawn. Use raw to read it all.', false)] : []);
508
+ const all = text.split('\n');
509
+ if (ext === 'json') {
510
+ let v; let err = null;
511
+ try { v = JSON.parse(text); } catch (e) { err = (e && e.message) || 'parse error'; }
512
+ if (err) return [head('JSON · read-only · NOT valid JSON: ' + err + ' · shown as written', true), ...lines(all), ...capNote(all.length)];
513
+ const pretty = JSON.stringify(v, null, 2).split('\n');
514
+ return [head('JSON · read-only · pretty-printed · ' + pretty.length + ' lines', false), ...lines(pretty), ...capNote(pretty.length)];
515
+ }
516
+ if (JSONL_EXT.has(ext)) {
517
+ const recs = []; let bad = 0; let n = 0;
518
+ all.forEach((t, i) => {
519
+ if (!t.trim()) return;
520
+ n++;
521
+ if (recs.length >= SRC_CAP) return;
522
+ try { recs.push(row(i + 1, JSON.stringify(JSON.parse(t), null, 2), false)); }
523
+ catch (e) { bad++; recs.push(row(i + 1, t + '\n⚠ line ' + (i + 1) + ' is not valid JSON: ' + ((e && e.message) || 'parse error'), true)); }
524
+ });
525
+ return [head('JSONL · read-only · ' + n + ' record' + (n === 1 ? '' : 's') + (bad ? ' · ' + bad + ' not valid JSON (marked)' : '') + (n > SRC_CAP ? ' · first ' + SRC_CAP + ' shown' : ''), bad > 0), ...recs];
526
+ }
527
+ return [head((ext || 'text') + ' · read-only · source', false), ...lines(all), ...capNote(all.length)];
528
+ });
529
+ }
530
+
476
531
  export function mountDoc(host, tab) {
477
532
  if (!FIXTURE && extOfPath(tab.path) === 'pdf') { setChildren(host, [pdfBody(tab)]); return; }
478
533
  if (!FIXTURE && extOfPath(tab.path) === 'xlsx') { setChildren(host, [sheetBody(tab)]); return; }
479
534
  const raw = docText(tab);
480
535
  const text = state.docEdits[docKey(tab)] ?? raw;
481
- const dm = state.docModes[tab.id] ?? 'view';
536
+ const nonMd = !FIXTURE && isSourcePath(tab.path); // file-viewer-any-text: read-only, shown as source
537
+ const dm = ((tab.readonly || nonMd) && (state.docModes[tab.id] === 'edit' || state.docModes[tab.id] === 'diff')) ? 'view' : (state.docModes[tab.id] ?? 'view');
482
538
  const dirty = text !== raw;
483
539
  const err = saveErr.get(docKey(tab));
484
540
 
485
- const modeBtns = ['view', 'raw', 'edit', 'diff'].map((mm) => el('button', {
541
+ // A file opened from the initiative's git files is evidence of what changed: it is read, never edited or saved here.
542
+ const readonly = !!tab.readonly || nonMd;
543
+ const modeBtns = ['view', 'raw', 'edit', 'diff'].filter((mm) => !(readonly && (mm === 'edit' || mm === 'diff'))).map((mm) => el('button', {
486
544
  onClick: () => setState((s) => ({ docModes: { ...s.docModes, [tab.id]: mm } })),
487
545
  style: 'font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:'
488
546
  + (dm === mm ? 'var(--secondary)' : 'transparent')
@@ -512,7 +570,7 @@ export function mountDoc(host, tab) {
512
570
  * clean file dirty for nothing is how a tool teaches you to ignore its dirty flag. */
513
571
  const norm = typeof window !== 'undefined' ? window.MdNormalize : null;
514
572
  const canNormalize = !!(norm && typeof norm.normalize === 'function')
515
- && !isHtmlDoc(tab.path) && !isReference(tab.path) && !unread.has(docKey(tab));
573
+ && !isHtmlDoc(tab.path) && !isReference(tab.path) && !unread.has(docKey(tab)) && !nonMd;
516
574
  const normBtn = canNormalize ? el('button', {
517
575
  onClick: () => {
518
576
  const r = norm.normalize(text);
@@ -561,7 +619,7 @@ export function mountDoc(host, tab) {
561
619
  if (body.defaultValue !== text) body.defaultValue = text;
562
620
  if (body.value !== text) body.value = text;
563
621
  } else if (!FIXTURE && (extOfPath(tab.path) === 'csv' || extOfPath(tab.path) === 'tsv')) body = gridBody(tab, text);
564
- else body = rendersAsPage(tab) ? htmlFrame(tab, text) : viewBody(tab, text);
622
+ else body = rendersAsPage(tab) ? htmlFrame(tab, text) : (nonMd && !unread.has(docKey(tab)) ? sourceBody(tab, text) : viewBody(tab, text));
565
623
 
566
624
  setChildren(host, [modeBar, body]);
567
625
  return host;