create-pmos 0.38.2 → 0.38.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.38.2",
3
+ "version": "0.38.3",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@6270efe
1
+ kernel-source: pmos@58c817d
2
2
  assembled: 2026-10-02
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-02T03:08:00Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-02T06:19:09Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-02T03:08:00Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-02T06:19:09Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -543,6 +543,12 @@ function profileOf(root) {
543
543
  } catch { return 'product-code'; }
544
544
  }
545
545
 
546
+ /** The lanes a repository's initiatives may declare — what the lane picker offers and the lane write accepts. */
547
+ function lanesFor(root) {
548
+ const profile = profileOf(path.resolve(root || process.cwd()));
549
+ return { profile, lanes: profile === 'pm-artifacts' ? PM_LANES.slice() : ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'] };
550
+ }
551
+
546
552
  // ── Step model ─────────────────────────────────────────────────────────────────────────────────
547
553
  // status ∈ done | current | blocked | pending | na | unknown
548
554
  // `blocking` marks a step whose failure fails CI. O5/O6/O7 are deliberately NOT blocking: none can
@@ -1499,6 +1505,7 @@ module.exports = {
1499
1505
  // under BOTH ranges, and `in this change` is false under the trunk one.
1500
1506
  historyRange,
1501
1507
  ENFORCED_FROM,
1508
+ lanesFor,
1502
1509
  };
1503
1510
 
1504
1511
  /*
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-02T03:08:00Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-02T06:19:09Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -543,6 +543,12 @@ function profileOf(root) {
543
543
  } catch { return 'product-code'; }
544
544
  }
545
545
 
546
+ /** The lanes a repository's initiatives may declare — what the lane picker offers and the lane write accepts. */
547
+ function lanesFor(root) {
548
+ const profile = profileOf(path.resolve(root || process.cwd()));
549
+ return { profile, lanes: profile === 'pm-artifacts' ? PM_LANES.slice() : ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'] };
550
+ }
551
+
546
552
  // ── Step model ─────────────────────────────────────────────────────────────────────────────────
547
553
  // status ∈ done | current | blocked | pending | na | unknown
548
554
  // `blocking` marks a step whose failure fails CI. O5/O6/O7 are deliberately NOT blocking: none can
@@ -1499,6 +1505,7 @@ module.exports = {
1499
1505
  // under BOTH ranges, and `in this change` is false under the trunk one.
1500
1506
  historyRange,
1501
1507
  ENFORCED_FROM,
1508
+ lanesFor,
1502
1509
  };
1503
1510
 
1504
1511
  /*
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.38.2",
3
+ "version": "0.38.3",
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.2",
3
+ "version": "0.38.3",
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",
@@ -15,7 +15,7 @@
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",
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",
19
19
  "test:offline": "node offline-tarball.test.js"
20
20
  },
21
21
  "engines": {
@@ -44,11 +44,39 @@ const DETAIL_TITLE = {
44
44
  const modeTitle = () => MODE_TITLE[state.mode] || 'initiatives';
45
45
  const detailTitle = () => DETAIL_TITLE[(state.detail && state.detail.kind) || ''] || 'detail';
46
46
 
47
+ /* Panels 1 and 2 are resizable: a drag handle on each right edge. The width is clamped, remembered
48
+ * (state.js PERSIST) and reset by a double-click. Before this the columns were a fixed 210px / 280px and a
49
+ * long repository or branch name was simply cut off. */
50
+ const W0 = { min: 180, max: 520, def: 210 };
51
+ const W1 = { min: 220, max: 560, def: 280 };
52
+ const clampW = (v, r) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(r.max, Math.max(r.min, Math.round(v))) : r.def);
53
+ const col0w = () => clampW(state.col0W, W0);
54
+ const col1w = () => clampW(state.col1W, W1);
55
+
47
56
  const gridCols = () =>
48
- (state.col0 ? '210px' : '36px') + ' ' +
49
- (state.col1 ? '280px' : '36px') + ' ' +
57
+ (state.col0 ? col0w() + 'px' : '36px') + ' ' +
58
+ (state.col1 ? col1w() + 'px' : '36px') + ' ' +
50
59
  (state.col2 ? 'minmax(420px,560px)' : '36px') + ' minmax(0,1fr)';
51
60
 
61
+ /** The right-hand 7px of a column is its drag handle. It is a hit-test on the host, not an extra element:
62
+ * the body grid is exactly four tracks and the panels repaint their own children. `key` is the state
63
+ * field holding the width. */
64
+ const EDGE = 7;
65
+ function makeEdgeDrag(host, key, range, getW) {
66
+ const onEdge = (e) => { const r = host.getBoundingClientRect(); return e.clientX >= r.right - EDGE && e.clientX <= r.right + 2; };
67
+ host.addEventListener('mousemove', (e) => { host.style.cursor = onEdge(e) ? 'col-resize' : ''; });
68
+ host.addEventListener('dblclick', (e) => { if (onEdge(e)) setState({ [key]: range.def }); });
69
+ host.addEventListener('mousedown', (e) => {
70
+ if (e.button !== 0 || !onEdge(e)) return;
71
+ e.preventDefault();
72
+ const x0 = e.clientX; const w0 = getW();
73
+ const move = (ev) => setState({ [key]: clampW(w0 + (ev.clientX - x0), range) });
74
+ const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); document.body.style.userSelect = ''; };
75
+ document.body.style.userSelect = 'none';
76
+ document.addEventListener('mousemove', move); document.addEventListener('mouseup', up);
77
+ });
78
+ }
79
+
52
80
  /* ── running-session count for the status bar ───────────────────────────────────────────────
53
81
  * The count is over every session of every initiative in every repository, seeded plus started
54
82
  * (`extraSess`), whose state is `working` or `needs`, skipping records that failed to read. That
@@ -181,6 +209,8 @@ const rail0Host = el('div', {
181
209
  * "this one is safe because of a rule elsewhere in the same line" is how the next edit breaks it. */
182
210
  const hostRepos = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0;min-height:0;overflow-y:auto' });
183
211
  const hostInits = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0' });
212
+ makeEdgeDrag(hostRepos, 'col0W', W0, col0w);
213
+ makeEdgeDrag(hostInits, 'col1W', W1, col1w);
184
214
  const hostDetail = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0;display:flex;flex-direction:column' });
