create-pmos 0.38.6 → 0.38.8

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 (40) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/okf/core/templates/report-rubric-template.md +35 -0
  4. package/payload/okf/core/templates/report-weekly-template.md +21 -10
  5. package/payload/planning/evals/eval-calibration.md +1 -1
  6. package/payload/planning/okrs/health-budgets.md +1 -1
  7. package/payload/skills/pm-communication.skill +123 -0
  8. package/payload/skills/pmos-router.skill +1 -0
  9. package/payload/skills/technical-writing-ste.skill +5 -3
  10. package/payload/skills-native/pm-communication/SKILL.md +125 -0
  11. package/payload/skills-native/pmos-router/SKILL.md +1 -0
  12. package/payload/skills-native/technical-writing-ste/SKILL.md +5 -3
  13. package/payload/state/discoveries.md +1 -1
  14. package/workspace/README.md +35 -0
  15. package/workspace/bin/pmos.js +10 -1
  16. package/workspace/lib/initiative-files.js +111 -0
  17. package/workspace/lib/next-steps.js +113 -0
  18. package/workspace/lib/prompt-input.js +18 -0
  19. package/workspace/lib/schedule-cli.js +256 -0
  20. package/workspace/lib/schedule-logic.js +23 -1
  21. package/workspace/lib/skill-store.js +11 -3
  22. package/workspace/lib/status-groups.js +66 -0
  23. package/workspace/lib/transcripts.js +73 -0
  24. package/workspace/package-lock.json +1 -1
  25. package/workspace/package.json +3 -3
  26. package/workspace/public/index.html +2 -0
  27. package/workspace/public/pm/data.js +23 -2
  28. package/workspace/public/pm/modes-live.js +19 -0
  29. package/workspace/public/pm/panel-detail.js +84 -0
  30. package/workspace/public/pm/panel-initiatives.js +37 -1
  31. package/workspace/public/pm/panel-tabs.js +38 -2
  32. package/workspace/public/pm/panel-trees.js +9 -2
  33. package/workspace/public/pm/state.js +7 -2
  34. package/workspace/server.js +112 -5
  35. package/workspace/skills/report-biweekly.skill +5 -5
  36. package/workspace/skills/report-daily.skill +5 -5
  37. package/workspace/skills/report-monthly.skill +5 -5
  38. package/workspace/skills/report-special.skill +3 -3
  39. package/workspace/skills/report-weekly.skill +5 -5
  40. package/workspace/skills/weekly-report.skill +3 -2
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+ // Grouping the initiative list by status (initiative-surface-batch R2).
3
+ //
4
+ // The status is not typed by anyone: the server derives it for every initiative from the ledgers and
5
+ // git (`deriveState` in server.js — stopped, accepted, blocked, delivered, building, planning, unknown)
6
+ // and, for a stopped one, the lifecycle ledger says which kind (on-hold, closed, cancelled). This maps
7
+ // that derived pair to the PM's words and puts the groups in one order. It reads the derived value and
8
+ // nothing else, so a hand-typed `status:` in a record can never decide where a row lands, and an
9
+ // unreadable history is its own group instead of passing for Planning.
10
+ //
11
+ // Classic script (a UMD setting window.PMOSStatusGroups) like the other lib/ files the board shares
12
+ // with node, so the order and the mapping are walkable in the test suite.
13
+ (function (root, factory) {
14
+ const api = factory();
15
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
16
+ else root.PMOSStatusGroups = api;
17
+ }(typeof self !== 'undefined' ? self : this, function () {
18
+
19
+ // In the order the PM reads them: what needs them, what is moving, what has not started, what waits on
20
+ // their acceptance, what is done, then what is parked. `active` marks the four that are live work.
21
+ const GROUPS = [
22
+ { key: 'needs-you', label: 'Needs you', active: true, hint: 'returned at acceptance, or has open items in the needs queue' },
23
+ { key: 'in-progress', label: 'In progress', active: true, hint: 'runs logged, nothing merged yet' },
24
+ { key: 'planning', label: 'Planning', active: true, hint: 'recorded, no run logged yet' },
25
+ { key: 'awaiting-acceptance', label: 'Awaiting acceptance', active: true, hint: 'delivered; the PM has not accepted it' },
26
+ { key: 'accepted', label: 'Accepted', active: false, hint: 'accepted by the PM' },
27
+ { key: 'on-hold', label: 'On hold', active: false, hint: 'paused by the PM; off the board, not gating' },
28
+ { key: 'closed', label: 'Closed', active: false, hint: 'closed by the PM; off the board, not gating' },
29
+ { key: 'cancelled', label: 'Cancelled', active: false, hint: 'cancelled by the PM; off the board, not gating' },
30
+ { key: 'unknown', label: 'Unknown', active: false, hint: 'the history could not be read, so the status cannot be derived' },
31
+ ];
32
+ const BY_KEY = Object.fromEntries(GROUPS.map((g) => [g.key, g]));
33
+
34
+ /** The group key for one derived status row `{status, lifecycle}`; `unknown` when the row is missing or unrecognised. */
35
+ function keyOf(row) {
36
+ if (!row || typeof row !== 'object') return 'unknown';
37
+ switch (row.status) {
38
+ case 'blocked': return 'needs-you';
39
+ case 'building': return 'in-progress';
40
+ case 'planning': return 'planning';
41
+ case 'delivered': return 'awaiting-acceptance';
42
+ case 'accepted': return 'accepted';
43
+ case 'stopped': {
44
+ const s = row.lifecycle && row.lifecycle.state;
45
+ return s === 'on-hold' || s === 'closed' || s === 'cancelled' ? s : 'unknown';
46
+ }
47
+ default: return 'unknown';
48
+ }
49
+ }
50
+
51
+ /**
52
+ * @param {string[]} slugs every initiative on the board
53
+ * @param {Object<string,Object>} rows slug → derived status row (missing → Unknown)
54
+ * @param {{activeOnly?:boolean}} opts
55
+ * @returns {Array<{key,label,hint,active,slugs:string[]}>} non-empty groups only, in order
56
+ */
57
+ function group(slugs, rows, opts) {
58
+ const activeOnly = !!(opts && opts.activeOnly);
59
+ const buckets = new Map(GROUPS.map((g) => [g.key, []]));
60
+ for (const slug of slugs || []) buckets.get(keyOf(rows && rows[slug])).push(slug);
61
+ return GROUPS.filter((g) => (!activeOnly || g.active) && buckets.get(g.key).length)
62
+ .map((g) => ({ ...g, slugs: buckets.get(g.key) }));
63
+ }
64
+
65
+ return { GROUPS, BY_KEY, keyOf, group };
66
+ }));
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+ /* transcripts.js — the record of a conversation that belongs to an initiative.
3
+ *
4
+ * A session's terminal text used to live and die with the session: removing it deleted the only copy,
5
+ * so an initiative's discussion could not be reviewed later. A session that belongs to an initiative is
6
+ * now saved here when it ends or is removed, and listed beside the initiative's other artifacts.
7
+ *
8
+ * WHERE: one JSON file per session under `<runtime state>/transcripts/`. The runtime state directory is
9
+ * machine-local and outside every repository (lib/state-root.js). A transcript can hold anything the PM
10
+ * typed or an agent printed — including a secret — so it is deliberately NOT written into the repository
11
+ * (PM ruling 2026-10-03) and is never sent anywhere. Pure of the network (D54): fs only.
12
+ */
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const MAX_CHARS = 400000; // the tail of a very long session; the record says when it was cut
17
+ const ID_RE = /^[A-Za-z0-9._-]+$/;
18
+
19
+ // Terminal text without its escape sequences: colours, cursor moves, the window-title and hyperlink codes.
20
+ function stripAnsi(text) {
21
+ return String(text == null ? '' : text)
22
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
23
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
24
+ .replace(/\x1b[()][A-Za-z0-9]/g, '')
25
+ .replace(/\r(?!\n)/g, '\n').replace(/\r/g, '');
26
+ }
27
+
28
+ const dirOf = (stateDir) => path.join(stateDir, 'transcripts');
29
+ const fileOf = (stateDir, id) => path.join(dirOf(stateDir), id + '.json');
30
+
31
+ /** Write (or refresh) one session's record. Returns {ok, path} or {ok:false, error}; never throws. */
32
+ function save(stateDir, rec) {
33
+ try {
34
+ const id = String(rec && rec.id || '');
35
+ if (!ID_RE.test(id)) return { ok: false, error: 'not a session id' };
36
+ if (!rec.initiative || !ID_RE.test(String(rec.initiative))) return { ok: false, error: 'the session is on no initiative — nothing to record' };
37
+ const full = stripAnsi(rec.text);
38
+ const cut = full.length > MAX_CHARS;
39
+ const out = {
40
+ id, name: String(rec.name || id).slice(0, 80), agent: rec.agent || null, ws: rec.ws || null,
41
+ initiative: String(rec.initiative), state: rec.state || 'exited',
42
+ started: rec.started || null, saved: rec.saved || new Date().toISOString(),
43
+ cut, chars: Math.min(full.length, MAX_CHARS), text: cut ? full.slice(-MAX_CHARS) : full,
44
+ };
45
+ fs.mkdirSync(dirOf(stateDir), { recursive: true });
46
+ const file = fileOf(stateDir, id);
47
+ fs.writeFileSync(file + '.tmp', JSON.stringify(out));
48
+ fs.renameSync(file + '.tmp', file);
49
+ return { ok: true, path: file };
50
+ } catch (e) { return { ok: false, error: String((e && e.message) || e).slice(0, 160) }; }
51
+ }
52
+
53
+ function read(stateDir, id) {
54
+ if (!ID_RE.test(String(id || ''))) return null;
55
+ try { return JSON.parse(fs.readFileSync(fileOf(stateDir, id), 'utf8')); } catch { return null; }
56
+ }
57
+
58
+ /** Every saved record for one initiative, newest first, without the text. */
59
+ function listFor(stateDir, initiative) {
60
+ let names = [];
61
+ try { names = fs.readdirSync(dirOf(stateDir)); } catch { return []; }
62
+ const out = [];
63
+ for (const n of names) {
64
+ if (!n.endsWith('.json')) continue;
65
+ let r = null;
66
+ try { r = JSON.parse(fs.readFileSync(path.join(dirOf(stateDir), n), 'utf8')); } catch { continue; }
67
+ if (!r || r.initiative !== initiative) continue;
68
+ out.push({ id: r.id, name: r.name, ws: r.ws, state: r.state, started: r.started, saved: r.saved, chars: r.chars, cut: !!r.cut });
69
+ }
70
+ return out.sort((a, b) => String(b.saved).localeCompare(String(a.saved)));
71
+ }
72
+
73
+ module.exports = { stripAnsi, save, read, listFor, dirOf, MAX_CHARS };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.38.6",
3
+ "version": "0.38.8",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.38.6",
3
+ "version": "0.38.8",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -10,12 +10,12 @@
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
12
  "test": "node test.js && npm run test:modules",
