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 +60 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/lib/tmux-control.js +79 -35
- package/workspace/public/app.js +61 -4
- package/workspace/public/styles.css +19 -0
- package/workspace/server.js +56 -17
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.
|
|
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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
-
|
|
33
|
-
|
|
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 <
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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++] =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
72
|
-
if (l
|
|
73
|
-
// %output %<pane> <
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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(
|
|
119
|
+
const text = dec.write(unescapeToBytes(data));
|
|
80
120
|
if (text) onOutput(pane, text);
|
|
81
121
|
return;
|
|
82
122
|
}
|
|
83
|
-
if (l
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
}
|
package/workspace/public/app.js
CHANGED
|
@@ -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
|
|
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:
|
|
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}
|
package/workspace/server.js
CHANGED
|
@@ -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 =
|
|
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,
|
|
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 {
|
|
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 };
|