create-pmos 0.7.0 → 0.7.2

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,66 @@ 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.2
337
+
338
+ **A fix — read this if you use the Terminal Workspace on a machine with `tmux` installed.**
339
+
340
+ 0.7.0 and 0.7.1 both shipped a workspace terminal that renders **gibberish** there. Box-drawing
341
+ characters, check marks, spinners, emoji and accented letters all arrived damaged, and the output
342
+ lost its structure — which matters because agent CLIs print those constantly, so in practice most
343
+ frames were affected. If your terminal has looked broken since 0.7.0, this is why, and it was our
344
+ bug, not your setup.
345
+
346
+ The cause: the workspace drives `tmux` in control mode, and that stream is **bytes**. `tmux` escapes
347
+ only the control characters and the backslash — every byte of a UTF-8 character passes through raw.
348
+ The workspace decoded that stream as text and then narrowed it back to bytes, which does not merely
349
+ lose characters, it *manufactures* control codes out of the wreckage. `┌` became a **form feed**,
350
+ which is why the layout collapsed and not just the glyphs.
351
+
352
+ - **Fixed.** The control stream is now handled as bytes end to end, with a single UTF-8 decode in
353
+ one place. A character split across two `tmux` frames still survives.
354
+ - **A second, smaller source of the same symptom, also fixed.** The scrollback ring trims from the
355
+ front once it fills, and it used to cut at an arbitrary offset — routinely *inside* an escape
356
+ sequence, so the leftover fragment replayed as literal rubble like `5;203m` when you reattached.
357
+ It now cuts at a line boundary.
358
+ - **Nothing else changed.** 0.7.2 carries this fix and only this fix.
359
+
360
+ **You were only affected if `tmux` is on your PATH.** The workspace falls back to a direct terminal
361
+ when it is not, and that path never had the defect — which is exactly why this survived two
362
+ releases. The regression test now reports which of the two transports it exercised, so a green run
363
+ on a machine without `tmux` can no longer read as if it had covered the one that broke.
364
+
365
+ ## What's new in 0.7.1
366
+
367
+ **A correction to 0.7.0 — read this if you used the review surface.**
368
+
369
+ 0.7.0 shipped the summoned document review with a defect in the part that records *why* you changed
370
+ an agent's proposal. The client sent a fixed reason — `incomplete` — on **every** edit, whatever the
371
+ actual reason was. So any correction record written by 0.7.0 carries a reason that is very likely
372
+ false, in the one file whose whole purpose is to make the human-correction rate trustworthy.
373
+
374
+ If you ran a review on 0.7.0, treat the `reason` field in `planning/runs/correction-records.jsonl` as
375
+ unreliable. Everything else in those records — the agent's text, your text, the attribution — is
376
+ sound.
377
+
378
+ - **You pick the reason now.** Applying an edit asks *what was wrong with it?* and offers
379
+ `factually wrong · incomplete · wrong scope · style · other…`. It **nudges rather than defaulting**,
380
+ and `other` asks for a few words. Dismiss is still one click — friction belongs on the action that
381
+ teaches something, not the common one.
382
+ - **A half-applied state is now impossible.** The record was validated *after* the document was
383
+ written, so a missing reason could leave your file changed with nothing recorded. Validation runs
384
+ first: every refusal leaves the repository byte-identical.
385
+ - **The counters are on the surface, and honest.** Dispositions, corrections and the action rate are
386
+ derived from the records themselves. Quiet-on-clean reads **`not measured`** rather than a
387
+ flattering zero — measuring it needs a corpus of documents known to be clean, which does not exist
388
+ yet.
389
+
390
+ **The disclosure from 0.7.0 still stands**, and it matters more than this release note: the review
391
+ surface has **not** been through its own review gate. Its false-positive rate is unmeasured. It
392
+ withholds any proposal that would close a recorded open question or overwrite a decision — that guard
393
+ is tested, including in a real browser — but nothing yet tells you how often its findings are simply
394
+ wrong. Use it on documents you are willing to read carefully.
395
+
336
396
  ## What's new in 0.7.0