13
- "test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js",
13
+ "test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js",
14
14
  "test:ui": "node ui-test.js",
15
15
  "test:parity": "node design-parity.js --states",
16
16
  "test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
17
17
  "doctor": "node doctor.js",
18
- "test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js",
18
+ "test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js",
19
19
  "test:offline": "node offline-tarball.test.js"
20
20
  },
21
21
  "engines": {
@@ -42,6 +42,8 @@
42
42
  — test.js requires it and walks its whole input domain. It reads window.PMOSSessionState for
43
43
  the attention order, so it must come AFTER that script. -->
44
44
  <script src="/lib/working-set.js"></script>
45
+ <!-- Group-by-status for the initiative list: the order and the mapping from the DERIVED status, walkable in node. -->
46
+ <script src="/lib/status-groups.js"></script>
45
47
  <!-- The phone's navigation reducer. DOM-free, loaded as a classic script (it is a UMD that sets
46
48
  window.PMOSMobileNav) and required by test.js, so the whole screen domain is walkable in node
47
49
  rather than only in a browser. pm/mobile.js reads it from the global; the module graph does not
@@ -452,6 +452,11 @@ function recordFrom(payload) {
452
452
  sessions: (payload.sessions || []).map(sessionRow),
453
453
  links: linksFor(payload),
454
454
  arts: artsFor(payload),
455
+ // R4/R5/R6 — what remains to acceptance, the conversations that belong to it, and the files git says
456
+ // were changed for it. Each arrives already derived by the server; this layer only carries them.
457
+ nextSteps: payload.next_steps || null,
458
+ convs: Array.isArray(payload.conversations) ? payload.conversations : [],
459
+ gitFiles: payload.files || null,
455
460
  eval: evalFor(row && row.steps),
456
461
  l2: l2For(row),
457
462
  };
