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 +68 -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 +23 -0
- package/workspace/public/index.html +1 -0
- package/workspace/server.js +96 -6
- package/workspace/skills/doc-review.skill +12 -7
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.
|
|
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": {
|
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
|
@@ -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>
|
package/workspace/server.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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.**
|
|
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>`)
|
|
74
|
-
|
|
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`
|
|
98
|
-
`422`, the contract rejected a finding — fix it; do not
|
|
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
|
|
101
|
-
exactly this object, and write the whole array back — it must stay
|
|
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"}
|