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
@@ -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
@@ -828,7 +832,61 @@ export function renderInitiatives(host) {
828
832
 
829
833
  const hits = searchHits(q, DATA, live);
830
834
 
831
- const kids = [header, searchBox];
835
+ /* GROUP BY STATUS (initiative-surface-batch R2). The status is the one the server DERIVES (deriveState + the
836
+ * lifecycle ledger), mapped to the PM's words by lib/status-groups.js; nothing typed in a record decides where a
837
+ * row lands. A failed read of the statuses is said under the control and everything files under Unknown. */
838
+ const SG = typeof window !== 'undefined' ? window.PMOSStatusGroups : null;
839
+ const byStatus = state.groupBy === 'status' && !!SG;
840
+ const statusRows = (state.statuses && state.statuses[product]) || {};
841
+ const statusErr = (state.statusErrors && state.statusErrors[product]) || null;
842
+ const STATUS_TONE = { 'needs-you': 'var(--destructive)', 'in-progress': 'var(--brand)', planning: 'var(--muted-foreground)',
843
+ 'awaiting-acceptance': 'var(--foreground)', accepted: 'var(--muted-foreground)', 'on-hold': 'var(--muted-foreground)',
844
+ closed: 'var(--muted-foreground)', cancelled: 'var(--muted-foreground)', unknown: 'var(--destructive)' };
845
+ if (byStatus) {
846
+ const seen = new Set(); const all = [];
847
+ for (const g of groupsFor(product)) for (const sl of g.slugs) if (!seen.has(sl)) { seen.add(sl); all.push(sl); }
848
+ const shown = all.filter((sl) => !q || sl.includes(q));
849
+ // An active search shows every match whatever its status: the "active" filter must not hide a hit.
850
+ const sgs = SG.group(shown, statusRows, { activeOnly: !!state.activeOnly && !q });
851
+ total = 0;
852
+ groups.length = 0;
853
+ for (const sg of sgs) {
854
+ const inits = sg.slugs.map((sl) => rowOf(sl, null));
855
+ total += inits.length;
856
+ groups.push({ gid: 'status:' + sg.key, idc: STATUS_TONE[sg.key] || 'var(--muted-foreground)', gname: sg.label, gct: String(inits.length),
857
+ hasKr: true, krid: '●', krname: sg.meaning || sg.hint, krct: String(inits.length), slugs: sg.slugs, inits, isEmpty: false });
858
+ }
859
+ }
860
+ const CHIP = 'font-family:var(--mono);font-size:10.5px;padding:2px 8px;border:1px solid var(--border);min-height:24px';
861
+ const chip = (label, on, onClick, attr) => el('button', { onClick, 'data-r16': attr,
862
+ style: CHIP + ';' + (on ? 'color:var(--foreground);background:var(--secondary);border-color:var(--input)' : 'color:var(--muted-foreground)'),
863
+ hover: 'color:var(--foreground)', 'aria-pressed': on ? 'true' : 'false' }, label);
864
+ const groupBar = FIXTURE ? null : el('div', { 'data-r16': 'group-bar', style: 'display:flex;gap:5px;flex-wrap:wrap;align-items:center;padding:4px 12px 6px' },
865
+ el('span', { style: 'font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted-foreground)' }, 'group by'),
866
+ chip('objective', !byStatus, () => setState({ groupBy: 'objective' }), 'by-objective'),
867
+ chip('status', byStatus, () => setState({ groupBy: 'status' }), 'by-status'),
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,
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);
871
+
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];
832
890
  /* THE WORKING SET, pinned above the Objectives.
833
891
  *
834
892
  * It is `groupNode` — the same element, the same collapse key, the same forced-open-on-search
@@ -177,8 +177,8 @@ export const UNBACKED_MODES = ['agent', 'connect']; // mode ids (labels: agent
177
177
  /* overhaul-r4 — the PORTFOLIO mode joins "all repositories" in live mode only (the fixture drawer is the
178
178
  * prototype's, and the design comparison reads it). Its meta lives here, not in fixture.js, for the
179
179
  * same reason. */
