create-pmos 0.38.1 → 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.1",
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@d1d47bf
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-02T01:57:56Z"}
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-02T01:57:56Z"}
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-02T01:57:56Z"}
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
  /*
@@ -123,14 +123,30 @@ function worktreePath(root, branch) {
123
123
  return path.join(path.dirname(top), path.basename(top) + '-worktrees', branch.replace(/\//g, '-'));
124
124
  }
125
125
 
126
+ // The start point of a NEW branch: an existing branch name or a commit id, nothing else. A name that
127
+ // passes validBranchName cannot begin with `-`, so it cannot be read by git as an option; it must
128
+ // also resolve to a commit, so a typo is refused here with a reason and not by git's own message.
129
+ // No base means "where this tree is now" — git's default.
130
+ function baseRef(root, from) {
131
+ if (from === null || from === undefined || from === '') return { ok: true, ref: null };
132
+ const ref = String(from);
133
+ if (!validBranchName(ref) && !/^[0-9a-f]{7,40}$/i.test(ref)) return { ok: false, error: 'invalid base' };
134
+ const r = git(root, ['rev-parse', '--verify', '--quiet', ref + '^{commit}']);
135
+ if (r.failed) return { ok: false, error: 'git not available' };
136
+ if (r.code !== 0) return { ok: false, error: `base '${ref}' does not exist` };
137
+ return { ok: true, ref };
138
+ }
139
+
126
140
  // Check out an existing branch, or create one with -b. THIS MOVES THE WHOLE REPO — every session
127
141
  // in every workspace under it. Confined to `root` by cwd; the name is validated first so nothing
128
142
  // malformed reaches git. On any failure returns { ok:false, error } and leaves the caller to NOT
129
143
  // spawn a session on the wrong branch (a failed checkout must never spawn).
130
- function checkout(root, branch, { create = false } = {}) {
144
+ function checkout(root, branch, { create = false, from = null } = {}) {
131
145
  if (!isRepo(root)) return { ok: false, error: 'not a git repository' };
132
146
  if (!validBranchName(branch)) return { ok: false, error: 'invalid branch name' };
133
- const args = create ? ['checkout', '-b', branch, '--'] : ['checkout', branch, '--'];
147
+ const base = baseRef(root, from);
148
+ if (!base.ok) return base;
149
+ const args = create ? ['checkout', '-b', branch, ...(base.ref ? [base.ref] : []), '--'] : ['checkout', branch, '--'];
134
150
  const r = git(root, args);
135
151
  if (r.failed) return { ok: false, error: 'git not available' };
136
152
  if (r.code !== 0) return { ok: false, error: (r.err || r.out || 'checkout failed').split('\n')[0].slice(0, 200) };
@@ -142,10 +158,12 @@ function checkout(root, branch, { create = false } = {}) {
142
158
  // session stays untouched. `create` makes the branch (`-b`); otherwise the branch must exist and
143
159
  // must not already be checked out somewhere else (git refuses — we return that plainly).
144
160
  // Returns { ok, dir, branch, reused } or { ok:false, error }.
145
- function addWorktree(root, branch, { create = false } = {}) {
161
+ function addWorktree(root, branch, { create = false, from = null } = {}) {
146
162
  const top = repoRoot(root);
147
163
  if (!top) return { ok: false, error: 'not a git repository' };
148
164
  if (!validBranchName(branch)) return { ok: false, error: 'invalid branch name' };
165
+ const base = baseRef(top, from);
166
+ if (!base.ok) return base;
149
167
 
150
168
  // Already checked out somewhere? Say where, rather than letting git's message be the first the
151
169
  // PM sees — and if it is exactly the worktree we would have made, reuse it instead of failing.
@@ -161,7 +179,7 @@ function addWorktree(root, branch, { create = false } = {}) {
161
179
  try { fs.mkdirSync(path.dirname(dir), { recursive: true }); }
162
180
  catch (e) { return { ok: false, error: `cannot create ${path.dirname(dir)}: ${e.code || e.message}` }; }
163
181
 
164
- const args = create ? ['worktree', 'add', '-b', branch, dir] : ['worktree', 'add', dir, branch];
182
+ const args = create ? ['worktree', 'add', '-b', branch, dir, ...(base.ref ? [base.ref] : [])] : ['worktree', 'add', dir, branch];
165
183
  const r = git(top, args);
166
184
  if (r.failed) return { ok: false, error: 'git not available' };
167
185
  if (r.code !== 0) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.38.1",
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.1",
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",
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());
@@ -997,7 +1014,7 @@ export function productTrees(name) {
997
1014
  const key = p.root;
998
1015
  return p.trees.map((t) => {
999
1016
  const m = /^initiative\/(.+)$/.exec(t.branch || '');
1000
- return { ws: t.id, branch: t.branch || null, main: t.root === key, sessions: t.sessions || 0,
1017
+ return { ws: t.id, dir: t.tree || t.root, branch: t.branch || null, main: t.root === key, sessions: t.sessions || 0,
1001
1018
  initiative: m ? m[1] : null, by: m ? 'branch' : null };
1002
1019
  }).sort((a, b) => (b.main - a.main));
1003
1020
  }
@@ -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
  /**
@@ -1202,6 +1222,36 @@ export async function preferProfile(agent, profile) {
1202
1222
  return postJson('/api/profiles/prefer', { agent, profile });
1203
1223
  }
1204
1224
 
1225
+ /** Re-read the registered trees only — no boards. A worktree created or removed anywhere (a spawn, the
1226
+ * branch form, a terminal) reaches panel 1 through this, instead of waiting for a reload. */
1227
+ export async function refreshTrees() {
1228
+ if (FIXTURE) return;
1229
+ const out = await getJson('/api/workspaces');
1230
+ if (!out || !Array.isArray(out.workspaces)) return;
1231
+ const rows = foldWorkspaces(out.workspaces);
1232
+ for (const r of rows) products.set(r.name, Object.assign(products.get(r.name) || {}, r));
1233
+ publishBoards();
1234
+ }
1235
+
1236
+ /** Create a branch with no session. `worktree` false = a checkout in the shared tree (the server
1237
+ * refuses it while sessions run there). */
1238
+ export async function createBranch({ ws, name, from, worktree }) {
1239
+ if (FIXTURE) return { ok: false, error: 'fixture mode creates no branch' };
1240
+ return postJson('/api/git/branch', { ws, name, from: from || null, worktree: worktree !== false });
1241
+ }
1242
+
1243
+ /** End a session and remove it, with an answer. */
1244
+ export async function removeSessionById(id) {
1245
+ if (FIXTURE) return { ok: false, error: 'fixture mode has no sessions' };
1246
+ return postJson('/api/session/remove', { id });
1247
+ }
1248
+
1249
+ /** Remove a linked worktree folder. The server refuses a busy or unsaved one and says why. */
1250
+ export async function removeWorktree(ws, dir) {
1251
+ if (FIXTURE) return { ok: false, error: 'fixture mode has no worktrees' };
1252
+ return postJson('/api/git/worktrees/remove', { ws, dir });
1253
+ }
1254
+
1205
1255
  /** Every working tree of this workspace's repo. The pre-flight reads it for ONE purpose: a branch
1206
1256
  * checked out elsewhere is a branch git will refuse, and the PM should read that in the picker
1207
1257
  * rather than as an error after the click. */
