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 +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/workflow-state.js +7 -0
- package/payload/state/discoveries.md +1 -1
- package/workspace/lib/engine/workflow-state.js +7 -0
- package/workspace/lib/git-info.js +22 -4
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +2 -2
- package/workspace/public/pm/app.js +32 -2
- package/workspace/public/pm/data.js +53 -3
- package/workspace/public/pm/modes-live.js +65 -11
- package/workspace/public/pm/panel-detail.js +28 -2
- package/workspace/public/pm/panel-initiatives.js +1 -1
- package/workspace/public/pm/panel-newsess.js +18 -5
- package/workspace/public/pm/panel-repos.js +28 -27
- package/workspace/public/pm/panel-tabs.js +6 -1
- package/workspace/public/pm/panel-trees.js +191 -0
- package/workspace/public/pm/state.js +6 -1
- package/workspace/public/pm/tab-term.js +7 -1
- package/workspace/server.js +140 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.38.
|
|
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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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
|
|
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) {
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.38.
|
|
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 ? '
|
|
49
|
-
(state.col1 ? '
|
|
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)
|
|
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:
|
|
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('
|
|
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:
|
|
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
|
|
357
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
?
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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 (
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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) => {
|
package/workspace/server.js
CHANGED
|
@@ -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;
|
|
4291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|