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.
@@ -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
- attachPty(s); // the process is alive — reconnect to it, live
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
- function attachPty(s) {
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
- s.pty = p; s.tmux = name; s.state = 'working';
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
- p.onData((d) => {
945
- if (s._reattempts) s._reattempts = 0; // a working attach clears the retry budget
946
- appendBuf(s, d);
947
- for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id: s.id, data: d }));
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}". Keep the frontmatter shape (name, label, description, run: agent, ` +
1501
- `agent: claude, and an output list = the sections it must ALWAYS produce). Sharpen the body into a concrete, ` +
1502
- `repeatable procedure grounded in THIS repository's local files and tools only (no network). Then stop.`;
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
- ws.send(JSON.stringify({ t: 'hello', workspaces: wsList(), home: HOME_ENABLED ? wsId(SELF_ROOT) : null, sessions: sessionList() }));
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 s = spawnSession({ name: m.name, agent: m.agent || 'shell', cwd: m.cwd, ws: m.ws });
1749
- ws.send(JSON.stringify({ t: 'created', id: s.id }));
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 };