337
397
 
338
398
  **The workspace GUI has layouts.** Until now it had exactly one arrangement: a fixed sidebar, the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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@a2227ff
1
+ kernel-source: pmos@bd8056d
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
  }
@@ -1905,9 +1905,27 @@ function reviewBody(p) {
1905
1905
  <div class="rv-lede">${esc(lede)}</div>
1906
1906
  <div class="rv-sub">Read ${p.read.map((r) => `<span class="mono">${esc(r)}</span>`).join(', ')}.</div>
1907
1907
  ${p.findings.map((f, i) => rvFinding(f, i)).join('')}
1908
- <div class="rv-tail">Read ${p.read.length} document${p.read.length === 1 ? '' : 's'}. <b>Nothing written.</b></div>
1908
+ <div class="rv-tail">Read ${p.read.length} document${p.read.length === 1 ? '' : 's'}. <b>Nothing written.</b>${rvStats(p.stats)}</div>
1909
1909
  </div>`;
1910
1910
  }
1911
+ // The PRD's success signal, on the face of the surface. Action rate comes straight off the correction
1912
+ // records. Quiet-rate CANNOT — it needs a corpus with a labelled clean class, which does not exist —
1913
+ // so it says "not measured" rather than rendering a flattering zero. An unmeasured number shown as a
1914
+ // number is the watermelon this whole surface exists to catch.
1915
+ function rvStats(s) {
1916
+ if (!s || !s.dispositions) return '';
1917
+ const pct = (v) => (v == null ? null : `${Math.round(v * 100)}%`);
1918
+ const bits = [
1919
+ `${s.dispositions} disposition${s.dispositions === 1 ? '' : 's'}`,
1920
+ `${s.corrections} correction${s.corrections === 1 ? '' : 's'}`,
1921
+ `acted on ${pct(s.action_rate)}`,
1922
+ `quiet on clean <i>not measured</i>`,
1923
+ ];
1924
+ const warn = s.inferred_attributions
1925
+ ? ` <b class="rv-bad">${s.inferred_attributions} inferred attribution${s.inferred_attributions === 1 ? '' : 's'}</b>`
1926
+ : '';
1927
+ return `<div class="rv-stats">${bits.join(' · ')}${warn}</div>`;
1928
+ }
1911
1929
  function rvFinding(f, i) {
1912
1930
  const ev = (f.evidence || []).map((e) => `${e.file}:${e.line}`).join(' · ');
1913
1931
  // Three states, and only one of them offers a write. `settled` is terminal for this review.
@@ -1930,6 +1948,13 @@ function rvFinding(f, i) {
1930
1948
  // The edit surface: opens IN PLACE under the finding — no modal, no mode switch, no navigation.
1931
1949
  // Gutter change bars are the VS Code cue; the replaced lines sit struck through above the new ones.
1932
1950
  // Cancel needs no write path at all — it just closes, which is the whole of "Cancel writes nothing".
1951
+ // The reason taxonomy, deliberately short. A vocabulary nobody can hold in their head gets skipped,
1952
+ // and a skipped reason is a record that teaches nothing. Values match correction-record.js's REASONS;
1953
+ // the server rejects anything outside it, so this list can never drift into inventing one.
1954
+ const RV_REASONS = [
1955
+ ['factually-wrong', 'factually wrong'], ['incomplete', 'incomplete'],
1956
+ ['wrong-scope', 'wrong scope'], ['style', 'style'], ['other', 'other…'],
1957
+ ];
1933
1958
  function rvEditor(f, i) {
1934
1959
  const before = (f.proposal && f.proposal.before) || '';
1935
1960
  const after = (f.proposal && f.proposal.after) || '';
@@ -1940,6 +1965,11 @@ function rvEditor(f, i) {
1940
1965
  ${before.split('\n').map((l) => `<div class="rv-l was"><span class="g"></span><span class="t">${esc(l)}</span></div>`).join('')}
1941
1966
  </div>
1942
1967
  <textarea class="rv-ta" data-rv-ta="${i}" spellcheck="false" rows="${Math.min(12, Math.max(3, nl(after) + 1))}">${esc(after)}</textarea>
1968
+ <div class="rv-reason" data-rv-reason="${i}">
1969
+ <span class="rv-only">What was wrong with it?</span>
1970
+ ${RV_REASONS.map((r) => `<button class="rv-rz" data-rv-rz="${i}" data-val="${r[0]}">${esc(r[1])}</button>`).join('')}
1971
+ </div>
1972
+ <input class="rv-note" data-rv-note="${i}" hidden placeholder="in a few words…" aria-label="Why" autocomplete="off" />
1943
1973
  <div class="rv-acts">
1944
1974
  <button data-rv-save="${i}">Apply</button>
1945
1975
  <button class="q" data-rv-cancel="${i}">Cancel</button>
@@ -1972,13 +2002,40 @@ function wireReview() {
1972
2002
  });
1973
2003
  $('docBody').querySelectorAll('[data-rv-apply]').forEach((b) => b.onclick = () => rvDispose(F(b.dataset.rvApply), 'apply'));
1974
2004
  $('docBody').querySelectorAll('[data-rv-dismiss]').forEach((b) => b.onclick = () => rvDispose(F(b.dataset.rvDismiss), 'dismiss'));
2005
+ // The reason tap. Required on an edit (R8 / correction-record.js) — an edit is where the agent was
2006
+ // wrong in a way worth knowing, and a record that cannot say HOW is the self-report this feature
2007
+ // exists to replace. Dismiss stays one click: friction belongs on the informative action, not the
2008
+ // common one (PRD open question 2).
2009
+ $('docBody').querySelectorAll('[data-rv-rz]').forEach((b) => b.onclick = () => {
2010
+ const n = b.dataset.rvRz;
2011
+ const row = $('docBody').querySelector(`[data-rv-reason="${n}"]`);
2012
+ row.querySelectorAll('.rv-rz').forEach((x) => x.classList.toggle('on', x === b));
2013
+ row.dataset.picked = b.dataset.val;
2014
+ const note = $('docBody').querySelector(`[data-rv-note="${n}"]`);
2015
+ note.hidden = b.dataset.val !== 'other'; // 'other' is only useful with words
2016
+ if (b.dataset.val === 'other') note.focus();
2017
+ const nudge = row.querySelector('.rv-nudge'); if (nudge) nudge.remove();
2018
+ });
1975
2019
  $('docBody').querySelectorAll('[data-rv-save]').forEach((b) => b.onclick = () => {
1976
2020
  const n = b.dataset.rvSave;
2021
+ const row = $('docBody').querySelector(`[data-rv-reason="${n}"]`);
2022
+ const picked = row && row.dataset.picked;
2023
+ if (!picked) {
2024
+ // An inline nudge, not a modal and not a silent default. Defaulting is what produced the bug
2025
+ // this replaces: every edit recorded "incomplete" whether or not it was.
2026
+ if (!row.querySelector('.rv-nudge')) {
2027
+ const s = document.createElement('span');
2028
+ s.className = 'rv-nudge'; s.textContent = 'Pick one — the record is worth nothing without it.';
2029
+ row.appendChild(s);
2030
+ }
2031
+ return;
2032
+ }
2033
+ const note = $('docBody').querySelector(`[data-rv-note="${n}"]`);
1977
2034
  const ta = $('docBody').querySelector(`[data-rv-ta="${n}"]`);
1978
- rvDispose(F(n), 'edit', ta ? ta.value : '');
2035
+ rvDispose(F(n), 'edit', ta ? ta.value : '', picked, note && !note.hidden ? note.value : undefined);
1979
2036
  });
1980
2037
  }
1981
- async function rvDispose(f, disposition, pmText) {
2038
+ async function rvDispose(f, disposition, pmText, reason, reasonNote) {
1982
2039
  if (!f) return;
1983
2040
  const rv = state.review || {};
1984
2041
  // The client sends back the exact text it rendered; the server re-derives the diff and refuses if
@@ -1995,7 +2052,7 @@ async function rvDispose(f, disposition, pmText) {
1995
2052
  const body = {
1996
2053
  ws: state.wsId, disposition, finding: f, rendered_after: renderedAfter,
1997
2054
  pm_text: disposition === 'edit' ? pmText : undefined,
1998
- reason: disposition === 'edit' ? 'incomplete' : undefined, // TODO: the reason tap (PRD open q2)
2055
+ reason, reason_note: reasonNote, // the PM's own word — never defaulted, never inferred
1999
2056
  run_id: rv.id, initiative_id: 'workspace-review-and-edit',
2000
2057
  };
2001
2058
  let out = {};
@@ -986,3 +986,22 @@ details.tree>summary:hover{color:var(--text-2)}
986
986
  padding:8px 11px;background:rgba(72,213,151,.06);color:var(--text-2);
987
987
  font-family:var(--mono);font-size:11.5px;line-height:1.8;outline:none}
988
988
  .rv-ta:focus{border-color:var(--accent);border-left-color:var(--ok)}
989
+
990
+ /* the reason tap — required on an edit, and never defaulted. The nudge is inline, not a modal:
991
+ a silent default is exactly the bug this replaced (every edit recorded "incomplete"). */
992
+ .rv-reason{display:flex;gap:7px;align-items:center;flex-wrap:wrap;margin-top:10px}
993
+ .rv-rz{padding:0 9px;height:28px;border-radius:999px;border:1px solid var(--border-strong);
994
+ background:var(--chip);font-size:11.5px;color:var(--text-3)}
995
+ .rv-rz:hover{background:var(--card-hover);color:var(--text)}
996
+ /* Selected reads as accent TEXT on a wash, not a filled pill: nothing else on this surface carries
997
+ a solid fill, and no token exists for text-on-accent because no component here needed one. */
998
+ .rv-rz.on{background:var(--accent-wash);border-color:var(--accent);color:var(--accent-text);font-weight:600}
999
+ .rv-nudge{font-size:11.5px;color:var(--warn-text);flex-basis:100%}
1000
+ .rv-note{width:100%;box-sizing:border-box;margin-top:8px;border:1px solid var(--border);
1001
+ border-radius:7px;padding:7px 10px;background:var(--chip);color:var(--text-2);
1002
+ font-family:var(--sans);font-size:12px;outline:none}
1003
+ .rv-note:focus{border-color:var(--accent)}
1004
+ /* the success-signal counters — one quiet line, and an unmeasured number says so */
1005
+ .rv-stats{margin-top:6px;font-family:var(--mono);font-size:10.5px;color:var(--text-dim);line-height:1.7}
1006
+ .rv-stats i{font-style:normal;color:var(--warn-text)}
1007
+ .rv-stats .rv-bad{color:var(--bad);font-weight:600}
@@ -898,9 +898,27 @@ function heartbeat() {
898
898
  }
899
899
  try { const _hb = setInterval(heartbeat, LIVENESS_MS); if (_hb.unref) _hb.unref(); } catch {}
900
900
 
901
+ // Trim the ring from the FRONT at a LINE boundary. A blind slice cuts wherever the byte count
902
+ // lands — which is routinely INSIDE an escape sequence, and the truncated tail then replays as
903
+ // literal rubble ("5;203m") the next time a client attaches and we write the buffer to xterm.js;
904
+ // or BETWEEN the halves of a surrogate pair, and the lone surrogate becomes U+FFFD on the JSON hop
905
+ // to the browser. Neither is recoverable downstream. An escape sequence never spans a newline, so
906
+ // moving the cut forward to just past the next one makes both impossible. If the kept region has no
907
+ // newline near its head (one enormous line) the raw cut stands, stepping off a lone low surrogate.
908
+ const TRIM_SCAN = 8192; // how far past the cut to look for a boundary
909
+ function trimBuf(buf, limit) {
910
+ if (buf.length <= limit) return buf;
911
+ let cut = buf.length - limit;
912
+ const nl = buf.indexOf('\n', cut);
913
+ if (nl !== -1 && nl - cut <= TRIM_SCAN) return buf.slice(nl + 1);
914
+ const c = buf.charCodeAt(cut);
915
+ if (c >= 0xdc00 && c <= 0xdfff) cut++; // never start on a low surrogate
916
+ return buf.slice(cut);
917
+ }
918
+
901
919
  function appendBuf(s, data) {
902
920
  s.buf += data;
903
- if (s.buf.length > SCROLLBACK) s.buf = s.buf.slice(s.buf.length - SCROLLBACK);
921
+ if (s.buf.length > SCROLLBACK) s.buf = trimBuf(s.buf, SCROLLBACK);
904
922
  // Parser 2's whole input: the moment output last arrived. `quiet` is derived from this, never
905
923
  // from reading the bytes — which is why it is the only one of the five that cannot misread.
906
924
  s.lastOutputAt = Date.now();
@@ -1005,7 +1023,7 @@ function seedFromPaneHistory(s, name) {
1005
1023
  if (r.status !== 0 || !r.stdout) return false;
1006
1024
  const text = r.stdout.replace(/\s+$/, '').replace(/\r?\n/g, '\r\n');
1007
1025
  if (!text) return false;
1008
- s.buf = text + '\r\n';
1026
+ s.buf = trimBuf(text + '\r\n', SCROLLBACK); // 5000 lines of history can overrun the ring
1009
1027
  s.parsed = null;
1010
1028
  return true;
1011
1029
  }
@@ -1022,8 +1040,14 @@ function attachPty(s, { seed = false } = {}) {
1022
1040
  const startedMs = Date.now();
1023
1041
  let p;
1024
1042
  try {
1043
+ // `encoding: null` is LOAD-BEARING, not a style choice. A control-mode stream is BYTES: tmux
1044
+ // escapes only c < 0x20 and the backslash, so every byte of a UTF-8 sequence rides through raw.
1045
+ // Letting node-pty decode it to a string first and re-narrowing to bytes in the parser destroyed
1046
+ // every non-ASCII character — box drawing became NULs and form feeds, ✓ became 0x13, emoji and
1047
+ // accents became U+FFFD. The parser owns the one and only decode. See lib/tmux-control.js.
1025
1048
  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() });
1049
+ { name: 'xterm-256color', cols: s.cols || 80, rows: s.rows || 24, cwd: s.cwd,
1050
+ env: sessionEnv(), encoding: null });
1027
1051
  } catch (e) {
1028
1052
  // A broken node-pty (e.g. a non-executable spawn-helper) throws synchronously here. This must
1029
1053
  // NOT propagate: on the startup first-shell it would crash the whole server and blank the
@@ -1904,19 +1928,30 @@ async function handleRequest(req, res) {
1904
1928
  // Merge in what the PM has already settled, so a disposed finding renders as settled rather than
1905
1929
  // being offered twice. Read-only: dispositions live in the correction records, not in the review.
1906
1930
  const settled = {};
1931
+ const records = [];
1907
1932
  try {
1908
1933
  const txt = fs.readFileSync(path.join(w.root, 'planning', 'runs', 'correction-records.jsonl'), 'utf8');
1909
1934
  for (const line of txt.split('\n')) {
1910
1935
  if (!line.trim()) continue;
1911
- try { const r = JSON.parse(line); if (r && r.finding_title) settled[r.finding_title] = r.disposition; } catch {}
1936
+ try {
1937
+ const r = JSON.parse(line);
1938
+ if (!r) continue;
1939
+ records.push(r);
1940
+ if (r.finding_title) settled[r.finding_title] = r.disposition;
1941
+ } catch {}
1912
1942
  }
1913
1943
  } catch {}
1944
+ // The PRD's success signal is explicit that the counters are part of the build, not a follow-up:
1945
+ // action rate must be derivable off the records with no manual counting. quiet-rate is NOT
1946
+ // derivable here — it needs a corpus with a labelled CLEAN class, which does not exist — so it is
1947
+ // reported as null and rendered as "not measured" rather than as a flattering zero.
1948
+ const stats = { ...correctionRecord.summarise(records), quiet_rate: findingContract.quietRate(null, null) };
1914
1949
  const findings = review.findings.map((f) => ({
1915
1950
  ...f,
1916
1951
  applyable: findingContract.isApplyable(f), // the client asks the contract, never re-derives it
1917
1952
  settled: settled[f.title] || null,
1918
1953
  }));
1919
- return json(res, { ok: true, id: pick.replace(/\.json$/, ''), read: review.read, at: review.at || null, findings, reviews: files.length });
1954
+ return json(res, { ok: true, id: pick.replace(/\.json$/, ''), read: review.read, at: review.at || null, findings, reviews: files.length, stats, reasons: [...correctionRecord.REASONS] });
1920
1955
  }
1921
1956
  if (p === '/api/review/dispose' && req.method === 'POST') {
1922
1957
  // The ONLY write path this surface has (workspace-review-and-edit R6/R8/R11). The reader was
@@ -1949,6 +1984,19 @@ async function handleRequest(req, res) {
1949
1984
  return json(res, { ok: false, error: 'this finding offers nothing to apply — it is context, and yours to settle' }, 400);
1950
1985
  }
1951
1986
 
1987
+ // R8, and it must happen BEFORE the write. buildRecord depends on nothing the write produces, and
1988
+ // validating it after would let a missing reason leave the document changed with no record behind
1989
+ // it — a half-applied state whose whole point was to be impossible. Refuse first, touch the file
1990
+ // second: every rejection below this line leaves the repository byte-identical.
1991
+ const built = correctionRecord.buildRecord({
1992
+ run_id: body.run_id, initiative_id: body.initiative_id, finding: f, disposition,
1993
+ pm_text: body.pm_text, reason: body.reason, reason_note: body.reason_note,
1994
+ pm: body.pm || 'wawan', at: nowISO(),
1995
+ });
1996
+ if (!built.ok) {
1997
+ return json(res, { ok: false, error: 'no correction record could be written, so nothing was changed', errors: built.errors }, 400);
1998
+ }
1999
+
1952
2000
  let wrote = null;
1953
2001
  if (writes) {
1954
2002
  // Path guard, identical to every read on this surface (R12): traversal and cross-workspace
@@ -1983,18 +2031,6 @@ async function handleRequest(req, res) {
1983
2031
  wrote = f.file;
1984
2032
  }
1985
2033
 
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
2034
  // Curated record, tracked and append-only — deliberately NOT stateDirFor()'s runtime directory
1999
2035
  // (PRD open question 1; pm-decisions.md 491–503 keeps that boundary the PM's to settle).
2000
2036
  const sink = path.join(w.root, 'planning', 'runs', 'correction-records.jsonl');
@@ -2198,6 +2234,9 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
2198
2234
  // the legacy-scrollback migration, exported so the suite can assert BOTH halves: the tmux
2199
2235
  // client's preamble is stripped, an inner TUI's own alt-screen use is not.
2200
2236
  stripLegacyClientScreen,
2237
+ // the scrollback ring's front trim, exported pure so the suite can assert that a cut never lands
2238
+ // inside an escape sequence or between the halves of a surrogate pair.
2239
+ trimBuf, SCROLLBACK,
2201
2240
  // the liveness heartbeat's guard, exported pure so the "idle machine stays idle" half can be
2202
2241
  // asserted without a server that has no clients (the suite always has both).
2203
2242
  shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS };