create-pmos 0.6.3 → 0.7.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 +54 -1
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/scripts/gate-initiative.sh +4 -1
- package/workspace/lib/apply-change.js +106 -0
- package/workspace/lib/correction-record.js +127 -0
- package/workspace/lib/finding-contract.js +162 -0
- package/workspace/lib/git-info.js +71 -0
- package/workspace/lib/harness-parse.js +5 -1
- package/workspace/lib/layout.js +171 -0
- package/workspace/lib/skill-store.js +28 -9
- package/workspace/lib/tmux-control.js +124 -0
- package/workspace/public/app.js +862 -89
- package/workspace/public/index.html +113 -23
- package/workspace/public/styles.css +232 -19
- package/workspace/server.js +384 -16
- package/workspace/skills/doc-review.skill +114 -0
package/workspace/server.js
CHANGED
|
@@ -69,6 +69,11 @@ const remoteAccess = require('./lib/remote-access'); // tailnet bind + token g
|
|
|
69
69
|
const rateLimit = require('./lib/rate-limit'); // per-connection token bucket for spawn/input (D64 A3)
|
|
70
70
|
const consoleState = require('./lib/console-state'); // read-only console surfaces over file-mode state (D64 B)
|
|
71
71
|
const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
|
|
72
|
+
const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
|
|
73
|
+
const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
|
|
74
|
+
const findingContract = require('./lib/finding-contract'); // what a review finding is + the withheld guard (R3/R7)
|
|
75
|
+
const applyChange = require('./lib/apply-change'); // pure change application, refuse-on-mismatch (R6/R9)
|
|
76
|
+
const correctionRecord = require('./lib/correction-record'); // attribution never inferred (R8)
|
|
72
77
|
// The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
|
|
73
78
|
// (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
|
|
74
79
|
// — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
|
|
@@ -95,9 +100,12 @@ const PUB = path.join(__dirname, 'public');
|
|
|
95
100
|
const XROOT = path.join(__dirname, 'node_modules', '@xterm');
|
|
96
101
|
const STATE = path.join(__dirname, 'state');
|
|
97
102
|
const SESS_DIR = path.join(STATE, 'sessions');
|
|
103
|
+
const SCRATCH_DIR = path.join(STATE, 'scratchpad'); // per-workspace staging text (D62; local-only, gitignored)
|
|
104
|
+
const LAYOUT_DIR = path.join(STATE, 'layouts'); // per-workspace arrangements (R7; local-only, gitignored)
|
|
98
105
|
const WS_FILE = path.join(STATE, 'workspaces.json');
|
|
99
106
|
const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : 7433;
|
|
100
107
|
const preview = require('./lib/preview-kind'); // the single reader classifier (md|text|html|image|inert)
|
|
108
|
+
const layoutModel = require('./lib/layout'); // the arrangement model — validated on BOTH sides
|
|
101
109
|
// Bind + token policy (D62). DEFAULT = loopback only, no token (unchanged). The opt-in remote flag
|
|
102
110
|
// (PMOS_REMOTE=tailnet / PMOS_BIND_TAILNET=1) adds the machine's OWN tailnet IPv4 (100.64.0.0/10)
|
|
103
111
|
// and requires a bearer token on every non-loopback peer. Any non-loopback, non-tailnet bind is
|
|
@@ -131,6 +139,14 @@ function sessionEnv() {
|
|
|
131
139
|
return env;
|
|
132
140
|
}
|
|
133
141
|
const SCROLLBACK = 200_000; // bytes of ring buffer per session
|
|
142
|
+
// How often the server re-broadcasts the session list while anything is alive (see heartbeat()).
|
|
143
|
+
// 2s is chosen against what it drives: the state dot and the quiet clock. QUIET_AFTER_MS is 30s, so
|
|
144
|
+
// a 2s cadence puts the worst-case error in "quiet 31s" at one tick — under the rounding the UI
|
|
145
|
+
// already does. Faster buys nothing a human reads; slower makes the dot feel laggy on the flip.
|
|
146
|
+
const LIVENESS_MS = Number(process.env.PMOS_LIVENESS_MS || 2000);
|
|
147
|
+
// The floor on re-parsing a session's transcript (see transcriptHarness). Deliberately looser than
|
|
148
|
+
// the heartbeat: the harness is near-static within a run, while readHarness may parse up to 8 MB.
|
|
149
|
+
const TX_REREAD_MS = Number(process.env.PMOS_TX_REREAD_MS || 3000);
|
|
134
150
|
|
|
135
151
|
fs.mkdirSync(SESS_DIR, { recursive: true });
|
|
136
152
|
|
|
@@ -584,6 +600,10 @@ function buildSurface(name, root, w) {
|
|
|
584
600
|
// extension, not v1. Cadence math is the pure shared module — the tick and the pane's next-run agree.
|
|
585
601
|
const SCHED_FILE = path.join(STATE, 'schedules.json');
|
|
586
602
|
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
|
+
const REVIEW_DIR = path.join(STATE, 'reviews');
|
|
587
607
|
// Skills are file-driven (workspace-skill-authoring): built-ins ship committed under workspace/skills/,
|
|
588
608
|
// user-authored ones are scaffolded into state/skills/ by the New Skill flow. There is ONE user-facing
|
|
589
609
|
// concept — a skill — so the picker never exposes the internal `run`/`kind`; how a skill runs (server
|
|
@@ -701,6 +721,22 @@ function unpersist(id) {
|
|
|
701
721
|
try { fs.unlinkSync(path.join(SESS_DIR, id + ext)); } catch {}
|
|
702
722
|
}
|
|
703
723
|
}
|
|
724
|
+
// Scrollback written by the PRE-control-mode architecture carries the tmux CLIENT's own screen
|
|
725
|
+
// setup, and that gets replayed verbatim into a fresh xterm.js on attach (public/app.js) — putting
|
|
726
|
+
// the terminal straight back into the alternate buffer, where there is no scrollback. Strip it.
|
|
727
|
+
//
|
|
728
|
+
// The two signatures below are the client's, not an application's: the alt-screen switch IMMEDIATELY
|
|
729
|
+
// followed by the window-title stack push/pop. Verified against every scrollback on disk — each
|
|
730
|
+
// `?1049h` present was followed by exactly `\x1b[22;0;0t`. An inner full-screen app (vim, lazygit)
|
|
731
|
+
// switches buffers without that adjacency, so its own alt-screen use is preserved and still replays
|
|
732
|
+
// correctly — which is what a live TUI pane should do.
|
|
733
|
+
const TMUX_CLIENT_SMCUP = /\x1b\[\?1049h\x1b\[22;0;0t/g;
|
|
734
|
+
const TMUX_CLIENT_RMCUP = /\x1b\[\?1049l\x1b\[23;0;0t/g;
|
|
735
|
+
function stripLegacyClientScreen(buf) {
|
|
736
|
+
if (!buf) return buf;
|
|
737
|
+
return buf.replace(TMUX_CLIENT_SMCUP, '').replace(TMUX_CLIENT_RMCUP, '');
|
|
738
|
+
}
|
|
739
|
+
|
|
704
740
|
function restorePersisted() {
|
|
705
741
|
// On server start, load prior sessions' metadata + scrollback. A tmux-backed session whose
|
|
706
742
|
// process is STILL ALIVE (it outlived this server via the tmux daemon) is RE-ATTACHED live —
|
|
@@ -714,7 +750,7 @@ function restorePersisted() {
|
|
|
714
750
|
const meta = JSON.parse(fs.readFileSync(path.join(SESS_DIR, f), 'utf8'));
|
|
715
751
|
if (!meta.id || sessions.has(meta.id)) continue;
|
|
716
752
|
let buf = '';
|
|
717
|
-
try { buf = fs.readFileSync(path.join(SESS_DIR, meta.id + '.scrollback'), 'utf8'); } catch {}
|
|
753
|
+
try { buf = stripLegacyClientScreen(fs.readFileSync(path.join(SESS_DIR, meta.id + '.scrollback'), 'utf8')); } catch {}
|
|
718
754
|
// re-home the session: its workspace must exist for it to be reachable in the UI
|
|
719
755
|
const root = meta.root || SELF_ROOT;
|
|
720
756
|
const w = workspaces.get(meta.ws) || registerWorkspace(root) || homeWorkspace();
|
|
@@ -725,7 +761,9 @@ function restorePersisted() {
|
|
|
725
761
|
sessions.set(meta.id, s);
|
|
726
762
|
if (TMUX && meta.tmux && tmuxHasSession(meta.tmux)) {
|
|
727
763
|
s.state = 'working'; s.note = '';
|
|
728
|
-
|
|
764
|
+
// seed: this server was DOWN, so output produced in the gap never reached our ring buffer.
|
|
765
|
+
// Control mode replays no history, so take tmux's — it is the superset (R7 + scrollback).
|
|
766
|
+
attachPty(s, { seed: true }); // the process is alive — reconnect to it, live
|
|
729
767
|
} else {
|
|
730
768
|
s.state = 'exited';
|
|
731
769
|
s.note = meta.note || 'restored after server restart — scrollback preserved; ↻ respawn to start a new process';
|
|
@@ -783,7 +821,16 @@ function transcriptHarness(s) {
|
|
|
783
821
|
let stamp = null;
|
|
784
822
|
try { const st = fs.statSync(found.file); stamp = `${st.size}:${st.mtimeMs}`; } catch {}
|
|
785
823
|
if (s.tx && s.txStamp === stamp) return s.tx;
|
|
824
|
+
// A re-read FLOOR, separate from the stamp cache above. The stamp only suppresses a re-read when
|
|
825
|
+
// the transcript has not moved — but a working agent appends constantly, so under the liveness
|
|
826
|
+
// heartbeat the stamp changes on every tick and readHarness would re-parse up to MAX_BYTES (8 MB)
|
|
827
|
+
// per active session per push. The floor caps that at one parse per TX_REREAD_MS. The cost of the
|
|
828
|
+
// floor is that the drill-in's numbers can lag the terminal by up to that interval — acceptable,
|
|
829
|
+
// because the harness is near-static within a run (the leg-A finding that made it a drill-in and
|
|
830
|
+
// not a rail). The state dot and the quiet clock do NOT go through here and are never floored.
|
|
831
|
+
if (s.tx && (now - (s.txReadAt || 0)) < TX_REREAD_MS) return s.tx;
|
|
786
832
|
const h = transcript.readHarness(found.file, scrub);
|
|
833
|
+
s.txReadAt = now;
|
|
787
834
|
h.cliVersion = found.version || null;
|
|
788
835
|
s.tx = h; s.txStamp = stamp;
|
|
789
836
|
return h;
|
|
@@ -828,6 +875,29 @@ function sessionList() {
|
|
|
828
875
|
function pushSessions() { broadcast({ t: 'sessions', sessions: sessionList() }); }
|
|
829
876
|
function pushWorkspaces() { broadcast({ t: 'workspaces', workspaces: wsList() }); }
|
|
830
877
|
|
|
878
|
+
// ── The liveness heartbeat. ───────────────────────────────────────────────────────────────────
|
|
879
|
+
// Every other pushSessions() call site is a LIFECYCLE event — spawn, exit, close, rename, note.
|
|
880
|
+
// None of them fires on output, so without this the client's copy of `lastOutputAt` was frozen at
|
|
881
|
+
// the last lifecycle event and only ever aged: every row crossed QUIET_AFTER_MS ~30s after spawn
|
|
882
|
+
// and read `quiet` forever, however hard the agent was working. The server always knew better
|
|
883
|
+
// (appendBuf stamps every byte) — it simply never said so. This is the saying-so.
|
|
884
|
+
//
|
|
885
|
+
// The signals are unchanged. `quiet` still means EXACTLY "no bytes for >= QUIET_AFTER_MS" and
|
|
886
|
+
// still never means "waiting on you" (PRD R1). Nothing new is derived here; an existing truth is
|
|
887
|
+
// merely delivered on a cadence instead of on an accident.
|
|
888
|
+
//
|
|
889
|
+
// Two guards keep an idle machine idle: no connected client and no live session means no push.
|
|
890
|
+
// A workspace left open overnight with everything exited costs one Map scan per tick. The decision
|
|
891
|
+
// is a pure function so the guards are testable without standing up a server that has no clients.
|
|
892
|
+
function shouldHeartbeat(clientCount, sessionStates) {
|
|
893
|
+
return clientCount > 0 && sessionStates.some((st) => st !== 'exited');
|
|
894
|
+
}
|
|
895
|
+
function heartbeat() {
|
|
896
|
+
if (!shouldHeartbeat(allClients.size, [...sessions.values()].map((s) => s.state))) return;
|
|
897
|
+
pushSessions();
|
|
898
|
+
}
|
|
899
|
+
try { const _hb = setInterval(heartbeat, LIVENESS_MS); if (_hb.unref) _hb.unref(); } catch {}
|
|
900
|
+
|
|
831
901
|
function appendBuf(s, data) {
|
|
832
902
|
s.buf += data;
|
|
833
903
|
if (s.buf.length > SCROLLBACK) s.buf = s.buf.slice(s.buf.length - SCROLLBACK);
|
|
@@ -916,14 +986,43 @@ function ensureTmuxSession(name, cmd, workdir, cols, rows) {
|
|
|
916
986
|
'-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
|
|
917
987
|
return r.status === 0;
|
|
918
988
|
}
|
|
989
|
+
// The pane id (%0, %1, …) the control client must address for input. One cheap lookup per attach;
|
|
990
|
+
// cached on the session because a pane id is stable for the life of the tmux session.
|
|
991
|
+
function tmuxPaneId(s, name) {
|
|
992
|
+
if (s.paneId) return s.paneId;
|
|
993
|
+
const r = tmuxCtl(['display-message', '-p', '-t', name, '#{pane_id}']);
|
|
994
|
+
const id = (r.stdout || '').trim();
|
|
995
|
+
if (/^%\d+$/.test(id)) s.paneId = id;
|
|
996
|
+
return s.paneId || null;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// tmux's OWN history is authoritative for what the pane has shown — and it is a superset of our
|
|
1000
|
+
// ring buffer, because output produced while this server was down never reached us at all. Control
|
|
1001
|
+
// mode reports only output produced AFTER attach (it is not a history replay), so on a REATTACH we
|
|
1002
|
+
// seed the buffer from tmux rather than leaving the pane showing a stale, truncated scrollback.
|
|
1003
|
+
function seedFromPaneHistory(s, name) {
|
|
1004
|
+
const r = tmuxCtl(['capture-pane', '-p', '-e', '-J', '-t', name, '-S', '-5000']);
|
|
1005
|
+
if (r.status !== 0 || !r.stdout) return false;
|
|
1006
|
+
const text = r.stdout.replace(/\s+$/, '').replace(/\r?\n/g, '\r\n');
|
|
1007
|
+
if (!text) return false;
|
|
1008
|
+
s.buf = text + '\r\n';
|
|
1009
|
+
s.parsed = null;
|
|
1010
|
+
return true;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
919
1013
|
// Spawn the server-side PTY that is a tmux client attached to session s's LIVE tmux session,
|
|
920
1014
|
// wiring streaming + exit. Reused by spawnSession (fresh) and restorePersisted (reattach).
|
|
921
|
-
|
|
1015
|
+
//
|
|
1016
|
+
// The client runs in CONTROL MODE (-CC): it never paints a screen, so it never emits the `smcup`
|
|
1017
|
+
// that used to trap xterm.js in the alternate buffer with no scrollback. Instead tmux reports the
|
|
1018
|
+
// pane's raw bytes as %output, which we forward verbatim — the same stream shape the direct-PTY
|
|
1019
|
+
// fallback produces, so xterm.js keeps its own 5000-line scrollback. See lib/tmux-control.js.
|
|
1020
|
+
function attachPty(s, { seed = false } = {}) {
|
|
922
1021
|
const name = tmuxName(s.id);
|
|
923
1022
|
const startedMs = Date.now();
|
|
924
1023
|
let p;
|
|
925
1024
|
try {
|
|
926
|
-
p = pty.spawn(TMUX, ['-L', TMUX_SOCK, 'attach-session', '-t', name],
|
|
1025
|
+
p = pty.spawn(TMUX, ['-L', TMUX_SOCK, '-CC', 'attach-session', '-t', name],
|
|
927
1026
|
{ name: 'xterm-256color', cols: s.cols || 80, rows: s.rows || 24, cwd: s.cwd, env: sessionEnv() });
|
|
928
1027
|
} catch (e) {
|
|
929
1028
|
// A broken node-pty (e.g. a non-executable spawn-helper) throws synchronously here. This must
|
|
@@ -936,16 +1035,42 @@ function attachPty(s) {
|
|
|
936
1035
|
persist(s); pushSessions();
|
|
937
1036
|
return null;
|
|
938
1037
|
}
|
|
939
|
-
|
|
1038
|
+
const paneId = tmuxPaneId(s, name);
|
|
1039
|
+
// On a reattach, replace our (possibly stale, possibly truncated) buffer with tmux's own history
|
|
1040
|
+
// BEFORE any live %output lands, so the two never interleave out of order.
|
|
1041
|
+
if (seed) { try { seedFromPaneHistory(s, name); } catch {} }
|
|
1042
|
+
|
|
1043
|
+
// Everything outside this function treats s.pty as a PTY: .write() .resize() .kill() .pid, and
|
|
1044
|
+
// truthiness meaning "live". Control mode changes HOW each is done, not the shape — so the ws
|
|
1045
|
+
// handlers, the kill path and the liveness checks are all untouched.
|
|
1046
|
+
const client = {
|
|
1047
|
+
_client: p,
|
|
1048
|
+
get pid() { return p.pid; },
|
|
1049
|
+
write(data) {
|
|
1050
|
+
if (!paneId) return;
|
|
1051
|
+
for (const c of tmuxControl.sendKeysCommands(paneId, data)) p.write(c);
|
|
1052
|
+
},
|
|
1053
|
+
resize(cols, rows) { p.write(tmuxControl.resizeCommand(cols, rows)); },
|
|
1054
|
+
kill() { try { p.kill(); } catch {} },
|
|
1055
|
+
};
|
|
1056
|
+
s.pty = client; s.tmux = name; s.state = 'working';
|
|
940
1057
|
// Start the quiet clock at ATTACH, not at first byte: a session that spawns and immediately
|
|
941
1058
|
// hangs should go quiet on schedule rather than read 'working' forever. On a restore this
|
|
942
1059
|
// means "no output since we re-attached", which is the only thing we can honestly claim.
|
|
943
1060
|
s.lastOutputAt = Date.now(); s.parsed = null;
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1061
|
+
|
|
1062
|
+
const parser = tmuxControl.createParser({
|
|
1063
|
+
onOutput: (pane, text) => {
|
|
1064
|
+
if (paneId && pane !== paneId) return; // only this session's pane
|
|
1065
|
+
if (s._reattempts) s._reattempts = 0; // a working attach clears the retry budget
|
|
1066
|
+
appendBuf(s, text);
|
|
1067
|
+
for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id: s.id, data: text }));
|
|
1068
|
+
},
|
|
948
1069
|
});
|
|
1070
|
+
p.onData((d) => parser.feed(d));
|
|
1071
|
+
// The pane follows the control client's DECLARED size, so state it explicitly rather than relying
|
|
1072
|
+
// on the client PTY's own window size.
|
|
1073
|
+
client.resize(s.cols || 80, s.rows || 24);
|
|
949
1074
|
p.onExit(({ exitCode }) => {
|
|
950
1075
|
s.pty = null;
|
|
951
1076
|
// Our PTY is the tmux CLIENT: it exits when the pane process ends AND on a mere detach. If
|
|
@@ -1058,6 +1183,61 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1058
1183
|
persist(s); pushSessions(); pushWorkspaces();
|
|
1059
1184
|
return s;
|
|
1060
1185
|
}
|
|
1186
|
+
|
|
1187
|
+
// New session, optionally on a chosen/created branch. The working tree is SHARED across a repo's
|
|
1188
|
+
// sessions, so the checkout is repo-wide — the UI says so. A FAILED checkout must never spawn a
|
|
1189
|
+
// session on the wrong branch (rubric hard floor): we return {ok:false} and spawn nothing. Kept as
|
|
1190
|
+
// its own function so the checkout-then-maybe-spawn contract is unit-testable without a WS client.
|
|
1191
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch }) {
|
|
1192
|
+
if (branch) {
|
|
1193
|
+
const w = getWs(ws);
|
|
1194
|
+
const r = gitInfo.checkout(w.root, branch, { create: !!createBranch });
|
|
1195
|
+
if (!r.ok) return { ok: false, error: r.error };
|
|
1196
|
+
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
1197
|
+
}
|
|
1198
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws });
|
|
1199
|
+
return { ok: true, session: s };
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// ── Scratchpad: per-workspace staging text (R4). Local-only, one file per workspace keyed by the
|
|
1203
|
+
// workspace's hashed id (never a caller-supplied path), capped so it can't be used as a blob store.
|
|
1204
|
+
const SCRATCH_MAX = 200_000;
|
|
1205
|
+
function scratchpadPath(w) { return w ? path.join(SCRATCH_DIR, w.id + '.txt') : null; }
|
|
1206
|
+
function readScratchpad(w) {
|
|
1207
|
+
const f = scratchpadPath(w);
|
|
1208
|
+
if (!f) return '';
|
|
1209
|
+
try { return fs.readFileSync(f, 'utf8'); } catch { return ''; }
|
|
1210
|
+
}
|
|
1211
|
+
function writeScratchpad(w, text) {
|
|
1212
|
+
const f = scratchpadPath(w);
|
|
1213
|
+
if (!f) throw new Error('no workspace');
|
|
1214
|
+
fs.mkdirSync(SCRATCH_DIR, { recursive: true });
|
|
1215
|
+
fs.writeFileSync(f, String(text == null ? '' : text).slice(0, SCRATCH_MAX));
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// ── Layouts: per-workspace arrangements (R7). Same shape as the scratchpad above — one file per
|
|
1219
|
+
// workspace keyed by the workspace's hashed id, never a caller-supplied path, capped so it can't
|
|
1220
|
+
// be used as a blob store. The arrangement belongs to the REPO you opened, so a second repo has
|
|
1221
|
+
// its own; it is machine-local and gitignored, not a repo artifact.
|
|
1222
|
+
const LAYOUT_MAX = 40_000;
|
|
1223
|
+
function layoutPath(w) { return w ? path.join(LAYOUT_DIR, w.id + '.json') : null; }
|
|
1224
|
+
// Always returns a usable document: an absent, empty, truncated or hand-mangled file yields the
|
|
1225
|
+
// four stock layouts rather than an error, because the alternative is a window the PM cannot use.
|
|
1226
|
+
function readLayouts(w) {
|
|
1227
|
+
const f = layoutPath(w);
|
|
1228
|
+
let raw = null;
|
|
1229
|
+
if (f) { try { raw = fs.readFileSync(f, 'utf8'); } catch { raw = null; } }
|
|
1230
|
+
return layoutModel.sanitize(raw);
|
|
1231
|
+
}
|
|
1232
|
+
function writeLayouts(w, doc) {
|
|
1233
|
+
const f = layoutPath(w);
|
|
1234
|
+
if (!f) throw new Error('no workspace');
|
|
1235
|
+
const clean = layoutModel.sanitize(doc); // never store what we would not accept back
|
|
1236
|
+
fs.mkdirSync(LAYOUT_DIR, { recursive: true });
|
|
1237
|
+
fs.writeFileSync(f, JSON.stringify(clean).slice(0, LAYOUT_MAX));
|
|
1238
|
+
return clean;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1061
1241
|
// Remove a session from the registry + disk. Only non-live sessions can be removed;
|
|
1062
1242
|
// a live one must be killed first (its onExit flips it to exited).
|
|
1063
1243
|
function removeSession(id) {
|
|
@@ -1394,6 +1574,7 @@ async function handleRequest(req, res) {
|
|
|
1394
1574
|
if (p === '/' || p === '/index.html') return serveFile(res, path.join(PUB, 'index.html'), null, true);
|
|
1395
1575
|
if (p === '/app.js') return serveFile(res, path.join(PUB, 'app.js'), null, true);
|
|
1396
1576
|
if (p === '/lib/preview-kind.js') return serveFile(res, path.join(__dirname, 'lib', 'preview-kind.js'));
|
|
1577
|
+
if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
|
|
1397
1578
|
if (p === '/styles.css') return serveFile(res, path.join(PUB, 'styles.css'));
|
|
1398
1579
|
// xterm assets straight from node_modules (offline; no CDN)
|
|
1399
1580
|
if (p === '/vendor/xterm.js') return serveFile(res, path.join(XROOT, 'xterm', 'lib', 'xterm.js'));
|
|
@@ -1497,9 +1678,16 @@ async function handleRequest(req, res) {
|
|
|
1497
1678
|
const w = getWs(body.ws);
|
|
1498
1679
|
const rel = path.relative(w ? w.root : SELF_ROOT, path.join(USER_SKILLS_DIR, built.slug + '.skill'));
|
|
1499
1680
|
const prompt = `Refine the PMOS workspace skill file at ${rel}. It was scaffolded from this ask: ` +
|
|
1500
|
-
`"${built.description || built.label}".
|
|
1501
|
-
`
|
|
1502
|
-
`
|
|
1681
|
+
`"${built.description || built.label}". First read skills/core/SKILL-FORMAT.md (the skill spec) and ` +
|
|
1682
|
+
`follow build-skills/skill-creator.skill (the authoring procedure). Keep the runtime frontmatter shape ` +
|
|
1683
|
+
`unchanged (name, label, description, run: agent, agent: claude, and an output list = the sections it must ` +
|
|
1684
|
+
`ALWAYS produce). Sharpen the BODY into a procedure-first, repeatable procedure grounded in THIS repository's ` +
|
|
1685
|
+
`own files and tools only (no network): a Goal line, then numbered steps each ending on a checkable "Done when:" ` +
|
|
1686
|
+
`condition. Keep the FILING CONTRACT the scaffold wrote — the run must write its deliverable to ` +
|
|
1687
|
+
`workspace/state/deliverables/<run-id>.md and append the structured object to workspace/state/needs.json ` +
|
|
1688
|
+
`(a valid JSON array), never a prose note. Then run the SKILL-FORMAT conformance checklist (items 6-9: ` +
|
|
1689
|
+
`predictability, per-step completion criteria, pruning, leading words) over the body — put no self-check block ` +
|
|
1690
|
+
`in the file itself. Then stop.`;
|
|
1503
1691
|
const s = spawnSession({ name: 'draft: ' + built.slug, agent: 'claude', ws: body.ws,
|
|
1504
1692
|
origin: 'skill-draft:' + built.slug, initialInput: prompt + '\r' });
|
|
1505
1693
|
const started = s.state !== 'exited';
|
|
@@ -1553,6 +1741,47 @@ async function handleRequest(req, res) {
|
|
|
1553
1741
|
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); return res.end(md);
|
|
1554
1742
|
}
|
|
1555
1743
|
if (p === '/api/sessions') return json(res, { sessions: sessionList() });
|
|
1744
|
+
if (p === '/api/git/branches') {
|
|
1745
|
+
// Read-only branch awareness for a workspace. A non-repo returns {ok:false} (never a fake
|
|
1746
|
+
// branch). Fixed git args, no caller input in the read path (R1).
|
|
1747
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
1748
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
1749
|
+
return json(res, gitInfo.branchInfo(w.root));
|
|
1750
|
+
}
|
|
1751
|
+
if (p === '/api/scratchpad' && req.method === 'POST') {
|
|
1752
|
+
let body = {};
|
|
1753
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
1754
|
+
catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
1755
|
+
const w = getWs(body.ws);
|
|
1756
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
1757
|
+
try { writeScratchpad(w, body.text); }
|
|
1758
|
+
catch (e) { warnWriteFail('scratchpad', e); return json(res, { ok: false, error: 'could not save scratchpad' }, 500); }
|
|
1759
|
+
return json(res, { ok: true });
|
|
1760
|
+
}
|
|
1761
|
+
if (p === '/api/scratchpad') {
|
|
1762
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
1763
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
1764
|
+
return json(res, { ok: true, text: readScratchpad(w) });
|
|
1765
|
+
}
|
|
1766
|
+
if (p === '/api/layouts' && req.method === 'POST') {
|
|
1767
|
+
let body = {};
|
|
1768
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
1769
|
+
catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
1770
|
+
const w = getWs(body.ws);
|
|
1771
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
1772
|
+
let saved;
|
|
1773
|
+
try { saved = writeLayouts(w, body); }
|
|
1774
|
+
catch (e) { warnWriteFail('layouts', e); return json(res, { ok: false, error: 'could not save the layout' }, 500); }
|
|
1775
|
+
// echo what was STORED, not what was sent — the client's copy stays identical to disk even
|
|
1776
|
+
// when sanitize() clamped a size or dropped a key it did not recognise
|
|
1777
|
+
return json(res, { ok: true, layouts: saved.layouts, currentId: saved.currentId });
|
|
1778
|
+
}
|
|
1779
|
+
if (p === '/api/layouts') {
|
|
1780
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
1781
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
1782
|
+
const doc = readLayouts(w);
|
|
1783
|
+
return json(res, { ok: true, layouts: doc.layouts, currentId: doc.currentId });
|
|
1784
|
+
}
|
|
1556
1785
|
if (p === '/api/doc') {
|
|
1557
1786
|
const w = getWs(url.searchParams.get('ws'));
|
|
1558
1787
|
const doc = w ? readTextDoc(w.root, url.searchParams.get('path') || '') : null;
|
|
@@ -1649,6 +1878,135 @@ async function handleRequest(req, res) {
|
|
|
1649
1878
|
} catch (e) { warnWriteFail('needs.json (clearing the accepted need)', e); }
|
|
1650
1879
|
return json(res, { ok: true, recorded: rec, sink, fallback });
|
|
1651
1880
|
}
|
|
1881
|
+
if (p === '/api/review') {
|
|
1882
|
+
// Read a filed review. Latest by default; ?id= for a specific one. Honest-by-construction like
|
|
1883
|
+
// the console surfaces (D64 B2): a broken or contract-failing read returns {ok:false,error} so the
|
|
1884
|
+
// client can render RED — never a calm empty over a review that exists but could not be parsed.
|
|
1885
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
1886
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
1887
|
+
const want = url.searchParams.get('id');
|
|
1888
|
+
let files = [];
|
|
1889
|
+
try { files = fs.readdirSync(REVIEW_DIR).filter((f) => f.endsWith('.json')).sort(); } catch {}
|
|
1890
|
+
if (want && !/^[A-Za-z0-9._-]+$/.test(want)) return json(res, { ok: false, error: 'bad review id' }, 400);
|
|
1891
|
+
const pick = want ? `${want}.json` : files[files.length - 1];
|
|
1892
|
+
if (!pick || !files.includes(pick)) return json(res, { ok: true, empty: true, reviews: files.length });
|
|
1893
|
+
|
|
1894
|
+
let review;
|
|
1895
|
+
try { review = JSON.parse(fs.readFileSync(path.join(REVIEW_DIR, pick), 'utf8')); }
|
|
1896
|
+
catch { return json(res, { ok: false, error: `review '${pick}' is unreadable or not valid JSON` }, 500); }
|
|
1897
|
+
|
|
1898
|
+
// The SAME contract the write path enforces (R3/R7). A review that fails it is surfaced as an
|
|
1899
|
+
// error rather than half-rendered: a malformed finding must never reach the UI, because the UI's
|
|
1900
|
+
// job is to decide whether an Apply control exists.
|
|
1901
|
+
const v = findingContract.validateReview(review);
|
|
1902
|
+
if (!v.ok) return json(res, { ok: false, error: 'this review failed the finding contract', errors: v.errors.slice(0, 12) }, 422);
|
|
1903
|
+
|
|
1904
|
+
// Merge in what the PM has already settled, so a disposed finding renders as settled rather than
|
|
1905
|
+
// being offered twice. Read-only: dispositions live in the correction records, not in the review.
|
|
1906
|
+
const settled = {};
|
|
1907
|
+
try {
|
|
1908
|
+
const txt = fs.readFileSync(path.join(w.root, 'planning', 'runs', 'correction-records.jsonl'), 'utf8');
|
|
1909
|
+
for (const line of txt.split('\n')) {
|
|
1910
|
+
if (!line.trim()) continue;
|
|
1911
|
+
try { const r = JSON.parse(line); if (r && r.finding_title) settled[r.finding_title] = r.disposition; } catch {}
|
|
1912
|
+
}
|
|
1913
|
+
} catch {}
|
|
1914
|
+
const findings = review.findings.map((f) => ({
|
|
1915
|
+
...f,
|
|
1916
|
+
applyable: findingContract.isApplyable(f), // the client asks the contract, never re-derives it
|
|
1917
|
+
settled: settled[f.title] || null,
|
|
1918
|
+
}));
|
|
1919
|
+
return json(res, { ok: true, id: pick.replace(/\.json$/, ''), read: review.read, at: review.at || null, findings, reviews: files.length });
|
|
1920
|
+
}
|
|
1921
|
+
if (p === '/api/review/dispose' && req.method === 'POST') {
|
|
1922
|
+
// The ONLY write path this surface has (workspace-review-and-edit R6/R8/R11). The reader was
|
|
1923
|
+
// designed around there being none, so every guard here is load-bearing rather than ceremony.
|
|
1924
|
+
//
|
|
1925
|
+
// Three dispositions, one endpoint: `apply` and `edit` write the file, `dismiss` writes only
|
|
1926
|
+
// the correction record. Never automatic — reached solely by an explicit PM click, like /api/gate.
|
|
1927
|
+
let body = {};
|
|
1928
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
1929
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
1930
|
+
|
|
1931
|
+
const w = getWs(body.ws);
|
|
1932
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
1933
|
+
|
|
1934
|
+
// The finding must satisfy the contract BEFORE anything else looks at it — a malformed
|
|
1935
|
+
// finding never reaches a write, and never grows an Apply (fails closed by construction).
|
|
1936
|
+
const f = body.finding;
|
|
1937
|
+
const v = findingContract.validateFinding(f);
|
|
1938
|
+
if (!v.ok) return json(res, { ok: false, error: 'finding failed the contract', errors: v.errors }, 400);
|
|
1939
|
+
|
|
1940
|
+
const disposition = body.disposition;
|
|
1941
|
+
if (!correctionRecord.DISPOSITIONS.has(disposition)) {
|
|
1942
|
+
return json(res, { ok: false, error: `disposition must be one of ${[...correctionRecord.DISPOSITIONS].join(' | ')}` }, 400);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// R7 CONTAINMENT, server side. The client renders no Apply for a withheld finding; this is the
|
|
1946
|
+
// half that survives a tampered or stale client. A withheld finding can only ever be dismissed.
|
|
1947
|
+
const writes = disposition === 'apply' || disposition === 'edit';
|
|
1948
|
+
if (writes && !findingContract.isApplyable(f)) {
|
|
1949
|
+
return json(res, { ok: false, error: 'this finding offers nothing to apply — it is context, and yours to settle' }, 400);
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
let wrote = null;
|
|
1953
|
+
if (writes) {
|
|
1954
|
+
// Path guard, identical to every read on this surface (R12): traversal and cross-workspace
|
|
1955
|
+
// paths are refused before the file is opened, not after.
|
|
1956
|
+
const full = path.resolve(w.root, f.file || '');
|
|
1957
|
+
if (!insideRoot(w.root, full)) return json(res, { ok: false, error: 'outside workspace' }, 404);
|
|
1958
|
+
if (preview.previewKind(path.basename(full)) !== 'markdown') {
|
|
1959
|
+
return json(res, { ok: false, error: 'only markdown documents are editable here' }, 400);
|
|
1960
|
+
}
|
|
1961
|
+
let current;
|
|
1962
|
+
try { current = fs.readFileSync(full, 'utf8'); }
|
|
1963
|
+
catch { return json(res, { ok: false, error: 'could not read the document' }, 404); }
|
|
1964
|
+
|
|
1965
|
+
// On `edit` the PM's text replaces the agent's — the proposal applied is what the PM saw.
|
|
1966
|
+
const proposal = disposition === 'edit'
|
|
1967
|
+
? { before: f.proposal.before, after: body.pm_text }
|
|
1968
|
+
: f.proposal;
|
|
1969
|
+
|
|
1970
|
+
// REFUSE ON MISMATCH (sprint criterion 4). The client sends back the exact text it rendered
|
|
1971
|
+
// and the server re-derives it; disagreement means the file moved, or the client is stale or
|
|
1972
|
+
// tampered. Either way the PM would be applying something they never looked at.
|
|
1973
|
+
const m = applyChange.matchesRendered(current, proposal, body.rendered_after);
|
|
1974
|
+
if (!m.ok) return json(res, { ok: false, error: 'refused: ' + m.reason, detail: m.detail || null }, 409);
|
|
1975
|
+
|
|
1976
|
+
// FAIL LOUD (D64 Track A2, same stance as /api/gate): a swallowed write returning {ok:true}
|
|
1977
|
+
// would tell the PM their edit landed when it did not. No header/secret content in the error (D08).
|
|
1978
|
+
try { fs.writeFileSync(full, m.text); }
|
|
1979
|
+
catch (e) {
|
|
1980
|
+
warnWriteFail('document apply (' + f.file + ')', e);
|
|
1981
|
+
return json(res, { ok: false, error: 'could not write the document — nothing was changed' }, 500);
|
|
1982
|
+
}
|
|
1983
|
+
wrote = f.file;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// R8: the record is built only where attribution is KNOWN. buildRecord refuses anything that
|
|
1987
|
+
// would need inference, and a refusal here means no record is written at all — never a guess.
|
|
1988
|
+
const built = correctionRecord.buildRecord({
|
|
1989
|
+
run_id: body.run_id, initiative_id: body.initiative_id, finding: f, disposition,
|
|
1990
|
+
pm_text: body.pm_text, reason: body.reason, reason_note: body.reason_note,
|
|
1991
|
+
pm: body.pm || 'wawan', at: nowISO(),
|
|
1992
|
+
});
|
|
1993
|
+
if (!built.ok) {
|
|
1994
|
+
// The document may already have been written — say so rather than imply a clean failure.
|
|
1995
|
+
return json(res, { ok: false, error: 'no correction record written (attribution would have to be inferred)', errors: built.errors, wrote }, 400);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// Curated record, tracked and append-only — deliberately NOT stateDirFor()'s runtime directory
|
|
1999
|
+
// (PRD open question 1; pm-decisions.md 491–503 keeps that boundary the PM's to settle).
|
|
2000
|
+
const sink = path.join(w.root, 'planning', 'runs', 'correction-records.jsonl');
|
|
2001
|
+
try {
|
|
2002
|
+
fs.mkdirSync(path.dirname(sink), { recursive: true });
|
|
2003
|
+
fs.appendFileSync(sink, JSON.stringify(built.record) + '\n');
|
|
2004
|
+
} catch (e) {
|
|
2005
|
+
warnWriteFail('correction-records.jsonl (the north-star numerator)', e);
|
|
2006
|
+
return json(res, { ok: false, error: 'the document changed but the correction record could NOT be written', wrote }, 500);
|
|
2007
|
+
}
|
|
2008
|
+
return json(res, { ok: true, wrote, recorded: built.record, sink });
|
|
2009
|
+
}
|
|
1652
2010
|
res.writeHead(404); res.end('not found');
|
|
1653
2011
|
}
|
|
1654
2012
|
|
|
@@ -1724,7 +2082,10 @@ wss.on('connection', (ws) => {
|
|
|
1724
2082
|
// dropped silently; excess spawns get an honest error rather than piling up shells.
|
|
1725
2083
|
ws._rlSpawn = rateLimit.createBucket(8, 0.5);
|
|
1726
2084
|
ws._rlInput = rateLimit.createBucket(400, 200);
|
|
1727
|
-
|
|
2085
|
+
// quietAfterMs travels with hello so the client derives `quiet` from the SAME threshold the
|
|
2086
|
+
// server does. It was a re-typed literal in app.js — two copies of a number that must agree.
|
|
2087
|
+
ws.send(JSON.stringify({ t: 'hello', workspaces: wsList(), home: HOME_ENABLED ? wsId(SELF_ROOT) : null,
|
|
2088
|
+
sessions: sessionList(), quietAfterMs: harness.QUIET_AFTER_MS }));
|
|
1728
2089
|
|
|
1729
2090
|
ws.on('message', (raw) => {
|
|
1730
2091
|
let m; try { m = JSON.parse(raw); } catch { return; }
|
|
@@ -1745,8 +2106,9 @@ wss.on('connection', (ws) => {
|
|
|
1745
2106
|
} else if (m.t === 'resize') {
|
|
1746
2107
|
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 {} }
|
|
1747
2108
|
} else if (m.t === 'new') {
|
|
1748
|
-
const
|
|
1749
|
-
ws.send(JSON.stringify({ t: '
|
|
2109
|
+
const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch });
|
|
2110
|
+
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: 'branch: ' + r.error })); return; }
|
|
2111
|
+
ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
|
|
1750
2112
|
} else if (m.t === 'respawn') {
|
|
1751
2113
|
const old = sessions.get(m.id); if (!old || old.pty) return;
|
|
1752
2114
|
const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws });
|
|
@@ -1832,4 +2194,10 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
|
|
|
1832
2194
|
registerWorkspace, forgetWorkspace, workspaces, library, initiativeIndex, fsList,
|
|
1833
2195
|
// exported so the suite can assert the D08 invariant end-to-end: the harness parsers consume
|
|
1834
2196
|
// scrubbed text, so a secret printed into scrollback must not survive into a rendered value.
|
|
1835
|
-
scrub, harnessOf
|
|
2197
|
+
scrub, harnessOf,
|
|
2198
|
+
// the legacy-scrollback migration, exported so the suite can assert BOTH halves: the tmux
|
|
2199
|
+
// client's preamble is stripped, an inner TUI's own alt-screen use is not.
|
|
2200
|
+
stripLegacyClientScreen,
|
|
2201
|
+
// the liveness heartbeat's guard, exported pure so the "idle machine stays idle" half can be
|
|
2202
|
+
// asserted without a server that has no clients (the suite always has both).
|
|
2203
|
+
shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS };
|