create-pmos 0.38.7 → 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.
@@ -86,10 +86,25 @@ function nextSlot(schedule, now) {
86
86
  }
87
87
  function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
88
88
 
89
+ // R5 — an optional end. A schedule ends when it has run `max_runs` times or when the day after `until` begins
90
+ // (the `until` day itself still runs). No `until` and no `max_runs` means it never ends on its own.
91
+ function endOfDay(dateStr) {
92
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(dateStr || ''));
93
+ return m ? new Date(+m[1], +m[2] - 1, +m[3] + 1, 0, 0, 0, 0) : null;
94
+ }
95
+ function finishReason(schedule, now = new Date()) {
96
+ if (!schedule) return null;
97
+ const max = +schedule.max_runs;
98
+ if (max > 0 && (+schedule.runs || 0) >= max) return `max_runs reached (${max} run${max === 1 ? '' : 's'})`;
99
+ const end = endOfDay(schedule.until);
100
+ if (end && now >= end) return `until ${schedule.until} has passed`;
101
+ return null;
102
+ }
89
103
  // Is the schedule due to fire at `now`? Enabled + a slot in (lastRun, now]. For bi-weekly, also
90
104
  // require ≥ 13 days since lastRun (weekly slots, fired every other week).
91
105
  function isDue(schedule, now = new Date()) {
92
106
  if (!schedule || !schedule.enabled) return false;
107
+ if (finishReason(schedule, now)) return false;
93
108
  const slot = lastSlot(schedule, now);
94
109
  if (!slot) return false;
95
110
  const last = schedule.lastRun ? new Date(schedule.lastRun) : null;
@@ -130,6 +145,11 @@ function normalizeSchedule(input) {
130
145
  if (!s.name || !String(s.name).trim()) return { ok: false, error: 'name is required' };
131
146
  if (!CADENCES.includes(s.cadence)) return { ok: false, error: 'invalid cadence' };
132
147
  if (!s.skill || !String(s.skill).trim()) return { ok: false, error: 'a skill-backed task is required' };
148
+ if (s.until != null && s.until !== '') {
149
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(s.until)) || !endOfDay(s.until) || Number.isNaN(endOfDay(s.until).getTime())) return { ok: false, error: 'until must be a date, YYYY-MM-DD' };
150
+ if (endOfDay(s.until) <= new Date()) return { ok: false, error: 'until is already in the past — the schedule would never run' };
151
+ }
152
+ if (s.max_runs != null && s.max_runs !== '' && !(Number.isInteger(+s.max_runs) && +s.max_runs >= 1 && +s.max_runs <= 10000)) return { ok: false, error: 'max_runs must be a whole number from 1 to 10000' };
133
153
  if (s.cadence === 'once') {
134
154
  if (!/^\d{4}-\d{2}-\d{2}$/.test(String(s.date || ''))) return { ok: false, error: 'a one-off needs a date (YYYY-MM-DD)' };
135
155
  const slot = onceSlot({ date: s.date, time: s.time });
@@ -153,9 +173,11 @@ function normalizeSchedule(input) {
153
173
  task: s.task || s.skill,
154
174
  skill: String(s.skill).trim(),
155
175
  enabled: s.enabled !== false,
176
+ ...(s.until ? { until: String(s.until) } : {}),
177
+ ...(s.max_runs ? { max_runs: Math.floor(+s.max_runs) } : {}),
156
178
  },
157
179
  };
158
180
  }
159
181
  function parseTimeStr(t) { const { h, m } = parseTime(t); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; }
160
182
 
161
- module.exports = { CADENCES, isMissed, ONCE_GRACE_MS, isDue, nextRun, humanCadence, normalizeSchedule, lastSlot, nextSlot };
183
+ module.exports = { finishReason, endOfDay, CADENCES, isMissed, ONCE_GRACE_MS, isDue, nextRun, humanCadence, normalizeSchedule, lastSlot, nextSlot };
@@ -266,7 +266,7 @@ function retargetFiling(text, filing) {
266
266
  // Scaffold a well-formed user skill file from the New Skill flow's fields. Returns { ok, slug, content }
267
267
  // or { ok:false, error }. User skills default to run:agent — a user-authored skill reasons through a
268
268
  // claude session (the existing agent path); the deterministic fast-path is reserved for the built-ins.
269
- function scaffoldSkill({ name, description, output, body, filing } = {}) {
269
+ function scaffoldSkill({ name, description, output, body, filing, work } = {}) {
270
270
  const slug = slugify(name);
271
271
  if (!NAME_RE.test(slug)) return { ok: false, error: 'name must be a short slug (letters, digits, hyphens)' };
272
272
  const label = titleCase(name) || slug;
@@ -276,7 +276,7 @@ function scaffoldSkill({ name, description, output, body, filing } = {}) {
276
276
  if (!out.length) out = ['Summary', 'Findings', 'Next steps']; // always a valid contract
277
277
  // A hand-written body is retargeted too: a PM pasting the old contract text in gets a skill that
278
278
  // files where the console reads, not one that quietly writes into a directory nobody opens.
279
- const proc = retargetFiling(String(body || '').trim(), filing) || defaultBody(label, desc, out, slug, filing);
279
+ const proc = retargetFiling(String(body || '').trim(), filing) || defaultBody(label, desc, out, slug, filing, work ? String(work).slice(0, 8000) : null);
280
280
  const content = [
281
281
  '---',
282
282
  `name: ${slug}`,
@@ -306,7 +306,7 @@ function scaffoldSkill({ name, description, output, body, filing } = {}) {
306
306
  // suite could not reach it. A scaffold that still said "workspace/state/deliverables/" would send
307
307
  // every scheduled agent's report to a directory the console never reads: a silent, permanent
308
308
  // no-show in the needs-you queue. The defaults keep this module usable standalone.
309
- function defaultBody(label, desc, out, slug, filing) {
309
+ function defaultBody(label, desc, out, slug, filing, work) {
310
310
  const id = slug || slugify(label) || 'run';
311
311
  const deliverables = (filing && filing.deliverables) || 'workspace/state/deliverables';
312
312
  const needs = (filing && filing.needs) || 'workspace/state/needs.json';
@@ -318,9 +318,17 @@ function defaultBody(label, desc, out, slug, filing) {
318
318
  '',
319
319
  'Procedure — run the numbered steps in order; each ends on a checkable done-condition:',
320
320
  '',
321
+ ...(work ? [
322
+ '1. Repeat the work below. It was captured from an earlier terminal session by the PM: do the same job again,',
323
+ " with today's dates and this repository's current files. Do not repeat one-off details (old names, old dates, old errors).",
324
+ ' Done when: the same kind of result exists for today, each statement traceable to a real source.',
325
+ ' Captured work:',
326
+ ...String(work).split('\n').map((l) => ' | ' + l),
327
+ ] : [
321
328
  "1. Gather inputs from this repo's own local sources — name the exact commands/paths the run",
322
329
  ' depends on (e.g. `git log`, `gh pr view <n> --json title,body`, a file under `planning/`).',
323
330
  ' Done when: every input the sections below need is in hand, each traceable to a real source.',
331
+ ]),
324
332
  '2. Produce each required section from those inputs, in the exact order of the output contract.',
325
333
  ' An empty section is the literal line "None this period." — never a dropped heading, never invented content.',
326
334
  ' Done when: every contract section is filled or explicitly "None this period.".',
@@ -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.7",
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.7",
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) {