@@ -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] : '·'),
@@ -33,6 +33,7 @@ import { el } from './dom.js';
33
33
  import { state, setState, FIXTURE } from './state.js';
34
34
  import * as FX from './fixture.js';
35
35
  import { openTerm as openTermTab } from './panel-initiatives.js';
36
+ import { startHere } from './panel-trees.js';
36
37
 
37
38
  /* Both are another module's file, loaded lazily for the reason panel-tabs.js gives: a static import
38
39
  * of a file that fails to load takes this whole module down with it, and a panel that cannot draw
@@ -214,7 +215,7 @@ function branchSection() {
214
215
  return out;
215
216
  }
216
217
 
217
- const current = info.branch || null;
218
+ const current = info.current || info.branch || null;
218
219
  const branches = Array.isArray(info.branches) ? info.branches : [];
219
220
  /* A branch checked out in ANOTHER working tree is a branch git will refuse to check out here.
220
221
  * Offering it and letting the server say no would be technically honest and practically rude:
@@ -237,11 +238,22 @@ function branchSection() {
237
238
  for (const b of branches) {
238
239
  if (b === current) continue;
239
240
  const taken = elsewhere.get(b);
241
+ /* A branch already open in another working tree cannot be checked out twice, but it CAN be worked
242
+ * in: the chip starts a session in that tree. It used to be a disabled chip, so the folder the PM
243
+ * already had was the one place they could not start. */
244
+ const treeOf = taken && dataMod && typeof dataMod.productTrees === 'function'
245
+ ? dataMod.productTrees(state.product).find((t) => t.branch === b && !t.main) : null;
240
246
  chips.push(taken
241
- ? el('button', {
242
- disabled: true, title: 'git will not check this out twice — it is already in ' + taken,
243
- style: CHIP_DEAD,
244
- }, iv(b + ' — in ' + shortPath(taken)))
247
+ ? (treeOf
248
+ ? el('button', {
249
+ onClick: () => { closeNewSession(); startHere(state.product, treeOf); },
250
+ title: 'this branch is already open in ' + taken + ' — start the session there',
251
+ style: CHIP, hover: HOVER, 'data-r14': 'open-worktree',
252
+ }, iv(b + ' — open that worktree'))
253
+ : el('button', {
254
+ disabled: true, title: 'git will not check this out twice — it is already in ' + taken,
255
+ style: CHIP_DEAD,
256
+ }, iv(b + ' — in ' + shortPath(taken))))
245
257
  : el('button', {
246
258
  onClick: () => pick(b),
247
259
  style: state.newSessBranch === b && !state.newSessBranchMode ? CHIP_ON : CHIP, hover: HOVER,
@@ -435,6 +447,7 @@ async function onStart() {
435
447
  name,
436
448
  agent: state.newSessAgent || 'shell',
437
449
  ws: state.wsId || null,
450
+ initiative: slug || undefined,
438
451
  branch: branch || null,
439
452
  createBranch,
440
453
  // Sent explicitly, ALWAYS, and only when a branch is in play — the server defaults an omitted
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { el, setChildren } from './dom.js';
27
27
  import { syncButton } from './lifecycle.js';
28
+ import { treesBlock, branchForm, openBranchForm, treeNoteFor } from './panel-trees.js';
28
29
  import { state, setState, FIXTURE } from './state.js';
29
30
  import {
30
31
  productsList, repoRow, firstSlugOf, MODE_META, SETTINGS_GROUPS, ADDABLE_PRODUCTS,
@@ -259,9 +260,15 @@ function treesOf(name) {
259
260
  }
260
261
 
261
262
  async function renameRepo(name) {
262
- const label = typeof window !== 'undefined' && window.prompt ? window.prompt('Rename “' + name + '” to:', name) : null;
263
- if (label === null || !String(label).trim() || String(label).trim() === name) return;
264
- 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());
265
272
  if (!out || out.ok === false) { setState({ repoNotice: { name, tone: 'bad', text: (out && out.error) || 'rename refused' } }); return; }
266
273
  await connectData();
267
274
  setState({ product: String(label).trim(), repoNotice: null });
@@ -282,17 +289,18 @@ function repoEntry(tuple, product) {
282
289
  if (FIXTURE || !dataMod) return row;
283
290
  const name = tuple[0];
284
291
  const trees = treesOf(name);
285
- const linked = trees.filter((t) => !t.main);
286
- const open = !!(state.treesOpen || {})[name];
292
+ /* The trees list is always available — the main tree is a line of work too, and a repository with
293
+ * one tree still has branches to create. The active repository opens it by default; others on a
294
+ * click of the chip. */
295
+ const stored = (state.treesOpen || {})[name];
296
+ const open = stored === undefined ? product === name : !!stored;
287
297
  const tools = [];
288
- if (linked.length) {
289
- tools.push(el('button', {
290
- onClick: () => setState((s) => ({ treesOpen: { ...(s.treesOpen || {}), [name]: !open } })),
291
- style: chipStyle, hover: 'color:var(--foreground)',
292
- title: linked.length + ' linked worktree' + (linked.length === 1 ? '' : 's') + ' — show the lines of work',
293
- 'data-r1': 'trees-chip',
294
- }, ip('⑂ ' + linked.length)));
295
- }
298
+ tools.push(el('button', {
299
+ onClick: () => setState((s) => ({ treesOpen: { ...(s.treesOpen || {}), [name]: !open } })),
300
+ style: chipStyle, hover: 'color:var(--foreground)',
301
+ title: trees.length + ' working tree' + (trees.length === 1 ? '' : 's') + ' — show the lines of work, their sessions and branches',
302
+ 'data-r1': 'trees-chip', 'data-repo': name,
303
+ }, ip('⑂ ' + trees.length)));
296
304
  if (product === name) {
297
305
  { const sb = syncButton(name); if (sb) tools.push(sb); }
298
306
  tools.push(el('button', { onClick: () => renameRepo(name), style: actStyle, hover: 'color:var(--foreground)', title: 'rename this repository', 'data-r1': 'rename' }, '✎'));
@@ -301,18 +309,9 @@ function repoEntry(tuple, product) {
301
309
  const kids = [el('div', { style: 'display:flex;align-items:center' },
302
310
  el('div', { style: 'flex:1;min-width:0' }, row),
303
311
  tools.length ? el('div', { style: 'display:flex;align-items:center;gap:2px;padding-right:8px' }, tools) : null)];
304
- if (open && trees.length) {
305
- kids.push(el('div', { style: 'padding:2px 14px 8px 39px', 'data-r1': 'trees' },
306
- trees.map((t) => el('div', {
307
- style: 'display:flex;gap:8px;font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground);padding:2px 0',
308
- title: t.initiative ? 'initiative read from the branch name (initiative/<id>) — not a recorded attribution' : '',
309
- },
310
- el('span', { style: 'color:var(--foreground)' }, ip((t.main ? '⎇ ' : '⑂ ') + (t.branch || '(detached)'))),
311
- el('span', null, ip(t.sessions + ' session' + (t.sessions === 1 ? '' : 's'))),
312
- t.initiative ? el('span', null, ip('#' + t.initiative)) : null,
313
- )),
314
- ));
315
- }
312
+ { const bf = branchForm(name); if (bf) kids.push(bf); }
313
+ if (open && trees.length) kids.push(treesBlock(name, trees, product === name));
314
+ { const tn = treeNoteFor(name); if (tn) kids.push(tn); }
316
315
  const n = state.repoNotice;
317
316
  if (n && n.name === name) {
318
317
  kids.push(el('div', {
@@ -340,12 +339,14 @@ export function railGlyphs() {
340
339
  const wss = new Set(treesOf(name).map((x) => x.ws));
341
340
  const needs = sess.some((x) => x && wss.has(x.ws) && x.shown === 'needs');
342
341
  const active = product === name;
343
- 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)
344
343
  .map((w) => w[0]).join('') || String(name).slice(0, 2)).toUpperCase();
345
344
  const said = name + ' — ' + rp.kindLabel + ' repository' + (needs ? ' · a session needs you' : '');
346
345
  return el('button', {
347
346
  onClick: (e) => { if (e && e.stopPropagation) e.stopPropagation(); selectRepo(name); },
348
- 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,
349
350
  'data-r1': 'rail-glyph', 'data-repo': name, 'data-active': active ? '1' : '0',
350
351
  'data-ini': ini, 'data-kind': rp.kindGlyph,
351
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
@@ -0,0 +1,191 @@
1
+ /* panel-trees.js — the working trees of a repository, as things you can use (workspace-branch-sessions).
2
+ *
3
+ * Panel 1 used to list a repository's trees as plain text behind a chip that existed only when a
4
+ * linked worktree did. A branch could not be clicked, its sessions could not be seen, a session
5
+ * could not be started in it, and a branch could not be made without starting a session. This module
6
+ * is that surface:
7
+ *
8
+ * treesBlock(name) every tree, main first. A row opens to show that tree's sessions and its
9
+ * actions: start a session HERE, remove the folder (linked trees only).
10
+ * branchForm(name) `+ branch` — a name, a base and an own-folder toggle. Creates the branch and
11
+ * starts nothing; then offers a session on it.
12
+ *
13
+ * Every action states its outcome in the panel. A refusal says why and the repository is unchanged.
14
+ * Live mode only: the fixture has no server, and panel-repos.js never calls this there.
15
+ */
16
+ import { el } from './dom.js';
17
+ import { state, setState, subscribe } from './state.js';
18
+ import { boardOf } from './board-read.js';
19
+ import { openNewSession } from './panel-newsess.js';
20
+
21
+ const ip = (v) => el('span', null, v);
22
+ const MONO = 'font-family:var(--mono);font-size:10.5px';
23
+ const BTN = MONO + ';padding:2px 8px;border:1px solid var(--border);color:var(--muted-foreground);min-height:24px';
24
+ const MUTED = 'color:var(--muted-foreground)';
25
+
26
+ let dataMod = null; let termMod = null; let iniMod = null;
27
+ import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
28
+ import('./tab-term.js').then((m) => { termMod = m; }).catch(() => { termMod = null; });
29
+ import('./panel-initiatives.js').then((m) => { iniMod = m; }).catch(() => { iniMod = null; });
30
+
31
+ const note = (name, text, tone) => setState({ treeNote: { name, text, tone: tone || null } });
32
+ const refresh = () => (dataMod && dataMod.refreshTrees ? dataMod.refreshTrees() : Promise.resolve());
33
+
34
+ /* A tree was registered, removed or renamed somewhere (the server's `workspaces` push, a branch
35
+ * change in a terminal): re-read the tree list. Guarded by the push stamp so the repaint this causes
36
+ * does not ask again. */
37
+ let lastPush = 0;
38
+ subscribe(() => {
39
+ if (state.treesPush && state.treesPush !== lastPush) { lastPush = state.treesPush; refresh(); }
40
+ });
41
+
42
+ /** The server's sessions that run in this tree, from the list the page already polls. */
43
+ export function sessionsIn(ws) {
44
+ return (Array.isArray(state.sessions) ? state.sessions : []).filter((s) => s && s.ws === ws);
45
+ }
46
+ const isEnded = (s) => s.state === 'exited' || s.shown === 'exited';
47
+
48
+ /** Open a session's terminal. The row carries the server's id, so the tab attaches to that exact PTY. */
49
+ function openSession(s) {
50
+ if (!iniMod) return;
51
+ iniMod.openTerm(null, { id: s.id, name: s.name, st: isEnded(s) ? 'exited' : 'working', profile: s.profile_id || null });
52
+ }
53
+
54
+ /** Start a session in THIS tree: the workspace id is the tree's, so the server runs it in that folder. */
55
+ export async function startHere(name, t) {
56
+ if (!termMod || !iniMod) { note(name, 'the terminal module has not loaded — nothing was started', 'bad'); return; }
57
+ const label = (t.branch || 'tree').replace(/^initiative\//, '');
58
+ const res = await termMod.requestSpawn({ name: 'shell · ' + label, agent: 'shell', ws: t.ws, branch: null, createBranch: false, isolate: null, profile: null });
59
+ if (!res || !res.ok) { note(name, (res && res.error) || 'the workspace server refused, without saying why', 'bad'); return; }
60
+ note(name, 'started in ' + (t.branch || 'this tree'));
61
+ openSession({ id: res.id, name: 'shell · ' + label, profile_id: null });
62
+ }
63
+
64
+ async function endSession(name, s) {
65
+ const out = dataMod ? await dataMod.removeSessionById(s.id) : null;
66
+ setState({ treeConfirm: null });
67
+ 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');
69
+ }
70
+
71
+ async function removeTree(name, t) {
72
+ const ok = typeof window === 'undefined' || !window.confirm ? true
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.');
74
+ if (!ok) return;
75
+ const out = dataMod ? await dataMod.removeWorktree(t.ws, t.dir) : null;
76
+ if (!out || out.ok === false) { note(name, (out && out.error) || 'the folder could not be removed', 'bad'); return; }
77
+ setState((s) => ({ treeSel: { ...(s.treeSel || {}), [name]: null } }));
78
+ note(name, 'removed the folder for ' + t.branch + ' — the branch is kept, nothing was merged' + (out.note ? '. ' + out.note : ''));
79
+ refresh();
80
+ }
81
+
82
+ function sessionRow(name, s) {
83
+ const armed = state.treeConfirm === s.id;
84
+ return el('div', { style: 'display:flex;align-items:center;gap:6px;padding:1px 0', 'data-r14': 'tree-session', 'data-id': s.id },
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'))),
87
+ el('button', {
88
+ onClick: () => (armed ? endSession(name, s) : setState({ treeConfirm: s.id })),
89
+ style: BTN + (armed ? ';color:var(--destructive);border-color:var(--destructive)' : ''), hover: 'color:var(--destructive)',
90
+ title: isEnded(s) ? 'remove this ended session' : 'end this session and remove it', 'data-r14': 'tree-end',
91
+ }, armed ? 'sure?' : (isEnded(s) ? 'remove' : 'end')));
92
+ }
93
+
94
+ function treeRow(name, t) {
95
+ const sel = (state.treeSel || {})[name] === t.ws;
96
+ const sess = sessionsIn(t.ws);
97
+ const live = sess.filter((s) => !isEnded(s)).length;
98
+ const head = el('button', {
99
+ onClick: () => setState((s) => ({ treeSel: { ...(s.treeSel || {}), [name]: sel ? null : t.ws }, treeConfirm: null })),
100
+ style: MONO + ';display:flex;gap:8px;width:100%;text-align:left;padding:3px 0;min-height:26px;align-items:baseline', hover: 'background:var(--accent)',
101
+ title: t.initiative ? 'initiative read from the branch name (initiative/<id>) — not a recorded attribution' : 'show this tree’s sessions',
102
+ 'data-r14': 'tree', 'data-ws': t.ws,
103
+ },
104
+ el('span', { style: MUTED }, ip(sel ? '▾' : '▸')),
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' : ''))),
107
+ t.initiative ? el('span', { style: MUTED }, ip('#' + t.initiative)) : null);
108
+ if (!sel) return head;
109
+ return el('div', null, head, el('div', { style: 'padding:2px 0 6px 14px', 'data-r14': 'tree-open' },
110
+ sess.length ? sess.map((s) => sessionRow(name, s)) : el('div', { style: MONO + ';' + MUTED + ';padding:2px 0' }, 'no sessions in this tree'),
111
+ el('div', { style: 'display:flex;gap:6px;padding-top:4px;flex-wrap:wrap' },
112
+ el('button', { onClick: () => startHere(name, t), style: BTN, hover: 'color:var(--foreground)', title: 'start a shell session in ' + (t.dir || 'this tree'), 'data-r14': 'tree-start' }, '+ session here'),
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'))));
114
+ }
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
+
127
+ /** The trees of one repository, main first. `name` is the repository's name in the data layer. */
128
+ export function treesBlock(name, trees, addable) {
129
+ return el('div', { style: 'padding:2px 14px 8px 39px', 'data-r1': 'trees', 'data-repo': name }, trees.map((t) => treeRow(name, t)),
130
+ addable ? el('button', { onClick: () => openBranchForm(name, trees), style: BTN + ';margin-top:4px', hover: 'color:var(--foreground)',
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);
134
+ }
135
+
136
+ /* ── + branch ───────────────────────────────────────────────────────────────────────────────── */
137
+ const INPUT = MONO + ';width:100%;padding:4px 6px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);min-height:26px';
138
+
139
+ export function openBranchForm(name, trees) {
140
+ const main = (trees || []).find((t) => t.main) || (trees || [])[0];
141
+ setState({ branchForm: { repo: name, name: '', from: '', folder: true, busy: false, error: null, ws: main ? main.ws : null, current: main ? main.branch : null } });
142
+ }
143
+
144
+ async function submitBranch() {
145
+ const f = state.branchForm;
146
+ if (!f || f.busy || !dataMod) return;
147
+ const name = String(f.name || '').trim();
148
+ if (!name) { setState({ branchForm: { ...f, error: 'name the new branch' } }); return; }
149
+ setState({ branchForm: { ...f, busy: true, error: null } });
150
+ const out = await dataMod.createBranch({ ws: f.ws, name, from: String(f.from || '').trim() || null, worktree: f.folder });
151
+ if (!out || out.ok === false) { setState({ branchForm: { ...f, busy: false, error: (out && out.error) || 'the server refused, without saying why' } }); return; }
152
+ setState((s) => ({ branchForm: null, treesOpen: { ...(s.treesOpen || {}), [f.repo]: true }, treeSel: { ...(s.treeSel || {}), [f.repo]: out.ws }, treeNote: { name: f.repo, text: '“' + name + '” created' + (f.folder ? ' in its own folder — ' + out.dir : ' in the shared folder'), tone: null } }));
153
+ refresh();
154
+ }
155
+
156
+ /* The panel repaints on every state change, which replaces the input the PM is typing in. The value
157
+ * survives (it lives on the form record); the focus and caret are restored here so typing is not cut. */
158
+ function keepFocus(f, which) {
159
+ return (n) => {
160
+ if ((f.focus || 'name') !== which) return;
161
+ setTimeout(() => { try { n.focus(); n.setSelectionRange(n.value.length, n.value.length); } catch { /* detached */ } }, 0);
162
+ };
163
+ }
164
+
165
+ /** The `+ branch` form for the repository it was opened on, or null. */
166
+ export function branchForm(name) {
167
+ const f = state.branchForm;
168
+ if (!f || f.repo !== name) return null;
169
+ const upd = (patch) => setState((s) => ({ branchForm: { ...s.branchForm, ...patch } }));
170
+ return el('div', { style: 'padding:6px 14px 10px 39px;display:grid;gap:5px', 'data-r14': 'branch-form' },
171
+ el('input', { value: f.name, placeholder: 'initiative/my-branch', style: INPUT, 'data-r14': 'branch-name',
172
+ ref: keepFocus(f, 'name'), onFocus: () => { f.focus = 'name'; },
173
+ onInput: (e) => { f.name = e.target.value; }, onKeydown: (e) => { if (e.key === 'Enter') submitBranch(); if (e.key === 'Escape') setState({ branchForm: null }); } }),
174
+ el('input', { value: f.from, placeholder: 'start from: ' + (f.current || 'the current branch') + ' (or another branch)', style: INPUT, 'data-r14': 'branch-from',
175
+ ref: keepFocus(f, 'from'), onFocus: () => { f.focus = 'from'; },
176
+ onInput: (e) => { f.from = e.target.value; }, onKeydown: (e) => { if (e.key === 'Enter') submitBranch(); } }),
177
+ el('button', { onClick: () => upd({ folder: !f.folder }), style: MONO + ';text-align:left;' + MUTED, hover: 'color:var(--foreground)', 'data-r14': 'branch-folder',
178
+ title: f.folder ? 'the branch gets its own folder; sessions already running are untouched' : 'the branch is checked out in the shared folder; refused while a session runs there' },
179
+ ip((f.folder ? '☑' : '☐') + ' its own folder (worktree)')),
180
+ f.error ? el('div', { style: 'font-size:11px;color:var(--destructive)', 'data-r14': 'branch-error' }, ip(f.error)) : null,
181
+ el('div', { style: 'display:flex;gap:6px' },
182
+ el('button', { onClick: submitBranch, disabled: f.busy ? 'disabled' : null, style: BTN, hover: 'color:var(--foreground)', 'data-r14': 'branch-create' }, f.busy ? 'creating…' : 'create branch'),
183
+ el('button', { onClick: () => setState({ branchForm: null }), style: BTN, hover: 'color:var(--foreground)' }, 'cancel')));
184
+ }
185
+
186
+ /** The outcome line of the last tree action for this repository. */
187
+ export function treeNoteFor(name) {
188
+ const n = state.treeNote;
189
+ if (!n || n.name !== name) return null;
190
+ return el('div', { style: 'padding:4px 14px 8px 39px;font-size:11px;overflow-wrap:anywhere;color:' + (n.tone === 'bad' ? 'var(--destructive)' : 'var(--muted-foreground)'), 'data-r14': 'tree-note' }, ip(n.text));
191
+ }
@@ -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,
@@ -244,6 +244,9 @@ export function requestSpawn(opts) {
244
244
  name: o.name,
245
245
  agent: o.agent || 'shell',
246
246
  ws: o.ws,
247
+ // The initiative the session is FOR, when the caller knows it (workspace-branch-sessions R8):
248
+ // the `⋯ options` path used to drop it, so a session started with a branch carried none.
249
+ ...(o.initiative ? { initiative: o.initiative } : {}),
247
250
  /* Sent even when the PM chose nothing, and that is deliberate on two of them. `isolate` has
248
251
  * a server-side default (`server.js` liveSessionCount) whose own comment says the client
249
252
  * should state what the PM actually saw; `profile: null` is a DELIBERATE "no profile",
@@ -429,9 +432,12 @@ function handle(m) {
429
432
  * cache of the server's last word about a workspace, and a reader that is not open ignores it.
430
433
  * The `ws` is carried so a reader can tell whether the news is about the repository it is
431
434
  * asking about. */
432
- setState({ branchInfo: { ...m, at: Date.now() } });
435
+ setState({ branchInfo: { ...m, at: Date.now() }, treesPush: Date.now() });
433
436
  return;
434
437
  }
438
+ /* A tree was registered, removed or renamed. Panel 1 re-reads the tree list on `treesPush`
439
+ * (panel-repos.js); without this the new folder appeared only after a reload. */
440
+ if (m.t === 'workspaces') { setState({ treesPush: Date.now() }); return; }
435
441
  }
436
442
 
437
443
  const recFor = (sessionId) => {
@@ -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`
@@ -3044,6 +3044,40 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
3044
3044
  return { ok: true, session: s, notice: treeShare.joinNotices(notice, shared) };
3045
3045
  }
3046
3046
 
3047
+ // Create a branch WITHOUT starting a session (workspace-branch-sessions R5). Two shapes, as in the
3048
+ // spawn path: its own folder (a worktree, registered as a workspace so the page can start sessions in
3049
+ // it) or a checkout in the shared tree. The shared checkout moves every session in that tree, so it is
3050
+ // refused while any are running — the spawn path may do that on a PM's explicit choice, but a control
3051
+ // that only creates a branch must not move other people's work as a side effect.
3052
+ function createBranchOnly({ ws, name, from, worktree }) {
3053
+ const w = ws ? workspaces.get(ws) : null;
3054
+ if (!w) return { ok: false, error: 'no such workspace' };
3055
+ if (!gitInfo.isRepo(w.root)) return { ok: false, error: `“${w.name}” is not a git repository` };
3056
+ const branch = String(name == null ? '' : name).trim();
3057
+ if (!branch) return { ok: false, error: 'name the new branch' };
3058
+ if (worktree) {
3059
+ const r = gitInfo.addWorktree(w.root, branch, { create: true, from });
3060
+ if (!r.ok) return { ok: false, error: r.error };
3061
+ const info = gitInfo.branchInfo(w.root);
3062
+ const mirrored = info.ok && info.subdir ? path.join(r.dir, info.subdir) : r.dir;
3063
+ const dir = isDir(mirrored) ? mirrored : r.dir;
3064
+ const nw = registerWorkspace(dir, `${w.name} ⑂ ${branch}`);
3065
+ if (!nw) return { ok: false, error: `worktree created at ${r.dir} but could not be registered as a workspace` };
3066
+ broadcast({ t: 'branch', ws: nw.id, ...gitInfo.branchInfo(nw.root) });
3067
+ pushWorkspaces();
3068
+ return { ok: true, ws: nw.id, dir: r.dir, branch };
3069
+ }
3070
+ const live = treeTenants(treeForWs(w.id), null).length;
3071
+ if (live > 0) {
3072
+ return { ok: false, error: `${live} session${live === 1 ? ' is' : 's are'} running in this folder, and a checkout would move ${live === 1 ? 'it' : 'them'} — create the branch in its own folder instead` };
3073
+ }
3074
+ const r = gitInfo.checkout(w.root, branch, { create: true, from });
3075
+ if (!r.ok) return { ok: false, error: r.error };
3076
+ broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) });
3077
+ pushWorkspaces();
3078
+ return { ok: true, ws: w.id, dir: w.root, branch };
3079
+ }
3080
+
3047
3081
  /* ── workspace-repo-first · start work in one click (PRD R1 · R3) ───────────────────────────────
3048
3082
  * The composer asked six questions to open a terminal, and the one question the PM actually has —
3049
3083
  * "which initiative am I working on?" — was not among them. It asked for a BRANCH instead, plus an
@@ -4287,8 +4321,36 @@ async function handleRequest(req, res) {
4287
4321
  if (!name) return json(res, { ok: false, error: 'a name is required' }, 400);
4288
4322
  // `label` is the PM's name for the REPOSITORY. The client names a product after its folder
4289
4323
  // (`repo_name`), so changing `name` alone would never show; `label` wins over both.
4290
- w.label = name; saveWorkspaces(); pushWorkspaces();
4291
- 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 } });
4333
+ }
4334
+ if (p === '/api/git/branch' && req.method === 'POST') {
4335
+ let body = {};
4336
+ try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
4337
+ const out = createBranchOnly({ ws: body.ws, name: body.name, from: body.from || null, worktree: body.worktree !== false });
4338
+ return json(res, out, out.ok ? 200 : 400);
4339
+ }
4340
+ // End a session and remove it, with an answer. The WebSocket kill/remove messages return nothing,
4341
+ // so a refusal or a slow exit left the PM clicking a control that seemed to do nothing.
4342
+ if (p === '/api/session/remove' && req.method === 'POST') {
4343
+ let body = {};
4344
+ try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
4345
+ const sess = sessions.get(String(body.id || ''));
4346
+ if (!sess) return json(res, { ok: false, error: 'no such session' }, 404);
4347
+ if (sess.pty) {
4348
+ if (sess.tmux) tmuxKillSession(sess.tmux);
4349
+ try { sess.pty.kill(); } catch {}
4350
+ for (let i = 0; i < 30 && sess.pty; i++) await new Promise((r) => setTimeout(r, 100));
4351
+ if (sess.pty) return json(res, { ok: true, removed: false, note: 'asked it to end; it has not exited yet — press again to remove it' });
4352
+ }
4353
+ return json(res, { ok: removeSession(sess.id), removed: true });
4292
4354
  }
4293
4355
  if (p === '/api/git/worktrees/remove' && req.method === 'POST') {
4294
4356
  // Reclaim a worktree's folder. `dir` comes from a client, so it is checked against git's own
@@ -4308,9 +4370,13 @@ async function handleRequest(req, res) {
4308
4370
  // the workspace entry goes with the folder; forgetWorkspace never touches a repo, and it
4309
4371
  // refuses while sessions remain, so a race here fails closed rather than orphaning scrollback
4310
4372
  const owner = [...workspaces.values()].find((x) => path.resolve(x.root) === path.resolve(row.dir));
4311
- 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;
4312
4377
  broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) });
4313
- 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 });
4314
4380
  }
4315
4381
  if (p === '/api/pick-folder' && req.method === 'POST') {
4316
4382
  let body = {};
@@ -5082,7 +5148,40 @@ async function handleRequest(req, res) {
5082
5148
  homeDir: os.homedir(),
5083
5149
  });
5084
5150
  // Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
5085
- 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 });
5086
5185
  }
5087
5186
  if (p === '/api/session/initiative' && req.method === 'POST') {
5088
5187
  /* Bind a RUNNING session to an initiative — the PM's own word about work already in flight
@@ -5198,6 +5297,23 @@ async function handleRequest(req, res) {
5198
5297
  // search scrubs secrets out of text an agent typed, and the file search reports a walk bound.
5199
5298
  // Collapsing them into one payload would mean one of the two lying about what it looked at.
5200
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
+ }
5201
5317
  if (p === '/api/reports' && req.method === 'POST') {
5202
5318
  let body = {};
5203
5319
  try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
@@ -5332,7 +5448,23 @@ async function handleRequest(req, res) {
5332
5448
  const c = memoryLib.core();
5333
5449
  if (!c.ok) return json(res, { ok: false, error: c.error });
5334
5450
  const cur = c.core.load(w.root);
5335
- 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,
5336
5468
  usage: cur.usage, torn: cur.torn, config: { write_approval: !!cur.cfg.write_approval }, wired: memoryLib.wired(w.root),
5337
5469
  has_state_dir: !!c.core.stateDir(w.root, false) });
5338
5470
  }