@@ -646,10 +651,24 @@ function foldWorkspaces(rows) {
646
651
 
647
652
  /** Load one product's board: the spine (structure) joined with the library (artifacts). */
648
653
  async function loadBoard(prod) {
649
- const [spine, lib] = await Promise.all([
654
+ const [spine, lib, surf] = await Promise.all([
650
655
  getJson('/api/console', { name: 'spine', ws: prod.ws }),
651
656
  getJson('/api/library', { ws: prod.ws }),
657
+ // The DERIVED status of every initiative (server.js deriveState + the lifecycle ledger) — what "group by
658
+ // status" reads. A failed read is kept as a reason, never as an empty map: an empty map would file every
659
+ // initiative under Unknown without saying why.
660
+ getJson('/api/surface', { name: 'initiatives', ws: prod.ws }),
652
661
  ]);
662
+ {
663
+ const items = (surf && surf.ok !== false && ((surf.surface && surf.surface.initiatives) || surf.initiatives)) || null;
664
+ prod.statuses = {};
665
+ if (items) {
666
+ for (const i of items) if (i && i.id) prod.statuses[i.id] = { status: i.status, statusText: i.statusText || '', lifecycle: i.lifecycle || null };
667
+ prod.statusError = null;
668
+ } else {
669
+ prod.statusError = (surf && surf.error) || 'the derived status of the initiatives could not be read';
670
+ }
671
+ }
653
672
  if (spine.ok === false) {
654
673
  prod.error = spine.error || 'the initiative spine could not be read';
655
674
  prod.board = [];
@@ -722,6 +741,7 @@ function publishBoards() {
722
741
  * Scoping it by the selected workspace is how a tab in one repo attached to a shell in another
723
742
  * (tab-term.js `wsForTab`). */
724
743
  const productWs = {};
744
+ const statuses = {}; const statusErrors = {};
725
745
  let records = {};
726
746
  for (const name of productOrder) {
727
747
  const p = products.get(name);
@@ -729,13 +749,14 @@ function publishBoards() {
729
749
  if (p.ws) productWs[name] = p.ws;
730
750
  if (!p.board) continue;
731
751
  board[name] = p.board;
752
+ statuses[name] = p.statuses || {}; if (p.statusError) statusErrors[name] = p.statusError;
732
753
  records = { ...records, ...p.records };
733
754
  }
734
755
  // `wsBranches` rides the same patch as `productWs` and for the same reason: it is a fact about
735
756
  // every registered working tree, read once from /api/workspaces, that a panel needs in order to
736
757
  // draw a row. panel 2 joins it against `state.sessions` to say which initiative each live agent is
737
758
  // on — the same `initiative/<id>` join lib/four-panel.js makes per initiative on the server.
738
- setState({ board, productWs, wsBranches: branchMap(), records: { ...records, ...(hydrated) } });
759
+ setState({ board, statuses, statusErrors, productWs, wsBranches: branchMap(), records: { ...records, ...(hydrated) } });
739
760
  }
740
761
 
741
762
  /* Hydrated (fully derived) records live apart from the light ones so that a board reload cannot
@@ -392,6 +392,25 @@ async function openSkillTab(sk) {
392
392
  const ini = await import('./panel-initiatives.js');
393
393
  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
394
  }
395
+ /** The body of a conversation tab (panel 4): one recorded conversation, read-only. The text is the live session's, or
396
+ * the saved record once the session has ended; the line above it says where it is kept. */
397
+ export function mountTranscriptTab(host, tab) {
398
+ const r = read('conv:' + tab.convId, '/api/transcript?id=' + encodeURIComponent(tab.convId || ''));
399
+ const st = status(r, 'this conversation');
400
+ const kids = [];
401
+ if (st) kids.push(st);
402
+ else {
403
+ const d = r.data;
404
+ kids.push(el('div', { 'data-r16': 'transcript-head', style: 'padding:12px 14px 4px;font-size:11.5px;color:var(--muted-foreground)' },
405
+ iv((d.live ? 'live — ' : 'ended — ') + (d.name || d.id) + (d.initiative ? ' · initiative ' + d.initiative : '') + (d.cut ? ' · the start was cut (long session)' : '') + ' · ' + (d.stored || ''))));
406
+ kids.push(el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r16': 'transcript-refresh', onClick: () => { reload('conv:' + tab.convId); setState({}); } }, 'refresh'));
407
+ 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)' },
408
+ iv(d.text && d.text.trim() ? d.text : '(this conversation recorded no text)')));
409
+ }
410
+ setChildren(host, [el('div', { style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground)' }, kids)]);
411
+ return host;
412
+ }
413
+
395
414
  /** The body of a skill tab (panel 4). Rebuilt on each paint, like the task tab; the draft lives in `skillDraft`. */
396
415
  export function mountSkillTab(host, tab) {
397
416
  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 }));
@@ -989,6 +989,85 @@ function realSyncPanel(slug, a, key, dest) {
989
989
  );
990
990
  }
991
991
 
992
+ /* ── NEXT STEPS TO DONE (initiative-surface-batch R4) ─────────────────────────────────────────
993
+ * The whole remaining path, in order, from the ladder, the ledgers and the sessions — derived on the server
994
+ * (lib/next-steps.js), never written by a model. Each item names the fact it was read from; the first is
995
+ * marked. A stopped initiative says so, and an accepted one says nothing remains. */
996
+ function nextStepsBlock(rec) {
997
+ const ns = rec && rec.nextSteps;
998
+ if (FIXTURE || !ns) return null;
999
+ 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,
1001
+ style: 'display:grid;grid-template-columns:22px 1fr;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)' + (it.held ? ';opacity:.6' : '') },
1002
+ el('span', { style: 'font-family:var(--mono);font-size:11px;color:' + (TONE[it.status] || 'var(--muted-foreground)') }, iv(String(it.n))),
1003
+ el('div', { style: 'min-width:0;display:flex;flex-direction:column;gap:2px' },
1004
+ el('div', { style: 'display:flex;gap:7px;align-items:baseline;flex-wrap:wrap' },
1005
+ el('span', { style: 'font-size:12.5px;color:var(--foreground)' }, iv(it.text)),
1006
+ 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,
1007
+ 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,
1008
+ 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
+ el('div', { style: 'font-size:11.5px;line-height:1.5;color:var(--foreground)' }, iv(it.action)),
1010
+ 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)))));
1012
+ return el('div', { 'data-r16': 'next-steps', style: 'padding:8px 14px;border-top:1px solid var(--border)' },
1013
+ 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
+ el('div', { 'data-r16': 'next-summary', style: 'font-size:12px;line-height:1.5;color:var(--foreground);padding-bottom:4px' }, iv(ns.summary || '')),
1015
+ rows);
1016
+ }
1017
+
1018
+ /* 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. */
1021
+ function openConversation(slug, c) {
1022
+ const ini = iniModLazy;
1023
+ if (!ini || typeof ini.openTab !== 'function') return;
1024
+ 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' });
1025
+ }
1026
+ let iniModLazy = null;
1027
+ import('./panel-initiatives.js').then((m) => { iniModLazy = m; }).catch(() => { iniModLazy = null; });
1028
+
1029
+ function conversationsBlock(slug, rec) {
1030
+ if (FIXTURE) return null;
1031
+ const cs = rec.convs || [];
1032
+ const LAB = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)';
1033
+ const kids = cs.map((c) => el('button', { onClick: () => openConversation(slug, c), 'data-r16': 'conv-row', 'data-id': c.id,
1034
+ 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)',
1035
+ title: 'a recorded conversation — read-only; kept in the runtime state directory on this machine, not in the repository' },
1036
+ el('span', { style: 'flex:none;width:52px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;color:var(--info)' }, 'talk'),
1037
+ 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)),
1038
+ el('span', { style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' },
1039
+ iv((c.live ? 'live' : c.state === 'exited' ? 'ended' : 'saved') + (c.saved ? ' · ' + String(c.saved).slice(0, 10) : '')))));
1040
+ return el('div', { 'data-r16': 'conversations', style: 'display:flex;flex-direction:column;gap:2px' },
1041
+ el('span', { style: LAB + ';padding-top:4px' }, 'conversations · ' + cs.length),
1042
+ cs.length ? kids : el('span', { 'data-r16': 'conv-empty', style: 'font-size:11.5px;color:var(--muted-foreground);font-style:italic' },
1043
+ '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.'),
1044
+ 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
+ }
1046
+
1047
+ function gitFilesBlock(rec) {
1048
+ if (FIXTURE) return null;
1049
+ const f = rec.gitFiles;
1050
+ const LAB = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)';
1051
+ if (!f) return null;
1052
+ if (f.ok === false) {
1053
+ return el('div', { 'data-r16': 'git-files', style: 'display:flex;flex-direction:column;gap:2px' },
1054
+ el('span', { style: LAB + ';padding-top:4px' }, 'files from git'),
1055
+ el('span', { 'data-r16': 'git-error', style: 'font-size:11.5px;color:var(--destructive)' }, 'could not be read — ' + (f.reason || 'git did not answer')));
1056
+ }
1057
+ const groups = {};
1058
+ for (const it of f.items || []) (groups[it.group] = groups[it.group] || []).push(it);
1059
+ const order = ['code', 'tests', 'documents', 'other'].filter((g) => groups[g]);
1060
+ return el('div', { 'data-r16': 'git-files', style: 'display:flex;flex-direction:column;gap:2px' },
1061
+ el('span', { style: LAB + ';padding-top:4px' }, 'files from git · ' + (f.total || 0) + (f.via && f.via.length ? ' · via ' + f.via.join(', ') : '')),
1062
+ order.length ? order.map((g) => el('div', { style: 'display:flex;flex-direction:column;gap:1px' },
1063
+ 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)))))
1066
+ : el('span', { 'data-r16': 'git-empty', style: 'font-size:11.5px;color:var(--muted-foreground);font-style:italic' },
1067
+ 'none — git shows no commit naming this initiative and no initiative/<id> branch with changes'),
1068
+ f.truncated ? el('span', { style: 'font-size:10.5px;color:var(--muted-foreground)' }, 'showing the first 200 of ' + f.total) : null);
1069
+ }
1070
+
992
1071
  function artifactsRow(slug, rec) {
993
1072
  const arts = rec.arts || [];
994
1073
  return el('div', { style: 'display:grid;grid-template-columns:76px 1fr;gap:10px;padding:8px 14px' },
@@ -998,6 +1077,8 @@ function artifactsRow(slug, rec) {
998
1077
  arts.length === 0
999
1078
  ? el('span', { style: 'font-size:11.5px;color:var(--muted-foreground);font-style:italic' }, 'none filed')
1000
1079
  : null,
1080
+ conversationsBlock(slug, rec),
1081
+ gitFilesBlock(rec),
1001
1082
  ),
1002
1083
  );
1003
1084
  }
@@ -1050,6 +1131,9 @@ function initBody(slug) {
1050
1131
  const sess = sessionsBlock(slug);
1051
1132
  if (sess) out.push(sess);
1052
1133
 
1134
+ const nextB = nextStepsBlock(rec);
1135
+ if (nextB) out.push(nextB);
1136
+
1053
1137
  const phases = phaseBlock(slug, rec);
1054
1138
  if (phases) out.push(phases);
1055
1139
 
@@ -828,7 +828,43 @@ export function renderInitiatives(host) {
828
828
 
829
829
  const hits = searchHits(q, DATA, live);
830
830
 
831
- const kids = [header, searchBox];
831
+ /* GROUP BY STATUS (initiative-surface-batch R2). The status is the one the server DERIVES (deriveState + the
832
+ * lifecycle ledger), mapped to the PM's words by lib/status-groups.js; nothing typed in a record decides where a
833
+ * row lands. A failed read of the statuses is said under the control and everything files under Unknown. */
834
+ const SG = typeof window !== 'undefined' ? window.PMOSStatusGroups : null;
835
+ const byStatus = state.groupBy === 'status' && !!SG;
836
+ const statusRows = (state.statuses && state.statuses[product]) || {};
837
+ const statusErr = (state.statusErrors && state.statusErrors[product]) || null;
838
+ const STATUS_TONE = { 'needs-you': 'var(--destructive)', 'in-progress': 'var(--brand)', planning: 'var(--muted-foreground)',
839
+ 'awaiting-acceptance': 'var(--foreground)', accepted: 'var(--muted-foreground)', 'on-hold': 'var(--muted-foreground)',
840
+ closed: 'var(--muted-foreground)', cancelled: 'var(--muted-foreground)', unknown: 'var(--destructive)' };
841
+ if (byStatus) {
842
+ const seen = new Set(); const all = [];
843
+ for (const g of groupsFor(product)) for (const sl of g.slugs) if (!seen.has(sl)) { seen.add(sl); all.push(sl); }
844
+ const shown = all.filter((sl) => !q || sl.includes(q));
845
+ // An active search shows every match whatever its status: the "active" filter must not hide a hit.
846
+ const sgs = SG.group(shown, statusRows, { activeOnly: !!state.activeOnly && !q });
847
+ total = 0;
848
+ groups.length = 0;
849
+ for (const sg of sgs) {
850
+ const inits = sg.slugs.map((sl) => rowOf(sl, null));
851
+ total += inits.length;
852
+ 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 });
854
+ }
855
+ }
856
+ const CHIP = 'font-family:var(--mono);font-size:10.5px;padding:2px 8px;border:1px solid var(--border);min-height:24px';
857
+ const chip = (label, on, onClick, attr) => el('button', { onClick, 'data-r16': attr,
858
+ style: CHIP + ';' + (on ? 'color:var(--foreground);background:var(--secondary);border-color:var(--input)' : 'color:var(--muted-foreground)'),
859
+ hover: 'color:var(--foreground)', 'aria-pressed': on ? 'true' : 'false' }, label);
860
+ 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' },
861
+ el('span', { style: 'font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted-foreground)' }, 'group by'),
862
+ chip('objective', !byStatus, () => setState({ groupBy: 'objective' }), 'by-objective'),
863
+ chip('status', byStatus, () => setState({ groupBy: 'status' }), 'by-status'),
864
+ byStatus ? chip('active only', !!state.activeOnly, () => setState({ activeOnly: !state.activeOnly }), 'active-only') : null,
865
+ 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
+
867
+ const kids = [header, searchBox, groupBar];
832
868
  /* THE WORKING SET, pinned above the Objectives.
833
869
  *
834
870
  * It is `groupNode` — the same element, the same collapse key, the same forced-open-on-search
@@ -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, 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) {
@@ -181,6 +183,9 @@ const PERSIST = {
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,