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.
- package/README.md +83 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/lib/apply-change.js +51 -1
- package/workspace/public/app.js +122 -200
- package/workspace/public/index.html +6 -3
- package/workspace/public/styles.css +54 -83
- package/workspace/server.js +64 -133
- package/workspace/lib/correction-record.js +0 -127
- package/workspace/lib/finding-contract.js +0 -162
- package/workspace/skills/doc-review.skill +0 -114
|
@@ -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
|
-
/*
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
.
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
.
|
|
938
|
-
.
|
|
939
|
-
.
|
|
940
|
-
.
|
|
941
|
-
.
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
.
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
.
|
|
948
|
-
|
|
949
|
-
.
|
|
950
|
-
.
|
|
951
|
-
.
|
|
952
|
-
.
|
|
953
|
-
.
|
|
954
|
-
.
|
|
955
|
-
.
|
|
956
|
-
|
|
957
|
-
.
|
|
958
|
-
.
|
|
959
|
-
.
|
|
960
|
-
.
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
.
|
|
964
|
-
.
|
|
965
|
-
|
|
966
|
-
.
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
.
|
|
972
|
-
.
|
|
973
|
-
.
|
|
974
|
-
.
|
|
975
|
-
.
|
|
976
|
-
.
|
|
977
|
-
.
|
|
978
|
-
|
|
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}
|
package/workspace/server.js
CHANGED
|
@@ -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
|
|
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/
|
|
1906
|
-
//
|
|
1907
|
-
// the
|
|
1908
|
-
//
|
|
1909
|
-
|
|
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
|
-
//
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
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
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
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
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
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
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
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
|
-
//
|
|
2035
|
-
|
|
2036
|
-
|
|
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.
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
return json(res, { ok: true,
|
|
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
|
-
});
|