create-pmos 0.7.1 → 0.7.3

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 CHANGED
@@ -333,6 +333,74 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
333
333
  `okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
334
334
  exactly what you're running.
335
335
 
336
+ ## What's new in 0.7.3
337
+
338
+ **A fix — read this if you ran a document review on 0.7.1 or 0.7.2, and especially if you have more
339
+ than one repository open in the workspace.**
340
+
341
+ Reviews were filed into the **workspace app's own install directory** rather than into the repository
342
+ being reviewed. Two consequences, and the first is the one that matters:
343
+
344
+ - **Reviews from different repositories were pooled together.** Open two repos and the review you
345
+ filed against one appeared when you opened the review surface in the other. A review names the
346
+ documents it read and quotes their contents, so this put one project's material in front of
347
+ another — including in front of an agent session working in that second repo.
348
+ - **In a product repo using this kit, reviews were written nowhere useful.** The review skill was
349
+ written against PMOS's own layout and told the agent to write to `workspace/state/reviews/` and
350
+ read `workspace/state/needs.json`. **A product repo has no `workspace/` folder**, so the agent
351
+ either created a stray one or filed the review where nothing would ever read it.
352
+
353
+ **Both are fixed.** Reviews now resolve through the same per-workspace state directory that
354
+ `needs.json` and `acceptances.jsonl` already used — your repo's own `pmos/state/reviews/`. The skill
355
+ no longer assumes any layout: the paths it needs are passed to it as absolute facts.
356
+
357
+ **If you reviewed anything on 0.7.1 or 0.7.2**, look in the workspace app's install directory under
358
+ `workspace/state/reviews/` for reviews that belong to your repositories, and delete any you would
359
+ rather not have sitting there. Nothing was sent anywhere — this is local files in the wrong folder —
360
+ but if you had two repos open, they saw each other.
361
+
362
+ **And the reason you probably never ran one: there was no way to say what to review.** The skill could
363
+ only be launched from the scheduled lens, and a schedule carries no scope — so its own instruction to
364
+ read exactly the documents you named had nothing behind it.
365
+
366
+ - **A `review` control now sits on the open document.** You are reading a document; you click
367
+ `review`; an agent reviews *that* document. Nothing to type, no schedule to create. The scope is
368
+ the thing in front of you.
369
+
370
+ **The standing disclosure is unchanged**, and it still matters more than any of the above: the review
371
+ surface has **not** been through its own review gate, and its false-positive rate is unmeasured. The
372
+ guard that withholds any proposal which would close a recorded open question or overwrite a decision
373
+ is tested, including in a real browser. How often its findings are simply *wrong* is not yet known.
374
+
375
+ ## What's new in 0.7.2
376
+
377
+ **A fix — read this if you use the Terminal Workspace on a machine with `tmux` installed.**
378
+
379
+ 0.7.0 and 0.7.1 both shipped a workspace terminal that renders **gibberish** there. Box-drawing
380
+ characters, check marks, spinners, emoji and accented letters all arrived damaged, and the output
381
+ lost its structure — which matters because agent CLIs print those constantly, so in practice most
382
+ frames were affected. If your terminal has looked broken since 0.7.0, this is why, and it was our
383
+ bug, not your setup.
384
+
385
+ The cause: the workspace drives `tmux` in control mode, and that stream is **bytes**. `tmux` escapes
386
+ only the control characters and the backslash — every byte of a UTF-8 character passes through raw.
387
+ The workspace decoded that stream as text and then narrowed it back to bytes, which does not merely
388
+ lose characters, it *manufactures* control codes out of the wreckage. `┌` became a **form feed**,
389
+ which is why the layout collapsed and not just the glyphs.
390
+
391
+ - **Fixed.** The control stream is now handled as bytes end to end, with a single UTF-8 decode in
392
+ one place. A character split across two `tmux` frames still survives.
393
+ - **A second, smaller source of the same symptom, also fixed.** The scrollback ring trims from the
394
+ front once it fills, and it used to cut at an arbitrary offset — routinely *inside* an escape
395
+ sequence, so the leftover fragment replayed as literal rubble like `5;203m` when you reattached.
396
+ It now cuts at a line boundary.
397
+ - **Nothing else changed.** 0.7.2 carries this fix and only this fix.
398
+
399
+ **You were only affected if `tmux` is on your PATH.** The workspace falls back to a direct terminal
400
+ when it is not, and that path never had the defect — which is exactly why this survived two
401
+ releases. The regression test now reports which of the two transports it exercised, so a green run
402
+ on a machine without `tmux` can no longer read as if it had covered the one that broke.
403
+
336
404
  ## What's new in 0.7.1
337
405
 
338
406
  **A correction to 0.7.0 — read this if you used the review surface.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@0c57593
1
+ kernel-source: pmos@56ab797
2
2
  assembled: 2026-07-28
3
3
  mode: vendor
@@ -17,28 +17,55 @@
17
17
  // The protocol, as it actually arrives on the wire:
18
18
  // \x1bP1000p DCS introducer, once, before the first line
19
19
  // %begin <t> <n> <f> … %end a command's reply is wrapped in a begin/end block
20
- // %output %0 line-1\015\012… pane bytes, NON-PRINTABLES OCTAL-ESCAPED (\015 = CR)
20
+ // %output %0 line-1\015\012… pane bytes, with SOME bytes octal-escaped (\015 = CR)
21
21
  // %exit [reason] the control client is going away
22
22
  // %session-changed, %window-*, … notifications we do not need
23
- // Lines are CRLF-terminated. Because every byte outside printable ASCII is octal-escaped, the
24
- // control stream itself is pure ASCII — so reading the client PTY as utf8 is safe, and the real
25
- // UTF-8 decoding happens HERE, after unescaping, with a streaming decoder (a multi-byte character
26
- // can be split across two %output frames).
23
+ //
24
+ // THE CONTROL STREAM IS BYTES, NOT TEXT. tmux escapes only `c < 0x20` and the backslash itself
25
+ // (control.c); every byte >= 0x80 is written through RAW. So a `─` (E2 94 80) arrives on the wire
26
+ // as three raw high bytes, NOT as three octal escapes. Reading the client PTY as a utf8 STRING and
27
+ // then narrowing each code unit back to a byte therefore destroys every non-ASCII character —
28
+ // U+2500 & 0xff = 0x00, U+250C & 0xff = 0x0C (a form feed), U+2713 & 0xff = 0x13 — which is
29
+ // injected control codes and replacement characters in the pane: box-drawing rubble, mangled
30
+ // layout, gibberish. Verified against tmux 3.4.
31
+ //
32
+ // So this parser works on BUFFERS end to end, and the single UTF-8 decode happens HERE, after
33
+ // unescaping, with a per-pane streaming decoder (a multi-byte character can be split across two
34
+ // %output frames). The caller must read the control client's PTY with `encoding: null`.
27
35
 
28
36
  const { StringDecoder } = require('string_decoder');
29
37
 
38
+ const LF = 0x0a, CR = 0x0d, BACKSLASH = 0x5c, SPACE = 0x20, PCT = 0x25;
39
+ const EMPTY = Buffer.alloc(0);
40
+ const DCS = Buffer.from('\x1bP1000p', 'latin1'); // introducer, once, before the first line
41
+ const ST = Buffer.from('\x1b\\', 'latin1'); // string terminator, may trail the last
42
+
43
+ const isOct = (b) => b >= 0x30 && b <= 0x37;
44
+
45
+ // A control-mode chunk is a byte stream. A string is accepted for ASCII test fixtures and is read
46
+ // as latin1 — one code unit per byte — because that is the only faithful string view of bytes.
47
+ const toBytes = (chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'latin1'));
48
+
49
+ function startsWithAscii(buf, s) {
50
+ if (buf.length < s.length) return false;
51
+ for (let i = 0; i < s.length; i++) if (buf[i] !== s.charCodeAt(i)) return false;
52
+ return true;
53
+ }
54
+
30
55
  // tmux escapes a byte as a backslash + exactly three octal digits (backslash itself is \134),
31
- // so there is no two-character `\\` case to handle — only the octal form.
32
- function unescapeToBytes(s) {
33
- const out = Buffer.allocUnsafe(s.length);
56
+ // so there is no two-character `\\` case to handle — only the octal form. Every other byte,
57
+ // INCLUDING the raw >= 0x80 bytes of a UTF-8 sequence, passes through untouched.
58
+ function unescapeToBytes(input) {
59
+ const buf = toBytes(input);
60
+ const out = Buffer.allocUnsafe(buf.length);
34
61
  let n = 0;
35
- for (let i = 0; i < s.length; i++) {
36
- const c = s.charCodeAt(i);
37
- if (c === 0x5c && i + 3 < s.length) { // '\'
38
- const oct = s.slice(i + 1, i + 4);
39
- if (/^[0-7]{3}$/.test(oct)) { out[n++] = parseInt(oct, 8) & 0xff; i += 3; continue; }
62
+ for (let i = 0; i < buf.length; i++) {
63
+ if (buf[i] === BACKSLASH && i + 3 < buf.length && isOct(buf[i + 1]) && isOct(buf[i + 2]) && isOct(buf[i + 3])) {
64
+ out[n++] = (((buf[i + 1] - 0x30) << 6) | ((buf[i + 2] - 0x30) << 3) | (buf[i + 3] - 0x30)) & 0xff;
65
+ i += 3;
66
+ continue;
40
67
  }
41
- out[n++] = c & 0xff;
68
+ out[n++] = buf[i];
42
69
  }
43
70
  return out.subarray(0, n);
44
71
  }
@@ -49,51 +76,68 @@ function unescapeToBytes(s) {
49
76
  * @param {(paneId: string, text: string) => void} h.onOutput decoded pane bytes
50
77
  * @param {(reason: string) => void} [h.onExit] %exit seen
51
78
  * @param {(lines: string[], isError: boolean) => void} [h.onBlock] a %begin…%end command reply
52
- * @returns {{feed: (chunk: string) => void}}
79
+ * @returns {{feed: (chunk: Buffer|string) => void}}
53
80
  */
54
81
  function createParser({ onOutput, onExit, onBlock } = {}) {
55
- let pending = ''; // partial line carried between chunks
82
+ let pending = EMPTY; // partial line carried between chunks, as BYTES
56
83
  let inBlock = false;
57
84
  let block = [];
58
85
  const decoders = new Map(); // paneId → StringDecoder, for split multi-byte chars
59
86
 
60
87
  function line(raw) {
61
88
  // The DCS introducer rides in front of the very first line; the terminator can trail the last.
62
- let l = raw.replace(/^\x1bP1000p/, '').replace(/\x1b\\$/, '');
89
+ let l = raw;
90
+ if (startsWithAscii(l, '\x1bP1000p')) l = l.subarray(DCS.length);
91
+ if (l.length >= ST.length && l[l.length - 2] === ST[0] && l[l.length - 1] === ST[1]) {
92
+ l = l.subarray(0, l.length - ST.length);
93
+ }
63
94
  if (inBlock) {
64
- if (l.startsWith('%end') || l.startsWith('%error')) {
95
+ // A command reply is text (it is what tmux printed for `capture-pane` and friends), so it
96
+ // decodes as UTF-8 in one shot — a reply line always arrives whole.
97
+ if (startsWithAscii(l, '%end') || startsWithAscii(l, '%error')) {
65
98
  inBlock = false;
66
- if (onBlock) onBlock(block, l.startsWith('%error'));
99
+ if (onBlock) onBlock(block, startsWithAscii(l, '%error'));
67
100
  block = [];
68
- } else block.push(l);
101
+ } else block.push(l.toString('utf8'));
69
102
  return;
70
103
  }
71
- if (l.startsWith('%begin')) { inBlock = true; block = []; return; }
72
- if (l.startsWith('%output ')) {
73
- // %output %<pane> <escaped bytes> — the data may legitimately be empty
74
- const m = /^%output (%\d+) ?([\s\S]*)$/.exec(l);
75
- if (!m || !onOutput) return;
76
- const pane = m[1];
104
+ if (startsWithAscii(l, '%begin')) { inBlock = true; block = []; return; }
105
+ if (startsWithAscii(l, '%output ')) {
106
+ // %output %<pane> <bytes> — the data may legitimately be empty. The header is pure ASCII,
107
+ // so it is scanned by byte offset; everything after the separating space is PANE BYTES and is
108
+ // never treated as text until the decoder below.
109
+ if (!onOutput) return;
110
+ let j = 8; // past '%output '
111
+ if (l[j] !== PCT) return; // '%' of the pane id
112
+ let k = j + 1;
113
+ while (k < l.length && l[k] >= 0x30 && l[k] <= 0x39) k++;
114
+ if (k === j + 1) return; // no digits — not a pane id
115
+ const pane = l.subarray(j, k).toString('latin1');
116
+ const data = l[k] === SPACE ? l.subarray(k + 1) : l.subarray(k);
77
117
  let dec = decoders.get(pane);
78
118
  if (!dec) { dec = new StringDecoder('utf8'); decoders.set(pane, dec); }
79
- const text = dec.write(unescapeToBytes(m[2]));
119
+ const text = dec.write(unescapeToBytes(data));
80
120
  if (text) onOutput(pane, text);
81
121
  return;
82
122
  }
83
- if (l.startsWith('%exit')) { if (onExit) onExit(l.slice(5).trim()); return; }
123
+ if (startsWithAscii(l, '%exit')) { if (onExit) onExit(l.subarray(5).toString('utf8').trim()); return; }
84
124
  // every other %notification (session-changed, window-add, layout-change, …) is not needed here
85
125
  }
86
126
 
87
127
  return {
88
128
  feed(chunk) {
89
- pending += chunk;
90
- let i;
91
- while ((i = pending.indexOf('\n')) !== -1) {
92
- let l = pending.slice(0, i);
93
- pending = pending.slice(i + 1);
94
- if (l.endsWith('\r')) l = l.slice(0, -1);
95
- line(l);
129
+ const b = toBytes(chunk);
130
+ pending = pending.length ? Buffer.concat([pending, b]) : b;
131
+ // %output data never contains a raw LF — tmux escapes every byte below 0x20 — so splitting
132
+ // the stream on LF can never cut a frame in half.
133
+ let start = 0, i;
134
+ while ((i = pending.indexOf(LF, start)) !== -1) {
135
+ const end = i > start && pending[i - 1] === CR ? i - 1 : i;
136
+ line(pending.subarray(start, end));
137
+ start = i + 1;
96
138
  }
139
+ // Copy the remainder rather than holding a subarray: a subarray pins the whole parent chunk.
140
+ pending = start >= pending.length ? EMPTY : (start ? Buffer.from(pending.subarray(start)) : pending);
97
141
  },
98
142
  };
99
143
  }
@@ -883,6 +883,8 @@ async function openDoc(pth, kind) {
883
883
  body.querySelectorAll('[data-doc]').forEach((a) => a.onclick = (e) => { e.preventDefault(); openDoc(a.dataset.doc); });
884
884
  wireImgFallback(body);
885
885
  markActiveDoc(pth);
886
+ // Only a markdown document can be reviewed, and only a document — never a summoned surface.
887
+ $('docReview').hidden = !(state.docPath && /\.(md|markdown|mdown|mkd|mdx|skill)$/i.test(state.docPath));
886
888
  refit();
887
889
  }
888
890
  function markActiveDoc(pth) {
@@ -929,6 +931,7 @@ function renderHtmlDoc(body, text) {
929
931
  }
930
932
  function closeDoc() {
931
933
  state.docPath = null; state.surface = null;
934
+ $('docReview').hidden = true;
932
935
  $('paneDoc').hidden = true;
933
936
  $('railSplit').hidden = true;
934
937
  $('docBody').innerHTML = '';
@@ -1807,6 +1810,25 @@ $('themeBtn').onclick = () => {
1807
1810
  };
1808
1811
  $('closeDoc').onclick = closeDoc;
1809
1812
 
1813
+ // "review" on an open document. The whole point of putting it HERE is that the scope needs no
1814
+ // naming — the thing to review is the thing you are reading. The server assembles the prompt
1815
+ // (skill body + resolved paths) because a tenant repo has no workspace/ folder to point at.
1816
+ $('docReview').onclick = async () => {
1817
+ const pth = state.docPath;
1818
+ if (!pth) return;
1819
+ const btn = $('docReview');
1820
+ btn.disabled = true; btn.textContent = 'starting…';
1821
+ let out = {};
1822
+ try {
1823
+ out = await (await fetch('/api/review/start', { method: 'POST', headers: { 'Content-Type': 'application/json' },
1824
+ body: JSON.stringify({ ws: state.wsId, path: pth }) })).json();
1825
+ } catch { out = { ok: false, error: 'could not reach the workspace server' }; }
1826
+ btn.disabled = false; btn.textContent = 'review';
1827
+ if (!out.ok) { flash(out.error || 'could not start the review'); return; }
1828
+ flash('reviewing ' + pth.split('/').pop() + ' — watch the session');
1829
+ if (out.sessionId) selectSession(out.sessionId);
1830
+ };
1831
+
1810
1832
  // ── ⌘K / Ctrl-K jump palette (client-side: workspaces + sessions + initiatives + docs) ──
1811
1833
  // ── on-demand surfaces (Plan · Deliver · Measure · Report) ─────────────────────────────────────
1812
1834
  // Summoned from ⌘K, rendered in the reader, derived through the server's local state adapter and
@@ -1834,6 +1856,7 @@ async function openSurface(name) {
1834
1856
  showRail();
1835
1857
  $('paneDoc').hidden = false; $('railSplit').hidden = false; foldPane('doc', true);
1836
1858
  state.surface = name; state.docPath = null;
1859
+ $('docReview').hidden = true;
1837
1860
  document.querySelectorAll('.doc').forEach((b) => b.classList.remove('on'));
1838
1861
  $('crumb').innerHTML = `<bdi>surface · ${esc(name)}</bdi>`;
1839
1862
  $('docBody').scrollTop = 0;
@@ -187,6 +187,7 @@
187
187
  <div class="cap">
188
188
  <button class="fold" id="docFold" aria-expanded="true" title="Hide the document"><span class="chev" aria-hidden="true">▾</span>document</button>
189
189
  <span class="crumb" id="crumb"></span>
190
+ <button class="mini docrev" id="docReview" hidden title="Have an agent review this document">review</button>
190
191
  <button class="x" id="closeDoc" aria-label="Close document">✕</button>
191
192
  </div>
192
193
  <div class="pane-body doc-body" id="docBody" tabindex="0"></div>
@@ -603,7 +603,15 @@ const DELIV_DIR = path.join(STATE, 'deliverables');
603
603
  // Reviews the doc-review skill files (workspace-review-and-edit). One JSON per run, written by the
604
604
  // skill session, read back by /api/review. Runtime, not the record: the durable artifact of a review
605
605
  // is the correction records its dispositions produce, which live in planning/runs/ and are tracked.
606
- const REVIEW_DIR = path.join(STATE, 'reviews');
606
+ // PER WORKSPACE, like every other piece of state here. An earlier build put this in the SERVER's own
607
+ // state/ — which meant two open repos shared one review pile, and a tenant repo (which has no
608
+ // workspace/ folder at all) had the reviews written into the installed app instead of into the repo
609
+ // being reviewed. Reviews name documents and quote their contents, so that was a cross-workspace leak,
610
+ // not just untidy filing. stateDirFor() is the same resolver needs.json and acceptances.jsonl use.
611
+ function reviewDirFor(w, opts) {
612
+ const d = stateDirFor(w, opts);
613
+ return d ? path.join(d, 'reviews') : null;
614
+ }
607
615
  // Skills are file-driven (workspace-skill-authoring): built-ins ship committed under workspace/skills/,
608
616
  // user-authored ones are scaffolded into state/skills/ by the New Skill flow. There is ONE user-facing
609
617
  // concept — a skill — so the picker never exposes the internal `run`/`kind`; how a skill runs (server
@@ -898,9 +906,27 @@ function heartbeat() {
898
906
  }
899
907
  try { const _hb = setInterval(heartbeat, LIVENESS_MS); if (_hb.unref) _hb.unref(); } catch {}
900
908
 
909
+ // Trim the ring from the FRONT at a LINE boundary. A blind slice cuts wherever the byte count
910
+ // lands — which is routinely INSIDE an escape sequence, and the truncated tail then replays as
911
+ // literal rubble ("5;203m") the next time a client attaches and we write the buffer to xterm.js;
912
+ // or BETWEEN the halves of a surrogate pair, and the lone surrogate becomes U+FFFD on the JSON hop
913
+ // to the browser. Neither is recoverable downstream. An escape sequence never spans a newline, so
914
+ // moving the cut forward to just past the next one makes both impossible. If the kept region has no
915
+ // newline near its head (one enormous line) the raw cut stands, stepping off a lone low surrogate.
916
+ const TRIM_SCAN = 8192; // how far past the cut to look for a boundary
917
+ function trimBuf(buf, limit) {
918
+ if (buf.length <= limit) return buf;
919
+ let cut = buf.length - limit;
920
+ const nl = buf.indexOf('\n', cut);
921
+ if (nl !== -1 && nl - cut <= TRIM_SCAN) return buf.slice(nl + 1);
922
+ const c = buf.charCodeAt(cut);
923
+ if (c >= 0xdc00 && c <= 0xdfff) cut++; // never start on a low surrogate
924
+ return buf.slice(cut);
925
+ }
926
+
901
927
  function appendBuf(s, data) {
902
928
  s.buf += data;
903
- if (s.buf.length > SCROLLBACK) s.buf = s.buf.slice(s.buf.length - SCROLLBACK);
929
+ if (s.buf.length > SCROLLBACK) s.buf = trimBuf(s.buf, SCROLLBACK);
904
930
  // Parser 2's whole input: the moment output last arrived. `quiet` is derived from this, never
905
931
  // from reading the bytes — which is why it is the only one of the five that cannot misread.
906
932
  s.lastOutputAt = Date.now();
@@ -1005,7 +1031,7 @@ function seedFromPaneHistory(s, name) {
1005
1031
  if (r.status !== 0 || !r.stdout) return false;
1006
1032
  const text = r.stdout.replace(/\s+$/, '').replace(/\r?\n/g, '\r\n');
1007
1033
  if (!text) return false;
1008
- s.buf = text + '\r\n';
1034
+ s.buf = trimBuf(text + '\r\n', SCROLLBACK); // 5000 lines of history can overrun the ring
1009
1035
  s.parsed = null;
1010
1036
  return true;
1011
1037
  }
@@ -1022,8 +1048,14 @@ function attachPty(s, { seed = false } = {}) {
1022
1048
  const startedMs = Date.now();
1023
1049
  let p;
1024
1050
  try {
1051
+ // `encoding: null` is LOAD-BEARING, not a style choice. A control-mode stream is BYTES: tmux
1052
+ // escapes only c < 0x20 and the backslash, so every byte of a UTF-8 sequence rides through raw.
1053
+ // Letting node-pty decode it to a string first and re-narrowing to bytes in the parser destroyed
1054
+ // every non-ASCII character — box drawing became NULs and form feeds, ✓ became 0x13, emoji and
1055
+ // accents became U+FFFD. The parser owns the one and only decode. See lib/tmux-control.js.
1025
1056
  p = pty.spawn(TMUX, ['-L', TMUX_SOCK, '-CC', 'attach-session', '-t', name],
1026
- { name: 'xterm-256color', cols: s.cols || 80, rows: s.rows || 24, cwd: s.cwd, env: sessionEnv() });
1057
+ { name: 'xterm-256color', cols: s.cols || 80, rows: s.rows || 24, cwd: s.cwd,
1058
+ env: sessionEnv(), encoding: null });
1027
1059
  } catch (e) {
1028
1060
  // A broken node-pty (e.g. a non-executable spawn-helper) throws synchronously here. This must
1029
1061
  // NOT propagate: on the startup first-shell it would crash the whole server and blank the
@@ -1886,13 +1918,14 @@ async function handleRequest(req, res) {
1886
1918
  if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
1887
1919
  const want = url.searchParams.get('id');
1888
1920
  let files = [];
1889
- try { files = fs.readdirSync(REVIEW_DIR).filter((f) => f.endsWith('.json')).sort(); } catch {}
1921
+ const rdir = reviewDirFor(w);
1922
+ try { files = rdir ? fs.readdirSync(rdir).filter((f) => f.endsWith('.json')).sort() : []; } catch {}
1890
1923
  if (want && !/^[A-Za-z0-9._-]+$/.test(want)) return json(res, { ok: false, error: 'bad review id' }, 400);
1891
1924
  const pick = want ? `${want}.json` : files[files.length - 1];
1892
1925
  if (!pick || !files.includes(pick)) return json(res, { ok: true, empty: true, reviews: files.length });
1893
1926
 
1894
1927
  let review;
1895
- try { review = JSON.parse(fs.readFileSync(path.join(REVIEW_DIR, pick), 'utf8')); }
1928
+ try { review = JSON.parse(fs.readFileSync(path.join(rdir, pick), 'utf8')); }
1896
1929
  catch { return json(res, { ok: false, error: `review '${pick}' is unreadable or not valid JSON` }, 500); }
1897
1930
 
1898
1931
  // The SAME contract the write path enforces (R3/R7). A review that fails it is surfaced as an
@@ -1929,6 +1962,60 @@ async function handleRequest(req, res) {
1929
1962
  }));
1930
1963
  return json(res, { ok: true, id: pick.replace(/\.json$/, ''), read: review.read, at: review.at || null, findings, reviews: files.length, stats, reasons: [...correctionRecord.REASONS] });
1931
1964
  }
1965
+ if (p === '/api/review/start' && req.method === 'POST') {
1966
+ // Review THIS document — the trigger that makes the scope implicit instead of typed. The PM is
1967
+ // reading a document; the thing they want reviewed is the thing in front of them.
1968
+ //
1969
+ // The prompt is assembled HERE, not in the browser, for one reason that matters beyond tidiness:
1970
+ // a tenant product repo has no workspace/ folder. Pointing the agent at
1971
+ // `workspace/skills/doc-review.skill` works in PMOS-self and breaks everywhere else, and so does
1972
+ // telling it to write to `workspace/state/reviews/`. The server knows the skill body and the
1973
+ // resolved state directory for THIS workspace, so both travel in the prompt as absolute facts.
1974
+ let body = {};
1975
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
1976
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
1977
+
1978
+ const w = getWs(body.ws);
1979
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
1980
+ const rel = String(body.path || '').trim();
1981
+ if (!rel) return json(res, { ok: false, error: 'no document named' }, 400);
1982
+ const full = path.resolve(w.root, rel);
1983
+ if (!insideRoot(w.root, full)) return json(res, { ok: false, error: 'outside workspace' }, 404);
1984
+ if (preview.previewKind(path.basename(full)) !== 'markdown') {
1985
+ return json(res, { ok: false, error: 'only markdown documents can be reviewed' }, 400);
1986
+ }
1987
+ if (!fs.existsSync(full)) return json(res, { ok: false, error: 'no such document' }, 404);
1988
+
1989
+ const task = loadTasks().find((t) => t.id === 'doc-review');
1990
+ if (!task || !task.prompt) return json(res, { ok: false, error: 'the doc-review skill is not installed' }, 500);
1991
+
1992
+ const rdir = reviewDirFor(w, { create: true });
1993
+ if (!rdir) return json(res, { ok: false, error: 'this workspace has no state directory to file a review into' }, 500);
1994
+ try { fs.mkdirSync(rdir, { recursive: true }); }
1995
+ catch (e) { warnWriteFail('reviews directory', e); return json(res, { ok: false, error: 'could not create the reviews directory' }, 500); }
1996
+
1997
+ // Scope first, so it is the last thing the agent reads before the procedure — and stated as
1998
+ // paths, because "the documents the PM named" is only a rule if something names them.
1999
+ const extra = Array.isArray(body.also) ? body.also.filter((x) => typeof x === 'string').slice(0, 9) : [];
2000
+ const scope = [rel, ...extra];
2001
+ const preamble = [
2002
+ 'Review these documents, and only these:',
2003
+ ...scope.map((s) => ` - ${s}`),
2004
+ '',
2005
+ `File the review as JSON into: ${rdir}`,
2006
+ `The needs-you queue for this workspace is: ${needsFileFor(w) || '(none — skip the needs step)'}`,
2007
+ 'Both paths are absolute and already exist. Use them exactly; do not guess a workspace/ path.',
2008
+ '',
2009
+ '--- the skill ---',
2010
+ '',
2011
+ ].join('\n');
2012
+
2013
+ const s = spawnSession({ name: 'review: ' + path.basename(rel), agent: task.agent || 'claude',
2014
+ ws: w.id, origin: 'review:' + rel, initialInput: preamble + task.prompt + '\r' });
2015
+ const started = !!(s && s.pty);
2016
+ return json(res, { ok: started, sessionId: s && s.id, scope, sink: rdir,
2017
+ error: started ? undefined : ((s && s.note) || 'could not start the session') });
2018
+ }
1932
2019
  if (p === '/api/review/dispose' && req.method === 'POST') {
1933
2020
  // The ONLY write path this surface has (workspace-review-and-edit R6/R8/R11). The reader was
1934
2021
  // designed around there being none, so every guard here is load-bearing rather than ceremony.
@@ -2210,6 +2297,9 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
2210
2297
  // the legacy-scrollback migration, exported so the suite can assert BOTH halves: the tmux
2211
2298
  // client's preamble is stripped, an inner TUI's own alt-screen use is not.
2212
2299
  stripLegacyClientScreen,
2300
+ // the scrollback ring's front trim, exported pure so the suite can assert that a cut never lands
2301
+ // inside an escape sequence or between the halves of a surrogate pair.
2302
+ trimBuf, SCROLLBACK,
2213
2303
  // the liveness heartbeat's guard, exported pure so the "idle machine stays idle" half can be
2214
2304
  // asserted without a server that has no clients (the suite always has both).
2215
2305
  shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS };
@@ -56,7 +56,8 @@ and is not — it is a measured open question (`pm-decisions.md` 491–503).
56
56
 
57
57
  ## Procedure
58
58
 
59
- 1. **Take the scope.** Read exactly the documents the PM named. Do not widen it.
59
+ 1. **Take the scope.** The documents are listed at the top of this prompt. Read exactly those and
60
+ do not widen the set — the PM chose them by opening one, not by describing a filter.
60
61
  *Done when:* every named path has been read in full, and you can list them.
61
62
 
62
63
  2. **Find, then cut.** For each document, ask the four questions above. Then remove every candidate
@@ -70,8 +71,10 @@ and is not — it is a measured open question (`pm-decisions.md` 491–503).
70
71
  guessing. Where the withheld test fires, propose nothing.
71
72
  *Done when:* every finding either carries an exact-match proposal or is explicitly withheld.
72
73
 
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:
74
+ 4. **File the review.** Pick a run id (`doc-review-<UTC-timestamp>`). Write it to the reviews
75
+ directory **named at the top of this prompt** — an absolute path that already exists. Do not
76
+ guess a `workspace/…` path: a product repo using the PMOS kit has no `workspace/` folder, and
77
+ inventing one files the review where nothing will read it. The shape:
75
78
 
76
79
  ```json
77
80
  {
@@ -94,11 +97,13 @@ and is not — it is a measured open question (`pm-decisions.md` 491–503).
94
97
 
95
98
  `withheld` is a required boolean on every finding and is never inferred. A withheld finding sets
96
99
  `"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.
100
+ *Done when:* the file is valid JSON in the directory the prompt named, and `GET /api/review`
101
+ returns `ok: true` for it. If it returns `422`, the contract rejected a finding — fix it; do not
102
+ file it anyway.
99
103
 
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:
104
+ 5. **Tell the PM it is ready.** Read the needs-you queue **named at the top of this prompt** (a
105
+ missing file is `[]`), append exactly this object, and write the whole array back — it must stay
106
+ a valid JSON array. If the prompt said there is none, skip this step:
102
107
 
103
108
  ```json
104
109
  {"run_id":"<run-id>","kind":"review","initiative_id":"workspace-review-and-edit","question":"Document review — <n> findings"}