create-pmos 0.7.3 → 0.9.0
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/README.md +68 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/skills/pmos-router.skill +22 -0
- package/payload/skills-native/pmos-router/SKILL.md +22 -0
- package/workspace/README.md +28 -0
- package/workspace/lib/apply-change.js +51 -1
- package/workspace/lib/console-state.js +56 -1
- package/workspace/lib/git-info.js +197 -25
- package/workspace/lib/session-state.js +139 -0
- package/workspace/lib/transcript-read.js +28 -6
- package/workspace/public/app.js +429 -274
- package/workspace/public/index.html +30 -7
- package/workspace/public/styles.css +898 -624
- package/workspace/public/vendor/ibm-plex-mono-400.woff2 +0 -0
- package/workspace/public/vendor/ibm-plex-mono-700.woff2 +0 -0
- package/workspace/public/vendor/space-grotesk-var.woff2 +0 -0
- package/workspace/server.js +401 -234
- package/workspace/lib/correction-record.js +0 -127
- package/workspace/lib/finding-contract.js +0 -162
- package/workspace/skills/doc-review.skill +0 -119
package/workspace/server.js
CHANGED
|
@@ -71,9 +71,8 @@ const consoleState = require('./lib/console-state'); // read-only console surf
|
|
|
71
71
|
const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
|
|
72
72
|
const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
|
|
73
73
|
const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const correctionRecord = require('./lib/correction-record'); // attribution never inferred (R8)
|
|
74
|
+
const applyChange = require('./lib/apply-change'); // anchored + whole-document saves, refuse-on-stale
|
|
75
|
+
const sessionState = require('./lib/session-state'); // the cadence×turn join — validated on BOTH sides
|
|
77
76
|
// The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
|
|
78
77
|
// (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
|
|
79
78
|
// — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
|
|
@@ -600,18 +599,6 @@ function buildSurface(name, root, w) {
|
|
|
600
599
|
// extension, not v1. Cadence math is the pure shared module — the tick and the pane's next-run agree.
|
|
601
600
|
const SCHED_FILE = path.join(STATE, 'schedules.json');
|
|
602
601
|
const DELIV_DIR = path.join(STATE, 'deliverables');
|
|
603
|
-
// Reviews the doc-review skill files (workspace-review-and-edit). One JSON per run, written by the
|
|
604
|
-
// skill session, read back by /api/review. Runtime, not the record: the durable artifact of a review
|
|
605
|
-
// is the correction records its dispositions produce, which live in planning/runs/ and are tracked.
|
|
606
|
-
// PER WORKSPACE, like every other piece of state here. An earlier build put this in the SERVER's own
|
|
607
|
-
// state/ — which meant two open repos shared one review pile, and a tenant repo (which has no
|
|
608
|
-
// workspace/ folder at all) had the reviews written into the installed app instead of into the repo
|
|
609
|
-
// being reviewed. Reviews name documents and quote their contents, so that was a cross-workspace leak,
|
|
610
|
-
// not just untidy filing. stateDirFor() is the same resolver needs.json and acceptances.jsonl use.
|
|
611
|
-
function reviewDirFor(w, opts) {
|
|
612
|
-
const d = stateDirFor(w, opts);
|
|
613
|
-
return d ? path.join(d, 'reviews') : null;
|
|
614
|
-
}
|
|
615
602
|
// Skills are file-driven (workspace-skill-authoring): built-ins ship committed under workspace/skills/,
|
|
616
603
|
// user-authored ones are scaffolded into state/skills/ by the New Skill flow. There is ONE user-facing
|
|
617
604
|
// concept — a skill — so the picker never exposes the internal `run`/`kind`; how a skill runs (server
|
|
@@ -752,7 +739,14 @@ function restorePersisted() {
|
|
|
752
739
|
// A session whose process is truly gone (tmux absent, or the daemon/process ended) loads as
|
|
753
740
|
// 'exited' with replayable scrollback and a one-click respawn — honest, never a fake-live pane.
|
|
754
741
|
let files = [];
|
|
755
|
-
try { files = fs.readdirSync(SESS_DIR).filter((f) => f.endsWith('.json')); } catch {
|
|
742
|
+
try { files = fs.readdirSync(SESS_DIR).filter((f) => f.endsWith('.json')); } catch { files = []; }
|
|
743
|
+
// Floor the counter on the FILENAMES first, before any parse. Doing it per-record meant a single
|
|
744
|
+
// unreadable file left `seq` low enough to re-issue an id another record already owned — and the
|
|
745
|
+
// next spawn would then overwrite that record's metadata and scrollback.
|
|
746
|
+
for (const f of files) {
|
|
747
|
+
const n = Number(f.replace(/\.json$/, '').split('-')[1]);
|
|
748
|
+
if (Number.isFinite(n) && n >= seq) seq = n + 1;
|
|
749
|
+
}
|
|
756
750
|
for (const f of files) {
|
|
757
751
|
try {
|
|
758
752
|
const meta = JSON.parse(fs.readFileSync(path.join(SESS_DIR, f), 'utf8'));
|
|
@@ -779,6 +773,66 @@ function restorePersisted() {
|
|
|
779
773
|
const n = Number(String(meta.id).split('-')[1]); if (Number.isFinite(n) && n >= seq) seq = n + 1;
|
|
780
774
|
} catch {}
|
|
781
775
|
}
|
|
776
|
+
adoptOrphanTmuxSessions();
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// The registered workspace a directory belongs to — the LONGEST matching root, so a repo opened
|
|
780
|
+
// inside another repo wins over its parent. Null when no registered workspace contains it.
|
|
781
|
+
function workspaceForPath(dir) {
|
|
782
|
+
if (!dir) return null;
|
|
783
|
+
let best = null;
|
|
784
|
+
for (const w of workspaces.values()) {
|
|
785
|
+
if (!insideRoot(w.root, dir)) continue;
|
|
786
|
+
if (!best || w.root.length > best.root.length) best = w;
|
|
787
|
+
}
|
|
788
|
+
return best;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// A live pane on our socket that NO restored record claims is still the PM's running agent — the
|
|
792
|
+
// server simply lost its note of it (state/sessions cleared or partly unreadable, a record written
|
|
793
|
+
// by an older build, a metadata write that never landed). Before this, those panes were invisible:
|
|
794
|
+
// the workspace came back apparently empty while the work was still running underneath, which is
|
|
795
|
+
// the "my sessions did not come back" defect. Adopting them puts every live pane back on screen,
|
|
796
|
+
// filed under the workspace its own directory belongs to — and it means an orphan is claimed
|
|
797
|
+
// DELIBERATELY, by the pane it actually is, rather than by accident through an id collision.
|
|
798
|
+
// What a recovered pane should call its agent. A shell (whatever the PM's login shell is) reads
|
|
799
|
+
// `shell`, exactly as a spawned one does; anything else keeps its own command name so the row does
|
|
800
|
+
// not claim to be a shell it is not. An unreadable start command falls back to `shell` — the
|
|
801
|
+
// label that promises the least.
|
|
802
|
+
function adoptedAgent(cmd) {
|
|
803
|
+
const base = path.basename(String(cmd || '').split(/\s+/)[0] || '');
|
|
804
|
+
if (!base) return 'shell';
|
|
805
|
+
if (base === path.basename(SHELL) || /^(sh|bash|zsh|fish|dash|powershell\.exe|cmd\.exe)$/i.test(base)) return 'shell';
|
|
806
|
+
return base;
|
|
807
|
+
}
|
|
808
|
+
function adoptOrphanTmuxSessions() {
|
|
809
|
+
if (!TMUX) return 0;
|
|
810
|
+
const claimed = new Set([...sessions.values()].map((s) => s.tmux).filter(Boolean));
|
|
811
|
+
let adopted = 0;
|
|
812
|
+
for (const t of tmuxList().sort((a, b) => a.created - b.created)) {
|
|
813
|
+
if (!t.name.startsWith('pmos-') || claimed.has(t.name)) continue;
|
|
814
|
+
const dir = t.dir && isDir(t.dir) ? path.resolve(t.dir) : null;
|
|
815
|
+
// Where it belongs: the workspace containing its pane directory; else — because a pane the PM
|
|
816
|
+
// can see must be a pane the PM can reach — that directory is registered as one, exactly as
|
|
817
|
+
// `pmos <path>` would. Only the home workspace is assumed when there is no readable directory.
|
|
818
|
+
const w = workspaceForPath(dir) || (dir && registerWorkspace(dir)) || homeWorkspace();
|
|
819
|
+
if (!w) continue;
|
|
820
|
+
const id = nextSessionId();
|
|
821
|
+
// The agent is the pane's own start command, not a guess: tmux spawned it directly (see
|
|
822
|
+
// createTmuxSession's `-- cmd`), so a `claude` pane comes back labelled `claude`.
|
|
823
|
+
const agent = adoptedAgent(t.cmd);
|
|
824
|
+
const s = {
|
|
825
|
+
id, ws: w.id, root: w.root, name: `recovered ${agent}`, agent,
|
|
826
|
+
origin: null, cwd: dir && insideRoot(w.root, dir) ? dir : w.root,
|
|
827
|
+
state: 'working', pty: null, tmux: t.name, note: '', buf: '',
|
|
828
|
+
cols: 80, rows: 24, clients: new Set(), created: nowISO(), recovered: true,
|
|
829
|
+
};
|
|
830
|
+
sessions.set(id, s);
|
|
831
|
+
attachPty(s, { seed: true }); // its history comes from tmux — ours was never written
|
|
832
|
+
persist(s);
|
|
833
|
+
adopted++;
|
|
834
|
+
}
|
|
835
|
+
return adopted;
|
|
782
836
|
}
|
|
783
837
|
|
|
784
838
|
// Broadcast to EVERY connected client exactly once (not per-session — a fresh client that
|
|
@@ -867,15 +921,22 @@ function sessionList() {
|
|
|
867
921
|
return [...sessions.values()].map((s) => {
|
|
868
922
|
const d = harness.deriveState(s.state, s.lastOutputAt, now);
|
|
869
923
|
const h = harnessOf(s);
|
|
924
|
+
const hx = transcriptHarness(s);
|
|
870
925
|
return {
|
|
871
926
|
id: s.id, ws: s.ws, name: s.name, agent: s.agent, state: s.state, created: s.created,
|
|
872
927
|
cwd: path.relative(s.root, s.cwd) || '.', note: s.note || '', origin: s.origin || null,
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
|
|
928
|
+
// Derived presentation state, JOINED from both sources: the cadence clock above and the
|
|
929
|
+
// transcript's answer to whose turn it is (lib/session-state). working | quiet | exited |
|
|
930
|
+
// needs | blocked. `shell`-ness stays on `agent`, because it is orthogonal to state;
|
|
931
|
+
// `failed` does not exist (no observable for it). The client re-derives this from the SAME
|
|
932
|
+
// module — see /lib/session-state.js below — so the two can never hold different opinions.
|
|
933
|
+
shown: sessionState.presented(d.state, hx && hx.turn), quietMs: d.quietMs,
|
|
934
|
+
lastOutputAt: s.lastOutputAt || null,
|
|
876
935
|
lastWords: h.lastWords,
|
|
877
|
-
// the harness proper — structured record, not rendered bytes
|
|
878
|
-
|
|
936
|
+
// the harness proper — structured record, not rendered bytes. Resolved ONCE above and
|
|
937
|
+
// reused: it also feeds the join, and calling it twice per session per broadcast would
|
|
938
|
+
// double the transcript work the TX_REREAD_MS floor exists to bound.
|
|
939
|
+
hx,
|
|
879
940
|
missing: h.missing,
|
|
880
941
|
};
|
|
881
942
|
});
|
|
@@ -985,7 +1046,24 @@ const TMUX = commandExists('tmux') ? 'tmux' : null;
|
|
|
985
1046
|
const TMUX_SOCK = process.env.PMOS_TMUX_SOCK || 'pmos'; // private socket; never the user's own
|
|
986
1047
|
const TMUX_CONF = path.join(STATE, 'tmux.conf');
|
|
987
1048
|
let shuttingDown = false; // set on SIGTERM/SIGINT — suppress reattach
|
|
1049
|
+
// The name a session written BEFORE unique names carries. Kept only so those sessions still
|
|
1050
|
+
// re-attach after an upgrade; nothing new is ever named this way (see freshTmuxName).
|
|
988
1051
|
function tmuxName(id) { return 'pmos-' + id; }
|
|
1052
|
+
// A NEW session's tmux name must be unique on the socket for as long as any pane can outlive the
|
|
1053
|
+
// server that made it — and `pmos-sess-<n>` was not. The counter restarts with the process, so
|
|
1054
|
+
// once a generation's metadata went missing (state/sessions cleared, a partial restore) the next
|
|
1055
|
+
// server handed a fresh session the name of a pane still running from the last one, and
|
|
1056
|
+
// ensureTmuxSession's "already alive → leave it" ADOPTED that stranger's pane. The PM asked for a
|
|
1057
|
+
// session in one repo and got a live shell sitting in another — the wrong-directory defect. The
|
|
1058
|
+
// random suffix makes the name unforgeable by a later generation, so an orphan can only ever be
|
|
1059
|
+
// adopted deliberately (adoptOrphanTmuxSessions), never by an id collision.
|
|
1060
|
+
function freshTmuxName(id) {
|
|
1061
|
+
for (let i = 0; i < 50; i++) {
|
|
1062
|
+
const n = `pmos-${id}-${crypto.randomBytes(4).toString('hex')}`;
|
|
1063
|
+
if (!tmuxHasSession(n)) return n;
|
|
1064
|
+
}
|
|
1065
|
+
return `pmos-${id}-${process.pid.toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
|
|
1066
|
+
}
|
|
989
1067
|
function writeTmuxConf() {
|
|
990
1068
|
if (!TMUX) return;
|
|
991
1069
|
const conf = [
|
|
@@ -1006,12 +1084,38 @@ function writeTmuxConf() {
|
|
|
1006
1084
|
function tmuxCtl(args) { return spawnSync(TMUX, ['-L', TMUX_SOCK, ...args], { env: sessionEnv(), encoding: 'utf8' }); }
|
|
1007
1085
|
function tmuxHasSession(name) { return !!TMUX && tmuxCtl(['has-session', '-t', name]).status === 0; }
|
|
1008
1086
|
function tmuxKillSession(name) { if (TMUX && name) tmuxCtl(['kill-session', '-t', name]); }
|
|
1009
|
-
|
|
1010
|
-
|
|
1087
|
+
// Create the tmux session a FRESH spawn runs in. It REFUSES a name that already exists rather
|
|
1088
|
+
// than adopting it: this path only ever runs for a brand-new session, so an existing pane of that
|
|
1089
|
+
// name is someone else's process, in someone else's directory. Silently reusing it is how a new
|
|
1090
|
+
// session came up in the wrong repo. freshTmuxName makes the collision practically impossible;
|
|
1091
|
+
// this is the floor that keeps it impossible even if it were not.
|
|
1092
|
+
function createTmuxSession(name, cmd, workdir, cols, rows) {
|
|
1093
|
+
if (tmuxHasSession(name)) return false;
|
|
1011
1094
|
const r = tmuxCtl(['-f', TMUX_CONF, 'new-session', '-d', '-s', name,
|
|
1012
1095
|
'-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
|
|
1013
1096
|
return r.status === 0;
|
|
1014
1097
|
}
|
|
1098
|
+
// Every session alive on our private socket, with the directory its pane is actually sitting in.
|
|
1099
|
+
// Deliberately one lookup PER FIELD rather than one packed -F line: tmux rewrites whitespace
|
|
1100
|
+
// inside a format string (a tab separator came back as `_`), which silently glued the fields into
|
|
1101
|
+
// a single unusable name. One field per call has no separator to get wrong, and this runs only at
|
|
1102
|
+
// startup over the handful of sessions on our own socket. [] when tmux is absent or not running.
|
|
1103
|
+
function tmuxField(name, fmt) {
|
|
1104
|
+
const r = tmuxCtl(['display-message', '-p', '-t', name, fmt]);
|
|
1105
|
+
return r && r.status === 0 ? String(r.stdout || '').trim() : '';
|
|
1106
|
+
}
|
|
1107
|
+
function tmuxList() {
|
|
1108
|
+
if (!TMUX) return [];
|
|
1109
|
+
const r = tmuxCtl(['list-sessions', '-F', '#{session_name}']);
|
|
1110
|
+
if (!r || r.status !== 0) return [];
|
|
1111
|
+
return String(r.stdout || '').split('\n').map((line) => line.trim()).filter(Boolean)
|
|
1112
|
+
.map((name) => ({
|
|
1113
|
+
name,
|
|
1114
|
+
dir: tmuxField(name, '#{pane_current_path}'),
|
|
1115
|
+
created: Number(tmuxField(name, '#{session_created}')) || 0,
|
|
1116
|
+
cmd: tmuxField(name, '#{pane_start_command}'),
|
|
1117
|
+
}));
|
|
1118
|
+
}
|
|
1015
1119
|
// The pane id (%0, %1, …) the control client must address for input. One cheap lookup per attach;
|
|
1016
1120
|
// cached on the session because a pane id is stable for the life of the tmux session.
|
|
1017
1121
|
function tmuxPaneId(s, name) {
|
|
@@ -1044,7 +1148,11 @@ function seedFromPaneHistory(s, name) {
|
|
|
1044
1148
|
// pane's raw bytes as %output, which we forward verbatim — the same stream shape the direct-PTY
|
|
1045
1149
|
// fallback produces, so xterm.js keeps its own 5000-line scrollback. See lib/tmux-control.js.
|
|
1046
1150
|
function attachPty(s, { seed = false } = {}) {
|
|
1047
|
-
|
|
1151
|
+
// The session's OWN recorded name, never one recomputed from its id — since freshTmuxName the
|
|
1152
|
+
// two no longer coincide, and re-deriving would attach to whatever else happened to hold the
|
|
1153
|
+
// id-shaped name. tmuxName() is the fallback for a session persisted before unique names.
|
|
1154
|
+
const name = s.tmux || tmuxName(s.id);
|
|
1155
|
+
s.tmux = name;
|
|
1048
1156
|
const startedMs = Date.now();
|
|
1049
1157
|
let p;
|
|
1050
1158
|
try {
|
|
@@ -1146,8 +1254,26 @@ function exitedSession(base, workdir, why) {
|
|
|
1146
1254
|
sessions.set(s.id, s); persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
1147
1255
|
}
|
|
1148
1256
|
|
|
1257
|
+
// Live (non-exited) sessions belonging to a workspace. Used to decide whether isolating a branch
|
|
1258
|
+
// would actually protect anyone, and to refuse removing a worktree somebody is working in.
|
|
1259
|
+
function liveSessionCount(wsIdent) {
|
|
1260
|
+
let n = 0;
|
|
1261
|
+
for (const s of sessions.values()) if (s.ws === wsIdent && s.state !== 'exited') n++;
|
|
1262
|
+
return n;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// The next unused session id. `seq` is a per-process counter, so it can only be trusted once it
|
|
1266
|
+
// has been floored by what is already on disk and in memory (see restorePersisted) — this loop is
|
|
1267
|
+
// the last guard: a fresh session must never take an id another session's record already owns,
|
|
1268
|
+
// because persist() would then overwrite that record's scrollback.
|
|
1269
|
+
function nextSessionId() {
|
|
1270
|
+
let id = 'sess-' + (seq++);
|
|
1271
|
+
while (sessions.has(id)) id = 'sess-' + (seq++);
|
|
1272
|
+
return id;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1149
1275
|
function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
1150
|
-
const id =
|
|
1276
|
+
const id = nextSessionId();
|
|
1151
1277
|
const w = getWs(ws);
|
|
1152
1278
|
const root = w ? w.root : SELF_ROOT;
|
|
1153
1279
|
const isShell = agent === 'shell';
|
|
@@ -1170,11 +1296,11 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1170
1296
|
|
|
1171
1297
|
// ── the tmux path: the process outlives this server, and we re-attach to it live (R7) ──
|
|
1172
1298
|
if (TMUX) {
|
|
1173
|
-
const tname =
|
|
1299
|
+
const tname = freshTmuxName(id);
|
|
1174
1300
|
const s = { ...base, cwd: workdir, state: 'working', pty: null, tmux: tname,
|
|
1175
1301
|
note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO() };
|
|
1176
1302
|
sessions.set(id, s);
|
|
1177
|
-
if (!
|
|
1303
|
+
if (!createTmuxSession(tname, cmd, workdir, 80, 24)) {
|
|
1178
1304
|
const why = `could not start a tmux session for '${cmd}'. Run \`npm run doctor\` in workspace/.`;
|
|
1179
1305
|
s.state = 'exited'; s.tmux = null; s.note = why;
|
|
1180
1306
|
s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
|
|
@@ -1216,36 +1342,143 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1216
1342
|
return s;
|
|
1217
1343
|
}
|
|
1218
1344
|
|
|
1219
|
-
// New session, optionally on a chosen/created branch.
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1222
|
-
//
|
|
1223
|
-
|
|
1345
|
+
// New session, optionally on a chosen/created branch. Three paths, and which one runs is the whole
|
|
1346
|
+
// point of this function:
|
|
1347
|
+
//
|
|
1348
|
+
// isolate — put the branch in its OWN worktree, register that directory as a workspace, and
|
|
1349
|
+
// start the session there. Every already-running session stays exactly where it
|
|
1350
|
+
// was. This is the default the UI offers for "+ new branch", because the shared
|
|
1351
|
+
// checkout moving every session was the single worst thing about this surface.
|
|
1352
|
+
// checkout — the shared-tree path: move THIS repo (and so every session in it) to the branch.
|
|
1353
|
+
// Still offered, still honest about its blast radius, no longer the only option.
|
|
1354
|
+
// no git here — a workspace that is not a repo has no branch to be on. A stale picker or an old
|
|
1355
|
+
// client must not be able to BLOCK a session over it: we spawn, and return a
|
|
1356
|
+
// notice saying the branch was ignored. Never silently, never fatally.
|
|
1357
|
+
//
|
|
1358
|
+
// A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
|
|
1359
|
+
// hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
|
|
1360
|
+
// git-then-maybe-spawn contract is unit-testable without a WS client.
|
|
1361
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate }) {
|
|
1362
|
+
const w = getWs(ws);
|
|
1363
|
+
if (!w) return { ok: false, error: 'no workspace' };
|
|
1364
|
+
let targetWs = w.id;
|
|
1365
|
+
let notice = null;
|
|
1366
|
+
// Isolation exists to protect OTHER sessions. Alone in a workspace there are none to protect, so
|
|
1367
|
+
// a plain checkout costs nothing and leaves no folder behind — which is the difference between
|
|
1368
|
+
// "a worktree when it earns its keep" and a directory per branch forever. The client sends what
|
|
1369
|
+
// the PM actually saw; this default only decides for a caller that expressed no preference (an
|
|
1370
|
+
// older client, a scheduled run).
|
|
1371
|
+
if (isolate === undefined || isolate === null) isolate = liveSessionCount(w.id) > 0;
|
|
1372
|
+
|
|
1224
1373
|
if (branch) {
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
if (
|
|
1228
|
-
|
|
1374
|
+
if (!gitInfo.isRepo(w.root)) {
|
|
1375
|
+
notice = `“${w.name}” is not a git repository — the session started here, with no branch applied`;
|
|
1376
|
+
} else if (isolate) {
|
|
1377
|
+
const r = gitInfo.addWorktree(w.root, branch, { create: !!createBranch });
|
|
1378
|
+
if (!r.ok) return { ok: false, error: r.error };
|
|
1379
|
+
// A worktree is created at the REPO's top. If this workspace was a subdirectory of the repo
|
|
1380
|
+
// (repo/packages/app — the ordinary shape for a tenant product repo added by path), the PM
|
|
1381
|
+
// should land in the matching subdirectory of the new tree, not at a root they never chose.
|
|
1382
|
+
const info = gitInfo.branchInfo(w.root);
|
|
1383
|
+
const mirrored = info.ok && info.subdir ? path.join(r.dir, info.subdir) : r.dir;
|
|
1384
|
+
const dir = isDir(mirrored) ? mirrored : r.dir;
|
|
1385
|
+
const nw = registerWorkspace(dir, `${w.name} ⑂ ${branch}`);
|
|
1386
|
+
if (!nw) return { ok: false, error: `worktree created at ${r.dir} but could not be registered as a workspace` };
|
|
1387
|
+
targetWs = nw.id;
|
|
1388
|
+
notice = r.reused
|
|
1389
|
+
? `reusing the existing worktree for “${branch}” at ${r.dir}`
|
|
1390
|
+
: `“${branch}” checked out in its own folder — ${r.dir}. Sessions already running are untouched.`;
|
|
1391
|
+
broadcast({ t: 'branch', ws: nw.id, ...gitInfo.branchInfo(nw.root) });
|
|
1392
|
+
} else {
|
|
1393
|
+
const r = gitInfo.checkout(w.root, branch, { create: !!createBranch });
|
|
1394
|
+
if (!r.ok) return { ok: false, error: r.error };
|
|
1395
|
+
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
1396
|
+
}
|
|
1229
1397
|
}
|
|
1230
|
-
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws });
|
|
1231
|
-
return { ok: true, session: s };
|
|
1398
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs });
|
|
1399
|
+
return { ok: true, session: s, notice };
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
|
|
1403
|
+
// Isolation buys "your other sessions keep running" at the price of a directory per branch. That
|
|
1404
|
+
// price is only fair if the PM can see the folders and reclaim them without leaving the workspace
|
|
1405
|
+
// — and without a delete ever being a guess. Three hazards block a removal, and every one is
|
|
1406
|
+
// reported by name: live sessions (ours to know), uncommitted changes and commits held nowhere
|
|
1407
|
+
// else (git's to know, see gitInfo.worktreeSafety).
|
|
1408
|
+
function worktreeReport(w) {
|
|
1409
|
+
const info = gitInfo.branchInfo(w.root);
|
|
1410
|
+
if (!info.ok) return { ok: false, code: info.code, error: info.error };
|
|
1411
|
+
const trees = info.worktrees || [];
|
|
1412
|
+
const rows = trees.map((t, i) => {
|
|
1413
|
+
const main = i === 0;
|
|
1414
|
+
const owner = [...workspaces.values()].find((x) => path.resolve(x.root) === path.resolve(t.dir));
|
|
1415
|
+
const live = owner ? liveSessionCount(owner.id) : 0;
|
|
1416
|
+
// the main worktree is the repo itself — never removable from here, and saying so beats an
|
|
1417
|
+
// absent button the PM has to reason about
|
|
1418
|
+
const gone = !main && !isDir(t.dir);
|
|
1419
|
+
const safety = main ? { removable: false, reasons: ['the repository’s own working tree'] }
|
|
1420
|
+
// deleted by hand already: nothing left to protect, only git bookkeeping to clear. Asking
|
|
1421
|
+
// `git status` about a folder that isn't there would fail and read as "unsafe".
|
|
1422
|
+
: gone ? { removable: true, reasons: ['the folder is already gone — this clears git’s record of it'] }
|
|
1423
|
+
: gitInfo.worktreeSafety(t.dir, t.branch);
|
|
1424
|
+
const reasons = safety.reasons.slice();
|
|
1425
|
+
if (!main && live > 0) reasons.unshift(`${live} session${live === 1 ? '' : 's'} still running here`);
|
|
1426
|
+
return { dir: t.dir, branch: t.branch, main, missing: gone,
|
|
1427
|
+
ws: owner ? owner.id : null, sessions: live,
|
|
1428
|
+
removable: !main && live === 0 && safety.removable, reasons };
|
|
1429
|
+
});
|
|
1430
|
+
return { ok: true, repoRoot: info.repoRoot, worktrees: rows };
|
|
1232
1431
|
}
|
|
1233
1432
|
|
|
1234
|
-
// ── Scratchpad:
|
|
1235
|
-
//
|
|
1433
|
+
// ── Scratchpad: PER-SESSION staging text (R4). One file per terminal tab, because that is the
|
|
1434
|
+
// unit the PM stages against: the text is the next thing to type into THAT agent, and a single
|
|
1435
|
+
// per-workspace pad meant every tab showed — and overwrote — the same buffer. Local-only, keyed by
|
|
1436
|
+
// the workspace's hashed id and the server-issued session id (never a caller-supplied path),
|
|
1437
|
+
// capped so it can't be used as a blob store.
|
|
1236
1438
|
const SCRATCH_MAX = 200_000;
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1439
|
+
// The pre-session file the workspace used to share. Still read, once: the first tab to open its
|
|
1440
|
+
// pad in that workspace CLAIMS it, so text staged before this change is not silently dropped.
|
|
1441
|
+
function legacyScratchpadPath(w) { return w ? path.join(SCRATCH_DIR, w.id + '.txt') : null; }
|
|
1442
|
+
// A session id is server-issued (`sess-<n>`), never echoed from the client — but this is the one
|
|
1443
|
+
// place a name reaches the filesystem, so it is checked rather than trusted.
|
|
1444
|
+
function scratchpadPath(w, sessionId) {
|
|
1445
|
+
if (!w) return null;
|
|
1446
|
+
if (!sessionId) return legacyScratchpadPath(w);
|
|
1447
|
+
const sid = String(sessionId);
|
|
1448
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(sid) || sid === '.' || sid === '..') return null;
|
|
1449
|
+
return path.join(SCRATCH_DIR, w.id, sid + '.txt');
|
|
1450
|
+
}
|
|
1451
|
+
function readScratchpad(w, sessionId) {
|
|
1452
|
+
const f = scratchpadPath(w, sessionId);
|
|
1240
1453
|
if (!f) return '';
|
|
1241
|
-
try { return fs.readFileSync(f, 'utf8'); } catch {
|
|
1454
|
+
try { return fs.readFileSync(f, 'utf8'); } catch {}
|
|
1455
|
+
// Nothing staged for this tab yet — inherit the workspace's pre-session pad if one survives,
|
|
1456
|
+
// by MOVING it, so exactly one tab gets it and it is never handed out twice.
|
|
1457
|
+
if (sessionId) {
|
|
1458
|
+
const legacy = legacyScratchpadPath(w);
|
|
1459
|
+
try {
|
|
1460
|
+
const text = fs.readFileSync(legacy, 'utf8');
|
|
1461
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
1462
|
+
fs.renameSync(legacy, f);
|
|
1463
|
+
return text;
|
|
1464
|
+
} catch {}
|
|
1465
|
+
}
|
|
1466
|
+
return '';
|
|
1242
1467
|
}
|
|
1243
|
-
function writeScratchpad(w, text) {
|
|
1244
|
-
const f = scratchpadPath(w);
|
|
1245
|
-
if (!f) throw new Error('no
|
|
1246
|
-
fs.mkdirSync(
|
|
1468
|
+
function writeScratchpad(w, sessionId, text) {
|
|
1469
|
+
const f = scratchpadPath(w, sessionId);
|
|
1470
|
+
if (!f) throw new Error('no scratchpad for that session');
|
|
1471
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
1247
1472
|
fs.writeFileSync(f, String(text == null ? '' : text).slice(0, SCRATCH_MAX));
|
|
1248
1473
|
}
|
|
1474
|
+
// A tab that is gone takes its staging text with it — the pad belongs to the session, not to a
|
|
1475
|
+
// slot a later session could inherit by reusing its id.
|
|
1476
|
+
function forgetScratchpad(s) {
|
|
1477
|
+
if (!s) return;
|
|
1478
|
+
const w = workspaces.get(s.ws);
|
|
1479
|
+
const f = scratchpadPath(w, s.id);
|
|
1480
|
+
if (f) { try { fs.unlinkSync(f); } catch {} }
|
|
1481
|
+
}
|
|
1249
1482
|
|
|
1250
1483
|
// ── Layouts: per-workspace arrangements (R7). Same shape as the scratchpad above — one file per
|
|
1251
1484
|
// workspace keyed by the workspace's hashed id, never a caller-supplied path, capped so it can't
|
|
@@ -1276,6 +1509,7 @@ function removeSession(id) {
|
|
|
1276
1509
|
const s = sessions.get(id);
|
|
1277
1510
|
if (!s || s.pty) return false;
|
|
1278
1511
|
if (s.tmux) tmuxKillSession(s.tmux); // best-effort: never orphan a detached tmux session
|
|
1512
|
+
forgetScratchpad(s); // the tab's staging text goes with the tab
|
|
1279
1513
|
sessions.delete(id); unpersist(id); pushSessions(); pushWorkspaces();
|
|
1280
1514
|
return true;
|
|
1281
1515
|
}
|
|
@@ -1444,6 +1678,9 @@ function buildConsole(name, w, root) {
|
|
|
1444
1678
|
if (name === 'needs') return needsResult(w);
|
|
1445
1679
|
if (name === 'metrics') return consoleState.metrics({ stateDir, snapshotDir });
|
|
1446
1680
|
if (name === 'radar') return consoleState.radar({ stateDir, snapshotDir });
|
|
1681
|
+
// The workflow ladder reads the OPENED workspace's repo (records + git), not the state dir — an
|
|
1682
|
+
// initiative's position is a property of the repo it lives in, snapshot or no snapshot.
|
|
1683
|
+
if (name === 'workflow') return consoleState.workflow({ root: w ? w.root : root });
|
|
1447
1684
|
return { ok: false, error: 'unknown console surface: ' + name };
|
|
1448
1685
|
}
|
|
1449
1686
|
|
|
@@ -1607,12 +1844,24 @@ async function handleRequest(req, res) {
|
|
|
1607
1844
|
if (p === '/app.js') return serveFile(res, path.join(PUB, 'app.js'), null, true);
|
|
1608
1845
|
if (p === '/lib/preview-kind.js') return serveFile(res, path.join(__dirname, 'lib', 'preview-kind.js'));
|
|
1609
1846
|
if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
|
|
1847
|
+
if (p === '/lib/session-state.js') return serveFile(res, path.join(__dirname, 'lib', 'session-state.js'));
|
|
1848
|
+
// The editor computes its live diff with the SAME module the server saves through, so what the PM
|
|
1849
|
+
// sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
|
|
1850
|
+
// a directory route — a <script src> for a file missing from this list 404s silently, and the
|
|
1851
|
+
// client feature that depends on it fails in a way no server-side test can see.
|
|
1852
|
+
if (p === '/lib/apply-change.js') return serveFile(res, path.join(__dirname, 'lib', 'apply-change.js'));
|
|
1610
1853
|
if (p === '/styles.css') return serveFile(res, path.join(PUB, 'styles.css'));
|
|
1611
1854
|
// xterm assets straight from node_modules (offline; no CDN)
|
|
1612
1855
|
if (p === '/vendor/xterm.js') return serveFile(res, path.join(XROOT, 'xterm', 'lib', 'xterm.js'));
|
|
1613
1856
|
if (p === '/vendor/xterm.css') return serveFile(res, path.join(XROOT, 'xterm', 'css', 'xterm.css'));
|
|
1614
1857
|
if (p === '/vendor/addon-fit.js') return serveFile(res, path.join(XROOT, 'addon-fit', 'lib', 'addon-fit.js'));
|
|
1615
1858
|
if (p === '/vendor/addon-web-links.js') return serveFile(res, path.join(XROOT, 'addon-web-links', 'lib', 'addon-web-links.js'));
|
|
1859
|
+
// The design system's faces (D38) are VENDORED, not linked from a CDN: the workspace is a local
|
|
1860
|
+
// companion app that has to render correctly with no network at all. Plex Mono ships 400 + 700
|
|
1861
|
+
// because the terminal renders bold — with only the regular face the browser synthesises it.
|
|
1862
|
+
if (p === '/vendor/space-grotesk-var.woff2') return serveFile(res, path.join(PUB, 'vendor', 'space-grotesk-var.woff2'));
|
|
1863
|
+
if (p === '/vendor/ibm-plex-mono-400.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-400.woff2'));
|
|
1864
|
+
if (p === '/vendor/ibm-plex-mono-700.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-700.woff2'));
|
|
1616
1865
|
|
|
1617
1866
|
if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
|
|
1618
1867
|
if (p === '/api/workspaces' && req.method === 'POST') {
|
|
@@ -1635,6 +1884,28 @@ async function handleRequest(req, res) {
|
|
|
1635
1884
|
const ok = forgetWorkspace(body.id);
|
|
1636
1885
|
return json(res, { ok, error: ok ? undefined : 'unknown workspace, the home workspace, or it still owns sessions' }, ok ? 200 : 409);
|
|
1637
1886
|
}
|
|
1887
|
+
if (p === '/api/git/worktrees/remove' && req.method === 'POST') {
|
|
1888
|
+
// Reclaim a worktree's folder. `dir` comes from a client, so it is checked against git's own
|
|
1889
|
+
// list of THIS repo's linked worktrees before anything is removed — the main tree is
|
|
1890
|
+
// unreachable by construction, and a tree with running sessions, uncommitted changes, or
|
|
1891
|
+
// commits held nowhere else is refused with the reason, not silently kept.
|
|
1892
|
+
let body = {};
|
|
1893
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); } catch {}
|
|
1894
|
+
const w = getWs(body.ws);
|
|
1895
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
1896
|
+
const row = (worktreeReport(w).worktrees || [])
|
|
1897
|
+
.find((t) => path.resolve(t.dir) === path.resolve(String(body.dir || '')));
|
|
1898
|
+
if (!row) return json(res, { ok: false, error: 'not a linked worktree of this repository' }, 400);
|
|
1899
|
+
if (row.sessions > 0) return json(res, { ok: false, error: `kept — ${row.reasons[0]}` }, 409);
|
|
1900
|
+
const r = gitInfo.removeWorktree(w.root, row.dir);
|
|
1901
|
+
if (!r.ok) return json(res, r, 409);
|
|
1902
|
+
// the workspace entry goes with the folder; forgetWorkspace never touches a repo, and it
|
|
1903
|
+
// refuses while sessions remain, so a race here fails closed rather than orphaning scrollback
|
|
1904
|
+
const owner = [...workspaces.values()].find((x) => path.resolve(x.root) === path.resolve(row.dir));
|
|
1905
|
+
if (owner) forgetWorkspace(owner.id);
|
|
1906
|
+
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) });
|
|
1907
|
+
return json(res, { ok: true, dir: r.dir, pruned: !!r.pruned });
|
|
1908
|
+
}
|
|
1638
1909
|
if (p === '/api/pick-folder' && req.method === 'POST') {
|
|
1639
1910
|
let body = {};
|
|
1640
1911
|
try { body = JSON.parse((await readBody(req)) || '{}'); } catch {}
|
|
@@ -1780,20 +2051,36 @@ async function handleRequest(req, res) {
|
|
|
1780
2051
|
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
1781
2052
|
return json(res, gitInfo.branchInfo(w.root));
|
|
1782
2053
|
}
|
|
2054
|
+
if (p === '/api/git/worktrees') {
|
|
2055
|
+
// Every working tree attached to this workspace's repo, each with the reasons it can or cannot
|
|
2056
|
+
// be removed. A worktree the workspace made is a folder the PM has to live with, so the app
|
|
2057
|
+
// that creates them is the app that has to show them.
|
|
2058
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
2059
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
2060
|
+
return json(res, worktreeReport(w));
|
|
2061
|
+
}
|
|
2062
|
+
// The pad belongs to a SESSION when one is named — and the workspace then comes from that
|
|
2063
|
+
// session, not from the caller, so a stale `ws` can never file one tab's text under another
|
|
2064
|
+
// repo. `session` omitted still addresses the workspace pad (a window with no tabs yet).
|
|
1783
2065
|
if (p === '/api/scratchpad' && req.method === 'POST') {
|
|
1784
2066
|
let body = {};
|
|
1785
2067
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
1786
2068
|
catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
1787
|
-
const
|
|
2069
|
+
const s = body.session ? sessions.get(String(body.session)) : null;
|
|
2070
|
+
if (body.session && !s) return json(res, { ok: false, error: 'no such session' }, 404);
|
|
2071
|
+
const w = s ? workspaces.get(s.ws) : getWs(body.ws);
|
|
1788
2072
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
1789
|
-
try { writeScratchpad(w, body.text); }
|
|
2073
|
+
try { writeScratchpad(w, s ? s.id : null, body.text); }
|
|
1790
2074
|
catch (e) { warnWriteFail('scratchpad', e); return json(res, { ok: false, error: 'could not save scratchpad' }, 500); }
|
|
1791
|
-
return json(res, { ok: true });
|
|
2075
|
+
return json(res, { ok: true, session: s ? s.id : null });
|
|
1792
2076
|
}
|
|
1793
2077
|
if (p === '/api/scratchpad') {
|
|
1794
|
-
const
|
|
2078
|
+
const sid = url.searchParams.get('session');
|
|
2079
|
+
const s = sid ? sessions.get(sid) : null;
|
|
2080
|
+
if (sid && !s) return json(res, { ok: false, error: 'no such session' }, 404);
|
|
2081
|
+
const w = s ? workspaces.get(s.ws) : getWs(url.searchParams.get('ws'));
|
|
1795
2082
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
1796
|
-
return json(res, { ok: true, text: readScratchpad(w) });
|
|
2083
|
+
return json(res, { ok: true, session: s ? s.id : null, text: readScratchpad(w, s ? s.id : null) });
|
|
1797
2084
|
}
|
|
1798
2085
|
if (p === '/api/layouts' && req.method === 'POST') {
|
|
1799
2086
|
let body = {};
|
|
@@ -1910,201 +2197,78 @@ async function handleRequest(req, res) {
|
|
|
1910
2197
|
} catch (e) { warnWriteFail('needs.json (clearing the accepted need)', e); }
|
|
1911
2198
|
return json(res, { ok: true, recorded: rec, sink, fallback });
|
|
1912
2199
|
}
|
|
1913
|
-
if (p === '/api/
|
|
1914
|
-
//
|
|
1915
|
-
// the
|
|
1916
|
-
//
|
|
1917
|
-
|
|
1918
|
-
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
1919
|
-
const want = url.searchParams.get('id');
|
|
1920
|
-
let files = [];
|
|
1921
|
-
const rdir = reviewDirFor(w);
|
|
1922
|
-
try { files = rdir ? fs.readdirSync(rdir).filter((f) => f.endsWith('.json')).sort() : []; } catch {}
|
|
1923
|
-
if (want && !/^[A-Za-z0-9._-]+$/.test(want)) return json(res, { ok: false, error: 'bad review id' }, 400);
|
|
1924
|
-
const pick = want ? `${want}.json` : files[files.length - 1];
|
|
1925
|
-
if (!pick || !files.includes(pick)) return json(res, { ok: true, empty: true, reviews: files.length });
|
|
1926
|
-
|
|
1927
|
-
let review;
|
|
1928
|
-
try { review = JSON.parse(fs.readFileSync(path.join(rdir, pick), 'utf8')); }
|
|
1929
|
-
catch { return json(res, { ok: false, error: `review '${pick}' is unreadable or not valid JSON` }, 500); }
|
|
1930
|
-
|
|
1931
|
-
// The SAME contract the write path enforces (R3/R7). A review that fails it is surfaced as an
|
|
1932
|
-
// error rather than half-rendered: a malformed finding must never reach the UI, because the UI's
|
|
1933
|
-
// job is to decide whether an Apply control exists.
|
|
1934
|
-
const v = findingContract.validateReview(review);
|
|
1935
|
-
if (!v.ok) return json(res, { ok: false, error: 'this review failed the finding contract', errors: v.errors.slice(0, 12) }, 422);
|
|
1936
|
-
|
|
1937
|
-
// Merge in what the PM has already settled, so a disposed finding renders as settled rather than
|
|
1938
|
-
// being offered twice. Read-only: dispositions live in the correction records, not in the review.
|
|
1939
|
-
const settled = {};
|
|
1940
|
-
const records = [];
|
|
1941
|
-
try {
|
|
1942
|
-
const txt = fs.readFileSync(path.join(w.root, 'planning', 'runs', 'correction-records.jsonl'), 'utf8');
|
|
1943
|
-
for (const line of txt.split('\n')) {
|
|
1944
|
-
if (!line.trim()) continue;
|
|
1945
|
-
try {
|
|
1946
|
-
const r = JSON.parse(line);
|
|
1947
|
-
if (!r) continue;
|
|
1948
|
-
records.push(r);
|
|
1949
|
-
if (r.finding_title) settled[r.finding_title] = r.disposition;
|
|
1950
|
-
} catch {}
|
|
1951
|
-
}
|
|
1952
|
-
} catch {}
|
|
1953
|
-
// The PRD's success signal is explicit that the counters are part of the build, not a follow-up:
|
|
1954
|
-
// action rate must be derivable off the records with no manual counting. quiet-rate is NOT
|
|
1955
|
-
// derivable here — it needs a corpus with a labelled CLEAN class, which does not exist — so it is
|
|
1956
|
-
// reported as null and rendered as "not measured" rather than as a flattering zero.
|
|
1957
|
-
const stats = { ...correctionRecord.summarise(records), quiet_rate: findingContract.quietRate(null, null) };
|
|
1958
|
-
const findings = review.findings.map((f) => ({
|
|
1959
|
-
...f,
|
|
1960
|
-
applyable: findingContract.isApplyable(f), // the client asks the contract, never re-derives it
|
|
1961
|
-
settled: settled[f.title] || null,
|
|
1962
|
-
}));
|
|
1963
|
-
return json(res, { ok: true, id: pick.replace(/\.json$/, ''), read: review.read, at: review.at || null, findings, reviews: files.length, stats, reasons: [...correctionRecord.REASONS] });
|
|
1964
|
-
}
|
|
1965
|
-
if (p === '/api/review/start' && req.method === 'POST') {
|
|
1966
|
-
// Review THIS document — the trigger that makes the scope implicit instead of typed. The PM is
|
|
1967
|
-
// reading a document; the thing they want reviewed is the thing in front of them.
|
|
1968
|
-
//
|
|
1969
|
-
// The prompt is assembled HERE, not in the browser, for one reason that matters beyond tidiness:
|
|
1970
|
-
// a tenant product repo has no workspace/ folder. Pointing the agent at
|
|
1971
|
-
// `workspace/skills/doc-review.skill` works in PMOS-self and breaks everywhere else, and so does
|
|
1972
|
-
// telling it to write to `workspace/state/reviews/`. The server knows the skill body and the
|
|
1973
|
-
// resolved state directory for THIS workspace, so both travel in the prompt as absolute facts.
|
|
2200
|
+
if (p === '/api/doc/save' && req.method === 'POST') {
|
|
2201
|
+
// The editor's save (workspace-doc-editor R3/R4). The ONLY write path on this surface, and it is
|
|
2202
|
+
// reached solely by the PM pressing save on a document they were editing. Nothing here consults
|
|
2203
|
+
// an agent, and nothing an agent produced can reach it — the agent is downstream of this write,
|
|
2204
|
+
// never upstream of it (rubric dim 1, the hard floor).
|
|
1974
2205
|
let body = {};
|
|
1975
2206
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
1976
2207
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
1977
2208
|
|
|
1978
2209
|
const w = getWs(body.ws);
|
|
1979
2210
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
const full = path.resolve(w.root,
|
|
2211
|
+
|
|
2212
|
+
// Path guard, identical to every read on this surface (R10).
|
|
2213
|
+
const full = path.resolve(w.root, String(body.path || ''));
|
|
1983
2214
|
if (!insideRoot(w.root, full)) return json(res, { ok: false, error: 'outside workspace' }, 404);
|
|
1984
2215
|
if (preview.previewKind(path.basename(full)) !== 'markdown') {
|
|
1985
|
-
return json(res, { ok: false, error: 'only markdown documents
|
|
1986
|
-
}
|
|
1987
|
-
if (!fs.existsSync(full)) return json(res, { ok: false, error: 'no such document' }, 404);
|
|
1988
|
-
|
|
1989
|
-
const task = loadTasks().find((t) => t.id === 'doc-review');
|
|
1990
|
-
if (!task || !task.prompt) return json(res, { ok: false, error: 'the doc-review skill is not installed' }, 500);
|
|
1991
|
-
|
|
1992
|
-
const rdir = reviewDirFor(w, { create: true });
|
|
1993
|
-
if (!rdir) return json(res, { ok: false, error: 'this workspace has no state directory to file a review into' }, 500);
|
|
1994
|
-
try { fs.mkdirSync(rdir, { recursive: true }); }
|
|
1995
|
-
catch (e) { warnWriteFail('reviews directory', e); return json(res, { ok: false, error: 'could not create the reviews directory' }, 500); }
|
|
1996
|
-
|
|
1997
|
-
// Scope first, so it is the last thing the agent reads before the procedure — and stated as
|
|
1998
|
-
// paths, because "the documents the PM named" is only a rule if something names them.
|
|
1999
|
-
const extra = Array.isArray(body.also) ? body.also.filter((x) => typeof x === 'string').slice(0, 9) : [];
|
|
2000
|
-
const scope = [rel, ...extra];
|
|
2001
|
-
const preamble = [
|
|
2002
|
-
'Review these documents, and only these:',
|
|
2003
|
-
...scope.map((s) => ` - ${s}`),
|
|
2004
|
-
'',
|
|
2005
|
-
`File the review as JSON into: ${rdir}`,
|
|
2006
|
-
`The needs-you queue for this workspace is: ${needsFileFor(w) || '(none — skip the needs step)'}`,
|
|
2007
|
-
'Both paths are absolute and already exist. Use them exactly; do not guess a workspace/ path.',
|
|
2008
|
-
'',
|
|
2009
|
-
'--- the skill ---',
|
|
2010
|
-
'',
|
|
2011
|
-
].join('\n');
|
|
2012
|
-
|
|
2013
|
-
const s = spawnSession({ name: 'review: ' + path.basename(rel), agent: task.agent || 'claude',
|
|
2014
|
-
ws: w.id, origin: 'review:' + rel, initialInput: preamble + task.prompt + '\r' });
|
|
2015
|
-
const started = !!(s && s.pty);
|
|
2016
|
-
return json(res, { ok: started, sessionId: s && s.id, scope, sink: rdir,
|
|
2017
|
-
error: started ? undefined : ((s && s.note) || 'could not start the session') });
|
|
2018
|
-
}
|
|
2019
|
-
if (p === '/api/review/dispose' && req.method === 'POST') {
|
|
2020
|
-
// The ONLY write path this surface has (workspace-review-and-edit R6/R8/R11). The reader was
|
|
2021
|
-
// designed around there being none, so every guard here is load-bearing rather than ceremony.
|
|
2022
|
-
//
|
|
2023
|
-
// Three dispositions, one endpoint: `apply` and `edit` write the file, `dismiss` writes only
|
|
2024
|
-
// the correction record. Never automatic — reached solely by an explicit PM click, like /api/gate.
|
|
2025
|
-
let body = {};
|
|
2026
|
-
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
2027
|
-
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
2028
|
-
|
|
2029
|
-
const w = getWs(body.ws);
|
|
2030
|
-
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
2031
|
-
|
|
2032
|
-
// The finding must satisfy the contract BEFORE anything else looks at it — a malformed
|
|
2033
|
-
// finding never reaches a write, and never grows an Apply (fails closed by construction).
|
|
2034
|
-
const f = body.finding;
|
|
2035
|
-
const v = findingContract.validateFinding(f);
|
|
2036
|
-
if (!v.ok) return json(res, { ok: false, error: 'finding failed the contract', errors: v.errors }, 400);
|
|
2037
|
-
|
|
2038
|
-
const disposition = body.disposition;
|
|
2039
|
-
if (!correctionRecord.DISPOSITIONS.has(disposition)) {
|
|
2040
|
-
return json(res, { ok: false, error: `disposition must be one of ${[...correctionRecord.DISPOSITIONS].join(' | ')}` }, 400);
|
|
2216
|
+
return json(res, { ok: false, error: 'only markdown documents are editable here' }, 400);
|
|
2041
2217
|
}
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2218
|
+
let current;
|
|
2219
|
+
try { current = fs.readFileSync(full, 'utf8'); }
|
|
2220
|
+
catch { return json(res, { ok: false, error: 'could not read the document' }, 404); }
|
|
2221
|
+
|
|
2222
|
+
// REFUSE ON STALE. `base` is what the editor loaded; if the file has moved since, writing would
|
|
2223
|
+
// silently discard whoever changed it. Loud failure beats a merge nobody asked for.
|
|
2224
|
+
const saved = applyChange.saveWhole(current, String(body.base == null ? '' : body.base), String(body.text == null ? '' : body.text));
|
|
2225
|
+
if (!saved.ok) {
|
|
2226
|
+
const code = saved.reason === 'stale' ? 409 : 400;
|
|
2227
|
+
return json(res, { ok: false, error: saved.reason, detail: saved.detail || null }, code);
|
|
2048
2228
|
}
|
|
2049
2229
|
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
const built = correctionRecord.buildRecord({
|
|
2055
|
-
run_id: body.run_id, initiative_id: body.initiative_id, finding: f, disposition,
|
|
2056
|
-
pm_text: body.pm_text, reason: body.reason, reason_note: body.reason_note,
|
|
2057
|
-
pm: body.pm || 'wawan', at: nowISO(),
|
|
2058
|
-
});
|
|
2059
|
-
if (!built.ok) {
|
|
2060
|
-
return json(res, { ok: false, error: 'no correction record could be written, so nothing was changed', errors: built.errors }, 400);
|
|
2230
|
+
try { fs.writeFileSync(full, saved.text); }
|
|
2231
|
+
catch (e) {
|
|
2232
|
+
warnWriteFail('document save (' + body.path + ')', e);
|
|
2233
|
+
return json(res, { ok: false, error: 'could not write the document — nothing was changed' }, 500);
|
|
2061
2234
|
}
|
|
2062
2235
|
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
const proposal = disposition === 'edit'
|
|
2078
|
-
? { before: f.proposal.before, after: body.pm_text }
|
|
2079
|
-
: f.proposal;
|
|
2080
|
-
|
|
2081
|
-
// REFUSE ON MISMATCH (sprint criterion 4). The client sends back the exact text it rendered
|
|
2082
|
-
// and the server re-derives it; disagreement means the file moved, or the client is stale or
|
|
2083
|
-
// tampered. Either way the PM would be applying something they never looked at.
|
|
2084
|
-
const m = applyChange.matchesRendered(current, proposal, body.rendered_after);
|
|
2085
|
-
if (!m.ok) return json(res, { ok: false, error: 'refused: ' + m.reason, detail: m.detail || null }, 409);
|
|
2086
|
-
|
|
2087
|
-
// FAIL LOUD (D64 Track A2, same stance as /api/gate): a swallowed write returning {ok:true}
|
|
2088
|
-
// would tell the PM their edit landed when it did not. No header/secret content in the error (D08).
|
|
2089
|
-
try { fs.writeFileSync(full, m.text); }
|
|
2090
|
-
catch (e) {
|
|
2091
|
-
warnWriteFail('document apply (' + f.file + ')', e);
|
|
2092
|
-
return json(res, { ok: false, error: 'could not write the document — nothing was changed' }, 500);
|
|
2093
|
-
}
|
|
2094
|
-
wrote = f.file;
|
|
2236
|
+
// R4/R9: append to THIS repository's own change log. stateDirFor() is the same resolver
|
|
2237
|
+
// needs.json and acceptances.jsonl use — a tenant product repo has no workspace/ folder, and
|
|
2238
|
+
// assuming one is exactly the defect shipped in 0.7.1/0.7.2.
|
|
2239
|
+
const region = applyChange.changedRegion(body.base, body.text);
|
|
2240
|
+
const rec = { file: String(body.path), at: nowISO(), pm: body.pm || 'wawan',
|
|
2241
|
+
note: typeof body.note === 'string' && body.note.trim() ? body.note.trim().slice(0, 400) : null,
|
|
2242
|
+
at_line: region.at_line, added: region.added, removed: region.removed,
|
|
2243
|
+
before: region.before, after: region.after };
|
|
2244
|
+
const dir = stateDirFor(w, { create: true });
|
|
2245
|
+
let logged = null;
|
|
2246
|
+
if (dir) {
|
|
2247
|
+
const sink = path.join(dir, 'doc-changes.jsonl');
|
|
2248
|
+
try { fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync(sink, JSON.stringify(rec) + '\n'); logged = sink; }
|
|
2249
|
+
catch (e) { warnWriteFail('doc-changes.jsonl (the change log)', e); }
|
|
2095
2250
|
}
|
|
2096
|
-
|
|
2097
|
-
//
|
|
2098
|
-
|
|
2099
|
-
|
|
2251
|
+
// The document IS saved at this point. A failed log is reported honestly rather than as success,
|
|
2252
|
+
// but it must not read as "your edit was lost".
|
|
2253
|
+
return json(res, { ok: true, saved: body.path, logged, change: { added: rec.added, removed: rec.removed, at_line: rec.at_line },
|
|
2254
|
+
warning: logged ? undefined : 'the document was saved but the change could not be logged' });
|
|
2255
|
+
}
|
|
2256
|
+
if (p === '/api/doc/changes') {
|
|
2257
|
+
// The change log, read back (R8 — this is what an agent reads before working here).
|
|
2258
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
2259
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
2260
|
+
const dir = stateDirFor(w);
|
|
2261
|
+
const sink = dir ? path.join(dir, 'doc-changes.jsonl') : null;
|
|
2262
|
+
const only = url.searchParams.get('path');
|
|
2263
|
+
let rows = [];
|
|
2100
2264
|
try {
|
|
2101
|
-
fs.
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
return json(res, { ok: true,
|
|
2265
|
+
if (sink && fs.existsSync(sink)) {
|
|
2266
|
+
rows = fs.readFileSync(sink, 'utf8').split('\n').filter(Boolean)
|
|
2267
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
2268
|
+
}
|
|
2269
|
+
} catch (e) { return json(res, { ok: false, error: 'the change log is unreadable' }, 500); }
|
|
2270
|
+
if (only) rows = rows.filter((r) => r.file === only);
|
|
2271
|
+
return json(res, { ok: true, changes: rows.slice(-200).reverse(), total: rows.length });
|
|
2108
2272
|
}
|
|
2109
2273
|
res.writeHead(404); res.end('not found');
|
|
2110
2274
|
}
|
|
@@ -2205,8 +2369,11 @@ wss.on('connection', (ws) => {
|
|
|
2205
2369
|
} else if (m.t === 'resize') {
|
|
2206
2370
|
const s = sessions.get(m.id); if (s && s.pty) { try { s.pty.resize(m.cols, m.rows); s.cols = m.cols; s.rows = m.rows; } catch {} }
|
|
2207
2371
|
} else if (m.t === 'new') {
|
|
2208
|
-
const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch });
|
|
2372
|
+
const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate });
|
|
2209
2373
|
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: 'branch: ' + r.error })); return; }
|
|
2374
|
+
// a non-fatal truth about what just happened (a worktree's path, a branch ignored on a
|
|
2375
|
+
// non-repo) — said out loud, never swallowed
|
|
2376
|
+
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
2210
2377
|
ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
|
|
2211
2378
|
} else if (m.t === 'respawn') {
|
|
2212
2379
|
const old = sessions.get(m.id); if (!old || old.pty) return;
|