create-pmos 0.38.2 → 0.38.4
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/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 +42 -2
- 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-repos.js +36 -5
- package/workspace/public/pm/panel-tabs.js +6 -1
- package/workspace/public/pm/panel-trees.js +22 -7
- package/workspace/public/pm/state.js +6 -1
- package/workspace/server.js +111 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.4",
|
|
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-02T08:57:21Z"}
|
|
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-02T08:57:21Z"}
|
|
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-02T08:57:21Z"}
|
|
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
|
/*
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.4",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"test:parity": "node design-parity.js --states",
|
|
16
16
|
"test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
|
|
17
17
|
"doctor": "node doctor.js",
|
|
18
|
-
"test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js",
|
|
18
|
+
"test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-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,8 @@ 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;
|
|
591
|
+
p.profile = w.profile === 'pm-artifacts' ? 'pm-artifacts' : 'product-code';
|
|
587
592
|
p.kind = w.kind === 'github' ? 'github' : 'local';
|
|
588
593
|
p.remote = w.remote || null;
|
|
589
594
|
}
|
|
@@ -724,6 +729,19 @@ function readStamp() {
|
|
|
724
729
|
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
725
730
|
}
|
|
726
731
|
|
|
732
|
+
/** Change an existing initiative's lane (the ladder re-derives from it), then re-read the record and its board. */
|
|
733
|
+
export async function setInitiativeLane(slug, lane) {
|
|
734
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode has no records to edit' };
|
|
735
|
+
const prod = products.get(currentProductName());
|
|
736
|
+
const out = await postJson('/api/initiative/lane', { ws: prod ? prod.ws : null, id: slug, lane });
|
|
737
|
+
if (out && out.ok && out.changed !== false) {
|
|
738
|
+
delete hydrated[slug];
|
|
739
|
+
await hydrate(slug);
|
|
740
|
+
if (prod) { try { await loadBoard(prod); publishBoards(); } catch { /* the record read above already shows the new ladder */ } }
|
|
741
|
+
}
|
|
742
|
+
return out || { ok: false, error: 'the workspace server did not answer' };
|
|
743
|
+
}
|
|
744
|
+
|
|
727
745
|
async function hydrate(slug) {
|
|
728
746
|
if (FIXTURE || !slug || inFlight.has(slug)) return null;
|
|
729
747
|
const prod = products.get(currentProductName());
|
|
@@ -1037,12 +1055,34 @@ export async function removeRepository(name) {
|
|
|
1037
1055
|
return res || { ok: false, error: 'the workspace server did not answer' };
|
|
1038
1056
|
}
|
|
1039
1057
|
|
|
1058
|
+
/** The PM's own abbreviation for the rail, or null (the rail derives one from the name). */
|
|
1059
|
+
export function repoInitials(name) { const p = products.get(name); return (p && p.initials) || null; }
|
|
1060
|
+
|
|
1061
|
+
/** panel-repos.js — the repository's profile (D68): 'pm-artifacts' (documents) or 'product-code'. */
|
|
1062
|
+
export function repoProfile(name) { const p = products.get(name); return (p && p.profile) || 'product-code'; }
|
|
1063
|
+
|
|
1064
|
+
/** panel-repos.js — write or remove `.pmos-profile`. Refusals come back as { ok:false, error }. */
|
|
1065
|
+
export async function setRepoProfile(name, profile) {
|
|
1066
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode — nothing is registered' };
|
|
1067
|
+
const p = products.get(name);
|
|
1068
|
+
if (!p) return { ok: false, reason: 'unknown', error: 'unknown repository' };
|
|
1069
|
+
const out = await postJson('/api/workspaces/profile', { id: p.ws, profile });
|
|
1070
|
+
if (out && out.ok) {
|
|
1071
|
+
p.profile = profile;
|
|
1072
|
+
// the lane picker and the ladder read the profile: drop every cached record and re-read the open one
|
|
1073
|
+
for (const k of Object.keys(hydrated)) delete hydrated[k];
|
|
1074
|
+
try { await loadBoard(p); publishBoards(); } catch { /* the chip already shows the new profile */ }
|
|
1075
|
+
if (state.sel) await hydrate(state.sel);
|
|
1076
|
+
}
|
|
1077
|
+
return out || { ok: false, error: 'the workspace server did not answer' };
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1040
1080
|
/** panel-repos.js — `renameRepository(name, label)`. A display name only (R3.1). */
|
|
1041
|
-
export async function renameRepository(name, label) {
|
|
1081
|
+
export async function renameRepository(name, label, initials) {
|
|
1042
1082
|
if (FIXTURE) return { ok: false, error: 'fixture mode — nothing is registered' };
|
|
1043
1083
|
const p = products.get(name);
|
|
1044
1084
|
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' };
|
|
1085
|
+
return (await postJson('/api/workspaces/rename', { id: p.ws, name: label, ...(initials !== undefined ? { initials } : {}) })) || { ok: false, error: 'the workspace server did not answer' };
|
|
1046
1086
|
}
|
|
1047
1087
|
|
|
1048
1088
|
/**
|
|
@@ -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] : '·'),
|
|
@@ -260,14 +260,38 @@ function treesOf(name) {
|
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
async function renameRepo(name) {
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
263
|
+
const win = typeof window !== 'undefined' && window.prompt ? window : null;
|
|
264
|
+
const label = win ? win.prompt('Rename “' + name + '” to:', name) : null;
|
|
265
|
+
if (label === null || !String(label).trim()) return;
|
|
266
|
+
// the collapsed rail shows an abbreviation; empty keeps (or restores) the one derived from the name
|
|
267
|
+
const cur = dataMod.repoInitials ? dataMod.repoInitials(name) : null;
|
|
268
|
+
const ini = win.prompt('Abbreviation on the collapsed rail (up to 3 letters or digits; empty = derived from the name):', cur || '');
|
|
269
|
+
if (ini === null) return;
|
|
270
|
+
if (String(label).trim() === name && String(ini).trim() === (cur || '')) return;
|
|
271
|
+
const out = await dataMod.renameRepository(name, String(label).trim(), String(ini).trim());
|
|
266
272
|
if (!out || out.ok === false) { setState({ repoNotice: { name, tone: 'bad', text: (out && out.error) || 'rename refused' } }); return; }
|
|
267
273
|
await connectData();
|
|
268
274
|
setState({ product: String(label).trim(), repoNotice: null });
|
|
269
275
|
}
|
|
270
276
|
|
|
277
|
+
/* The repository's profile (D68). `pm-artifacts` offers document lanes and no build rungs; `product-code` is the
|
|
278
|
+
* default. The confirm states the consequence before anything is written. */
|
|
279
|
+
const DOC_LANES = 'strategy, prd, project-plan, discovery, decision-record, report, chore';
|
|
280
|
+
const CODE_LANES = 'feature, discovery, chore, incident, content, prototype';
|
|
281
|
+
async function switchProfile(name) {
|
|
282
|
+
if (!dataMod || !dataMod.repoProfile) return;
|
|
283
|
+
const toDocs = dataMod.repoProfile(name) !== 'pm-artifacts';
|
|
284
|
+
const msg = toDocs
|
|
285
|
+
? 'Switch “' + name + '” to document work?\n\nLanes offered: ' + DOC_LANES + '.\nThe ladder becomes route, anchor, brief, draft, review, approved, handed off — no contract, build, eval or explainer.'
|
|
286
|
+
: 'Switch “' + name + '” to code work?\n\nLanes offered: ' + CODE_LANES + '.\nThe ladder returns to spec, contract, build, eval, explainer, acceptance.';
|
|
287
|
+
const tail = '\n\nAn initiative whose lane is not in the new set is treated as the strictest lane (' + (toDocs ? 'a governed prd' : 'feature') + '). No initiative record is edited.\n\nThis ' + (toDocs ? 'writes' : 'removes') + ' the file .pmos-profile in the repository. It is a change for you to commit.';
|
|
288
|
+
const ok = typeof window === 'undefined' || !window.confirm ? true : window.confirm(msg + tail);
|
|
289
|
+
if (!ok) return;
|
|
290
|
+
const out = await dataMod.setRepoProfile(name, toDocs ? 'pm-artifacts' : 'product-code');
|
|
291
|
+
if (!out || out.ok === false) { setState({ repoNotice: { name, tone: 'bad', text: (out && out.error) || 'the profile was not changed' } }); return; }
|
|
292
|
+
setState({ repoNotice: { name, tone: null, text: 'now ' + (toDocs ? 'document work (pm-artifacts)' : 'code work (product-code)') + ' — .pmos-profile ' + (toDocs ? 'written' : 'removed') + '; commit it' } });
|
|
293
|
+
}
|
|
294
|
+
|
|
271
295
|
async function removeRepo(name) {
|
|
272
296
|
const ok = typeof window === 'undefined' || !window.confirm ? true
|
|
273
297
|
: window.confirm('Remove “' + name + '” from PMOS?\n\nThis forgets the repository and its worktree rows. No folder is deleted.');
|
|
@@ -297,6 +321,11 @@ function repoEntry(tuple, product) {
|
|
|
297
321
|
}, ip('⑂ ' + trees.length)));
|
|
298
322
|
if (product === name) {
|
|
299
323
|
{ const sb = syncButton(name); if (sb) tools.push(sb); }
|
|
324
|
+
{ const docs = dataMod.repoProfile && dataMod.repoProfile(name) === 'pm-artifacts';
|
|
325
|
+
tools.push(el('button', { onClick: () => switchProfile(name), style: chipStyle, hover: 'color:var(--foreground)',
|
|
326
|
+
title: docs ? 'document work (pm-artifacts): lanes are strategy, prd, project-plan … with no build rungs. Press to switch to code work.'
|
|
327
|
+
: 'code work (product-code): lanes are feature, discovery, chore … with the full build ladder. Press to switch to document work.',
|
|
328
|
+
'data-r16': 'profile', 'data-profile': docs ? 'pm-artifacts' : 'product-code' }, ip(docs ? 'docs' : 'code'))); }
|
|
300
329
|
tools.push(el('button', { onClick: () => renameRepo(name), style: actStyle, hover: 'color:var(--foreground)', title: 'rename this repository', 'data-r1': 'rename' }, '✎'));
|
|
301
330
|
tools.push(el('button', { onClick: () => removeRepo(name), style: actStyle, hover: 'color:var(--destructive)', title: 'remove this repository from PMOS (no folder is deleted)', 'data-r1': 'remove' }, '✕'));
|
|
302
331
|
}
|
|
@@ -333,12 +362,14 @@ export function railGlyphs() {
|
|
|
333
362
|
const wss = new Set(treesOf(name).map((x) => x.ws));
|
|
334
363
|
const needs = sess.some((x) => x && wss.has(x.ws) && x.shown === 'needs');
|
|
335
364
|
const active = product === name;
|
|
336
|
-
const ini = (String(name).replace(/[^A-Za-z0-9]+/g, ' ').trim().split(' ').filter(Boolean).slice(0, 2)
|
|
365
|
+
const ini = (dataMod && dataMod.repoInitials && dataMod.repoInitials(name)) || (String(name).replace(/[^A-Za-z0-9]+/g, ' ').trim().split(' ').filter(Boolean).slice(0, 2)
|
|
337
366
|
.map((w) => w[0]).join('') || String(name).slice(0, 2)).toUpperCase();
|
|
338
367
|
const said = name + ' — ' + rp.kindLabel + ' repository' + (needs ? ' · a session needs you' : '');
|
|
339
368
|
return el('button', {
|
|
340
369
|
onClick: (e) => { if (e && e.stopPropagation) e.stopPropagation(); selectRepo(name); },
|
|
341
|
-
|
|
370
|
+
// right-click renames from the rail, where the abbreviation is shown
|
|
371
|
+
onContextmenu: (e) => { if (e && e.preventDefault) e.preventDefault(); renameRepo(name); },
|
|
372
|
+
title: said + ' · right-click to rename', 'aria-label': said,
|
|
342
373
|
'data-r1': 'rail-glyph', 'data-repo': name, 'data-active': active ? '1' : '0',
|
|
343
374
|
'data-ini': ini, 'data-kind': rp.kindGlyph,
|
|
344
375
|
style: 'position:relative;flex:none;width:28px;height:28px;margin:3px auto;display:flex;align-items:center;justify-content:center;'
|
|
@@ -40,6 +40,9 @@ import { boardOf, currentProduct } from './board-read.js';
|
|
|
40
40
|
* file that is not on disk yet takes the whole panel down with it, and an empty terminal pane is a
|
|
41
41
|
* far smaller failure than a dead panel 4. When it lands, the panel repaints itself. */
|
|
42
42
|
let termMod = null;
|
|
43
|
+
let liveMod = null;
|
|
44
|
+
/* Loaded lazily; a repaint is asked for only when a skill tab is already open (restored on reload) and waiting for it. */
|
|
45
|
+
import('./modes-live.js').then((m) => { liveMod = m; if ((state.tabs || []).some((t) => t.kind === 'skill')) setState({}); }).catch(() => { liveMod = null; });
|
|
43
46
|
import('./tab-term.js')
|
|
44
47
|
.then((m) => { termMod = m; setState({}); })
|
|
45
48
|
.catch(() => { termMod = null; });
|
|
@@ -792,6 +795,8 @@ function paneFor(tab) {
|
|
|
792
795
|
if (termMod && typeof termMod.mountTerm === 'function') termMod.mountTerm(host, tab);
|
|
793
796
|
} else if (tab.kind === 'task') {
|
|
794
797
|
mountTask(host, tab);
|
|
798
|
+
} else if (tab.kind === 'skill') {
|
|
799
|
+
if (liveMod && typeof liveMod.mountSkillTab === 'function') liveMod.mountSkillTab(host, tab);
|
|
795
800
|
} else if (tab.kind === 'web') {
|
|
796
801
|
mountWeb(host, tab); // overhaul-r5 — never falls through to an empty doc tab
|
|
797
802
|
} else {
|
|
@@ -908,7 +913,7 @@ function tabNode(t, act, splitTab) {
|
|
|
908
913
|
* fixture render keeps the design's literal — the comparator reads this text node. */
|
|
909
914
|
const meta = t.kind === 'term'
|
|
910
915
|
? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
|
|
911
|
-
: (t.kind === 'task' ? 'task' : t.kind === 'web' ? 'web' : t.role);
|
|
916
|
+
: (t.kind === 'task' ? 'task' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.role);
|
|
912
917
|
|
|
913
918
|
return el('div', {
|
|
914
919
|
style: 'flex:none;display:flex;align-items:center;gap:8px;padding:0 10px;background:' + bg
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { el } from './dom.js';
|
|
17
17
|
import { state, setState, subscribe } from './state.js';
|
|
18
|
+
import { boardOf } from './board-read.js';
|
|
19
|
+
import { openNewSession } from './panel-newsess.js';
|
|
18
20
|
|
|
19
21
|
const ip = (v) => el('span', null, v);
|
|
20
22
|
const MONO = 'font-family:var(--mono);font-size:10.5px';
|
|
@@ -68,20 +70,20 @@ async function endSession(name, s) {
|
|
|
68
70
|
|
|
69
71
|
async function removeTree(name, t) {
|
|
70
72
|
const ok = typeof window === 'undefined' || !window.confirm ? true
|
|
71
|
-
: window.confirm('Remove the folder for “' + t.branch + '”?\n\n' + t.dir + '\n\
|
|
73
|
+
: window.confirm('Remove the folder for “' + t.branch + '”?\n\n' + t.dir + '\n\nThis only removes the folder (git worktree remove). The branch is kept and nothing is merged into main — merge through a pull request. A folder with unsaved work, or a running session, is refused: end the session first.');
|
|
72
74
|
if (!ok) return;
|
|
73
75
|
const out = dataMod ? await dataMod.removeWorktree(t.ws, t.dir) : null;
|
|
74
76
|
if (!out || out.ok === false) { note(name, (out && out.error) || 'the folder could not be removed', 'bad'); return; }
|
|
75
77
|
setState((s) => ({ treeSel: { ...(s.treeSel || {}), [name]: null } }));
|
|
76
|
-
note(name, 'removed the folder for ' + t.branch + ' — the branch is kept');
|
|
78
|
+
note(name, 'removed the folder for ' + t.branch + ' — the branch is kept, nothing was merged' + (out.note ? '. ' + out.note : ''));
|
|
77
79
|
refresh();
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
function sessionRow(name, s) {
|
|
81
83
|
const armed = state.treeConfirm === s.id;
|
|
82
84
|
return el('div', { style: 'display:flex;align-items:center;gap:6px;padding:1px 0', 'data-r14': 'tree-session', 'data-id': s.id },
|
|
83
|
-
el('button', { onClick: () => openSession(s), style: MONO + ';flex:1;min-width:0;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--foreground)', hover: 'text-decoration:underline', title: 'open this session' },
|
|
84
|
-
ip(s.name + ' · ' + (isEnded(s) ? 'ended' : (s.shown || s.state || 'running')))),
|
|
85
|
+
el('button', { onClick: () => openSession(s), style: MONO + ';flex:1;min-width:0;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--foreground)', hover: 'text-decoration:underline', title: s.initiative ? 'open this session — recorded on initiative ' + s.initiative : 'open this session — it is on no initiative; a session maps to one initiative or none (bind it from the initiatives list)' },
|
|
86
|
+
ip(s.name + ' · ' + (isEnded(s) ? 'ended' : (s.shown || s.state || 'running')) + (s.initiative ? ' · #' + s.initiative : ' · no initiative'))),
|
|
85
87
|
el('button', {
|
|
86
88
|
onClick: () => (armed ? endSession(name, s) : setState({ treeConfirm: s.id })),
|
|
87
89
|
style: BTN + (armed ? ';color:var(--destructive);border-color:var(--destructive)' : ''), hover: 'color:var(--destructive)',
|
|
@@ -100,8 +102,8 @@ function treeRow(name, t) {
|
|
|
100
102
|
'data-r14': 'tree', 'data-ws': t.ws,
|
|
101
103
|
},
|
|
102
104
|
el('span', { style: MUTED }, ip(sel ? '▾' : '▸')),
|
|
103
|
-
el('span', { style: 'color:var(--foreground)' }, ip((t.main ? '⎇ ' : '⑂ ') + (t.branch || '(detached)'))),
|
|
104
|
-
el('span', { style: MUTED }, ip(live + ' live' + (sess.length > live ? ' · ' + (sess.length - live) + ' ended' : ''))),
|
|
105
|
+
el('span', { style: 'color:var(--foreground);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', title: t.branch || '(detached)' }, ip((t.main ? '⎇ ' : '⑂ ') + (t.branch || '(detached)'))),
|
|
106
|
+
el('span', { style: MUTED + ';flex:none' }, ip(live + ' live' + (sess.length > live ? ' · ' + (sess.length - live) + ' ended' : ''))),
|
|
105
107
|
t.initiative ? el('span', { style: MUTED }, ip('#' + t.initiative)) : null);
|
|
106
108
|
if (!sel) return head;
|
|
107
109
|
return el('div', null, head, el('div', { style: 'padding:2px 0 6px 14px', 'data-r14': 'tree-open' },
|
|
@@ -111,11 +113,24 @@ function treeRow(name, t) {
|
|
|
111
113
|
t.main ? null : el('button', { onClick: () => removeTree(name, t), style: BTN, hover: 'color:var(--destructive)', title: 'remove this worktree folder (the branch is kept)', 'data-r14': 'tree-remove' }, 'remove folder'))));
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
/** `+ session` on the repository: the pre-flight form (branch, agent, profile) on an initiative of THIS
|
|
117
|
+
* repository — the one selected, else the first on its board. A repository with no initiative has nothing
|
|
118
|
+
* to attach the form to, so the session starts in its main tree and the panel says so. */
|
|
119
|
+
export function newSessionHere(name, trees) {
|
|
120
|
+
const slugs = (boardOf()[name] || []).flatMap((g) => g.slugs || []);
|
|
121
|
+
const slug = slugs.includes(state.sel) ? state.sel : slugs[0];
|
|
122
|
+
if (slug) { openNewSession(slug); return; }
|
|
123
|
+
const main = (trees || []).find((t) => t.main) || (trees || [])[0];
|
|
124
|
+
if (main) { note(name, 'no initiative here to attach the form to — started a shell in the main tree'); startHere(name, main); }
|
|
125
|
+
}
|
|
126
|
+
|
|
114
127
|
/** The trees of one repository, main first. `name` is the repository's name in the data layer. */
|
|
115
128
|
export function treesBlock(name, trees, addable) {
|
|
116
129
|
return el('div', { style: 'padding:2px 14px 8px 39px', 'data-r1': 'trees', 'data-repo': name }, trees.map((t) => treeRow(name, t)),
|
|
117
130
|
addable ? el('button', { onClick: () => openBranchForm(name, trees), style: BTN + ';margin-top:4px', hover: 'color:var(--foreground)',
|
|
118
|
-
title: 'create a branch — with or without its own folder — without starting a session', 'data-r14': 'branch-add' }, '+ branch') : null
|
|
131
|
+
title: 'create a branch — with or without its own folder — without starting a session', 'data-r14': 'branch-add' }, '+ branch') : null,
|
|
132
|
+
addable ? el('button', { onClick: () => newSessionHere(name, trees), style: BTN + ';margin-top:4px;margin-left:6px', hover: 'color:var(--foreground)',
|
|
133
|
+
title: 'start a session — choose the branch, the agent and the profile first', 'data-r15': 'session-add' }, '+ session') : null);
|
|
119
134
|
}
|
|
120
135
|
|
|
121
136
|
/* ── + branch ───────────────────────────────────────────────────────────────────────────────── */
|
|
@@ -151,7 +151,9 @@ const TAB_FIELDS = ['id', 'kind', 'title', 'slug', 'repo', 'sess', 'sessionId',
|
|
|
151
151
|
// reading the branch-only file from ITS tree, not fall back to the main tree
|
|
152
152
|
'ws', 'branch',
|
|
153
153
|
// a web tab's address (overhaul-r5, D69) — restored, re-validated, and loaded only on a PM press
|
|
154
|
-
'url'
|
|
154
|
+
'url',
|
|
155
|
+
// a skill tab's skill id — a reload reopens the same skill
|
|
156
|
+
'skillId'];
|
|
155
157
|
const MAX_TABS = 40;
|
|
156
158
|
|
|
157
159
|
function packTab(t) {
|
|
@@ -172,6 +174,9 @@ const isTabList = (v) =>
|
|
|
172
174
|
const PERSIST = {
|
|
173
175
|
theme: (v) => v === 'dark' || v === 'light',
|
|
174
176
|
col0: isBool, col1: isBool, col2: isBool,
|
|
177
|
+
// the dragged widths of panels 1 and 2 (clamped again by app.js on the way out)
|
|
178
|
+
col0W: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 100 && v <= 1200,
|
|
179
|
+
col1W: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 100 && v <= 1200,
|
|
175
180
|
settingsOpen: isBool,
|
|
176
181
|
product: isSlug,
|
|
177
182
|
sel: isSlug,
|
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
|
}
|
|
@@ -584,11 +584,18 @@ function treeNoticeFor(s, mates) {
|
|
|
584
584
|
role: holder === s.id ? 'writer' : 'reader', writerName: held, writerId: holder, tree: s.tree,
|
|
585
585
|
});
|
|
586
586
|
}
|
|
587
|
+
/** The repository's declared profile (D68), read fresh from `.pmos-profile`; anything else is `product-code`. */
|
|
588
|
+
function profileOfRoot(root) {
|
|
589
|
+
try {
|
|
590
|
+
const line = fs.readFileSync(path.join(root, '.pmos-profile'), 'utf8').split('\n').map((l) => l.replace(/#.*$/, '').trim()).find(Boolean);
|
|
591
|
+
return line === 'pm-artifacts' ? 'pm-artifacts' : 'product-code';
|
|
592
|
+
} catch { return 'product-code'; }
|
|
593
|
+
}
|
|
587
594
|
function wsList() {
|
|
588
595
|
return [...workspaces.values()].map((w) => {
|
|
589
596
|
const rid = repoIdentity(w.root);
|
|
590
597
|
return {
|
|
591
|
-
id: w.id, name: w.name, label: w.label || null, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
|
|
598
|
+
id: w.id, name: w.name, label: w.label || null, initials: w.initials || null, profile: profileOfRoot(w.root), root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
|
|
592
599
|
shape: wsShape(w.root), addedAt: w.addedAt,
|
|
593
600
|
sessions: [...sessions.values()].filter((s) => s.ws === w.id).length,
|
|
594
601
|
// the repository this working tree belongs to, and the branch it is on. `branch_code`
|
|
@@ -4321,8 +4328,34 @@ async function handleRequest(req, res) {
|
|
|
4321
4328
|
if (!name) return json(res, { ok: false, error: 'a name is required' }, 400);
|
|
4322
4329
|
// `label` is the PM's name for the REPOSITORY. The client names a product after its folder
|
|
4323
4330
|
// (`repo_name`), so changing `name` alone would never show; `label` wins over both.
|
|
4324
|
-
w.label = name;
|
|
4325
|
-
|
|
4331
|
+
w.label = name;
|
|
4332
|
+
// The rail's abbreviation. Optional: undefined leaves it alone, '' clears it back to the derived initials.
|
|
4333
|
+
if (body.initials !== undefined) {
|
|
4334
|
+
const ini = String(body.initials || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 3).toUpperCase();
|
|
4335
|
+
if (String(body.initials || '').trim() && !ini) return json(res, { ok: false, error: 'the abbreviation takes letters and digits, up to three' }, 400);
|
|
4336
|
+
if (ini) w.initials = ini; else delete w.initials;
|
|
4337
|
+
}
|
|
4338
|
+
saveWorkspaces(); pushWorkspaces();
|
|
4339
|
+
return json(res, { ok: true, workspace: { id: w.id, name, label: name, initials: w.initials || null } });
|
|
4340
|
+
}
|
|
4341
|
+
if (p === '/api/workspaces/profile' && req.method === 'POST') {
|
|
4342
|
+
/* Switch a repository between document work (`pm-artifacts`) and code work (`product-code`), D68. The profile
|
|
4343
|
+
* is the one-line `.pmos-profile` file at the repository root, so this writes or removes that file and nothing
|
|
4344
|
+
* else. No initiative record is edited: a lane that is not in the new set is read as the profile's strictest
|
|
4345
|
+
* lane by the engine. The PM commits the file. */
|
|
4346
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
4347
|
+
const w = workspaces.get(body.id);
|
|
4348
|
+
if (!w) return json(res, { ok: false, reason: 'unknown', error: 'unknown repository' }, 404);
|
|
4349
|
+
const want = String(body.profile || '');
|
|
4350
|
+
if (want !== 'pm-artifacts' && want !== 'product-code') return json(res, { ok: false, error: `“${want || '(none)'}” is not a profile — one of: pm-artifacts, product-code` }, 400);
|
|
4351
|
+
const file = path.join(w.root, '.pmos-profile');
|
|
4352
|
+
try {
|
|
4353
|
+
if (want === 'pm-artifacts') { fs.writeFileSync(file + '.tmp', 'pm-artifacts\n'); fs.renameSync(file + '.tmp', file); }
|
|
4354
|
+
else fs.rmSync(file, { force: true });
|
|
4355
|
+
} catch (e) { return json(res, { ok: false, error: 'could not write .pmos-profile — ' + String((e && e.message) || e).slice(0, 120) }, 500); }
|
|
4356
|
+
boardCache.invalidate(w.root);
|
|
4357
|
+
pushWorkspaces();
|
|
4358
|
+
return json(res, { ok: true, id: w.id, profile: want, file: '.pmos-profile', commit: 'the file is a change in the repository — commit it' });
|
|
4326
4359
|
}
|
|
4327
4360
|
if (p === '/api/git/branch' && req.method === 'POST') {
|
|
4328
4361
|
let body = {};
|
|
@@ -4363,9 +4396,13 @@ async function handleRequest(req, res) {
|
|
|
4363
4396
|
// the workspace entry goes with the folder; forgetWorkspace never touches a repo, and it
|
|
4364
4397
|
// refuses while sessions remain, so a race here fails closed rather than orphaning scrollback
|
|
4365
4398
|
const owner = [...workspaces.values()].find((x) => path.resolve(x.root) === path.resolve(row.dir));
|
|
4366
|
-
|
|
4399
|
+
// forgetWorkspace refuses while ANY session (ended ones included) still names the row. Say so: the folder
|
|
4400
|
+
// is gone, and the row stays until those ended sessions are removed — it used to stay without a word.
|
|
4401
|
+
const forgotten = owner ? forgetWorkspace(owner.id) : true;
|
|
4402
|
+
const blocker = owner && !forgotten ? forgetBlocker(owner.id) : null;
|
|
4367
4403
|
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) });
|
|
4368
|
-
return json(res, { ok: true, dir: r.dir, pruned: !!r.pruned
|
|
4404
|
+
return json(res, { ok: true, dir: r.dir, pruned: !!r.pruned, row_kept: !!blocker,
|
|
4405
|
+
note: blocker ? `the folder is removed; the repository row stays until its ended session${blocker.sessions.length === 1 ? '' : 's'} (${blocker.sessions.join(', ')}) ${blocker.sessions.length === 1 ? 'is' : 'are'} removed` : null });
|
|
4369
4406
|
}
|
|
4370
4407
|
if (p === '/api/pick-folder' && req.method === 'POST') {
|
|
4371
4408
|
let body = {};
|
|
@@ -5137,7 +5174,40 @@ async function handleRequest(req, res) {
|
|
|
5137
5174
|
homeDir: os.homedir(),
|
|
5138
5175
|
});
|
|
5139
5176
|
// Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
|
|
5140
|
-
|
|
5177
|
+
let lanes = null;
|
|
5178
|
+
try { if (typeof engine.engine.lanesFor === 'function') lanes = engine.engine.lanesFor(root); } catch { lanes = null; }
|
|
5179
|
+
return json(res, { ws: w ? w.id : null, ...out, lanes });
|
|
5180
|
+
}
|
|
5181
|
+
if (p === '/api/initiative/lane' && req.method === 'POST') {
|
|
5182
|
+
/* Change an existing initiative's lane — the PM's word. The ladder is DERIVED from `lane:` (workflow-state.js),
|
|
5183
|
+
* so this edits that one frontmatter line and nothing else; the next read re-derives the rungs. It never
|
|
5184
|
+
* touches `flow:` (a label the engine ignores), the PRD, the rubric or any run. An unknown lane is refused
|
|
5185
|
+
* and nothing is written. */
|
|
5186
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5187
|
+
const w = getWs(body.ws);
|
|
5188
|
+
const root = w ? w.root : SELF_ROOT;
|
|
5189
|
+
const id = String(body.id || '');
|
|
5190
|
+
if (!fourPanel.validId(id)) return json(res, { ok: false, error: 'an initiative id is required, and it must be a record name' }, 400);
|
|
5191
|
+
const engine = consoleState.loadEngine();
|
|
5192
|
+
if (!engine.ok || typeof engine.engine.lanesFor !== 'function') return json(res, { ok: false, error: 'the workflow engine is not available here' }, 500);
|
|
5193
|
+
const allowed = engine.engine.lanesFor(root).lanes;
|
|
5194
|
+
const lane = String(body.lane || '').trim();
|
|
5195
|
+
if (!allowed.includes(lane)) return json(res, { ok: false, error: `“${lane || '(none)'}” is not a lane of this repository — one of: ${allowed.join(', ')}` }, 400);
|
|
5196
|
+
const file = path.join(engine.engine.layout(root).initiatives, `${id}.md`);
|
|
5197
|
+
let text; try { text = fs.readFileSync(file, 'utf8'); } catch { return json(res, { ok: false, error: `no initiative record for ${id}` }, 404); }
|
|
5198
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(text);
|
|
5199
|
+
if (!m) return json(res, { ok: false, error: 'the record has no frontmatter block to edit — nothing was changed' }, 409);
|
|
5200
|
+
const fmLines = m[1].split(/\r?\n/);
|
|
5201
|
+
const at = fmLines.findIndex((l) => /^lane\s*:/.test(l));
|
|
5202
|
+
const before = at >= 0 ? fmLines[at].replace(/^lane\s*:\s*/, '').replace(/^["']|["']$/g, '').trim() : null;
|
|
5203
|
+
if (before === lane) return json(res, { ok: true, id, lane, previous: before, changed: false });
|
|
5204
|
+
if (at >= 0) fmLines[at] = `lane: ${lane}`;
|
|
5205
|
+
else { const idAt = fmLines.findIndex((l) => /^id\s*:/.test(l)); fmLines.splice(idAt >= 0 ? idAt + 1 : fmLines.length, 0, `lane: ${lane}`); }
|
|
5206
|
+
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));
|
|
5207
|
+
try { fs.writeFileSync(file + '.tmp', next); fs.renameSync(file + '.tmp', file); }
|
|
5208
|
+
catch (e) { return json(res, { ok: false, error: 'could not write the record — ' + String((e && e.message) || e).slice(0, 120) }, 500); }
|
|
5209
|
+
boardCache.invalidate(root);
|
|
5210
|
+
return json(res, { ok: true, id, lane, previous: before, changed: true });
|
|
5141
5211
|
}
|
|
5142
5212
|
if (p === '/api/session/initiative' && req.method === 'POST') {
|
|
5143
5213
|
/* Bind a RUNNING session to an initiative — the PM's own word about work already in flight
|
|
@@ -5253,6 +5323,23 @@ async function handleRequest(req, res) {
|
|
|
5253
5323
|
// search scrubs secrets out of text an agent typed, and the file search reports a walk bound.
|
|
5254
5324
|
// Collapsing them into one payload would mean one of the two lying about what it looked at.
|
|
5255
5325
|
if (p === '/api/portfolio' && req.method === 'GET') return json(res, portfolio());
|
|
5326
|
+
if (p === '/api/portfolio/init' && req.method === 'POST') {
|
|
5327
|
+
// The PM presses "make this repository the hub": write a starter portfolio.yaml there. Never guessed — the
|
|
5328
|
+
// PM names the repository, an existing hub is never overwritten, and only a registered repository qualifies.
|
|
5329
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5330
|
+
const leads = repoLeads();
|
|
5331
|
+
if (portfolioHub(leads)) return json(res, { ok: false, reason: 'has-hub', error: 'a hub already exists — edit its portfolio.yaml instead' }, 409);
|
|
5332
|
+
const lead = leads.find((r) => r.ws === body.ws || r.ids.has(body.ws));
|
|
5333
|
+
if (!lead) return json(res, { ok: false, reason: 'unknown', error: 'that repository is not registered' }, 400);
|
|
5334
|
+
if (!isDir(lead.root)) return json(res, { ok: false, error: 'the repository folder is missing' }, 400);
|
|
5335
|
+
const file = path.join(lead.root, 'portfolio.yaml');
|
|
5336
|
+
const lines = ['# The portfolio hub (D-3): this repository holds the weekly / OKR / query reports.',
|
|
5337
|
+
'# `repos:` maps each registered repository to its domain — edit the domains to match how you group your work.',
|
|
5338
|
+
'repos:', ...leads.map((r) => ` ${r.name.replace(/[:"'\n]/g, '')}: general`), ''];
|
|
5339
|
+
try { fs.writeFileSync(file, lines.join('\n'), { flag: 'wx' }); }
|
|
5340
|
+
catch (e) { return json(res, { ok: false, error: 'could not write portfolio.yaml — ' + String((e && e.message) || e).slice(0, 120) }, 500); }
|
|
5341
|
+
return json(res, { ok: true, hub: lead.name, ws: lead.ws, path: 'portfolio.yaml', repos: leads.length });
|
|
5342
|
+
}
|
|
5256
5343
|
if (p === '/api/reports' && req.method === 'POST') {
|
|
5257
5344
|
let body = {};
|
|
5258
5345
|
try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
@@ -5387,7 +5474,23 @@ async function handleRequest(req, res) {
|
|
|
5387
5474
|
const c = memoryLib.core();
|
|
5388
5475
|
if (!c.ok) return json(res, { ok: false, error: c.error });
|
|
5389
5476
|
const cur = c.core.load(w.root);
|
|
5390
|
-
|
|
5477
|
+
// Entries recorded in the repository's OTHER working trees. The ledger lives in each checkout, so a fact an
|
|
5478
|
+
// agent saved on an initiative branch is invisible here until that branch merges (merge=union). Listed, read
|
|
5479
|
+
// only, and never copied into this ledger: the tool is the only writer (D72).
|
|
5480
|
+
const here = new Set(cur.state.entries.map((e) => e.target + '\u0000' + e.text));
|
|
5481
|
+
const elsewhere = [];
|
|
5482
|
+
try {
|
|
5483
|
+
for (const t of (worktreeReport(w).worktrees || [])) {
|
|
5484
|
+
if (path.resolve(t.dir) === path.resolve(w.root)) continue;
|
|
5485
|
+
for (const e of c.core.load(t.dir).state.entries) {
|
|
5486
|
+
const k = e.target + '\u0000' + e.text;
|
|
5487
|
+
if (here.has(k)) continue;
|
|
5488
|
+
here.add(k);
|
|
5489
|
+
elsewhere.push({ ...e, branch: t.branch || null, dir: t.dir });
|
|
5490
|
+
}
|
|
5491
|
+
}
|
|
5492
|
+
} catch { /* an unreadable tree is simply not listed */ }
|
|
5493
|
+
return json(res, { ok: true, ws: w.id, name: w.name, entries: cur.state.entries, elsewhere, pending: cur.state.pending, blocked: cur.state.blocked,
|
|
5391
5494
|
usage: cur.usage, torn: cur.torn, config: { write_approval: !!cur.cfg.write_approval }, wired: memoryLib.wired(w.root),
|
|
5392
5495
|
has_state_dir: !!c.core.stateDir(w.root, false) });
|
|
5393
5496
|
}
|