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.
- package/README.md +37 -0
- 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/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 +727 -87
- package/workspace/public/index.html +88 -23
- package/workspace/public/styles.css +195 -19
- package/workspace/server.js +325 -14
- package/workspace/skills/doc-review.skill +114 -0
package/workspace/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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}".
|
|
1535
|
-
`
|
|
1536
|
-
`
|
|
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
|
-
|
|
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.
|