185
215
  const hostTabs = el('div', { style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden' });
186
216
 
@@ -403,6 +403,9 @@ function recordFrom(payload) {
403
403
  }
404
404
  const rec = {
405
405
  tag: fm.title || (row && row.title) || '',
406
+ // the lane this record declares, and the lanes this repository allows — what the header's lane picker offers
407
+ lane: lane || null,
408
+ lanes: payload.lanes && Array.isArray(payload.lanes.lanes) ? payload.lanes.lanes : null,
406
409
  stage: row ? row.stage : null,
407
410
  flow: fm.flow || null,
408
411
  pills: pillsFor({ lane, anchor: payload.anchor, flow: fm.flow || null }),
@@ -584,6 +587,7 @@ function foldWorkspaces(rows) {
584
587
  p.ws = w.id;
585
588
  // the PM's own name for the repository (overhaul-r1 R3.1) lives on the main tree's row
586
589
  if (w.label) p.name = w.label;
590
+ p.initials = w.initials || null;
587
591
  p.kind = w.kind === 'github' ? 'github' : 'local';
588
592
  p.remote = w.remote || null;
589
593
  }
@@ -724,6 +728,19 @@ function readStamp() {
724
728
  return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
725
729
  }
726
730
 
731
+ /** Change an existing initiative's lane (the ladder re-derives from it), then re-read the record and its board. */
732
+ export async function setInitiativeLane(slug, lane) {
733
+ if (FIXTURE) return { ok: false, error: 'fixture mode has no records to edit' };
734
+ const prod = products.get(currentProductName());
735
+ const out = await postJson('/api/initiative/lane', { ws: prod ? prod.ws : null, id: slug, lane });
736
+ if (out && out.ok && out.changed !== false) {
737
+ delete hydrated[slug];
738
+ await hydrate(slug);
739
+ if (prod) { try { await loadBoard(prod); publishBoards(); } catch { /* the record read above already shows the new ladder */ } }
740
+ }
741
+ return out || { ok: false, error: 'the workspace server did not answer' };
742
+ }
743
+
727
744
  async function hydrate(slug) {
728
745
  if (FIXTURE || !slug || inFlight.has(slug)) return null;
729
746
  const prod = products.get(currentProductName());
@@ -1037,12 +1054,15 @@ export async function removeRepository(name) {
1037
1054
  return res || { ok: false, error: 'the workspace server did not answer' };
1038
1055
  }
1039
1056
 
1057
+ /** The PM's own abbreviation for the rail, or null (the rail derives one from the name). */
1058
+ export function repoInitials(name) { const p = products.get(name); return (p && p.initials) || null; }
1059
+
1040
1060
  /** panel-repos.js — `renameRepository(name, label)`. A display name only (R3.1). */
1041
- export async function renameRepository(name, label) {
1061
+ export async function renameRepository(name, label, initials) {
1042
1062
  if (FIXTURE) return { ok: false, error: 'fixture mode — nothing is registered' };
1043
1063
  const p = products.get(name);
1044
1064
  if (!p) return { ok: false, reason: 'unknown', error: 'unknown repository' };
1045
- return (await postJson('/api/workspaces/rename', { id: p.ws, name: label })) || { ok: false, error: 'the workspace server did not answer' };
1065
+ return (await postJson('/api/workspaces/rename', { id: p.ws, name: label, ...(initials !== undefined ? { initials } : {}) })) || { ok: false, error: 'the workspace server did not answer' };
1046
1066
  }
1047
1067
 
1048
1068
  /**
@@ -14,7 +14,7 @@
14
14
  * Every list states its read: loading, failed (with the reason), or a successful read of nothing.
15
15
  * Fixture mode never reaches this module (modes.js routes here only when !FIXTURE).
16
16
  */
17
- import { el } from './dom.js';
17
+ import { el, setChildren } from './dom.js';
18
18
  import { state, setState } from './state.js';
19
19
 
20
20
  const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
@@ -108,7 +108,18 @@ export function portfolioList() {
108
108
  const st = status(r, 'the portfolio');
109
109
  if (st) return [...kids, st];
110
110
  const pf = r.data || { repos: [], rows: [] };
111
- if (!pf.hub) kids.push(el('div', { style: NOTE }, iv('No hub yet — reports need a portfolio.yaml in your PM repository (D-3).')));
111
+ if (!pf.hub) {
112
+ const here = state.product;
113
+ kids.push(el('div', { style: NOTE, 'data-r15': 'no-hub' }, iv('No hub yet — reports need a portfolio.yaml in a registered repository (D-3). A report is never written to a guessed place.')));
114
+ if (here) kids.push(el('div', { style: 'padding:0 14px 6px' }, el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r15': 'make-hub',
115
+ title: 'writes a starter portfolio.yaml in ' + here + ' listing your registered repositories; edit the domains afterwards',
116
+ onClick: async () => {
117
+ const w = dataMod ? dataMod.wsOf(here) : null;
118
+ const out = w ? await post('/api/portfolio/init', { ws: w }) : null;
119
+ reportNote = out && out.ok ? { tone: 'ok', text: 'wrote portfolio.yaml in ' + out.hub + ' — it is now the hub; edit the domains there' } : { tone: 'bad', text: (out && out.error) || 'the hub was not created' };
120
+ reload('portfolio'); setState({});
121
+ } }, 'make “' + here + '” the hub')));
122
+ }
112
123
  const q = String(state.q || '').trim().toLowerCase();
113
124
  const rows = (pf.rows || []).filter((x) => !q || [x.id, x.title, x.group, x.repo, x.domain].some((v) => v && String(v).toLowerCase().includes(q)));
114
125
  // coverage first: what was read, how fresh, and what was NOT read
@@ -188,6 +199,25 @@ function specialRow() {
188
199
  specialNote ? el('span', { 'data-r11': 'special-note', style: 'display:block;font-size:11px;color:var(--muted-foreground);margin-top:4px' }, iv(specialNote)) : null);
189
200
  }
190
201
 
202
+ /* Run a schedule now and SAY what happened. The handlers used to discard the answer, so a session that
203
+ * failed to start (no agent binary, a refused model, a missing schedule) looked like a button that did nothing. */
204
+ let runNote = null;
205
+ async function runSchedule(s, name) {
206
+ runNote = { tone: 'mut', text: 'starting ' + (name || 'the task') + ' …' };
207
+ setState({});
208
+ const out = await post('/api/schedules/run', { id: s.id });
209
+ const r = out && out.result;
210
+ if (!out || out.ok === false) runNote = { tone: 'bad', text: (out && out.error) || 'the workspace server did not answer — nothing was started' };
211
+ else if (r && r.ok === false) runNote = { tone: 'bad', text: 'did not start — ' + (r.reason || 'the server gave no reason') };
212
+ else if (r && r.sessionId) {
213
+ runNote = { tone: 'ok', text: (name || 'task') + ' started as a session — opened in the tabs' };
214
+ try { const ini = await import('./panel-initiatives.js'); ini.openTerm(null, { id: r.sessionId, name: name || 'scheduled run', st: 'working', profile: null }); } catch {}
215
+ } else runNote = { tone: 'ok', text: (name || 'task') + ' ran — the result is in needs-you' };
216
+ reload('schedules');
217
+ setState({});
218
+ }
219
+ const runNoteEl = () => (runNote ? el('div', { 'data-r4': 'run-note', style: NOTE + ';color:' + (runNote.tone === 'bad' ? 'var(--destructive)' : 'var(--muted-foreground)') }, iv(runNote.text)) : null);
220
+
191
221
  /* ── scheduled ──────────────────────────────────────────────────────────────────────────────── */
192
222
  export function scheduledLive() {
193
223
  const r = read('schedules', '/api/schedules?all=1');
@@ -198,7 +228,7 @@ export function scheduledLive() {
198
228
  seedRow('reports', 'set up report drafts (daily · weekly · bi-weekly · monthly)', 'schedules the four report skills for the selected repository; each follows your own definition and files a draft in that repository. A special report is run on demand.'),
199
229
  seedRow('skills', 'set up weekly skill discovery', 'schedules the skill-discover skill weekly (Wednesday 09:00) for the selected repository; it files candidates for you to read and never approves them'),
200
230
  seedRow('memory', 'set up weekly memory grooming', 'schedules the memory-groom skill weekly (Monday 08:00) for the selected repository; it proposes changes and never makes them'),
201
- specialRow()];
231
+ specialRow(), runNoteEl()];
202
232
  for (const s of list) {
203
233
  const name = s.label || s.name || s.task || s.skill || s.id;
204
234
  const cadence = (s.cadence || '') + (s.day ? ' ' + s.day : '') + (s.time ? ' at ' + s.time : '');
@@ -209,7 +239,7 @@ export function scheduledLive() {
209
239
  el('span', { style: 'display:block;font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground)' },
210
240
  iv('next ' + (s.next ? String(s.next).slice(0, 16).replace('T', ' ') : '—') + ' · last ' + (s.lastRun ? String(s.lastRun).slice(0, 16).replace('T', ' ') : 'never'))),
211
241
  el('span', { style: 'display:flex;gap:4px;margin-top:4px' },
212
- el('button', { onClick: async () => { await post('/api/schedules/run', { id: s.id }); reload('schedules'); setState({}); }, style: BTN, hover: 'color:var(--foreground)', 'data-r4': 'run' }, 'run now'),
242
+ el('button', { onClick: () => runSchedule(s, name), style: BTN, hover: 'color:var(--foreground)', 'data-r4': 'run' }, 'run now'),
213
243
  el('button', { onClick: async () => {
214
244
  if (typeof window !== 'undefined' && window.confirm && !window.confirm('Delete the schedule "' + name + '"?')) return;
215
245
  await post('/api/schedules/delete', { id: s.id }); reload('schedules'); setState({});
@@ -257,7 +287,9 @@ export function memoryLive() {
257
287
  const u = (d.usage || {})[t] || { used: 0, limit: 0, percent: 0 };
258
288
  kids.push(el('div', { style: LABEL, 'data-r9': 'usage-' + t }, iv((t === 'memory' ? 'repository memory' : 'about you') + ' · ' + u.percent + '% · ' + u.used + '/' + u.limit)));
259
289
  const es = (d.entries || []).filter((e) => e.target === t);
260
- if (!es.length) kids.push(el('div', { style: NOTE }, iv('Nothing recorded yet.')));
290
+ if (!es.length) kids.push(el('div', { style: NOTE, 'data-r9': 'empty' }, iv(t === 'memory' && !d.wired
291
+ ? 'Nothing recorded yet. Sessions read this memory — and agents add to it — only once the repository is wired: press “wire into CLAUDE.md”. Nothing is added automatically; an agent’s fact stays unconfirmed until you confirm it.'
292
+ : 'Nothing recorded yet.')));
261
293
  for (const e of es) {
262
294
  kids.push(el('div', { style: ROW, 'data-r9': 'entry', 'data-id': e.id },
263
295
  el('span', { style: 'display:block;font-size:12px;line-height:1.45' }, iv(e.text)),
@@ -269,6 +301,15 @@ export function memoryLive() {
269
301
  el('button', { style: BTN, hover: 'color:var(--destructive)', 'data-r9': 'remove', onClick: () => { if (window.confirm && !window.confirm('Remove this entry?')) return; memDo({ op: 'remove', target: t, old: e.text }); } }, 'remove'))));
270
302
  }
271
303
  }
304
+ if ((d.elsewhere || []).length) {
305
+ kids.push(el('div', { style: LABEL + ';color:var(--foreground)', 'data-r9': 'elsewhere-label', title: 'the ledger lives in each checkout; these arrive in this tree when their branch merges' }, iv('recorded on other branches · ' + d.elsewhere.length + ' · read only')));
306
+ for (const e of d.elsewhere) {
307
+ kids.push(el('div', { style: ROW, 'data-r9': 'elsewhere', 'data-id': e.id },
308
+ el('span', { style: 'display:block;font-size:12px;line-height:1.45' }, iv(e.text)),
309
+ el('span', { style: 'display:block;font-family:var(--mono);font-size:10px;color:var(--muted-foreground);margin-top:2px' },
310
+ iv([e.target === 'user' ? 'about you' : null, e.confidence, e.branch ? '⑂ ' + e.branch : null, e.by, String(e.at).slice(0, 10)].filter(Boolean).join(' · ')))));
311
+ }
312
+ }
272
313
  if ((d.pending || []).length) {
273
314
  kids.push(el('div', { style: LABEL + ';color:var(--foreground)', 'data-r9': 'pending-label' }, iv('waiting for your approval · ' + d.pending.length)));
274
315
  for (const p of d.pending) {
@@ -341,10 +382,21 @@ function discoveryBlock(wsId) {
341
382
  const kids = [el('div', { style: LABEL + ';color:var(--foreground)', 'data-r13': 'discovery-label' }, iv('skill discovery')),
342
383
  el('div', { style: NOTE }, iv('Finds skills in your own repository and from sources you name. Nothing it finds runs until you read it and approve it.')),
343
384
  el('div', { style: 'display:flex;gap:4px;flex-wrap:wrap;padding:0 14px 6px' },
344
- mine ? el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r13': 'discover-now', onClick: async () => { await post('/api/schedules/run', { id: mine.id }); reload('schedules'); setState({}); } }, 'find skills now') : null,
385
+ mine ? el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r13': 'discover-now', onClick: () => runSchedule(mine, 'skill discovery') }, 'find skills now') : null,
345
386
  mine ? el('span', { style: 'font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground);align-self:center' }, iv('weekly · next ' + (mine.next ? String(mine.next).slice(0, 16).replace('T', ' ') : '—')))
346
387
  : seedRow('skills', 'set up weekly skill discovery', 'schedules the skill-discover skill weekly (Wednesday 09:00) for the selected repository; it files candidates for you to read and never approves them'))];
347
- return [...kids, ...candidatesBlock(wsId)];
388
+ return [...kids, runNoteEl(), ...candidatesBlock(wsId)];
389
+ }
390
+ /** A skill opens in a tab, like a Markdown file: it sits beside the terminals and survives a mode switch. */
391
+ async function openSkillTab(sk) {
392
+ const ini = await import('./panel-initiatives.js');
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
+ }
395
+ /** The body of a skill tab (panel 4). Rebuilt on each paint, like the task tab; the draft lives in `skillDraft`. */
396
+ export function mountSkillTab(host, tab) {
397
+ 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 }));
398
+ setChildren(host, [body]);
399
+ return host;
348
400
  }
349
401
  export function skillsLive() {
350
402
  const wsId = dataMod && state.product ? dataMod.wsOf(state.product) : null;
@@ -353,9 +405,8 @@ export function skillsLive() {
353
405
  const head = discoveryBlock(wsId);
354
406
  if (st) return [el('div', { style: 'padding:6px 0' }, [...head, st])];
355
407
  const list = (r.data && r.data.skills) || [];
356
- const open = state.detail && state.detail.kind === 'skill' ? state.detail.id : null;
357
- const kids = list.map((sk) => el('button', { style: ROW + (open === sk.id ? ';background:var(--accent)' : ''), hover: 'background:var(--accent)', 'data-r4': 'skill', 'data-id': sk.id,
358
- onClick: () => setState({ detail: { kind: 'skill', id: sk.id } }) },
408
+ const kids = list.map((sk) => el('button', { style: ROW + (state.active === 'skill:' + sk.id ? ';background:var(--accent)' : ''), hover: 'background:var(--accent)', 'data-r4': 'skill', 'data-id': sk.id,
409
+ onClick: () => openSkillTab(sk) },
359
410
  el('span', { style: 'display:flex;gap:8px;align-items:baseline' },
360
411
  el('span', { style: 'font-family:var(--mono);font-size:12px' }, iv(sk.label || sk.skill || sk.id)),
361
412
  el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(sk.source))),
@@ -394,7 +445,10 @@ export function skillDetailLive(det) {
394
445
  setState({});
395
446
  } }, 'save') : el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r13': 'skill-copy', onClick: async () => {
396
447
  const res = await post('/api/skills/copy', { id, ws: wsId });
397
- if (res && res.ok) { skillNote = 'copied as ' + res.id + ' — it is yours to edit'; reload('skills:' + (wsId || '')); setState({ detail: { kind: 'skill', id: res.id } }); }
448
+ if (res && res.ok) {
449
+ skillNote = 'copied as ' + res.id + ' — it is yours to edit'; reload('skills:' + (wsId || ''));
450
+ if (det && det.tab) openSkillTab({ id: res.id, label: res.id }); else setState({ detail: { kind: 'skill', id: res.id } });
451
+ }
398
452
  else { skillNote = (res && res.error) || 'not copied'; setState({}); }
399
453
  } }, 'make my own copy to edit'),
400
454
  dirty ? el('span', { style: 'font-size:11px;color:var(--muted-foreground)' }, 'unsaved changes') : null,
@@ -289,7 +289,32 @@ function crumbBlock(c) {
289
289
  );
290
290
  }
291
291
 
292
- function headerBlock(name, tag, pills, crumb) {
292
+ /* The lane picker: an existing initiative's workflow. The ladder is derived from the record's `lane:`, so this
293
+ * edits that line (POST /api/initiative/lane) after a confirm, and the rungs re-derive. Live mode only. */
294
+ let laneNote = null;
295
+ function lanePicker(slug, rec) {
296
+ if (FIXTURE || !rec || !rec.lane || !Array.isArray(rec.lanes) || !rec.lanes.length) return null;
297
+ const opts = rec.lanes.includes(rec.lane) ? rec.lanes : [rec.lane, ...rec.lanes];
298
+ return el('div', { 'data-r15': 'lane-bar', style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:8px;font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground)' },
299
+ el('span', null, 'workflow'),
300
+ el('select', { 'data-r15': 'lane-pick', title: 'change this initiative’s workflow (lane); the ladder re-derives from it',
301
+ style: 'font-family:var(--mono);font-size:10.5px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);padding:2px 4px;min-height:24px',
302
+ onChange: async (e) => {
303
+ const next = e.target.value;
304
+ if (next === rec.lane) return;
305
+ const ok = typeof window === 'undefined' || !window.confirm ? true
306
+ : window.confirm('Change “' + slug + '” from the ' + rec.lane + ' lane to the ' + next + ' lane?\n\nThe lane decides which steps the workflow ladder requires (spec, rubric, explainer, …). Only the lane: line of the record changes; the steps are re-derived and some may show as missing.');
307
+ if (!ok) { e.target.value = rec.lane; return; }
308
+ const data = sibling('data', './data.js');
309
+ const out = data && data.setInitiativeLane ? await data.setInitiativeLane(slug, next) : null;
310
+ laneNote = out && out.ok ? 'lane is now ' + next : 'not changed — ' + ((out && out.error) || 'the server did not answer');
311
+ setState({});
312
+ } },
313
+ opts.map((l) => el('option', { value: l, selected: l === rec.lane ? 'selected' : null }, l))),
314
+ laneNote ? el('span', { 'data-r15': 'lane-note', style: 'color:var(--muted-foreground)' }, iv(laneNote)) : null);
315
+ }
316
+
317
+ function headerBlock(name, tag, pills, crumb, lane) {
293
318
  return el('div', { style: 'padding:13px 14px 11px;border-bottom:1px solid var(--border)' },
294
319
  el('div', { style: 'font-family:var(--mono);font-size:15px;font-weight:600;letter-spacing:-.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(name)),
295
320
  el('div', { style: 'font-size:12px;color:var(--muted-foreground);margin-top:2px' }, iv(tag)),
@@ -298,6 +323,7 @@ function headerBlock(name, tag, pills, crumb) {
298
323
  style: 'font-family:var(--mono);font-size:10px;padding:2px 6px;background:var(--secondary);border:1px solid var(--border);color:' + pl.c,
299
324
  }, iv(pl.t)))),
300
325
  crumbBlock(crumb),
326
+ lane || null,
301
327
  );
302
328
  }
303
329
 
@@ -1001,7 +1027,7 @@ function initBody(slug) {
1001
1027
  return out;
1002
1028
  }
1003
1029
 
1004
- out.push(headerBlock(slug, rec.tag || '', rec.pills || [], rec.crumb || null));
1030
+ out.push(headerBlock(slug, rec.tag || '', rec.pills || [], rec.crumb || null, lanePicker(slug, rec)));
1005
1031
  { const lc = lifecycleRow(repoOf(slug), slug); if (lc) out.push(lc); }
1006
1032
 
1007
1033
  if (rec.noLadder) {
@@ -412,7 +412,7 @@ function orphanRows(rows) {
412
412
  style: 'display:flex;align-items:center;gap:7px;width:100%;padding:5px 12px 5px 27px;min-height:32px;border-left:2px solid transparent;background:transparent',
413
413
  hover: 'background:var(--accent)',
414
414
  title: (s.name || s.id) + ' — ' + (s.label || s.shown || '')
415
- + '\nPMOS was never told what this session is for. + bind records it.',
415
+ + '\nPMOS was never told what this session is for. + bind records it. A session maps to one initiative or none.',
416
416
  },
417
417
  el('span', { style: 'flex:none;font-size:12px;color:' + (st && ss ? ss.COLOR[st] : 'var(--muted-foreground)') },
418
418
  st && ss ? ss.GLYPH[st] : '·'),
@@ -260,9 +260,15 @@ function treesOf(name) {
260
260
  }
261
261
 
262
262
  async function renameRepo(name) {
263
- const label = typeof window !== 'undefined' && window.prompt ? window.prompt('Rename “' + name + '” to:', name) : null;
264
- if (label === null || !String(label).trim() || String(label).trim() === name) return;
265
- const out = await dataMod.renameRepository(name, String(label).trim());
263
+ const win = typeof window !== 'undefined' && window.prompt ? window : null;
264
+ const label = win ? win.prompt('Rename “' + name + '” to:', name) : null;
265
+ if (label === null || !String(label).trim()) return;
266
+ // the collapsed rail shows an abbreviation; empty keeps (or restores) the one derived from the name
267
+ const cur = dataMod.repoInitials ? dataMod.repoInitials(name) : null;
268
+ const ini = win.prompt('Abbreviation on the collapsed rail (up to 3 letters or digits; empty = derived from the name):', cur || '');
269
+ if (ini === null) return;
270
+ if (String(label).trim() === name && String(ini).trim() === (cur || '')) return;
271
+ const out = await dataMod.renameRepository(name, String(label).trim(), String(ini).trim());
266
272
  if (!out || out.ok === false) { setState({ repoNotice: { name, tone: 'bad', text: (out && out.error) || 'rename refused' } }); return; }
267
273
  await connectData();
268
274
  setState({ product: String(label).trim(), repoNotice: null });
@@ -333,12 +339,14 @@ export function railGlyphs() {
333
339
  const wss = new Set(treesOf(name).map((x) => x.ws));
334
340
  const needs = sess.some((x) => x && wss.has(x.ws) && x.shown === 'needs');
335
341
  const active = product === name;
336
- const ini = (String(name).replace(/[^A-Za-z0-9]+/g, ' ').trim().split(' ').filter(Boolean).slice(0, 2)
342
+ const ini = (dataMod && dataMod.repoInitials && dataMod.repoInitials(name)) || (String(name).replace(/[^A-Za-z0-9]+/g, ' ').trim().split(' ').filter(Boolean).slice(0, 2)
337
343
  .map((w) => w[0]).join('') || String(name).slice(0, 2)).toUpperCase();
338
344
  const said = name + ' — ' + rp.kindLabel + ' repository' + (needs ? ' · a session needs you' : '');
339
345
  return el('button', {
340
346
  onClick: (e) => { if (e && e.stopPropagation) e.stopPropagation(); selectRepo(name); },
341
- title: said, 'aria-label': said,
347
+ // right-click renames from the rail, where the abbreviation is shown
348
+ onContextmenu: (e) => { if (e && e.preventDefault) e.preventDefault(); renameRepo(name); },
349
+ title: said + ' · right-click to rename', 'aria-label': said,
342
350
  'data-r1': 'rail-glyph', 'data-repo': name, 'data-active': active ? '1' : '0',
343
351
  'data-ini': ini, 'data-kind': rp.kindGlyph,
344
352
  style: 'position:relative;flex:none;width:28px;height:28px;margin:3px auto;display:flex;align-items:center;justify-content:center;'
@@ -40,6 +40,9 @@ import { boardOf, currentProduct } from './board-read.js';
40
40
  * file that is not on disk yet takes the whole panel down with it, and an empty terminal pane is a
41
41
  * far smaller failure than a dead panel 4. When it lands, the panel repaints itself. */
42
42
  let termMod = null;
43
+ let liveMod = null;
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; });
43
46
  import('./tab-term.js')
44
47
  .then((m) => { termMod = m; setState({}); })
45
48
  .catch(() => { termMod = null; });
@@ -792,6 +795,8 @@ function paneFor(tab) {
792
795
  if (termMod && typeof termMod.mountTerm === 'function') termMod.mountTerm(host, tab);
793
796
  } else if (tab.kind === 'task') {
794
797
  mountTask(host, tab);
798
+ } else if (tab.kind === 'skill') {
799
+ if (liveMod && typeof liveMod.mountSkillTab === 'function') liveMod.mountSkillTab(host, tab);
795
800
  } else if (tab.kind === 'web') {
796
801
  mountWeb(host, tab); // overhaul-r5 — never falls through to an empty doc tab
797
802
  } else {
@@ -908,7 +913,7 @@ function tabNode(t, act, splitTab) {
908
913
  * fixture render keeps the design's literal — the comparator reads this text node. */
909
914
  const meta = t.kind === 'term'
910
915
  ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
911
- : (t.kind === 'task' ? 'task' : t.kind === 'web' ? 'web' : t.role);
916
+ : (t.kind === 'task' ? 'task' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.role);
912
917
 
913
918
  return el('div', {
914
919
  style: 'flex:none;display:flex;align-items:center;gap:8px;padding:0 10px;background:' + bg
@@ -15,6 +15,8 @@
15
15
  */
16
16
  import { el } from './dom.js';
17
17
  import { state, setState, subscribe } from './state.js';
18
+ import { boardOf } from './board-read.js';
19
+ import { openNewSession } from './panel-newsess.js';
18
20
 
19
21
  const ip = (v) => el('span', null, v);
20
22
  const MONO = 'font-family:var(--mono);font-size:10.5px';
@@ -68,20 +70,20 @@ async function endSession(name, s) {
68
70
 
69
71
  async function removeTree(name, t) {
70
72
  const ok = typeof window === 'undefined' || !window.confirm ? true
71
- : window.confirm('Remove the folder for “' + t.branch + '”?\n\n' + t.dir + '\n\nThe branch is kept. A folder with unsaved work or a running session is refused.');
73
+ : window.confirm('Remove the folder for “' + t.branch + '”?\n\n' + t.dir + '\n\nThis only removes the folder (git worktree remove). The branch is kept and nothing is merged into main — merge through a pull request. A folder with unsaved work, or a running session, is refused: end the session first.');
72
74
  if (!ok) return;
73
75
  const out = dataMod ? await dataMod.removeWorktree(t.ws, t.dir) : null;
74
76
  if (!out || out.ok === false) { note(name, (out && out.error) || 'the folder could not be removed', 'bad'); return; }
75
77
  setState((s) => ({ treeSel: { ...(s.treeSel || {}), [name]: null } }));
76
- note(name, 'removed the folder for ' + t.branch + ' — the branch is kept');
78
+ note(name, 'removed the folder for ' + t.branch + ' — the branch is kept, nothing was merged' + (out.note ? '. ' + out.note : ''));
77
79
  refresh();
78
80
  }
79
81
 
80
82
  function sessionRow(name, s) {
81
83
  const armed = state.treeConfirm === s.id;
82
84
  return el('div', { style: 'display:flex;align-items:center;gap:6px;padding:1px 0', 'data-r14': 'tree-session', 'data-id': s.id },
83
- el('button', { onClick: () => openSession(s), style: MONO + ';flex:1;min-width:0;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--foreground)', hover: 'text-decoration:underline', title: 'open this session' },
84
- ip(s.name + ' · ' + (isEnded(s) ? 'ended' : (s.shown || s.state || 'running')))),
85
+ el('button', { onClick: () => openSession(s), style: MONO + ';flex:1;min-width:0;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--foreground)', hover: 'text-decoration:underline', title: s.initiative ? 'open this session — recorded on initiative ' + s.initiative : 'open this session — it is on no initiative; a session maps to one initiative or none (bind it from the initiatives list)' },
86
+ ip(s.name + ' · ' + (isEnded(s) ? 'ended' : (s.shown || s.state || 'running')) + (s.initiative ? ' · #' + s.initiative : ' · no initiative'))),
85
87
  el('button', {
86
88
  onClick: () => (armed ? endSession(name, s) : setState({ treeConfirm: s.id })),
87
89
  style: BTN + (armed ? ';color:var(--destructive);border-color:var(--destructive)' : ''), hover: 'color:var(--destructive)',
@@ -100,8 +102,8 @@ function treeRow(name, t) {
100
102
  'data-r14': 'tree', 'data-ws': t.ws,
101
103
  },
102
104
  el('span', { style: MUTED }, ip(sel ? '▾' : '▸')),
103
- el('span', { style: 'color:var(--foreground)' }, ip((t.main ? '⎇ ' : '⑂ ') + (t.branch || '(detached)'))),
104
- el('span', { style: MUTED }, ip(live + ' live' + (sess.length > live ? ' · ' + (sess.length - live) + ' ended' : ''))),
105
+ el('span', { style: 'color:var(--foreground);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', title: t.branch || '(detached)' }, ip((t.main ? '⎇ ' : '⑂ ') + (t.branch || '(detached)'))),
106
+ el('span', { style: MUTED + ';flex:none' }, ip(live + ' live' + (sess.length > live ? ' · ' + (sess.length - live) + ' ended' : ''))),
105
107
  t.initiative ? el('span', { style: MUTED }, ip('#' + t.initiative)) : null);
106
108
  if (!sel) return head;
107
109
  return el('div', null, head, el('div', { style: 'padding:2px 0 6px 14px', 'data-r14': 'tree-open' },
@@ -111,11 +113,24 @@ function treeRow(name, t) {
111
113
  t.main ? null : el('button', { onClick: () => removeTree(name, t), style: BTN, hover: 'color:var(--destructive)', title: 'remove this worktree folder (the branch is kept)', 'data-r14': 'tree-remove' }, 'remove folder'))));
112
114
  }
113
115
 
116
+ /** `+ session` on the repository: the pre-flight form (branch, agent, profile) on an initiative of THIS
117
+ * repository — the one selected, else the first on its board. A repository with no initiative has nothing
118
+ * to attach the form to, so the session starts in its main tree and the panel says so. */
119
+ export function newSessionHere(name, trees) {
120
+ const slugs = (boardOf()[name] || []).flatMap((g) => g.slugs || []);
121
+ const slug = slugs.includes(state.sel) ? state.sel : slugs[0];
122
+ if (slug) { openNewSession(slug); return; }
123
+ const main = (trees || []).find((t) => t.main) || (trees || [])[0];
124
+ if (main) { note(name, 'no initiative here to attach the form to — started a shell in the main tree'); startHere(name, main); }
125
+ }
126
+
114
127
  /** The trees of one repository, main first. `name` is the repository's name in the data layer. */
115
128
  export function treesBlock(name, trees, addable) {
116
129
  return el('div', { style: 'padding:2px 14px 8px 39px', 'data-r1': 'trees', 'data-repo': name }, trees.map((t) => treeRow(name, t)),
117
130
  addable ? el('button', { onClick: () => openBranchForm(name, trees), style: BTN + ';margin-top:4px', hover: 'color:var(--foreground)',
118
- title: 'create a branch — with or without its own folder — without starting a session', 'data-r14': 'branch-add' }, '+ branch') : null);
131
+ title: 'create a branch — with or without its own folder — without starting a session', 'data-r14': 'branch-add' }, '+ branch') : null,
132
+ addable ? el('button', { onClick: () => newSessionHere(name, trees), style: BTN + ';margin-top:4px;margin-left:6px', hover: 'color:var(--foreground)',
133
+ title: 'start a session — choose the branch, the agent and the profile first', 'data-r15': 'session-add' }, '+ session') : null);
119
134
  }
120
135
 
121
136
  /* ── + branch ───────────────────────────────────────────────────────────────────────────────── */
@@ -151,7 +151,9 @@ const TAB_FIELDS = ['id', 'kind', 'title', 'slug', 'repo', 'sess', 'sessionId',
151
151
  // reading the branch-only file from ITS tree, not fall back to the main tree
152
152
  'ws', 'branch',
153
153
  // a web tab's address (overhaul-r5, D69) — restored, re-validated, and loaded only on a PM press
154
- 'url'];
154
+ 'url',
155
+ // a skill tab's skill id — a reload reopens the same skill
156
+ 'skillId'];
155
157
  const MAX_TABS = 40;
156
158
 
157
159
  function packTab(t) {
@@ -172,6 +174,9 @@ const isTabList = (v) =>
172
174
  const PERSIST = {
173
175
  theme: (v) => v === 'dark' || v === 'light',
174
176
  col0: isBool, col1: isBool, col2: isBool,
177
+ // the dragged widths of panels 1 and 2 (clamped again by app.js on the way out)
178
+ 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,
175
180
  settingsOpen: isBool,
176
181
  product: isSlug,
177
182
  sel: isSlug,
@@ -360,7 +360,7 @@ function loadWorkspaces() {
360
360
  if (!w || !w.root) continue;
361
361
  const root = path.resolve(w.root);
362
362
  if (!isDir(root)) continue; // a moved/deleted repo drops out quietly
363
- workspaces.set(wsId(root), { id: wsId(root), name: w.name || path.basename(root), root, addedAt: w.addedAt || nowISO(), ...(w.label ? { label: String(w.label) } : {}) });
363
+ workspaces.set(wsId(root), { id: wsId(root), name: w.name || path.basename(root), root, addedAt: w.addedAt || nowISO(), ...(w.label ? { label: String(w.label) } : {}), ...(w.initials ? { initials: String(w.initials) } : {}) });
364
364
  }
365
365
  }
366
366
  }
@@ -588,7 +588,7 @@ function wsList() {
588
588
  return [...workspaces.values()].map((w) => {
589
589
  const rid = repoIdentity(w.root);
590
590
  return {
591
- id: w.id, name: w.name, label: w.label || null, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
591
+ id: w.id, name: w.name, label: w.label || null, initials: w.initials || null, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
592
592
  shape: wsShape(w.root), addedAt: w.addedAt,
593
593
  sessions: [...sessions.values()].filter((s) => s.ws === w.id).length,
594
594
  // the repository this working tree belongs to, and the branch it is on. `branch_code`
@@ -4321,8 +4321,15 @@ async function handleRequest(req, res) {
4321
4321
  if (!name) return json(res, { ok: false, error: 'a name is required' }, 400);
4322
4322
  // `label` is the PM's name for the REPOSITORY. The client names a product after its folder
4323
4323
  // (`repo_name`), so changing `name` alone would never show; `label` wins over both.
4324
- w.label = name; saveWorkspaces(); pushWorkspaces();
4325
- return json(res, { ok: true, workspace: { id: w.id, name, label: name } });
4324
+ w.label = name;
4325
+ // The rail's abbreviation. Optional: undefined leaves it alone, '' clears it back to the derived initials.
4326
+ if (body.initials !== undefined) {
4327
+ const ini = String(body.initials || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 3).toUpperCase();
4328
+ if (String(body.initials || '').trim() && !ini) return json(res, { ok: false, error: 'the abbreviation takes letters and digits, up to three' }, 400);
4329
+ if (ini) w.initials = ini; else delete w.initials;
4330
+ }
4331
+ saveWorkspaces(); pushWorkspaces();
4332
+ return json(res, { ok: true, workspace: { id: w.id, name, label: name, initials: w.initials || null } });
4326
4333
  }
4327
4334
  if (p === '/api/git/branch' && req.method === 'POST') {
4328
4335
  let body = {};
@@ -4363,9 +4370,13 @@ async function handleRequest(req, res) {
4363
4370
  // the workspace entry goes with the folder; forgetWorkspace never touches a repo, and it
4364
4371
  // refuses while sessions remain, so a race here fails closed rather than orphaning scrollback
4365
4372
  const owner = [...workspaces.values()].find((x) => path.resolve(x.root) === path.resolve(row.dir));
4366
- if (owner) forgetWorkspace(owner.id);
4373
+ // forgetWorkspace refuses while ANY session (ended ones included) still names the row. Say so: the folder
4374
+ // is gone, and the row stays until those ended sessions are removed — it used to stay without a word.
4375
+ const forgotten = owner ? forgetWorkspace(owner.id) : true;
4376
+ const blocker = owner && !forgotten ? forgetBlocker(owner.id) : null;
4367
4377
  broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) });
4368
- return json(res, { ok: true, dir: r.dir, pruned: !!r.pruned });
4378
+ return json(res, { ok: true, dir: r.dir, pruned: !!r.pruned, row_kept: !!blocker,
4379
+ note: blocker ? `the folder is removed; the repository row stays until its ended session${blocker.sessions.length === 1 ? '' : 's'} (${blocker.sessions.join(', ')}) ${blocker.sessions.length === 1 ? 'is' : 'are'} removed` : null });
4369
4380
  }
4370
4381
  if (p === '/api/pick-folder' && req.method === 'POST') {
4371
4382
  let body = {};
@@ -5137,7 +5148,40 @@ async function handleRequest(req, res) {
5137
5148
  homeDir: os.homedir(),
5138
5149
  });
5139
5150
  // Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
5140
- return json(res, { ws: w ? w.id : null, ...out });
5151
+ let lanes = null;
5152
+ try { if (typeof engine.engine.lanesFor === 'function') lanes = engine.engine.lanesFor(root); } catch { lanes = null; }
5153
+ return json(res, { ws: w ? w.id : null, ...out, lanes });
5154
+ }
5155
+ if (p === '/api/initiative/lane' && req.method === 'POST') {
5156
+ /* Change an existing initiative's lane — the PM's word. The ladder is DERIVED from `lane:` (workflow-state.js),
5157
+ * so this edits that one frontmatter line and nothing else; the next read re-derives the rungs. It never
5158
+ * touches `flow:` (a label the engine ignores), the PRD, the rubric or any run. An unknown lane is refused
5159
+ * and nothing is written. */
5160
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
5161
+ const w = getWs(body.ws);
5162
+ const root = w ? w.root : SELF_ROOT;
5163
+ const id = String(body.id || '');
5164
+ if (!fourPanel.validId(id)) return json(res, { ok: false, error: 'an initiative id is required, and it must be a record name' }, 400);
5165
+ const engine = consoleState.loadEngine();
5166
+ if (!engine.ok || typeof engine.engine.lanesFor !== 'function') return json(res, { ok: false, error: 'the workflow engine is not available here' }, 500);
5167
+ const allowed = engine.engine.lanesFor(root).lanes;
5168
+ const lane = String(body.lane || '').trim();
5169
+ if (!allowed.includes(lane)) return json(res, { ok: false, error: `“${lane || '(none)'}” is not a lane of this repository — one of: ${allowed.join(', ')}` }, 400);
5170
+ const file = path.join(engine.engine.layout(root).initiatives, `${id}.md`);
5171
+ let text; try { text = fs.readFileSync(file, 'utf8'); } catch { return json(res, { ok: false, error: `no initiative record for ${id}` }, 404); }
5172
+ const m = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(text);
5173
+ if (!m) return json(res, { ok: false, error: 'the record has no frontmatter block to edit — nothing was changed' }, 409);
5174
+ const fmLines = m[1].split(/\r?\n/);
5175
+ const at = fmLines.findIndex((l) => /^lane\s*:/.test(l));
5176
+ const before = at >= 0 ? fmLines[at].replace(/^lane\s*:\s*/, '').replace(/^["']|["']$/g, '').trim() : null;
5177
+ if (before === lane) return json(res, { ok: true, id, lane, previous: before, changed: false });
5178
+ if (at >= 0) fmLines[at] = `lane: ${lane}`;
5179
+ else { const idAt = fmLines.findIndex((l) => /^id\s*:/.test(l)); fmLines.splice(idAt >= 0 ? idAt + 1 : fmLines.length, 0, `lane: ${lane}`); }
5180
+ const next = text.slice(0, m.index) + '---\n' + fmLines.join('\n') + '\n---' + text.slice(m.index + m[0].length - (m[2] === '' ? 0 : m[2].length));
5181
+ try { fs.writeFileSync(file + '.tmp', next); fs.renameSync(file + '.tmp', file); }
5182
+ catch (e) { return json(res, { ok: false, error: 'could not write the record — ' + String((e && e.message) || e).slice(0, 120) }, 500); }
5183
+ boardCache.invalidate(root);
5184
+ return json(res, { ok: true, id, lane, previous: before, changed: true });
5141
5185
  }
5142
5186
  if (p === '/api/session/initiative' && req.method === 'POST') {
5143
5187
  /* Bind a RUNNING session to an initiative — the PM's own word about work already in flight
@@ -5253,6 +5297,23 @@ async function handleRequest(req, res) {
5253
5297
  // search scrubs secrets out of text an agent typed, and the file search reports a walk bound.
5254
5298
  // Collapsing them into one payload would mean one of the two lying about what it looked at.
5255
5299
  if (p === '/api/portfolio' && req.method === 'GET') return json(res, portfolio());
5300
+ if (p === '/api/portfolio/init' && req.method === 'POST') {
5301
+ // The PM presses "make this repository the hub": write a starter portfolio.yaml there. Never guessed — the
5302
+ // PM names the repository, an existing hub is never overwritten, and only a registered repository qualifies.
5303
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
5304
+ const leads = repoLeads();
5305
+ if (portfolioHub(leads)) return json(res, { ok: false, reason: 'has-hub', error: 'a hub already exists — edit its portfolio.yaml instead' }, 409);
5306
+ const lead = leads.find((r) => r.ws === body.ws || r.ids.has(body.ws));
5307
+ if (!lead) return json(res, { ok: false, reason: 'unknown', error: 'that repository is not registered' }, 400);
5308
+ if (!isDir(lead.root)) return json(res, { ok: false, error: 'the repository folder is missing' }, 400);
5309
+ const file = path.join(lead.root, 'portfolio.yaml');
5310
+ const lines = ['# The portfolio hub (D-3): this repository holds the weekly / OKR / query reports.',
5311
+ '# `repos:` maps each registered repository to its domain — edit the domains to match how you group your work.',
5312
+ 'repos:', ...leads.map((r) => ` ${r.name.replace(/[:"'\n]/g, '')}: general`), ''];
5313
+ try { fs.writeFileSync(file, lines.join('\n'), { flag: 'wx' }); }
5314
+ catch (e) { return json(res, { ok: false, error: 'could not write portfolio.yaml — ' + String((e && e.message) || e).slice(0, 120) }, 500); }
5315
+ return json(res, { ok: true, hub: lead.name, ws: lead.ws, path: 'portfolio.yaml', repos: leads.length });
5316
+ }
5256
5317
  if (p === '/api/reports' && req.method === 'POST') {
5257
5318
  let body = {};
5258
5319
  try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
@@ -5387,7 +5448,23 @@ async function handleRequest(req, res) {
5387
5448
  const c = memoryLib.core();
5388
5449
  if (!c.ok) return json(res, { ok: false, error: c.error });
5389
5450
  const cur = c.core.load(w.root);
5390
- return json(res, { ok: true, ws: w.id, name: w.name, entries: cur.state.entries, pending: cur.state.pending, blocked: cur.state.blocked,
5451
+ // Entries recorded in the repository's OTHER working trees. The ledger lives in each checkout, so a fact an
5452
+ // agent saved on an initiative branch is invisible here until that branch merges (merge=union). Listed, read
5453
+ // only, and never copied into this ledger: the tool is the only writer (D72).
5454
+ const here = new Set(cur.state.entries.map((e) => e.target + '\u0000' + e.text));
5455
+ const elsewhere = [];
5456
+ try {
5457
+ for (const t of (worktreeReport(w).worktrees || [])) {
5458
+ if (path.resolve(t.dir) === path.resolve(w.root)) continue;
5459
+ for (const e of c.core.load(t.dir).state.entries) {
5460
+ const k = e.target + '\u0000' + e.text;
5461
+ if (here.has(k)) continue;
5462
+ here.add(k);
5463
+ elsewhere.push({ ...e, branch: t.branch || null, dir: t.dir });
5464
+ }
5465
+ }
5466
+ } catch { /* an unreadable tree is simply not listed */ }
5467
+ return json(res, { ok: true, ws: w.id, name: w.name, entries: cur.state.entries, elsewhere, pending: cur.state.pending, blocked: cur.state.blocked,
5391
5468
  usage: cur.usage, torn: cur.torn, config: { write_approval: !!cur.cfg.write_approval }, wired: memoryLib.wired(w.root),
5392
5469
  has_state_dir: !!c.core.stateDir(w.root, false) });
5393
5470
  }