180
- const META = Object.assign({}, MODE_META, { portfolio: ['▦', 'portfolio'] });
181
- const LIVE_EXTRA = { 'all repositories': ['portfolio'] };
180
+ const META = Object.assign({}, MODE_META, { portfolio: ['▦', 'portfolio'], guide: ['?', 'guide'], validate: ['◉', 'validate'] });
181
+ const LIVE_EXTRA = { 'all repositories': ['portfolio', 'guide', 'validate'] };
182
182
  const DRAWER_GROUPS = SETTINGS_GROUPS.map((g) => ({
183
183
  label: g.label,
184
184
  items: (FIXTURE ? [] : LIVE_EXTRA[g.label] || []).concat(
@@ -42,7 +42,7 @@ import { boardOf, currentProduct } from './board-read.js';
42
42
  let termMod = null;
43
43
  let liveMod = null;
44
44
  /* Loaded lazily; a repaint is asked for only when a skill tab is already open (restored on reload) and waiting for it. */
45
- import('./modes-live.js').then((m) => { liveMod = m; if ((state.tabs || []).some((t) => t.kind === 'skill')) setState({}); }).catch(() => { liveMod = null; });
45
+ import('./modes-live.js').then((m) => { liveMod = m; if ((state.tabs || []).some((t) => t.kind === 'skill' || t.kind === 'transcript')) setState({}); }).catch(() => { liveMod = null; });
46
46
  import('./tab-term.js')
47
47
  .then((m) => { termMod = m; setState({}); })
48
48
  .catch(() => { termMod = null; });
@@ -144,6 +144,40 @@ export function closeTab(id) {
144
144
  });
145
145
  }
146
146
 
147
+ /**
148
+ * The terminal tabs attached to ONE session, by tab id (initiative-surface-batch R1).
149
+ *
150
+ * A terminal tab carries the SERVER's session id (`sessionId`), which is the exact join. Read it BEFORE the
151
+ * server is asked to end the session, not after: the moment the server drops the session it pushes the new
152
+ * list, and a tab whose id is no longer listed re-joins by NAME (tab-term.js `resolveSessionId`) — so when two
153
+ * sessions share a name the doomed tab quietly adopts its sibling and starts reporting the sibling's id, and a
154
+ * lookup made after the answer finds nothing to close.
155
+ */
156
+ export function tabIdsOfSession(sessionId) {
157
+ if (!sessionId) return [];
158
+ return (state.tabs || []).filter((t) => t && t.kind === 'term' && t.sessionId === sessionId).map((t) => t.id);
159
+ }
160
+
161
+ /**
162
+ * Close tabs by id. Called only after the server said the session is gone; closing a tab by hand still only
163
+ * detaches. Another session's tab, and a tab that is not in `ids`, are untouched. Returns how many closed.
164
+ */
165
+ export function closeTabsById(ids) {
166
+ const doomed = (state.tabs || []).filter((t) => t && (ids || []).includes(t.id)).map((t) => t.id);
167
+ if (!doomed.length) return 0;
168
+ setState((s) => {
169
+ const rest = s.tabs.filter((t) => !doomed.includes(t.id));
170
+ return {
171
+ tabs: rest,
172
+ active: doomed.includes(s.active) ? (rest.length ? rest[rest.length - 1].id : null) : s.active,
173
+ splitId: doomed.includes(s.splitId) ? null : s.splitId,
174
+ };
175
+ });
176
+ return doomed.length;
177
+ }
178
+
179
+ export const closeTabsOfSession = (sessionId) => closeTabsById(tabIdsOfSession(sessionId));
180
+
147
181
  /* The data layer's optional session-start hook. In fixture mode nothing is ever started; in real
148
182
  * mode the PTY is data.js's to create, and panel 4's part is only to show the tab for it. */
149
183
  let dataMod = null;
@@ -797,6 +831,8 @@ function paneFor(tab) {
797
831
  mountTask(host, tab);
798
832
  } else if (tab.kind === 'skill') {
799
833
  if (liveMod && typeof liveMod.mountSkillTab === 'function') liveMod.mountSkillTab(host, tab);
834
+ } else if (tab.kind === 'transcript') {
835
+ if (liveMod && typeof liveMod.mountTranscriptTab === 'function') liveMod.mountTranscriptTab(host, tab);
800
836
  } else if (tab.kind === 'web') {
801
837
  mountWeb(host, tab); // overhaul-r5 — never falls through to an empty doc tab
802
838
  } else {
@@ -913,7 +949,7 @@ function tabNode(t, act, splitTab) {
913
949
  * fixture render keeps the design's literal — the comparator reads this text node. */
914
950
  const meta = t.kind === 'term'
915
951
  ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
916
- : (t.kind === 'task' ? 'task' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.role);
952
+ : (t.kind === 'task' ? 'task' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
917
953
 
918
954
  return el('div', {
919
955
  style: 'flex:none;display:flex;align-items:center;gap:8px;padding:0 10px;background:' + bg
@@ -23,10 +23,11 @@ const MONO = 'font-family:var(--mono);font-size:10.5px';
23
23
  const BTN = MONO + ';padding:2px 8px;border:1px solid var(--border);color:var(--muted-foreground);min-height:24px';
24
24
  const MUTED = 'color:var(--muted-foreground)';
25
25
 
26
- let dataMod = null; let termMod = null; let iniMod = null;
26
+ let dataMod = null; let termMod = null; let iniMod = null; let tabsMod = null;
27
27
  import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
28
28
  import('./tab-term.js').then((m) => { termMod = m; }).catch(() => { termMod = null; });
29
29
  import('./panel-initiatives.js').then((m) => { iniMod = m; }).catch(() => { iniMod = null; });
30
+ import('./panel-tabs.js').then((m) => { tabsMod = m; }).catch(() => { tabsMod = null; });
30
31
 
31
32
  const note = (name, text, tone) => setState({ treeNote: { name, text, tone: tone || null } });
32
33
  const refresh = () => (dataMod && dataMod.refreshTrees ? dataMod.refreshTrees() : Promise.resolve());
@@ -62,10 +63,16 @@ export async function startHere(name, t) {
62
63
  }
63
64
 
64
65
  async function endSession(name, s) {
66
+ // Which tabs are on this session, read BEFORE asking: once the server drops it, a tab re-joins by name and stops saying.
67
+ const tabIds = tabsMod && typeof tabsMod.tabIdsOfSession === 'function' ? tabsMod.tabIdsOfSession(s.id) : [];
65
68
  const out = dataMod ? await dataMod.removeSessionById(s.id) : null;
66
69
  setState({ treeConfirm: null });
67
70
  if (!out || out.ok === false) { note(name, (out && out.error) || 'the session could not be removed', 'bad'); return; }
68
- note(name, out.removed === false ? (out.note || 'asked it to end') : 'session removed');
71
+ // The session is gone: the tab attached to it has nothing left to show, so it closes too (R1). A refusal returned
72
+ // above and leaves the tab open beside the reason; so does "asked it to end, not exited yet" — the process is still
73
+ // there, and pressing again removes it and closes the tab.
74
+ const closed = out.removed !== false && tabsMod && typeof tabsMod.closeTabsById === 'function' ? tabsMod.closeTabsById(tabIds) : 0;
75
+ note(name, (out.removed === false ? (out.note || 'asked it to end') : 'session removed') + (closed ? ' — its tab was closed' : ''));
69
76
  }
70
77
 
71
78
  async function removeTree(name, t) {
@@ -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: {}, 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: {},
@@ -153,7 +153,9 @@ const TAB_FIELDS = ['id', 'kind', 'title', 'slug', 'repo', 'sess', 'sessionId',
153
153
  // a web tab's address (overhaul-r5, D69) — restored, re-validated, and loaded only on a PM press
154
154
  'url',
155
155
  // a skill tab's skill id — a reload reopens the same skill
156
- 'skillId'];
156
+ 'skillId',
157
+ // a conversation tab's session id — a reload reopens the same transcript
158
+ 'convId'];
157
159
  const MAX_TABS = 40;
158
160
 
159
161
  function packTab(t) {
@@ -174,13 +176,16 @@ const isTabList = (v) =>
174
176
  const PERSIST = {
175
177
  theme: (v) => v === 'dark' || v === 'light',
176
178
  col0: isBool, col1: isBool, col2: isBool,
177
- // 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.
178
181
  col0W: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 100 && v <= 1200,
179
- col1W: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 100 && v <= 1200,
180
182
  settingsOpen: isBool,
181
183
  product: isSlug,
182
184
  sel: isSlug,
183
185
  collapsedGroups: isFlagMap,
186
+ // how the initiative list is grouped: by objective (default) or by derived status (initiative-surface-batch R2)
187
+ groupBy: (v) => v === 'objective' || v === 'status',
188
+ activeOnly: isBool,
184
189
  collapsedPhases: isFlagMap,
185
190
  // per doc tab: true = 72ch measure, false = full width; absent = the viewport default (R5.1)
186
191
  docNarrow: isFlagMap,
@@ -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)); },
@@ -478,11 +481,13 @@ export function mountDoc(host, tab) {
478
481
  if (!FIXTURE && extOfPath(tab.path) === 'xlsx') { setChildren(host, [sheetBody(tab)]); return; }
479
482
  const raw = docText(tab);
480
483
  const text = state.docEdits[docKey(tab)] ?? raw;
481
- const dm = state.docModes[tab.id] ?? 'view';
484
+ const dm = (tab.readonly && (state.docModes[tab.id] === 'edit' || state.docModes[tab.id] === 'diff')) ? 'view' : (state.docModes[tab.id] ?? 'view');
482
485
  const dirty = text !== raw;
483
486
  const err = saveErr.get(docKey(tab));
484
487
 
485
- const modeBtns = ['view', 'raw', 'edit', 'diff'].map((mm) => el('button', {
488
+ // A file opened from the initiative's git files is evidence of what changed: it is read, never edited or saved here.
489
+ const readonly = !!tab.readonly;
490
+ const modeBtns = ['view', 'raw', 'edit', 'diff'].filter((mm) => !(readonly && (mm === 'edit' || mm === 'diff'))).map((mm) => el('button', {
486
491
  onClick: () => setState((s) => ({ docModes: { ...s.docModes, [tab.id]: mm } })),
487
492
  style: 'font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:'
488
493
  + (dm === mm ? 'var(--secondary)' : 'transparent')