create-pmos 0.6.4 → 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.
@@ -70,6 +70,10 @@ const rateLimit = require('./lib/rate-limit'); // per-connection token b
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
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)
73
77
  // The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
74
78
  // (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
75
79
  // — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
@@ -97,9 +101,11 @@ const XROOT = path.join(__dirname, 'node_modules', '@xterm');
97
101
  const STATE = path.join(__dirname, 'state');
98
102
  const SESS_DIR = path.join(STATE, 'sessions');
99
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)
100
105
  const WS_FILE = path.join(STATE, 'workspaces.json');
101
106
  const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : 7433;
102
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
103
109
  // Bind + token policy (D62). DEFAULT = loopback only, no token (unchanged). The opt-in remote flag
104
110
  // (PMOS_REMOTE=tailnet / PMOS_BIND_TAILNET=1) adds the machine's OWN tailnet IPv4 (100.64.0.0/10)
105
111
  // and requires a bearer token on every non-loopback peer. Any non-loopback, non-tailnet bind is
@@ -133,6 +139,14 @@ function sessionEnv() {
133
139
  return env;
134
140
  }
135
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);
136
150
 
137
151
  fs.mkdirSync(SESS_DIR, { recursive: true });
138
152
 
@@ -586,6 +600,10 @@ function buildSurface(name, root, w) {
586
600
  // extension, not v1. Cadence math is the pure shared module — the tick and the pane's next-run agree.
587
601
  const SCHED_FILE = path.join(STATE, 'schedules.json');
588
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');
589
607
  // Skills are file-driven (workspace-skill-authoring): built-ins ship committed under workspace/skills/,
590
608
  // user-authored ones are scaffolded into state/skills/ by the New Skill flow. There is ONE user-facing
591
609
  // concept — a skill — so the picker never exposes the internal `run`/`kind`; how a skill runs (server
@@ -703,6 +721,22 @@ function unpersist(id) {
703
721
  try { fs.unlinkSync(path.join(SESS_DIR, id + ext)); } catch {}
704
722
  }
705
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
+
706
740
  function restorePersisted() {
707
741
  // On server start, load prior sessions' metadata + scrollback. A tmux-backed session whose
708
742
  // process is STILL ALIVE (it outlived this server via the tmux daemon) is RE-ATTACHED live —
@@ -716,7 +750,7 @@ function restorePersisted() {
716
750
  const meta = JSON.parse(fs.readFileSync(path.join(SESS_DIR, f), 'utf8'));
717
751
  if (!meta.id || sessions.has(meta.id)) continue;
718
752
  let buf = '';
719
- 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 {}
720
754
  // re-home the session: its workspace must exist for it to be reachable in the UI
721
755
  const root = meta.root || SELF_ROOT;
722
756
  const w = workspaces.get(meta.ws) || registerWorkspace(root) || homeWorkspace();
@@ -727,7 +761,9 @@ function restorePersisted() {
727
761
  sessions.set(meta.id, s);
728
762
  if (TMUX && meta.tmux && tmuxHasSession(meta.tmux)) {
729
763
  s.state = 'working'; s.note = '';
730
- 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
731
767
  } else {
732
768
  s.state = 'exited';
733
769
  s.note = meta.note || 'restored after server restart — scrollback preserved; ↻ respawn to start a new process';
@@ -785,7 +821,16 @@ function transcriptHarness(s) {
785
821
  let stamp = null;
786
822
  try { const st = fs.statSync(found.file); stamp = `${st.size}:${st.mtimeMs}`; } catch {}
787
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;
788
832
  const h = transcript.readHarness(found.file, scrub);
833
+ s.txReadAt = now;
789
834
  h.cliVersion = found.version || null;
790
835
  s.tx = h; s.txStamp = stamp;
791
836
  return h;
@@ -830,6 +875,29 @@ function sessionList() {
830
875
  function pushSessions() { broadcast({ t: 'sessions', sessions: sessionList() }); }
831
876
  function pushWorkspaces() { broadcast({ t: 'workspaces', workspaces: wsList() }); }
832
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
+
833
901
  function appendBuf(s, data) {
834
902
  s.buf += data;
835
903
  if (s.buf.length > SCROLLBACK) s.buf = s.buf.slice(s.buf.length - SCROLLBACK);
@@ -918,14 +986,43 @@ function ensureTmuxSession(name, cmd, workdir, cols, rows) {
918
986
  '-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
919
987
  return r.status === 0;
920
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
+
921
1013
  // Spawn the server-side PTY that is a tmux client attached to session s's LIVE tmux session,
922
1014
  // wiring streaming + exit. Reused by spawnSession (fresh) and restorePersisted (reattach).
923
- 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 } = {}) {
924
1021
  const name = tmuxName(s.id);
925
1022
  const startedMs = Date.now();
926
1023
  let p;
927
1024
  try {
928
- p = pty.spawn(TMUX, ['-L', TMUX_SOCK, 'attach-session', '-t', name],
1025
+ p = pty.spawn(TMUX, ['-L', TMUX_SOCK, '-CC', 'attach-session', '-t', name],
929
1026
  { name: 'xterm-256color', cols: s.cols || 80, rows: s.rows || 24, cwd: s.cwd, env: sessionEnv() });
930
1027
  } catch (e) {
931
1028
  // A broken node-pty (e.g. a non-executable spawn-helper) throws synchronously here. This must
@@ -938,16 +1035,42 @@ function attachPty(s) {
938
1035
  persist(s); pushSessions();
939
1036
  return null;
940
1037
  }
941
- 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';
942
1057
  // Start the quiet clock at ATTACH, not at first byte: a session that spawns and immediately
943
1058
  // hangs should go quiet on schedule rather than read 'working' forever. On a restore this
944
1059
  // means "no output since we re-attached", which is the only thing we can honestly claim.
945
1060
  s.lastOutputAt = Date.now(); s.parsed = null;
946
- p.onData((d) => {
947
- if (s._reattempts) s._reattempts = 0; // a working attach clears the retry budget
948
- appendBuf(s, d);
949
- 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
+ },
950
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);
951
1074
  p.onExit(({ exitCode }) => {
952
1075
  s.pty = null;
953
1076
  // Our PTY is the tmux CLIENT: it exits when the pane process ends AND on a mere detach. If
@@ -1092,6 +1215,29 @@ function writeScratchpad(w, text) {
1092
1215
  fs.writeFileSync(f, String(text == null ? '' : text).slice(0, SCRATCH_MAX));
1093
1216
  }
1094
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
+
1095
1241
  // Remove a session from the registry + disk. Only non-live sessions can be removed;
1096
1242
  // a live one must be killed first (its onExit flips it to exited).
1097
1243
  function removeSession(id) {
@@ -1428,6 +1574,7 @@ async function handleRequest(req, res) {
1428
1574
  if (p === '/' || p === '/index.html') return serveFile(res, path.join(PUB, 'index.html'), null, true);
1429
1575
  if (p === '/app.js') return serveFile(res, path.join(PUB, 'app.js'), null, true);
1430
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'));
1431
1578
  if (p === '/styles.css') return serveFile(res, path.join(PUB, 'styles.css'));
1432
1579
  // xterm assets straight from node_modules (offline; no CDN)
1433
1580
  if (p === '/vendor/xterm.js') return serveFile(res, path.join(XROOT, 'xterm', 'lib', 'xterm.js'));
@@ -1531,9 +1678,16 @@ async function handleRequest(req, res) {
1531
1678
  const w = getWs(body.ws);
1532
1679
  const rel = path.relative(w ? w.root : SELF_ROOT, path.join(USER_SKILLS_DIR, built.slug + '.skill'));
1533
1680
  const prompt = `Refine the PMOS workspace skill file at ${rel}. It was scaffolded from this ask: ` +
1534
- `"${built.description || built.label}". Keep the frontmatter shape (name, label, description, run: agent, ` +
1535
- `agent: claude, and an output list = the sections it must ALWAYS produce). Sharpen the body into a concrete, ` +
1536
- `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.`;
1537
1691
  const s = spawnSession({ name: 'draft: ' + built.slug, agent: 'claude', ws: body.ws,
1538
1692
  origin: 'skill-draft:' + built.slug, initialInput: prompt + '\r' });
1539
1693
  const started = s.state !== 'exited';
@@ -1609,6 +1763,25 @@ async function handleRequest(req, res) {
1609
1763
  if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
1610
1764
  return json(res, { ok: true, text: readScratchpad(w) });
1611
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
+ }
1612
1785
  if (p === '/api/doc') {
1613
1786
  const w = getWs(url.searchParams.get('ws'));
1614
1787
  const doc = w ? readTextDoc(w.root, url.searchParams.get('path') || '') : null;
@@ -1705,6 +1878,135 @@ async function handleRequest(req, res) {
1705
1878
  } catch (e) { warnWriteFail('needs.json (clearing the accepted need)', e); }
1706
1879
  return json(res, { ok: true, recorded: rec, sink, fallback });
1707
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
+ }
1708
2010
  res.writeHead(404); res.end('not found');
1709
2011
  }
1710
2012
 
@@ -1780,7 +2082,10 @@ wss.on('connection', (ws) => {
1780
2082
  // dropped silently; excess spawns get an honest error rather than piling up shells.
1781
2083
  ws._rlSpawn = rateLimit.createBucket(8, 0.5);
1782
2084
  ws._rlInput = rateLimit.createBucket(400, 200);
1783
- 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 }));
1784
2089
 
1785
2090
  ws.on('message', (raw) => {
1786
2091
  let m; try { m = JSON.parse(raw); } catch { return; }
@@ -1889,4 +2194,10 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
1889
2194
  registerWorkspace, forgetWorkspace, workspaces, library, initiativeIndex, fsList,
1890
2195
  // exported so the suite can assert the D08 invariant end-to-end: the harness parsers consume
1891
2196
  // scrubbed text, so a secret printed into scrollback must not survive into a rendered value.
1892
- 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 };
@@ -0,0 +1,114 @@
1
+ ---
2
+ name: doc-review
3
+ label: "Review documents"
4
+ description: "Read the planning documents you name and report only defects the artifact cannot detect about itself — never lint. Each finding carries its evidence and, where one exists, a proposed change you dispose of."
5
+ run: agent
6
+ agent: claude
7
+ output:
8
+ - Read
9
+ - Findings
10
+ - Withheld
11
+ ---
12
+
13
+ You are running the "Review documents" skill in this PMOS workspace (local, file mode). Work only
14
+ from this repository's own files, git, and CLIs — no network (D54).
15
+
16
+ Goal: surface defects in the named documents that **the artifact cannot detect about itself**, and
17
+ file them as a contract-checked review the PM disposes of one at a time.
18
+
19
+ ## What counts as a finding — and what does not
20
+
21
+ The whole value of this review is that the PM keeps reading it. A review that reports what a linter
22
+ already reports becomes a linter with manners, and gets ignored by Thursday.
23
+
24
+ **Report only defects the document cannot detect about itself.** The four kinds that recur:
25
+
26
+ - **A metric that does not measure its own claim** — a PRD bets on an outcome, then defines success as
27
+ a capability demo, so it can score full marks and move the outcome not at all.
28
+ - **An anchor that cannot score wrong** — a rubric dimension whose anchors award credit for descoping,
29
+ or that cannot distinguish a correct implementation from an absent one.
30
+ - **A gate that scripts away its own question** — a rubric prescribing the Acceptance Gate as the same
31
+ checklist it already scored, turning D12's strategic judgement into a second review pass.
32
+ - **A constraint contradicted elsewhere** — a non-goal or design rule in one shipped document that
33
+ silently rules out what another document proposes.
34
+
35
+ **NEVER report** — these belong to lint and the Frontmatter Gate, and emitting them fails the
36
+ contract outright: frontmatter or schema validity · broken links or missing paths · string/regex
37
+ matches · counting occurrences · spelling, typos, formatting.
38
+
39
+ **Say nothing when there is nothing to say.** An empty findings list is a correct and valued outcome.
40
+ Do not pad a review to look thorough — a false finding costs the PM more than a missed one, because
41
+ they pay for it every time.
42
+
43
+ ## The withheld class — where you must NOT propose a fix
44
+
45
+ Some defects are not defects. Before proposing any change, ask: **would applying this close a
46
+ question the PM has parked, or overwrite a decision they recorded?** Check `pm-decisions.md`,
47
+ `DECISIONS.md`, and the document's own history.
48
+
49
+ If yes, the finding is **withheld**: `"withheld": true`, `"proposal": null`, and a
50
+ `"withheld_reason"` saying why it is the PM's to settle. The client renders no Apply control for it —
51
+ the absence of the button is the guard, so a withheld finding carrying a proposal is rejected by the
52
+ contract, not merely discouraged.
53
+
54
+ The recorded instance: `CLAUDE.md` points file-mode state at `pmos/state/*.jsonl`, which looks stale
55
+ and is not — it is a measured open question (`pm-decisions.md` 491–503).
56
+
57
+ ## Procedure
58
+
59
+ 1. **Take the scope.** Read exactly the documents the PM named. Do not widen it.
60
+ *Done when:* every named path has been read in full, and you can list them.
61
+
62
+ 2. **Find, then cut.** For each document, ask the four questions above. Then remove every candidate
63
+ that a regex could have found, and every one you cannot evidence to a specific line.
64
+ *Done when:* each surviving finding names, in one sentence, what the artifact cannot see about
65
+ itself — the `self_undetectable` field. If you cannot write that sentence, drop the finding.
66
+
67
+ 3. **Propose, or withhold.** Where a change would genuinely fix it, write `proposal.before` as text
68
+ copied **verbatim** from the file and `proposal.after` as its replacement. `before` must appear in
69
+ the file **exactly once** — the apply path refuses a stale or ambiguous anchor rather than
70
+ guessing. Where the withheld test fires, propose nothing.
71
+ *Done when:* every finding either carries an exact-match proposal or is explicitly withheld.
72
+
73
+ 4. **File the review.** Pick a run id (`doc-review-<UTC-timestamp>`), then write
74
+ `workspace/state/reviews/<run-id>.json` with exactly this shape:
75
+
76
+ ```json
77
+ {
78
+ "at": "<ISO 8601>",
79
+ "read": ["planning/prd/example-prd.md"],
80
+ "findings": [
81
+ {
82
+ "title": "One sentence, the defect stated plainly.",
83
+ "file": "planning/prd/example-prd.md",
84
+ "kind": "metric-claim-mismatch",
85
+ "argument": "Why this is a defect — the reasoning, not a restatement.",
86
+ "self_undetectable": "What this artifact cannot see about itself.",
87
+ "evidence": [{ "file": "planning/prd/example-prd.md", "line": 17 }],
88
+ "withheld": false,
89
+ "proposal": { "before": "<verbatim text from the file>", "after": "<replacement>" }
90
+ }
91
+ ]
92
+ }
93
+ ```
94
+
95
+ `withheld` is a required boolean on every finding and is never inferred. A withheld finding sets
96
+ `"proposal": null` and adds `"withheld_reason"`.
97
+ *Done when:* the file is valid JSON and `GET /api/review` returns `ok: true` for it. If it returns
98
+ `422`, the contract rejected a finding — fix it; do not file it anyway.
99
+
100
+ 5. **Tell the PM it is ready.** Read `workspace/state/needs.json` (a missing file is `[]`), append
101
+ exactly this object, and write the whole array back — it must stay a valid JSON array:
102
+
103
+ ```json
104
+ {"run_id":"<run-id>","kind":"review","initiative_id":"workspace-review-and-edit","question":"Document review — <n> findings"}
105
+ ```
106
+ *Done when:* `needs.json` is a valid array containing the new object.
107
+
108
+ ## Output contract — produce EXACTLY these sections
109
+
110
+ - **Read** — the documents actually read, one per line.
111
+ - **Findings** — one line per finding: the title and its `file:line`. `None this review.` if empty,
112
+ which is a good outcome, not a failed run.
113
+ - **Withheld** — findings deliberately left for the PM, with the one-line reason. `None this review.`
114
+ if empty.