create-pmos 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -922,86 +922,57 @@ details.tree>summary:hover{color:var(--text-2)}
922
922
  font-family:var(--mono);font-size:12px;line-height:1.5;outline:none}
923
923
  .scratch[data-open="true"] .scratch-text{display:block}
924
924
 
925
- /* ── the summoned review (workspace-review-and-edit) ──────────────────────────────────────────
926
- Deliberately the quietest surface in the app. The PM's disposition on the prototype was "too much
927
- guardrails and ceremony — guardrails is ok, just make it less obvious", so every guard here is
928
- structural and none of them announces itself: no cards, no severity hues, no taxonomy headers.
929
- A withheld finding is marked only by a hollow dot and the ABSENCE of an Apply button. */
930
- /* The rail is the surface's real home (~400-520px), not the full-width page the prototype was
931
- judged on — so the rhythm is tuned for the column it actually lives in, and the max-width only
932
- bites once the PM drags the rail wide. */
933
- .rv{max-width:660px;padding:16px 18px 36px}
934
- .rv-lede{font-size:15px;line-height:1.45;letter-spacing:-.01em;color:var(--text);margin-bottom:5px}
935
- .rv-sub{font-size:11.5px;line-height:1.55;color:var(--text-muted);margin-bottom:18px;
936
- word-break:break-word;overflow-wrap:anywhere}
937
- .rv-sub .mono{font-size:10.5px}
938
- .rv-empty{padding:24px 22px;max-width:560px}
939
- .rv-empty p{font-size:14px;line-height:1.6;color:var(--text-2);margin-bottom:8px}
940
- .rv-empty .rv-dim{font-size:12.5px;color:var(--text-muted)}
941
- .rv-errs{margin:10px 0 0 18px;font-family:var(--mono);font-size:11px;color:var(--text-3);line-height:1.7}
942
-
943
- .rv-f{position:relative;padding:14px 0 14px 18px;border-top:1px solid var(--border-3);cursor:pointer}
944
- .rv-f:first-of-type{border-top:0}
945
- .rv-f::before{content:"";position:absolute;left:0;top:20px;width:5px;height:5px;border-radius:50%;
946
- background:var(--text-dim)}
947
- .rv-f.on::before{background:var(--accent)}
948
- /* hollow = nothing to apply. The shape carries it, so meaning is never in hue alone. */
949
- .rv-f.rv-quiet::before{background:transparent;box-shadow:inset 0 0 0 1px var(--text-dim)}
950
- .rv-f.rv-done{opacity:.55}
951
- .rv-f.rv-done .rv-h{text-decoration:line-through;text-decoration-color:var(--text-dim)}
952
- .rv-h{font-size:13.5px;line-height:1.5;color:var(--text-2);letter-spacing:-.005em}
953
- .rv-f:hover .rv-h,.rv-f.on .rv-h{color:var(--text)}
954
- .rv-src{margin-top:4px;font-family:var(--mono);font-size:10.5px;color:var(--text-dim)}
955
- .rv-d{display:none;padding-top:11px}
956
- .rv-f.on .rv-d,.rv-f[data-editing="1"] .rv-d{display:block}
957
- .rv-d p{font-size:12.5px;line-height:1.7;color:var(--text-3);margin-bottom:10px}
958
- .rv-why{padding-left:11px;box-shadow:inset 2px 0 0 var(--border-strong);color:var(--text-muted)}
959
- .rv-acts{display:flex;gap:15px;align-items:center;flex-wrap:wrap;margin-top:12px}
960
- .rv-acts button{font-size:12.5px;color:var(--accent-text);padding:3px 0;min-height:30px}
961
- .rv-acts button:hover{text-decoration:underline}
962
- .rv-acts button.q{color:var(--text-dim)}
963
- .rv-only{font-size:12px;color:var(--text-dim)}
964
- .rv-tail{margin-top:30px;padding-top:14px;border-top:1px solid var(--border-3);
965
- font-size:11.5px;line-height:1.7;color:var(--text-dim)}
966
- .rv-tail b{color:var(--text-muted);font-weight:500}
967
- .rv-say{margin-bottom:14px;padding:9px 12px;border-radius:9px;border:1px solid var(--bad);
968
- background:rgba(242,101,95,.08);font-size:12px;line-height:1.55;color:var(--text-2)}
969
-
970
- /* the in-place editor — opens under the finding, never a modal or a mode switch */
971
- .rv-ed:empty{display:none}
972
- .rv-ed{padding-top:12px}
973
- .rv-edfile{display:flex;align-items:baseline;gap:10px;font-size:10.5px;color:var(--text-dim);margin-bottom:8px}
974
- .rv-n{margin-left:auto;font-family:var(--mono)}
975
- .rv-n b{color:var(--ok-text);font-weight:600}
976
- .rv-n i{color:var(--bad);font-style:normal;font-weight:600}
977
- .rv-code{font-family:var(--mono);font-size:11.5px;line-height:1.8;
978
- border-top:1px solid var(--border-3);border-bottom:1px solid var(--border-3);padding:6px 0}
979
- .rv-l{display:grid;grid-template-columns:3px 1fr;align-items:start}
980
- .rv-l .g{align-self:stretch;border-radius:2px;background:transparent}
981
- .rv-l .t{padding:0 4px 0 11px;white-space:pre-wrap;word-break:break-word;color:var(--text-3)}
982
- .rv-l.was .g{background:rgba(242,101,95,.5)}
983
- .rv-l.was .t{color:var(--text-dim);text-decoration:line-through;text-decoration-color:rgba(242,101,95,.55)}
984
- .rv-ta{width:100%;box-sizing:border-box;margin-top:8px;resize:vertical;
985
- border:1px solid var(--border);border-left:3px solid var(--ok);border-radius:0 7px 7px 0;
986
- padding:8px 11px;background:rgba(72,213,151,.06);color:var(--text-2);
987
- font-family:var(--mono);font-size:11.5px;line-height:1.8;outline:none}
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}
925
+ /* The cap's controls carry TEXT labels, not icons, so they size to content. And `hidden` has to
926
+ actually hide: `.cap .mini` sets display:grid, which beats the UA's [hidden]{display:none} — the
927
+ attribute was being set correctly and doing nothing, so every control showed at once. Only the
928
+ first control takes the margin-left:auto that pushes the group right. */
929
+ .cap .mini[hidden],.cap .x[hidden]{display:none}
930
+ .cap .docrev{width:auto;min-width:0;padding:0 9px;font-size:11px}
931
+ .cap .mini ~ .mini{margin-left:0}
932
+
933
+ /* ── the document editor (workspace-doc-editor) ──────────────────────────────────────────────
934
+ The reader gains an edit mode. Quiet on purpose: editing a document should feel local and
935
+ undramatic, so there is no toolbar, no modal, and no mode banner — a control in the cap, a
936
+ textarea in the reader's own type, and the diff shown beside it as you type. */
937
+ .ed-wrap{display:grid;grid-template-rows:minmax(0,1fr) auto;height:100%;min-height:0}
938
+ .ed-split{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:0;min-height:0;overflow:hidden}
939
+ .ed-split[data-diff="off"]{grid-template-columns:minmax(0,1fr)}
940
+ .ed-split[data-diff="off"] .ed-diff{display:none}
941
+ .ed-ta{width:100%;height:100%;box-sizing:border-box;resize:none;border:0;outline:none;
942
+ padding:14px 16px;background:var(--bg);color:var(--text-2);
943
+ font-family:var(--mono);font-size:12px;line-height:1.8;tab-size:2}
944
+ .ed-diff{border-left:1px solid var(--border-3);overflow-y:auto;background:var(--panel);
945
+ font-family:var(--mono);font-size:11px;line-height:1.75;padding:8px 0}
946
+ .ed-l{display:grid;grid-template-columns:3px 1fr;align-items:start}
947
+ .ed-l .g{align-self:stretch;border-radius:2px;background:transparent}
948
+ .ed-l .t{padding:0 10px;white-space:pre-wrap;word-break:break-word;color:var(--text-3)}
949
+ .ed-l.del .g{background:var(--bad)}
950
+ .ed-l.del .t{color:var(--text-dim);text-decoration:line-through;text-decoration-color:rgba(242,101,95,.5)}
951
+ .ed-l.add .g{background:var(--ok)}
952
+ .ed-l.add .t{color:var(--text-2)}
953
+ .ed-l.ctx .t{color:var(--text-dim)}
954
+ .ed-none{padding:14px 16px;font-size:11.5px;color:var(--text-dim);line-height:1.6}
955
+ .ed-bar{display:flex;align-items:center;gap:12px;padding:8px 14px;border-top:1px solid var(--border-3);
956
+ background:var(--panel-head);font-size:11.5px;color:var(--text-dim)}
957
+ .ed-bar .n{font-family:var(--mono)}
958
+ .ed-bar .n b{color:var(--ok-text);font-weight:600}
959
+ .ed-bar .n i{color:var(--bad);font-style:normal;font-weight:600}
960
+ .ed-bar input{flex:1;min-width:80px;border:1px solid var(--border);border-radius:7px;
961
+ padding:6px 10px;background:var(--chip);color:var(--text-2);font-family:var(--sans);
962
+ font-size:11.5px;outline:none}
963
+ .ed-bar input:focus{border-color:var(--accent)}
964
+ .ed-bar .dirty{color:var(--warn-text)}
965
+ .ed-err{margin:0 14px 8px;padding:8px 11px;border-radius:8px;border:1px solid var(--bad);
966
+ background:rgba(242,101,95,.08);font-size:11.5px;line-height:1.5;color:var(--text-2)}
967
+
968
+ /* the change log for one document — read, not managed */
969
+ .hist{padding:16px 18px;max-width:640px}
970
+ .hist h3{font-size:14px;font-weight:600;margin-bottom:3px}
971
+ .hist .sub{font-size:11.5px;color:var(--text-muted);margin-bottom:18px;line-height:1.6}
972
+ .hist-row{padding:11px 0;border-top:1px solid var(--border-3)}
973
+ .hist-row:first-of-type{border-top:0}
974
+ .hist-when{font-family:var(--mono);font-size:10.5px;color:var(--text-dim)}
975
+ .hist-when b{color:var(--ok-text);font-weight:600}
976
+ .hist-when i{color:var(--bad);font-style:normal;font-weight:600}
977
+ .hist-note{margin-top:4px;font-size:12.5px;color:var(--text-2);line-height:1.55}
978
+ .hist-none{font-size:12px;color:var(--text-dim);line-height:1.6}
@@ -71,9 +71,7 @@ const consoleState = require('./lib/console-state'); // read-only console surf
71
71
  const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
