create-pmos 0.38.1 → 0.38.2
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/state/discoveries.md +1 -1
- 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/data.js +31 -1
- package/workspace/public/pm/panel-newsess.js +18 -5
- package/workspace/public/pm/panel-repos.js +15 -22
- package/workspace/public/pm/panel-trees.js +176 -0
- package/workspace/public/pm/tab-term.js +7 -1
- package/workspace/server.js +55 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.2",
|
|
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-02T03:08:00Z"}
|
|
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-02T03:08:00Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -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-02T03:08:00Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -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.2",
|
|
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",
|
|
19
19
|
"test:offline": "node offline-tarball.test.js"
|
|
20
20
|
},
|
|
21
21
|
"engines": {
|
|
@@ -997,7 +997,7 @@ export function productTrees(name) {
|
|
|
997
997
|
const key = p.root;
|
|
998
998
|
return p.trees.map((t) => {
|
|
999
999
|
const m = /^initiative\/(.+)$/.exec(t.branch || '');
|
|
1000
|
-
return { ws: t.id, branch: t.branch || null, main: t.root === key, sessions: t.sessions || 0,
|
|
1000
|
+
return { ws: t.id, dir: t.tree || t.root, branch: t.branch || null, main: t.root === key, sessions: t.sessions || 0,
|
|
1001
1001
|
initiative: m ? m[1] : null, by: m ? 'branch' : null };
|
|
1002
1002
|
}).sort((a, b) => (b.main - a.main));
|
|
1003
1003
|
}
|
|
@@ -1202,6 +1202,36 @@ export async function preferProfile(agent, profile) {
|
|
|
1202
1202
|
return postJson('/api/profiles/prefer', { agent, profile });
|
|
1203
1203
|
}
|
|
1204
1204
|
|
|
1205
|
+
/** Re-read the registered trees only — no boards. A worktree created or removed anywhere (a spawn, the
|
|
1206
|
+
* branch form, a terminal) reaches panel 1 through this, instead of waiting for a reload. */
|
|
1207
|
+
export async function refreshTrees() {
|
|
1208
|
+
if (FIXTURE) return;
|
|
1209
|
+
const out = await getJson('/api/workspaces');
|
|
1210
|
+
if (!out || !Array.isArray(out.workspaces)) return;
|
|
1211
|
+
const rows = foldWorkspaces(out.workspaces);
|
|
1212
|
+
for (const r of rows) products.set(r.name, Object.assign(products.get(r.name) || {}, r));
|
|
1213
|
+
publishBoards();
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** Create a branch with no session. `worktree` false = a checkout in the shared tree (the server
|
|
1217
|
+
* refuses it while sessions run there). */
|
|
1218
|
+
export async function createBranch({ ws, name, from, worktree }) {
|
|
1219
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode creates no branch' };
|
|
1220
|
+
return postJson('/api/git/branch', { ws, name, from: from || null, worktree: worktree !== false });
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/** End a session and remove it, with an answer. */
|
|
1224
|
+
export async function removeSessionById(id) {
|
|
1225
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode has no sessions' };
|
|
1226
|
+
return postJson('/api/session/remove', { id });
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** Remove a linked worktree folder. The server refuses a busy or unsaved one and says why. */
|
|
1230
|
+
export async function removeWorktree(ws, dir) {
|
|
1231
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode has no worktrees' };
|
|
1232
|
+
return postJson('/api/git/worktrees/remove', { ws, dir });
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1205
1235
|
/** Every working tree of this workspace's repo. The pre-flight reads it for ONE purpose: a branch
|
|
1206
1236
|
* checked out elsewhere is a branch git will refuse, and the PM should read that in the picker
|
|
1207
1237
|
* rather than as an error after the click. */
|
|
@@ -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,
|
|
@@ -282,17 +283,18 @@ function repoEntry(tuple, product) {
|
|
|
282
283
|
if (FIXTURE || !dataMod) return row;
|
|
283
284
|
const name = tuple[0];
|
|
284
285
|
const trees = treesOf(name);
|
|
285
|
-
|
|
286
|
-
|
|
286
|
+
/* The trees list is always available — the main tree is a line of work too, and a repository with
|
|
287
|
+
* one tree still has branches to create. The active repository opens it by default; others on a
|
|
288
|
+
* click of the chip. */
|
|
289
|
+
const stored = (state.treesOpen || {})[name];
|
|
290
|
+
const open = stored === undefined ? product === name : !!stored;
|
|
287
291
|
const tools = [];
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}, ip('⑂ ' + linked.length)));
|
|
295
|
-
}
|
|
292
|
+
tools.push(el('button', {
|
|
293
|
+
onClick: () => setState((s) => ({ treesOpen: { ...(s.treesOpen || {}), [name]: !open } })),
|
|
294
|
+
style: chipStyle, hover: 'color:var(--foreground)',
|
|
295
|
+
title: trees.length + ' working tree' + (trees.length === 1 ? '' : 's') + ' — show the lines of work, their sessions and branches',
|
|
296
|
+
'data-r1': 'trees-chip', 'data-repo': name,
|
|
297
|
+
}, ip('⑂ ' + trees.length)));
|
|
296
298
|
if (product === name) {
|
|
297
299
|
{ const sb = syncButton(name); if (sb) tools.push(sb); }
|
|
298
300
|
tools.push(el('button', { onClick: () => renameRepo(name), style: actStyle, hover: 'color:var(--foreground)', title: 'rename this repository', 'data-r1': 'rename' }, '✎'));
|
|
@@ -301,18 +303,9 @@ function repoEntry(tuple, product) {
|
|
|
301
303
|
const kids = [el('div', { style: 'display:flex;align-items:center' },
|
|
302
304
|
el('div', { style: 'flex:1;min-width:0' }, row),
|
|
303
305
|
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
|
-
}
|
|
306
|
+
{ const bf = branchForm(name); if (bf) kids.push(bf); }
|
|
307
|
+
if (open && trees.length) kids.push(treesBlock(name, trees, product === name));
|
|
308
|
+
{ const tn = treeNoteFor(name); if (tn) kids.push(tn); }
|
|
316
309
|
const n = state.repoNotice;
|
|
317
310
|
if (n && n.name === name) {
|
|
318
311
|
kids.push(el('div', {
|
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
|
|
19
|
+
const ip = (v) => el('span', null, v);
|
|
20
|
+
const MONO = 'font-family:var(--mono);font-size:10.5px';
|
|
21
|
+
const BTN = MONO + ';padding:2px 8px;border:1px solid var(--border);color:var(--muted-foreground);min-height:24px';
|
|
22
|
+
const MUTED = 'color:var(--muted-foreground)';
|
|
23
|
+
|
|
24
|
+
let dataMod = null; let termMod = null; let iniMod = null;
|
|
25
|
+
import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
|
|
26
|
+
import('./tab-term.js').then((m) => { termMod = m; }).catch(() => { termMod = null; });
|
|
27
|
+
import('./panel-initiatives.js').then((m) => { iniMod = m; }).catch(() => { iniMod = null; });
|
|
28
|
+
|
|
29
|
+
const note = (name, text, tone) => setState({ treeNote: { name, text, tone: tone || null } });
|
|
30
|
+
const refresh = () => (dataMod && dataMod.refreshTrees ? dataMod.refreshTrees() : Promise.resolve());
|
|
31
|
+
|
|
32
|
+
/* A tree was registered, removed or renamed somewhere (the server's `workspaces` push, a branch
|
|
33
|
+
* change in a terminal): re-read the tree list. Guarded by the push stamp so the repaint this causes
|
|
34
|
+
* does not ask again. */
|
|
35
|
+
let lastPush = 0;
|
|
36
|
+
subscribe(() => {
|
|
37
|
+
if (state.treesPush && state.treesPush !== lastPush) { lastPush = state.treesPush; refresh(); }
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** The server's sessions that run in this tree, from the list the page already polls. */
|
|
41
|
+
export function sessionsIn(ws) {
|
|
42
|
+
return (Array.isArray(state.sessions) ? state.sessions : []).filter((s) => s && s.ws === ws);
|
|
43
|
+
}
|
|
44
|
+
const isEnded = (s) => s.state === 'exited' || s.shown === 'exited';
|
|
45
|
+
|
|
46
|
+
/** Open a session's terminal. The row carries the server's id, so the tab attaches to that exact PTY. */
|
|
47
|
+
function openSession(s) {
|
|
48
|
+
if (!iniMod) return;
|
|
49
|
+
iniMod.openTerm(null, { id: s.id, name: s.name, st: isEnded(s) ? 'exited' : 'working', profile: s.profile_id || null });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Start a session in THIS tree: the workspace id is the tree's, so the server runs it in that folder. */
|
|
53
|
+
export async function startHere(name, t) {
|
|
54
|
+
if (!termMod || !iniMod) { note(name, 'the terminal module has not loaded — nothing was started', 'bad'); return; }
|
|
55
|
+
const label = (t.branch || 'tree').replace(/^initiative\//, '');
|
|
56
|
+
const res = await termMod.requestSpawn({ name: 'shell · ' + label, agent: 'shell', ws: t.ws, branch: null, createBranch: false, isolate: null, profile: null });
|
|
57
|
+
if (!res || !res.ok) { note(name, (res && res.error) || 'the workspace server refused, without saying why', 'bad'); return; }
|
|
58
|
+
note(name, 'started in ' + (t.branch || 'this tree'));
|
|
59
|
+
openSession({ id: res.id, name: 'shell · ' + label, profile_id: null });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function endSession(name, s) {
|
|
63
|
+
const out = dataMod ? await dataMod.removeSessionById(s.id) : null;
|
|
64
|
+
setState({ treeConfirm: null });
|
|
65
|
+
if (!out || out.ok === false) { note(name, (out && out.error) || 'the session could not be removed', 'bad'); return; }
|
|
66
|
+
note(name, out.removed === false ? (out.note || 'asked it to end') : 'session removed');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function removeTree(name, t) {
|
|
70
|
+
const ok = typeof window === 'undefined' || !window.confirm ? true
|
|
71
|
+
: window.confirm('Remove the folder for “' + t.branch + '”?\n\n' + t.dir + '\n\nThe branch is kept. A folder with unsaved work or a running session is refused.');
|
|
72
|
+
if (!ok) return;
|
|
73
|
+
const out = dataMod ? await dataMod.removeWorktree(t.ws, t.dir) : null;
|
|
74
|
+
if (!out || out.ok === false) { note(name, (out && out.error) || 'the folder could not be removed', 'bad'); return; }
|
|
75
|
+
setState((s) => ({ treeSel: { ...(s.treeSel || {}), [name]: null } }));
|
|
76
|
+
note(name, 'removed the folder for ' + t.branch + ' — the branch is kept');
|
|
77
|
+
refresh();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sessionRow(name, s) {
|
|
81
|
+
const armed = state.treeConfirm === s.id;
|
|
82
|
+
return el('div', { style: 'display:flex;align-items:center;gap:6px;padding:1px 0', 'data-r14': 'tree-session', 'data-id': s.id },
|
|
83
|
+
el('button', { onClick: () => openSession(s), style: MONO + ';flex:1;min-width:0;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--foreground)', hover: 'text-decoration:underline', title: 'open this session' },
|
|
84
|
+
ip(s.name + ' · ' + (isEnded(s) ? 'ended' : (s.shown || s.state || 'running')))),
|
|
85
|
+
el('button', {
|
|
86
|
+
onClick: () => (armed ? endSession(name, s) : setState({ treeConfirm: s.id })),
|
|
87
|
+
style: BTN + (armed ? ';color:var(--destructive);border-color:var(--destructive)' : ''), hover: 'color:var(--destructive)',
|
|
88
|
+
title: isEnded(s) ? 'remove this ended session' : 'end this session and remove it', 'data-r14': 'tree-end',
|
|
89
|
+
}, armed ? 'sure?' : (isEnded(s) ? 'remove' : 'end')));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function treeRow(name, t) {
|
|
93
|
+
const sel = (state.treeSel || {})[name] === t.ws;
|
|
94
|
+
const sess = sessionsIn(t.ws);
|
|
95
|
+
const live = sess.filter((s) => !isEnded(s)).length;
|
|
96
|
+
const head = el('button', {
|
|
97
|
+
onClick: () => setState((s) => ({ treeSel: { ...(s.treeSel || {}), [name]: sel ? null : t.ws }, treeConfirm: null })),
|
|
98
|
+
style: MONO + ';display:flex;gap:8px;width:100%;text-align:left;padding:3px 0;min-height:26px;align-items:baseline', hover: 'background:var(--accent)',
|
|
99
|
+
title: t.initiative ? 'initiative read from the branch name (initiative/<id>) — not a recorded attribution' : 'show this tree’s sessions',
|
|
100
|
+
'data-r14': 'tree', 'data-ws': t.ws,
|
|
101
|
+
},
|
|
102
|
+
el('span', { style: MUTED }, ip(sel ? '▾' : '▸')),
|
|
103
|
+
el('span', { style: 'color:var(--foreground)' }, ip((t.main ? '⎇ ' : '⑂ ') + (t.branch || '(detached)'))),
|
|
104
|
+
el('span', { style: MUTED }, ip(live + ' live' + (sess.length > live ? ' · ' + (sess.length - live) + ' ended' : ''))),
|
|
105
|
+
t.initiative ? el('span', { style: MUTED }, ip('#' + t.initiative)) : null);
|
|
106
|
+
if (!sel) return head;
|
|
107
|
+
return el('div', null, head, el('div', { style: 'padding:2px 0 6px 14px', 'data-r14': 'tree-open' },
|
|
108
|
+
sess.length ? sess.map((s) => sessionRow(name, s)) : el('div', { style: MONO + ';' + MUTED + ';padding:2px 0' }, 'no sessions in this tree'),
|
|
109
|
+
el('div', { style: 'display:flex;gap:6px;padding-top:4px;flex-wrap:wrap' },
|
|
110
|
+
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'),
|
|
111
|
+
t.main ? null : el('button', { onClick: () => removeTree(name, t), style: BTN, hover: 'color:var(--destructive)', title: 'remove this worktree folder (the branch is kept)', 'data-r14': 'tree-remove' }, 'remove folder'))));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The trees of one repository, main first. `name` is the repository's name in the data layer. */
|
|
115
|
+
export function treesBlock(name, trees, addable) {
|
|
116
|
+
return el('div', { style: 'padding:2px 14px 8px 39px', 'data-r1': 'trees', 'data-repo': name }, trees.map((t) => treeRow(name, t)),
|
|
117
|
+
addable ? el('button', { onClick: () => openBranchForm(name, trees), style: BTN + ';margin-top:4px', hover: 'color:var(--foreground)',
|
|
118
|
+
title: 'create a branch — with or without its own folder — without starting a session', 'data-r14': 'branch-add' }, '+ branch') : null);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* ── + branch ───────────────────────────────────────────────────────────────────────────────── */
|
|
122
|
+
const INPUT = MONO + ';width:100%;padding:4px 6px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);min-height:26px';
|
|
123
|
+
|
|
124
|
+
export function openBranchForm(name, trees) {
|
|
125
|
+
const main = (trees || []).find((t) => t.main) || (trees || [])[0];
|
|
126
|
+
setState({ branchForm: { repo: name, name: '', from: '', folder: true, busy: false, error: null, ws: main ? main.ws : null, current: main ? main.branch : null } });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function submitBranch() {
|
|
130
|
+
const f = state.branchForm;
|
|
131
|
+
if (!f || f.busy || !dataMod) return;
|
|
132
|
+
const name = String(f.name || '').trim();
|
|
133
|
+
if (!name) { setState({ branchForm: { ...f, error: 'name the new branch' } }); return; }
|
|
134
|
+
setState({ branchForm: { ...f, busy: true, error: null } });
|
|
135
|
+
const out = await dataMod.createBranch({ ws: f.ws, name, from: String(f.from || '').trim() || null, worktree: f.folder });
|
|
136
|
+
if (!out || out.ok === false) { setState({ branchForm: { ...f, busy: false, error: (out && out.error) || 'the server refused, without saying why' } }); return; }
|
|
137
|
+
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 } }));
|
|
138
|
+
refresh();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* The panel repaints on every state change, which replaces the input the PM is typing in. The value
|
|
142
|
+
* survives (it lives on the form record); the focus and caret are restored here so typing is not cut. */
|
|
143
|
+
function keepFocus(f, which) {
|
|
144
|
+
return (n) => {
|
|
145
|
+
if ((f.focus || 'name') !== which) return;
|
|
146
|
+
setTimeout(() => { try { n.focus(); n.setSelectionRange(n.value.length, n.value.length); } catch { /* detached */ } }, 0);
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The `+ branch` form for the repository it was opened on, or null. */
|
|
151
|
+
export function branchForm(name) {
|
|
152
|
+
const f = state.branchForm;
|
|
153
|
+
if (!f || f.repo !== name) return null;
|
|
154
|
+
const upd = (patch) => setState((s) => ({ branchForm: { ...s.branchForm, ...patch } }));
|
|
155
|
+
return el('div', { style: 'padding:6px 14px 10px 39px;display:grid;gap:5px', 'data-r14': 'branch-form' },
|
|
156
|
+
el('input', { value: f.name, placeholder: 'initiative/my-branch', style: INPUT, 'data-r14': 'branch-name',
|
|
157
|
+
ref: keepFocus(f, 'name'), onFocus: () => { f.focus = 'name'; },
|
|
158
|
+
onInput: (e) => { f.name = e.target.value; }, onKeydown: (e) => { if (e.key === 'Enter') submitBranch(); if (e.key === 'Escape') setState({ branchForm: null }); } }),
|
|
159
|
+
el('input', { value: f.from, placeholder: 'start from: ' + (f.current || 'the current branch') + ' (or another branch)', style: INPUT, 'data-r14': 'branch-from',
|
|
160
|
+
ref: keepFocus(f, 'from'), onFocus: () => { f.focus = 'from'; },
|
|
161
|
+
onInput: (e) => { f.from = e.target.value; }, onKeydown: (e) => { if (e.key === 'Enter') submitBranch(); } }),
|
|
162
|
+
el('button', { onClick: () => upd({ folder: !f.folder }), style: MONO + ';text-align:left;' + MUTED, hover: 'color:var(--foreground)', 'data-r14': 'branch-folder',
|
|
163
|
+
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' },
|
|
164
|
+
ip((f.folder ? '☑' : '☐') + ' its own folder (worktree)')),
|
|
165
|
+
f.error ? el('div', { style: 'font-size:11px;color:var(--destructive)', 'data-r14': 'branch-error' }, ip(f.error)) : null,
|
|
166
|
+
el('div', { style: 'display:flex;gap:6px' },
|
|
167
|
+
el('button', { onClick: submitBranch, disabled: f.busy ? 'disabled' : null, style: BTN, hover: 'color:var(--foreground)', 'data-r14': 'branch-create' }, f.busy ? 'creating…' : 'create branch'),
|
|
168
|
+
el('button', { onClick: () => setState({ branchForm: null }), style: BTN, hover: 'color:var(--foreground)' }, 'cancel')));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The outcome line of the last tree action for this repository. */
|
|
172
|
+
export function treeNoteFor(name) {
|
|
173
|
+
const n = state.treeNote;
|
|
174
|
+
if (!n || n.name !== name) return null;
|
|
175
|
+
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));
|
|
176
|
+
}
|
|
@@ -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
|
@@ -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
|
|
@@ -4290,6 +4324,27 @@ async function handleRequest(req, res) {
|
|
|
4290
4324
|
w.label = name; saveWorkspaces(); pushWorkspaces();
|
|
4291
4325
|
return json(res, { ok: true, workspace: { id: w.id, name, label: name } });
|
|
4292
4326
|
}
|
|
4327
|
+
if (p === '/api/git/branch' && req.method === 'POST') {
|
|
4328
|
+
let body = {};
|
|
4329
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
4330
|
+
const out = createBranchOnly({ ws: body.ws, name: body.name, from: body.from || null, worktree: body.worktree !== false });
|
|
4331
|
+
return json(res, out, out.ok ? 200 : 400);
|
|
4332
|
+
}
|
|
4333
|
+
// End a session and remove it, with an answer. The WebSocket kill/remove messages return nothing,
|
|
4334
|
+
// so a refusal or a slow exit left the PM clicking a control that seemed to do nothing.
|
|
4335
|
+
if (p === '/api/session/remove' && req.method === 'POST') {
|
|
4336
|
+
let body = {};
|
|
4337
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
4338
|
+
const sess = sessions.get(String(body.id || ''));
|
|
4339
|
+
if (!sess) return json(res, { ok: false, error: 'no such session' }, 404);
|
|
4340
|
+
if (sess.pty) {
|
|
4341
|
+
if (sess.tmux) tmuxKillSession(sess.tmux);
|
|
4342
|
+
try { sess.pty.kill(); } catch {}
|
|
4343
|
+
for (let i = 0; i < 30 && sess.pty; i++) await new Promise((r) => setTimeout(r, 100));
|
|
4344
|
+
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' });
|
|
4345
|
+
}
|
|
4346
|
+
return json(res, { ok: removeSession(sess.id), removed: true });
|
|
4347
|
+
}
|
|
4293
4348
|
if (p === '/api/git/worktrees/remove' && req.method === 'POST') {
|
|
4294
4349
|
// Reclaim a worktree's folder. `dir` comes from a client, so it is checked against git's own
|
|
4295
4350
|
// list of THIS repo's linked worktrees before anything is removed — the main tree is
|