72
72
  const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
73
73
  const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
74
- const findingContract = require('./lib/finding-contract'); // what a review finding is + the withheld guard (R3/R7)
75
- const applyChange = require('./lib/apply-change'); // pure change application, refuse-on-mismatch (R6/R9)
76
- const correctionRecord = require('./lib/correction-record'); // attribution never inferred (R8)
74
+ const applyChange = require('./lib/apply-change'); // anchored + whole-document saves, refuse-on-stale
77
75
  // The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
78
76
  // (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
79
77
  // — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
@@ -600,10 +598,6 @@ function buildSurface(name, root, w) {
600
598
  // extension, not v1. Cadence math is the pure shared module — the tick and the pane's next-run agree.
601
599
  const SCHED_FILE = path.join(STATE, 'schedules.json');
602
600
  const DELIV_DIR = path.join(STATE, 'deliverables');
603
- // Reviews the doc-review skill files (workspace-review-and-edit). One JSON per run, written by the
604
- // skill session, read back by /api/review. Runtime, not the record: the durable artifact of a review
605
- // is the correction records its dispositions produce, which live in planning/runs/ and are tracked.
606
- const REVIEW_DIR = path.join(STATE, 'reviews');
607
601
  // Skills are file-driven (workspace-skill-authoring): built-ins ship committed under workspace/skills/,
608
602
  // user-authored ones are scaffolded into state/skills/ by the New Skill flow. There is ONE user-facing
609
603
  // concept — a skill — so the picker never exposes the internal `run`/`kind`; how a skill runs (server
@@ -1599,6 +1593,11 @@ async function handleRequest(req, res) {
1599
1593
  if (p === '/app.js') return serveFile(res, path.join(PUB, 'app.js'), null, true);
1600
1594
  if (p === '/lib/preview-kind.js') return serveFile(res, path.join(__dirname, 'lib', 'preview-kind.js'));
1601
1595
  if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
1596
+ // The editor computes its live diff with the SAME module the server saves through, so what the PM
1597
+ // sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
1598
+ // a directory route — a <script src> for a file missing from this list 404s silently, and the
1599
+ // client feature that depends on it fails in a way no server-side test can see.
1600
+ if (p === '/lib/apply-change.js') return serveFile(res, path.join(__dirname, 'lib', 'apply-change.js'));
1602
1601
  if (p === '/styles.css') return serveFile(res, path.join(PUB, 'styles.css'));
1603
1602
  // xterm assets straight from node_modules (offline; no CDN)
1604
1603
  if (p === '/vendor/xterm.js') return serveFile(res, path.join(XROOT, 'xterm', 'lib', 'xterm.js'));
@@ -1902,63 +1901,11 @@ async function handleRequest(req, res) {
1902
1901
  } catch (e) { warnWriteFail('needs.json (clearing the accepted need)', e); }
1903
1902
  return json(res, { ok: true, recorded: rec, sink, fallback });
1904
1903
  }
1905
- if (p === '/api/review') {
1906
- // Read a filed review. Latest by default; ?id= for a specific one. Honest-by-construction like
1907
- // the console surfaces (D64 B2): a broken or contract-failing read returns {ok:false,error} so the
1908
- // client can render RED — never a calm empty over a review that exists but could not be parsed.
1909
- const w = getWs(url.searchParams.get('ws'));
1910
- if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
1911
- const want = url.searchParams.get('id');
1912
- let files = [];
1913
- try { files = fs.readdirSync(REVIEW_DIR).filter((f) => f.endsWith('.json')).sort(); } catch {}
1914
- if (want && !/^[A-Za-z0-9._-]+$/.test(want)) return json(res, { ok: false, error: 'bad review id' }, 400);
1915
- const pick = want ? `${want}.json` : files[files.length - 1];
1916
- if (!pick || !files.includes(pick)) return json(res, { ok: true, empty: true, reviews: files.length });
1917
-
1918
- let review;
1919
- try { review = JSON.parse(fs.readFileSync(path.join(REVIEW_DIR, pick), 'utf8')); }
1920
- catch { return json(res, { ok: false, error: `review '${pick}' is unreadable or not valid JSON` }, 500); }
1921
-
1922
- // The SAME contract the write path enforces (R3/R7). A review that fails it is surfaced as an
1923
- // error rather than half-rendered: a malformed finding must never reach the UI, because the UI's
1924
- // job is to decide whether an Apply control exists.
1925
- const v = findingContract.validateReview(review);
1926
- if (!v.ok) return json(res, { ok: false, error: 'this review failed the finding contract', errors: v.errors.slice(0, 12) }, 422);
1927
-
1928
- // Merge in what the PM has already settled, so a disposed finding renders as settled rather than
1929
- // being offered twice. Read-only: dispositions live in the correction records, not in the review.
1930
- const settled = {};
1931
- const records = [];
1932
- try {
1933
- const txt = fs.readFileSync(path.join(w.root, 'planning', 'runs', 'correction-records.jsonl'), 'utf8');
1934
- for (const line of txt.split('\n')) {
1935
- if (!line.trim()) continue;
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 {}
1942
- }
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) };
1949
- const findings = review.findings.map((f) => ({
1950
- ...f,
1951
- applyable: findingContract.isApplyable(f), // the client asks the contract, never re-derives it
1952
- settled: settled[f.title] || null,
1953
- }));
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] });
1955
- }
1956
- if (p === '/api/review/dispose' && req.method === 'POST') {
1957
- // The ONLY write path this surface has (workspace-review-and-edit R6/R8/R11). The reader was
1958
- // designed around there being none, so every guard here is load-bearing rather than ceremony.
1959
- //
1960
- // Three dispositions, one endpoint: `apply` and `edit` write the file, `dismiss` writes only
1961
- // the correction record. Never automatic — reached solely by an explicit PM click, like /api/gate.
1904
+ if (p === '/api/doc/save' && req.method === 'POST') {
1905
+ // The editor's save (workspace-doc-editor R3/R4). The ONLY write path on this surface, and it is
1906
+ // reached solely by the PM pressing save on a document they were editing. Nothing here consults
1907
+ // an agent, and nothing an agent produced can reach it — the agent is downstream of this write,
1908
+ // never upstream of it (rubric dim 1, the hard floor).
1962
1909
  let body = {};
1963
1910
  try { body = JSON.parse((await readBody(req)) || '{}'); }
1964
1911
  catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
@@ -1966,82 +1913,66 @@ async function handleRequest(req, res) {
1966
1913
  const w = getWs(body.ws);
1967
1914
  if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
1968
1915
 
1969
- // The finding must satisfy the contract BEFORE anything else looks at it — a malformed
1970
- // finding never reaches a write, and never grows an Apply (fails closed by construction).
1971
- const f = body.finding;
1972
- const v = findingContract.validateFinding(f);
1973
- if (!v.ok) return json(res, { ok: false, error: 'finding failed the contract', errors: v.errors }, 400);
1974
-
1975
- const disposition = body.disposition;
1976
- if (!correctionRecord.DISPOSITIONS.has(disposition)) {
1977
- return json(res, { ok: false, error: `disposition must be one of ${[...correctionRecord.DISPOSITIONS].join(' | ')}` }, 400);
1916
+ // Path guard, identical to every read on this surface (R10).
1917
+ const full = path.resolve(w.root, String(body.path || ''));
1918
+ if (!insideRoot(w.root, full)) return json(res, { ok: false, error: 'outside workspace' }, 404);
1919
+ if (preview.previewKind(path.basename(full)) !== 'markdown') {
1920
+ return json(res, { ok: false, error: 'only markdown documents are editable here' }, 400);
1978
1921
  }
1979
-
1980
- // R7 CONTAINMENT, server side. The client renders no Apply for a withheld finding; this is the
1981
- // half that survives a tampered or stale client. A withheld finding can only ever be dismissed.
1982
- const writes = disposition === 'apply' || disposition === 'edit';
1983
- if (writes && !findingContract.isApplyable(f)) {
1984
- return json(res, { ok: false, error: 'this finding offers nothing to apply — it is context, and yours to settle' }, 400);
1922
+ let current;
1923
+ try { current = fs.readFileSync(full, 'utf8'); }
1924
+ catch { return json(res, { ok: false, error: 'could not read the document' }, 404); }
1925
+
1926
+ // REFUSE ON STALE. `base` is what the editor loaded; if the file has moved since, writing would
1927
+ // silently discard whoever changed it. Loud failure beats a merge nobody asked for.
1928
+ const saved = applyChange.saveWhole(current, String(body.base == null ? '' : body.base), String(body.text == null ? '' : body.text));
1929
+ if (!saved.ok) {
1930
+ const code = saved.reason === 'stale' ? 409 : 400;
1931
+ return json(res, { ok: false, error: saved.reason, detail: saved.detail || null }, code);
1985
1932
  }
1986
1933
 
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);
1934
+ try { fs.writeFileSync(full, saved.text); }
1935
+ catch (e) {
1936
+ warnWriteFail('document save (' + body.path + ')', e);
1937
+ return json(res, { ok: false, error: 'could not write the document — nothing was changed' }, 500);
1998
1938
  }
1999
1939
 
2000
- let wrote = null;
2001
- if (writes) {
2002
- // Path guard, identical to every read on this surface (R12): traversal and cross-workspace
2003
- // paths are refused before the file is opened, not after.
2004
- const full = path.resolve(w.root, f.file || '');
2005
- if (!insideRoot(w.root, full)) return json(res, { ok: false, error: 'outside workspace' }, 404);
2006
- if (preview.previewKind(path.basename(full)) !== 'markdown') {
2007
- return json(res, { ok: false, error: 'only markdown documents are editable here' }, 400);
2008
- }
2009
- let current;
2010
- try { current = fs.readFileSync(full, 'utf8'); }
2011
- catch { return json(res, { ok: false, error: 'could not read the document' }, 404); }
2012
-
2013
- // On `edit` the PM's text replaces the agent's — the proposal applied is what the PM saw.
2014
- const proposal = disposition === 'edit'
2015
- ? { before: f.proposal.before, after: body.pm_text }
2016
- : f.proposal;
2017
-
2018
- // REFUSE ON MISMATCH (sprint criterion 4). The client sends back the exact text it rendered
2019
- // and the server re-derives it; disagreement means the file moved, or the client is stale or
2020
- // tampered. Either way the PM would be applying something they never looked at.
2021
- const m = applyChange.matchesRendered(current, proposal, body.rendered_after);
2022
- if (!m.ok) return json(res, { ok: false, error: 'refused: ' + m.reason, detail: m.detail || null }, 409);
2023
-
2024
- // FAIL LOUD (D64 Track A2, same stance as /api/gate): a swallowed write returning {ok:true}
2025
- // would tell the PM their edit landed when it did not. No header/secret content in the error (D08).
2026
- try { fs.writeFileSync(full, m.text); }
2027
- catch (e) {
2028
- warnWriteFail('document apply (' + f.file + ')', e);
2029
- return json(res, { ok: false, error: 'could not write the document — nothing was changed' }, 500);
2030
- }
2031
- wrote = f.file;
1940
+ // R4/R9: append to THIS repository's own change log. stateDirFor() is the same resolver
1941
+ // needs.json and acceptances.jsonl use — a tenant product repo has no workspace/ folder, and
1942
+ // assuming one is exactly the defect shipped in 0.7.1/0.7.2.
1943
+ const region = applyChange.changedRegion(body.base, body.text);
1944
+ const rec = { file: String(body.path), at: nowISO(), pm: body.pm || 'wawan',
1945
+ note: typeof body.note === 'string' && body.note.trim() ? body.note.trim().slice(0, 400) : null,
1946
+ at_line: region.at_line, added: region.added, removed: region.removed,
1947
+ before: region.before, after: region.after };
1948
+ const dir = stateDirFor(w, { create: true });
1949
+ let logged = null;
1950
+ if (dir) {
1951
+ const sink = path.join(dir, 'doc-changes.jsonl');
1952
+ try { fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync(sink, JSON.stringify(rec) + '\n'); logged = sink; }
1953
+ catch (e) { warnWriteFail('doc-changes.jsonl (the change log)', e); }
2032
1954
  }
2033
-
2034
- // Curated record, tracked and append-only — deliberately NOT stateDirFor()'s runtime directory
2035
- // (PRD open question 1; pm-decisions.md 491–503 keeps that boundary the PM's to settle).
2036
- const sink = path.join(w.root, 'planning', 'runs', 'correction-records.jsonl');
1955
+ // The document IS saved at this point. A failed log is reported honestly rather than as success,
1956
+ // but it must not read as "your edit was lost".
1957
+ return json(res, { ok: true, saved: body.path, logged, change: { added: rec.added, removed: rec.removed, at_line: rec.at_line },
1958
+ warning: logged ? undefined : 'the document was saved but the change could not be logged' });
1959
+ }
1960
+ if (p === '/api/doc/changes') {
1961
+ // The change log, read back (R8 — this is what an agent reads before working here).
1962
+ const w = getWs(url.searchParams.get('ws'));
1963
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
1964
+ const dir = stateDirFor(w);
1965
+ const sink = dir ? path.join(dir, 'doc-changes.jsonl') : null;
1966
+ const only = url.searchParams.get('path');
1967
+ let rows = [];
2037
1968
  try {
2038
- fs.mkdirSync(path.dirname(sink), { recursive: true });
2039
- fs.appendFileSync(sink, JSON.stringify(built.record) + '\n');
2040
- } catch (e) {
2041
- warnWriteFail('correction-records.jsonl (the north-star numerator)', e);
2042
- return json(res, { ok: false, error: 'the document changed but the correction record could NOT be written', wrote }, 500);
2043
- }
2044
- return json(res, { ok: true, wrote, recorded: built.record, sink });
1969
+ if (sink && fs.existsSync(sink)) {
1970
+ rows = fs.readFileSync(sink, 'utf8').split('\n').filter(Boolean)
1971
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
1972
+ }
1973
+ } catch (e) { return json(res, { ok: false, error: 'the change log is unreadable' }, 500); }
1974
+ if (only) rows = rows.filter((r) => r.file === only);
1975
+ return json(res, { ok: true, changes: rows.slice(-200).reverse(), total: rows.length });
2045
1976
  }
2046
1977
  res.writeHead(404); res.end('not found');
2047
1978
  }
@@ -1,127 +0,0 @@
1
- /* correction-record — what a PM disposition leaves behind. (workspace-review-and-edit · R8/R9)
2
- *
3
- * The north star is human correction rate per agent run, and its numerator is currently a
4
- * self-reported integer: all four runs in planning/runs/agent-runs.jsonl carry
5
- * "human_corrections": 0 with no artifact behind the number. correction-signal-derive.skill
6
- * explains why the obvious fix does not reach it — "pre-commit conversational steering is
7
- * git-invisible and stays the self-report's lane."
8
- *
9
- * This module builds the artifact for that lane, under one rule that is not negotiable:
10
- *
11
- * ATTRIBUTION IS NEVER INFERRED.
12
- *
13
- * A record exists only for text an agent PROPOSED in this review and the PM then disposed of.
14
- * That is the single case where authorship is known rather than guessed — no git blame, no
15
- * "the file changed while session X was live" timing heuristic. correction-signal-derive.skill
16
- * states the rule this upholds: "Never guess an attribution. A commit you cannot confidently
17
- * classify is ambiguous: flagged to the PM, held OUT of the score." A record that would need
18
- * inference is not written at all (sprint criterion 7), because a manufactured attribution
19
- * poisons the exact metric this exists to feed.
20
- *
21
- * It stores WHY and BY WHOM. It does not store the diff (R9) — git already holds what changed.
22
- */
23
- 'use strict';
24
- (function (root, factory) {
25
- const api = factory();
26
- if (typeof module !== 'undefined' && module.exports) module.exports = api;
27
- else root.CorrectionRecord = api;
28
- })(typeof self !== 'undefined' ? self : this, function () {
29
-
30
- const DISPOSITIONS = new Set(['apply', 'edit', 'dismiss']);
31
-
32
- /* Reasons the PM taps. Deliberately short — a taxonomy nobody can hold in their head gets
33
- * skipped, and a skipped reason is a record that teaches nothing. 'other' carries free text. */
34
- const REASONS = new Set(['factually-wrong', 'incomplete', 'wrong-scope', 'style', 'not-now', 'other']);
35
-
36
- /* Required on `edit` (the agent was wrong in a way worth knowing) and on `dismiss` only when
37
- * the PM chooses to give one (PRD open question 2: optional on dismiss, required on edit —
38
- * the most common action must not carry the most friction). Never required on `apply`:
39
- * accepting a proposal unchanged is not a correction and must not inflate the numerator. */
40
- function reasonRequired(disposition) {
41
- return disposition === 'edit';
42
- }
43
-
44
- /** Is this a correction at all? Applying an agent's proposal unchanged is agreement. */
45
- function isCorrection(disposition) {
46
- return disposition === 'edit' || disposition === 'dismiss';
47
- }
48
-
49
- const isStr = (v) => typeof v === 'string' && v.trim().length > 0;
50
-
51
- /**
52
- * Build a record from a disposition, or refuse.
53
- * input = { run_id, initiative_id, finding, disposition, pm_text?, reason?, reason_note?, pm, at }
54
- * Returns { ok: true, record } or { ok: false, errors[] }. Never throws, never guesses.
55
- */
56
- function buildRecord(input) {
57
- const e = [];
58
- const i = input || {};
59
-
60
- if (!DISPOSITIONS.has(i.disposition)) e.push(`disposition must be one of ${[...DISPOSITIONS].join(' | ')}`);
61
- if (!i.finding || typeof i.finding !== 'object') e.push('finding is required — the record is anchored to what was proposed');
62
- if (!isStr(i.pm)) e.push('pm is required');
63
- if (!isStr(i.at)) e.push('at (ISO 8601) is required');
64
-
65
- // ── the attribution rule (R8) ───────────────────────────────────────────────
66
- // The agent's text must be present and must have come from THIS review's proposal. Anything
67
- // else would mean deriving authorship from git or from timing, which is exactly forbidden.
68
- const f = i.finding || {};
69
- const agentText = f.proposal && f.proposal.after;
70
- if (i.disposition !== 'dismiss' && !isStr(agentText)) {
71
- e.push('no agent-proposed text on this finding — attribution would have to be inferred, so no record is written (R8)');
72
- }
73
- if (f.withheld === true) {
74
- // A withheld finding was never applyable, so a disposition other than dismiss is incoherent
75
- // and would imply the containment guard leaked.
76
- if (i.disposition !== 'dismiss') e.push('a withheld finding can only be dismissed — it never offered a change (R7)');
77
- }
78
-
79
- if (i.disposition === 'edit' && !isStr(i.pm_text)) {
80
- e.push('pm_text is required on an edit — the record exists to hold what the PM wrote instead');
81
- }
82
- if (reasonRequired(i.disposition) && !REASONS.has(i.reason)) {
83
- e.push(`reason is required on an edit and must be one of ${[...REASONS].join(' | ')}`);
84
- }
85
- if (i.reason != null && !REASONS.has(i.reason)) e.push(`unknown reason '${i.reason}'`);
86
- if (i.reason === 'other' && !isStr(i.reason_note)) e.push("reason 'other' requires reason_note");
87
-
88
- if (e.length) return { ok: false, errors: e };
89
-
90
- return {
91
- ok: true,
92
- record: {
93
- run_id: isStr(i.run_id) ? i.run_id : null,
94
- initiative_id: isStr(i.initiative_id) ? i.initiative_id : null,
95
- file: f.file || null,
96
- finding_title: f.title || null,
97
- disposition: i.disposition,
98
- is_correction: isCorrection(i.disposition),
99
- // attribution: known, because the agent proposed it here and the PM answered it here.
100
- attribution: 'proposed-in-review',
101
- agent_text: isStr(agentText) ? agentText : null,
102
- pm_text: isStr(i.pm_text) ? i.pm_text : null,
103
- reason: i.reason || null,
104
- reason_note: isStr(i.reason_note) ? i.reason_note : null,
105
- pm: i.pm,
106
- at: i.at,
107
- },
108
- };
109
- }
110
-
111
- /** Roll records up into the numbers the PRD's success signal names. No manual counting
112
- * (sprint criterion 8): both come straight off the records. */
113
- function summarise(records) {
114
- const r = Array.isArray(records) ? records : [];
115
- const total = r.length;
116
- const corrections = r.filter((x) => x && x.is_correction).length;
117
- const acted = r.filter((x) => x && (x.disposition === 'apply' || x.disposition === 'edit')).length;
118
- return {
119
- dispositions: total,
120
- corrections,
121
- action_rate: total === 0 ? null : acted / total,
122
- inferred_attributions: r.filter((x) => x && x.attribution !== 'proposed-in-review').length,
123
- };
124
- }
125
-
126
- return { DISPOSITIONS, REASONS, reasonRequired, isCorrection, buildRecord, summarise };
127
- });