create-pmos 0.7.3 → 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 +44 -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 +115 -216
- package/workspace/public/index.html +6 -4
- package/workspace/public/styles.css +54 -83
- package/workspace/server.js +63 -195
- package/workspace/lib/correction-record.js +0 -127
- package/workspace/lib/finding-contract.js +0 -162
- package/workspace/skills/doc-review.skill +0 -119
package/README.md
CHANGED
|
@@ -333,6 +333,50 @@ 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.8.0
|
|
337
|
+
|
|
338
|
+
**The document review is gone. There is a document editor instead.**
|
|
339
|
+
|
|
340
|
+
Minor, not patch: a feature that shipped in 0.7.0–0.7.3 has been **removed**, and what replaces it is
|
|
341
|
+
a different thing with a different point. If you were using the review surface, it is not there any
|
|
342
|
+
more — read on.
|
|
343
|
+
|
|
344
|
+
### Why it went
|
|
345
|
+
|
|
346
|
+
It answered a question nobody asked. An agent read your planning documents and told you what it
|
|
347
|
+
thought was wrong with them, before you had touched anything. What was actually wanted was the
|
|
348
|
+
opposite direction: **you** edit a document, and the tooling reacts to *your* change.
|
|
349
|
+
|
|
350
|
+
**It also never fully worked.** The browser's Edit → Apply path depended on a script the server was
|
|
351
|
+
never configured to serve, so it 404'd silently in every version that shipped it. The tests that
|
|
352
|
+
covered it went straight to the endpoint and never exercised the path a person takes. If you tried to
|
|
353
|
+
edit a proposal and apply it and nothing happened, that was why, and it was our bug.
|
|
354
|
+
|
|
355
|
+
### What replaces it
|
|
356
|
+
|
|
357
|
+
- **Edit any markdown document in the reader.** `edit` in the document header; `save`, `cancel` and
|
|
358
|
+
`history` while you are in it. ⌘S saves.
|
|
359
|
+
- **The diff is beside you as you type** — the replaced lines struck through, the new ones marked,
|
|
360
|
+
with a running `+n −n` and the line the change starts at. Nothing to summon; it appears when the
|
|
361
|
+
document differs from what you opened.
|
|
362
|
+
- **A save that refuses rather than clobbers.** If the file changed on disk since you opened it, the
|
|
363
|
+
save fails and says so, and **your text stays in the editor** so you can copy it out. It will not
|
|
364
|
+
merge over whoever changed it.
|
|
365
|
+
- **Every save is logged, per repository.** What changed, where, and — if you write one — why. It
|
|
366
|
+
lands in your repo's own state directory, never in the installed app. `history` in the document
|
|
367
|
+
header shows that document's own log.
|
|
368
|
+
|
|
369
|
+
The log records the changed region and your reason, **not a parallel history of your repository**.
|
|
370
|
+
`git log` remains the record of what your files contain; this records what you changed and why, which
|
|
371
|
+
git has no place to put.
|
|
372
|
+
|
|
373
|
+
### Not built yet, and named so you are not waiting for it
|
|
374
|
+
|
|
375
|
+
Two halves of the intended feature are **not** in this release: using a save to work out which *other*
|
|
376
|
+
documents are now out of step and drafting the edits that would realign them, and feeding the
|
|
377
|
+
accumulated change log to agents as context. The editor, the diff and the log are here; the
|
|
378
|
+
propagation is not.
|
|
379
|
+
|
|
336
380
|
## What's new in 0.7.3
|
|
337
381
|
|
|
338
382
|
**A fix — read this if you ran a document review on 0.7.1 or 0.7.2, and especially if you have more
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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
|
@@ -102,5 +102,55 @@
|
|
|
102
102
|
return { ok: true, text: r.text, eol: r.eol };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Save a whole edited document (workspace-doc-editor R3).
|
|
107
|
+
* current — the file on disk right now
|
|
108
|
+
* base — the text the editor loaded, i.e. what the PM's edit was made against
|
|
109
|
+
* next — what they wrote
|
|
110
|
+
* Refuses when `current` no longer matches `base`: the document moved under the PM, and writing
|
|
111
|
+
* `next` would silently discard whoever changed it. Same stance as applyChange's stale check —
|
|
112
|
+
* refuse loudly rather than merge — but for the case where the whole document is the unit.
|
|
113
|
+
*/
|
|
114
|
+
function saveWhole(current, base, next) {
|
|
115
|
+
if (typeof current !== 'string' || typeof base !== 'string' || typeof next !== 'string') {
|
|
116
|
+
return { ok: false, reason: 'not-text' };
|
|
117
|
+
}
|
|
118
|
+
if (toLf(current) !== toLf(base)) {
|
|
119
|
+
return { ok: false, reason: 'stale', detail: 'this document changed on disk since you opened it' };
|
|
120
|
+
}
|
|
121
|
+
if (toLf(current) === toLf(next)) return { ok: false, reason: 'no-change' };
|
|
122
|
+
const eol = detectEol(current);
|
|
123
|
+
const out = toLf(next);
|
|
124
|
+
return { ok: true, text: eol === '\n' ? out : out.replace(/\n/g, eol), eol };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The changed REGION of an edit, for the change log (R4/R8).
|
|
129
|
+
*
|
|
130
|
+
* Common leading and trailing lines are trimmed away, leaving the part that actually differs. That
|
|
131
|
+
* is deliberately not a stored diff history — git holds the repository's content, and R4 says so.
|
|
132
|
+
* What this exists for is R8: an agent reading the log should see *what the PM tends to change and
|
|
133
|
+
* how*, which line counts alone cannot convey and whole documents would bury.
|
|
134
|
+
*/
|
|
135
|
+
function changedRegion(base, next, maxLines = 40) {
|
|
136
|
+
const a = toLf(base == null ? '' : base).split('\n');
|
|
137
|
+
const b = toLf(next == null ? '' : next).split('\n');
|
|
138
|
+
let s = 0;
|
|
139
|
+
while (s < a.length && s < b.length && a[s] === b[s]) s++;
|
|
140
|
+
let e = 0;
|
|
141
|
+
while (e < a.length - s && e < b.length - s && a[a.length - 1 - e] === b[b.length - 1 - e]) e++;
|
|
142
|
+
const before = a.slice(s, a.length - e);
|
|
143
|
+
const after = b.slice(s, b.length - e);
|
|
144
|
+
const clip = (arr) => (arr.length > maxLines
|
|
145
|
+
? arr.slice(0, maxLines).concat([`… ${arr.length - maxLines} more line(s)`]) : arr);
|
|
146
|
+
return {
|
|
147
|
+
at_line: s + 1,
|
|
148
|
+
removed: before.length,
|
|
149
|
+
added: after.length,
|
|
150
|
+
before: clip(before).join('\n'),
|
|
151
|
+
after: clip(after).join('\n'),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { applyChange, changeStat, matchesRendered, detectEol, saveWhole, changedRegion };
|
|
106
156
|
});
|
package/workspace/public/app.js
CHANGED
|
@@ -872,7 +872,7 @@ async function openDoc(pth, kind) {
|
|
|
872
872
|
}
|
|
873
873
|
try {
|
|
874
874
|
const res = await fetch(`/api/doc?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`);
|
|
875
|
-
if (!res.ok) { body.innerHTML = `<p class="doc-empty">Could not read <code>${esc(pth)}</code>.</p>`; markActiveDoc(pth); refit(); return; }
|
|
875
|
+
if (!res.ok) { body.innerHTML = `<p class="doc-empty">Could not read <code>${esc(pth)}</code>.</p>`; markActiveDoc(pth); edControls(); refit(); return; }
|
|
876
876
|
const k = res.headers.get('X-Doc-Kind') || 'markdown';
|
|
877
877
|
const text = await res.text();
|
|
878
878
|
if (k === 'html') renderHtmlDoc(body, text);
|
|
@@ -883,8 +883,7 @@ 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
|
-
|
|
887
|
-
$('docReview').hidden = !(state.docPath && /\.(md|markdown|mdown|mkd|mdx|skill)$/i.test(state.docPath));
|
|
886
|
+
edControls();
|
|
888
887
|
refit();
|
|
889
888
|
}
|
|
890
889
|
function markActiveDoc(pth) {
|
|
@@ -931,7 +930,6 @@ function renderHtmlDoc(body, text) {
|
|
|
931
930
|
}
|
|
932
931
|
function closeDoc() {
|
|
933
932
|
state.docPath = null; state.surface = null;
|
|
934
|
-
$('docReview').hidden = true;
|
|
935
933
|
$('paneDoc').hidden = true;
|
|
936
934
|
$('railSplit').hidden = true;
|
|
937
935
|
$('docBody').innerHTML = '';
|
|
@@ -1810,24 +1808,122 @@ $('themeBtn').onclick = () => {
|
|
|
1810
1808
|
};
|
|
1811
1809
|
$('closeDoc').onclick = closeDoc;
|
|
1812
1810
|
|
|
1813
|
-
//
|
|
1814
|
-
//
|
|
1815
|
-
//
|
|
1816
|
-
|
|
1811
|
+
// ── the document editor (workspace-doc-editor) ────────────────────────────────────────────────
|
|
1812
|
+
// The reader gains an edit mode. Nothing an agent produced can reach this path: the PM opens a
|
|
1813
|
+
// document, changes it, and saves. Alignment happens AFTER, from the change — never before it.
|
|
1814
|
+
//
|
|
1815
|
+
// `base` is the text the editor loaded. It travels with the save so the server can refuse if the
|
|
1816
|
+
// file moved on disk in the meantime, rather than quietly discarding whoever changed it.
|
|
1817
|
+
const ed = { on: false, path: null, base: '', dirty: false };
|
|
1818
|
+
|
|
1819
|
+
function edControls() {
|
|
1820
|
+
const isMd = !!(state.docPath && /\.(md|markdown|mdown|mkd|mdx|skill)$/i.test(state.docPath));
|
|
1821
|
+
$('docEdit').hidden = !isMd || ed.on;
|
|
1822
|
+
$('docHistory').hidden = !isMd || ed.on;
|
|
1823
|
+
$('docSave').hidden = !ed.on;
|
|
1824
|
+
$('docCancel').hidden = !ed.on;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
async function edOpen() {
|
|
1817
1828
|
const pth = state.docPath;
|
|
1818
1829
|
if (!pth) return;
|
|
1819
|
-
|
|
1820
|
-
|
|
1830
|
+
let text = '';
|
|
1831
|
+
try {
|
|
1832
|
+
const r = await fetch(`/api/doc?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`);
|
|
1833
|
+
if (!r.ok) { flash('could not open this document for editing'); return; }
|
|
1834
|
+
text = await r.text();
|
|
1835
|
+
} catch { flash('could not reach the workspace server'); return; }
|
|
1836
|
+
|
|
1837
|
+
ed.on = true; ed.path = pth; ed.base = text; ed.dirty = false;
|
|
1838
|
+
$('docBody').innerHTML = `<div class="ed-wrap">
|
|
1839
|
+
<div class="ed-split" data-diff="off">
|
|
1840
|
+
<textarea class="ed-ta" id="edTa" spellcheck="false"></textarea>
|
|
1841
|
+
<div class="ed-diff" id="edDiff"></div>
|
|
1842
|
+
</div>
|
|
1843
|
+
<div class="ed-bar">
|
|
1844
|
+
<span class="n" id="edStat">no changes yet</span>
|
|
1845
|
+
<input id="edNote" placeholder="why (optional — it is what makes the log worth reading)" autocomplete="off" />
|
|
1846
|
+
</div>
|
|
1847
|
+
</div>`;
|
|
1848
|
+
$('edTa').value = text;
|
|
1849
|
+
$('edTa').oninput = edRender;
|
|
1850
|
+
$('edTa').focus();
|
|
1851
|
+
edControls();
|
|
1852
|
+
refit();
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// The diff is a VIEW of the edit in progress, computed from the same module the server saves
|
|
1856
|
+
// through — so what is shown and what is written cannot disagree. It is never stored (R4: git holds
|
|
1857
|
+
// the repository's content; the change log holds what changed and why).
|
|
1858
|
+
function edRender() {
|
|
1859
|
+
const next = $('edTa').value;
|
|
1860
|
+
ed.dirty = next !== ed.base;
|
|
1861
|
+
const r = window.ApplyChange.changedRegion(ed.base, next);
|
|
1862
|
+
const split = $('docBody').querySelector('.ed-split');
|
|
1863
|
+
split.dataset.diff = ed.dirty ? 'on' : 'off';
|
|
1864
|
+
$('edStat').innerHTML = ed.dirty
|
|
1865
|
+
? `<b>+${r.added}</b> <i>−${r.removed}</i> · from line ${r.at_line} · <span class="dirty">unsaved</span>`
|
|
1866
|
+
: 'no changes yet';
|
|
1867
|
+
if (!ed.dirty) { $('edDiff').innerHTML = ''; return; }
|
|
1868
|
+
const rows = [];
|
|
1869
|
+
if (r.before) r.before.split('\n').forEach((l) => rows.push(`<div class="ed-l del"><span class="g"></span><span class="t">${esc(l) || ' '}</span></div>`));
|
|
1870
|
+
if (r.after) r.after.split('\n').forEach((l) => rows.push(`<div class="ed-l add"><span class="g"></span><span class="t">${esc(l) || ' '}</span></div>`));
|
|
1871
|
+
$('edDiff').innerHTML = rows.join('') || '<div class="ed-none">whitespace only</div>';
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
function edClose(reload) {
|
|
1875
|
+
ed.on = false; ed.path = null; ed.base = ''; ed.dirty = false;
|
|
1876
|
+
edControls();
|
|
1877
|
+
if (reload && state.docPath) openDoc(state.docPath);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
async function edSave() {
|
|
1881
|
+
if (!ed.on) return;
|
|
1882
|
+
const text = $('edTa').value;
|
|
1883
|
+
if (text === ed.base) { flash('nothing changed'); return; }
|
|
1884
|
+
const btn = $('docSave'); btn.disabled = true;
|
|
1821
1885
|
let out = {};
|
|
1822
1886
|
try {
|
|
1823
|
-
out = await (await fetch('/api/
|
|
1824
|
-
body: JSON.stringify({ ws: state.wsId, path:
|
|
1887
|
+
out = await (await fetch('/api/doc/save', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1888
|
+
body: JSON.stringify({ ws: state.wsId, path: ed.path, base: ed.base, text, note: $('edNote').value }) })).json();
|
|
1825
1889
|
} catch { out = { ok: false, error: 'could not reach the workspace server' }; }
|
|
1826
|
-
btn.disabled = false;
|
|
1827
|
-
if (!out.ok) {
|
|
1828
|
-
|
|
1829
|
-
|
|
1890
|
+
btn.disabled = false;
|
|
1891
|
+
if (!out.ok) {
|
|
1892
|
+
// Refused — say so where the PM is looking, and DO NOT close: their text is still in the box.
|
|
1893
|
+
const box = $('docBody').querySelector('.ed-wrap');
|
|
1894
|
+
let e = box.querySelector('.ed-err');
|
|
1895
|
+
if (!e) { e = document.createElement('div'); e.className = 'ed-err'; box.insertBefore(e, box.querySelector('.ed-bar')); }
|
|
1896
|
+
e.textContent = out.error === 'stale'
|
|
1897
|
+
? (out.detail || 'this document changed on disk since you opened it') + ' — your text is still here; copy it before reopening.'
|
|
1898
|
+
: (out.detail || out.error || 'the save was refused');
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
flash(out.warning ? 'saved — but the change could not be logged' : `saved · +${out.change.added} −${out.change.removed}`);
|
|
1902
|
+
edClose(true);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
$('docEdit').onclick = edOpen;
|
|
1906
|
+
$('docCancel').onclick = () => edClose(true); // closing IS the whole of "cancel writes nothing"
|
|
1907
|
+
$('docSave').onclick = edSave;
|
|
1908
|
+
$('docHistory').onclick = async () => {
|
|
1909
|
+
const pth = state.docPath; if (!pth) return;
|
|
1910
|
+
let out = {};
|
|
1911
|
+
try { out = await (await fetch(`/api/doc/changes?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`)).json(); }
|
|
1912
|
+
catch { out = { ok: false, error: 'could not reach the workspace server' }; }
|
|
1913
|
+
if (!out.ok) { flash(out.error || 'could not read the change log'); return; }
|
|
1914
|
+
const rows = (out.changes || []).map((c) => `<div class="hist-row">
|
|
1915
|
+
<div class="hist-when"><b>+${c.added}</b> <i>−${c.removed}</i> · line ${c.at_line} · ${esc((c.at || '').replace('T', ' ').replace(/\..*$/, ''))}</div>
|
|
1916
|
+
${c.note ? `<div class="hist-note">${esc(c.note)}</div>` : ''}
|
|
1917
|
+
</div>`).join('');
|
|
1918
|
+
$('docBody').innerHTML = `<div class="hist"><h3>Your changes to this document</h3>
|
|
1919
|
+
<div class="sub">Recorded here as you saved them. The document's own history is <span class="mono">git log</span>; this is what changed and why.</div>
|
|
1920
|
+
${rows || '<div class="hist-none">You have not changed this document here yet.</div>'}</div>`;
|
|
1921
|
+
refit();
|
|
1830
1922
|
};
|
|
1923
|
+
document.addEventListener('keydown', (e) => {
|
|
1924
|
+
if (ed.on && (e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); edSave(); }
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1831
1927
|
|
|
1832
1928
|
// ── ⌘K / Ctrl-K jump palette (client-side: workspaces + sessions + initiatives + docs) ──
|
|
1833
1929
|
// ── on-demand surfaces (Plan · Deliver · Measure · Report) ─────────────────────────────────────
|
|
@@ -1843,11 +1939,6 @@ const SURFACES = [
|
|
|
1843
1939
|
{ name: 'needs', label: 'needs you', phase: 'deliver', console: true },
|
|
1844
1940
|
{ name: 'radar', label: 'radar', phase: 'measure', console: true },
|
|
1845
1941
|
{ name: 'metrics', label: 'metrics', phase: 'measure', console: true },
|
|
1846
|
-
// The summoned document review (workspace-review-and-edit). Summoned like every surface here and
|
|
1847
|
-
// never resident — nothing reviews the PM's documents until they ask. The only INTERACTIVE surface:
|
|
1848
|
-
// each finding is disposed of by Apply / Edit / Dismiss, and each disposition writes a correction
|
|
1849
|
-
// record. Whether an Apply control exists at all is decided by the finding contract, never here.
|
|
1850
|
-
{ name: 'review', label: 'review', phase: 'deliver', review: true },
|
|
1851
1942
|
];
|
|
1852
1943
|
const SURFACE_BY_NAME = {}; SURFACES.forEach((v) => { SURFACE_BY_NAME[v.name] = v; });
|
|
1853
1944
|
const SF_STAT = { planning: ['○', 'planning'], building: ['◐', 'building'], blocked: ['◆', 'blocked'], landed: ['▲', 'landed'] };
|
|
@@ -1856,7 +1947,7 @@ async function openSurface(name) {
|
|
|
1856
1947
|
showRail();
|
|
1857
1948
|
$('paneDoc').hidden = false; $('railSplit').hidden = false; foldPane('doc', true);
|
|
1858
1949
|
state.surface = name; state.docPath = null;
|
|
1859
|
-
|
|
1950
|
+
ed.on = false; edControls();
|
|
1860
1951
|
document.querySelectorAll('.doc').forEach((b) => b.classList.remove('on'));
|
|
1861
1952
|
$('crumb').innerHTML = `<bdi>surface · ${esc(name)}</bdi>`;
|
|
1862
1953
|
$('docBody').scrollTop = 0;
|
|
@@ -1865,18 +1956,15 @@ async function openSurface(name) {
|
|
|
1865
1956
|
$('docBody').innerHTML = `<div class="sf-wrap">${sfHead(name)}<div class="sf-body"><div class="sf-skel"><i></i><i></i><i></i></div></div></div>`;
|
|
1866
1957
|
wireSurface();
|
|
1867
1958
|
const meta = SURFACE_BY_NAME[name] || {};
|
|
1868
|
-
const endpoint = meta.
|
|
1869
|
-
? `/api/review?ws=${encodeURIComponent(state.wsId || '')}`
|
|
1870
|
-
: meta.console
|
|
1959
|
+
const endpoint = meta.console
|
|
1871
1960
|
? `/api/console?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`
|
|
1872
1961
|
: `/api/surface?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`;
|
|
1873
1962
|
try {
|
|
1874
1963
|
const r = await fetch(endpoint);
|
|
1875
1964
|
const payload = await r.json();
|
|
1876
|
-
const body = meta.
|
|
1965
|
+
const body = meta.console ? consoleBody(name, payload) : surfaceBody(name, payload);
|
|
1877
1966
|
const okFlag = payload.ok !== false;
|
|
1878
1967
|
$('docBody').innerHTML = `<div class="sf-wrap">${sfHead(name, okFlag)}<div class="sf-body">${body}</div></div>`;
|
|
1879
|
-
if (meta.review) { state.review = payload; wireReview(); }
|
|
1880
1968
|
} catch {
|
|
1881
1969
|
// A failed FETCH is a broken read — render RED, never a calm "nothing here" (honesty by construction).
|
|
1882
1970
|
$('docBody').innerHTML = `<div class="sf-wrap">${sfHead(name, false)}<div class="sf-body">${csError(name, 'could not reach the workspace server to build this surface')}</div></div>`;
|
|
@@ -1902,195 +1990,6 @@ function wireSurface() {
|
|
|
1902
1990
|
document.querySelectorAll('[data-open-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.openSurface));
|
|
1903
1991
|
$('docBody').querySelectorAll('.sf-art[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
|
|
1904
1992
|
}
|
|
1905
|
-
// ── the summoned review (workspace-review-and-edit) ───────────────────────────────────────────
|
|
1906
|
-
// Renders findings in the quiet register the PM validated on the prototype: no cards, no severity
|
|
1907
|
-
// colours, no taxonomy headers, no explanation of its own guardrails. Each finding is a title and a
|
|
1908
|
-
// source line; the argument and the actions appear on expansion. Detail is AVAILABLE, not ambient.
|
|
1909
|
-
//
|
|
1910
|
-
// The load-bearing line is `f.applyable`. It comes from the SERVER, which asks finding-contract.js —
|
|
1911
|
-
// the client never re-derives it. A withheld finding renders no Apply control at all: the missing
|
|
1912
|
-
// button IS the guard (R7), and a disabled-but-present button would be the failure it exists to stop.
|
|
1913
|
-
function reviewBody(p) {
|
|
1914
|
-
if (!p || p.ok === false) {
|
|
1915
|
-
return csError('review', (p && p.error) || 'the review could not be read')
|
|
1916
|
-
+ (p && p.errors ? `<ul class="rv-errs">${p.errors.map((e) => `<li>${esc(e)}</li>`).join('')}</ul>` : '');
|
|
1917
|
-
}
|
|
1918
|
-
if (p.empty) {
|
|
1919
|
-
return `<div class="rv-empty"><p>No review filed yet.</p>
|
|
1920
|
-
<p class="rv-dim">Run the <b>Review documents</b> skill on the documents you want read.
|
|
1921
|
-
Nothing reviews them until you ask.</p></div>`;
|
|
1922
|
-
}
|
|
1923
|
-
const open = p.findings.filter((f) => !f.settled);
|
|
1924
|
-
const lede = open.length === 0
|
|
1925
|
-
? 'Nothing here that these documents cannot already tell you about themselves.'
|
|
1926
|
-
: `${open.length} thing${open.length === 1 ? '' : 's'} ${open.length === 1 ? 'this document cannot' : 'these documents cannot'} tell you about ${open.length === 1 ? 'itself' : 'themselves'}.`;
|
|
1927
|
-
return `<div class="rv">
|
|
1928
|
-
<div class="rv-lede">${esc(lede)}</div>
|
|
1929
|
-
<div class="rv-sub">Read ${p.read.map((r) => `<span class="mono">${esc(r)}</span>`).join(', ')}.</div>
|
|
1930
|
-
${p.findings.map((f, i) => rvFinding(f, i)).join('')}
|
|
1931
|
-
<div class="rv-tail">Read ${p.read.length} document${p.read.length === 1 ? '' : 's'}. <b>Nothing written.</b>${rvStats(p.stats)}</div>
|
|
1932
|
-
</div>`;
|
|
1933
|
-
}
|
|
1934
|
-
// The PRD's success signal, on the face of the surface. Action rate comes straight off the correction
|
|
1935
|
-
// records. Quiet-rate CANNOT — it needs a corpus with a labelled clean class, which does not exist —
|
|
1936
|
-
// so it says "not measured" rather than rendering a flattering zero. An unmeasured number shown as a
|
|
1937
|
-
// number is the watermelon this whole surface exists to catch.
|
|
1938
|
-
function rvStats(s) {
|
|
1939
|
-
if (!s || !s.dispositions) return '';
|
|
1940
|
-
const pct = (v) => (v == null ? null : `${Math.round(v * 100)}%`);
|
|
1941
|
-
const bits = [
|
|
1942
|
-
`${s.dispositions} disposition${s.dispositions === 1 ? '' : 's'}`,
|
|
1943
|
-
`${s.corrections} correction${s.corrections === 1 ? '' : 's'}`,
|
|
1944
|
-
`acted on ${pct(s.action_rate)}`,
|
|
1945
|
-
`quiet on clean <i>not measured</i>`,
|
|
1946
|
-
];
|
|
1947
|
-
const warn = s.inferred_attributions
|
|
1948
|
-
? ` <b class="rv-bad">${s.inferred_attributions} inferred attribution${s.inferred_attributions === 1 ? '' : 's'}</b>`
|
|
1949
|
-
: '';
|
|
1950
|
-
return `<div class="rv-stats">${bits.join(' · ')}${warn}</div>`;
|
|
1951
|
-
}
|
|
1952
|
-
function rvFinding(f, i) {
|
|
1953
|
-
const ev = (f.evidence || []).map((e) => `${e.file}:${e.line}`).join(' · ');
|
|
1954
|
-
// Three states, and only one of them offers a write. `settled` is terminal for this review.
|
|
1955
|
-
const acts = f.settled
|
|
1956
|
-
? `<span class="rv-only">${esc(f.settled === 'dismiss' ? 'Dismissed.' : f.settled === 'edit' ? 'Edited and applied.' : 'Applied.')}</span>`
|
|
1957
|
-
: f.applyable
|
|
1958
|
-
? `<button data-rv-apply="${i}">Apply</button><button data-rv-edit="${i}">Edit</button><button class="q" data-rv-dismiss="${i}">Dismiss</button>`
|
|
1959
|
-
: `<span class="rv-only">${esc(f.withheld ? 'Yours to settle.' : 'Nothing to apply.')}</span><button class="q" data-rv-dismiss="${i}">Dismiss</button>`;
|
|
1960
|
-
return `<div class="rv-f${f.settled ? ' rv-done' : ''}${f.applyable ? '' : ' rv-quiet'}" data-rv="${i}">
|
|
1961
|
-
<div class="rv-h">${esc(f.title)}</div>
|
|
1962
|
-
<div class="rv-src">${esc(ev || f.file)}</div>
|
|
1963
|
-
<div class="rv-d">
|
|
1964
|
-
<p>${esc(f.argument)}</p>
|
|
1965
|
-
${f.withheld_reason ? `<p class="rv-why">${esc(f.withheld_reason)}</p>` : ''}
|
|
1966
|
-
<div class="rv-acts">${acts}</div>
|
|
1967
|
-
</div>
|
|
1968
|
-
<div class="rv-ed" data-rv-edbox="${i}"></div>
|
|
1969
|
-
</div>`;
|
|
1970
|
-
}
|
|
1971
|
-
// The edit surface: opens IN PLACE under the finding — no modal, no mode switch, no navigation.
|
|
1972
|
-
// Gutter change bars are the VS Code cue; the replaced lines sit struck through above the new ones.
|
|
1973
|
-
// Cancel needs no write path at all — it just closes, which is the whole of "Cancel writes nothing".
|
|
1974
|
-
// The reason taxonomy, deliberately short. A vocabulary nobody can hold in their head gets skipped,
|
|
1975
|
-
// and a skipped reason is a record that teaches nothing. Values match correction-record.js's REASONS;
|
|
1976
|
-
// the server rejects anything outside it, so this list can never drift into inventing one.
|
|
1977
|
-
const RV_REASONS = [
|
|
1978
|
-
['factually-wrong', 'factually wrong'], ['incomplete', 'incomplete'],
|
|
1979
|
-
['wrong-scope', 'wrong scope'], ['style', 'style'], ['other', 'other…'],
|
|
1980
|
-
];
|
|
1981
|
-
function rvEditor(f, i) {
|
|
1982
|
-
const before = (f.proposal && f.proposal.before) || '';
|
|
1983
|
-
const after = (f.proposal && f.proposal.after) || '';
|
|
1984
|
-
const nl = (s) => (s ? s.split('\n').length : 0);
|
|
1985
|
-
return `<div class="rv-edfile"><span class="mono">${esc(f.file)}</span>
|
|
1986
|
-
<span class="rv-n"><b>+${nl(after)}</b> <i>−${nl(before)}</i></span></div>
|
|
1987
|
-
<div class="rv-code">
|
|
1988
|
-
${before.split('\n').map((l) => `<div class="rv-l was"><span class="g"></span><span class="t">${esc(l)}</span></div>`).join('')}
|
|
1989
|
-
</div>
|
|
1990
|
-
<textarea class="rv-ta" data-rv-ta="${i}" spellcheck="false" rows="${Math.min(12, Math.max(3, nl(after) + 1))}">${esc(after)}</textarea>
|
|
1991
|
-
<div class="rv-reason" data-rv-reason="${i}">
|
|
1992
|
-
<span class="rv-only">What was wrong with it?</span>
|
|
1993
|
-
${RV_REASONS.map((r) => `<button class="rv-rz" data-rv-rz="${i}" data-val="${r[0]}">${esc(r[1])}</button>`).join('')}
|
|
1994
|
-
</div>
|
|
1995
|
-
<input class="rv-note" data-rv-note="${i}" hidden placeholder="in a few words…" aria-label="Why" autocomplete="off" />
|
|
1996
|
-
<div class="rv-acts">
|
|
1997
|
-
<button data-rv-save="${i}">Apply</button>
|
|
1998
|
-
<button class="q" data-rv-cancel="${i}">Cancel</button>
|
|
1999
|
-
<span class="rv-only">You are editing the change, not the file.</span>
|
|
2000
|
-
</div>`;
|
|
2001
|
-
}
|
|
2002
|
-
function wireReview() {
|
|
2003
|
-
const rv = state.review || {};
|
|
2004
|
-
const F = (n) => (rv.findings || [])[Number(n)];
|
|
2005
|
-
const box = (n) => $('docBody').querySelector(`[data-rv-edbox="${n}"]`);
|
|
2006
|
-
const card = (n) => $('docBody').querySelector(`[data-rv="${n}"]`);
|
|
2007
|
-
|
|
2008
|
-
$('docBody').querySelectorAll('.rv-f').forEach((el) => {
|
|
2009
|
-
el.addEventListener('click', (e) => {
|
|
2010
|
-
if (e.target.closest('button') || e.target.closest('textarea')) return;
|
|
2011
|
-
if (el.dataset.editing === '1') return; // never collapse an open editor by clicking it
|
|
2012
|
-
el.classList.toggle('on');
|
|
2013
|
-
});
|
|
2014
|
-
});
|
|
2015
|
-
$('docBody').querySelectorAll('[data-rv-edit]').forEach((b) => b.onclick = () => {
|
|
2016
|
-
const n = b.dataset.rvEdit;
|
|
2017
|
-
box(n).innerHTML = rvEditor(F(n), n);
|
|
2018
|
-
card(n).dataset.editing = '1';
|
|
2019
|
-
wireReview(); // re-wire the freshly rendered controls
|
|
2020
|
-
const ta = $('docBody').querySelector(`[data-rv-ta="${n}"]`); if (ta) ta.focus();
|
|
2021
|
-
});
|
|
2022
|
-
$('docBody').querySelectorAll('[data-rv-cancel]').forEach((b) => b.onclick = () => {
|
|
2023
|
-
const n = b.dataset.rvCancel;
|
|
2024
|
-
box(n).innerHTML = ''; card(n).dataset.editing = '0'; // closing IS the whole of "writes nothing"
|
|
2025
|
-
});
|
|
2026
|
-
$('docBody').querySelectorAll('[data-rv-apply]').forEach((b) => b.onclick = () => rvDispose(F(b.dataset.rvApply), 'apply'));
|
|
2027
|
-
$('docBody').querySelectorAll('[data-rv-dismiss]').forEach((b) => b.onclick = () => rvDispose(F(b.dataset.rvDismiss), 'dismiss'));
|
|
2028
|
-
// The reason tap. Required on an edit (R8 / correction-record.js) — an edit is where the agent was
|
|
2029
|
-
// wrong in a way worth knowing, and a record that cannot say HOW is the self-report this feature
|
|
2030
|
-
// exists to replace. Dismiss stays one click: friction belongs on the informative action, not the
|
|
2031
|
-
// common one (PRD open question 2).
|
|
2032
|
-
$('docBody').querySelectorAll('[data-rv-rz]').forEach((b) => b.onclick = () => {
|
|
2033
|
-
const n = b.dataset.rvRz;
|
|
2034
|
-
const row = $('docBody').querySelector(`[data-rv-reason="${n}"]`);
|
|
2035
|
-
row.querySelectorAll('.rv-rz').forEach((x) => x.classList.toggle('on', x === b));
|
|
2036
|
-
row.dataset.picked = b.dataset.val;
|
|
2037
|
-
const note = $('docBody').querySelector(`[data-rv-note="${n}"]`);
|
|
2038
|
-
note.hidden = b.dataset.val !== 'other'; // 'other' is only useful with words
|
|
2039
|
-
if (b.dataset.val === 'other') note.focus();
|
|
2040
|
-
const nudge = row.querySelector('.rv-nudge'); if (nudge) nudge.remove();
|
|
2041
|
-
});
|
|
2042
|
-
$('docBody').querySelectorAll('[data-rv-save]').forEach((b) => b.onclick = () => {
|
|
2043
|
-
const n = b.dataset.rvSave;
|
|
2044
|
-
const row = $('docBody').querySelector(`[data-rv-reason="${n}"]`);
|
|
2045
|
-
const picked = row && row.dataset.picked;
|
|
2046
|
-
if (!picked) {
|
|
2047
|
-
// An inline nudge, not a modal and not a silent default. Defaulting is what produced the bug
|
|
2048
|
-
// this replaces: every edit recorded "incomplete" whether or not it was.
|
|
2049
|
-
if (!row.querySelector('.rv-nudge')) {
|
|
2050
|
-
const s = document.createElement('span');
|
|
2051
|
-
s.className = 'rv-nudge'; s.textContent = 'Pick one — the record is worth nothing without it.';
|
|
2052
|
-
row.appendChild(s);
|
|
2053
|
-
}
|
|
2054
|
-
return;
|
|
2055
|
-
}
|
|
2056
|
-
const note = $('docBody').querySelector(`[data-rv-note="${n}"]`);
|
|
2057
|
-
const ta = $('docBody').querySelector(`[data-rv-ta="${n}"]`);
|
|
2058
|
-
rvDispose(F(n), 'edit', ta ? ta.value : '', picked, note && !note.hidden ? note.value : undefined);
|
|
2059
|
-
});
|
|
2060
|
-
}
|
|
2061
|
-
async function rvDispose(f, disposition, pmText, reason, reasonNote) {
|
|
2062
|
-
if (!f) return;
|
|
2063
|
-
const rv = state.review || {};
|
|
2064
|
-
// The client sends back the exact text it rendered; the server re-derives the diff and refuses if
|
|
2065
|
-
// they differ (sprint criterion 4). That is what makes a stale or tampered client safe.
|
|
2066
|
-
let renderedAfter = null;
|
|
2067
|
-
if (disposition !== 'dismiss') {
|
|
2068
|
-
try {
|
|
2069
|
-
const r = await fetch(`/api/doc?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(f.file)}`);
|
|
2070
|
-
const cur = await r.text();
|
|
2071
|
-
const prop = disposition === 'edit' ? { before: f.proposal.before, after: pmText } : f.proposal;
|
|
2072
|
-
renderedAfter = window.ApplyChange.applyChange(cur, prop).text;
|
|
2073
|
-
} catch { /* leave null — the server refuses rather than guessing */ }
|
|
2074
|
-
}
|
|
2075
|
-
const body = {
|
|
2076
|
-
ws: state.wsId, disposition, finding: f, rendered_after: renderedAfter,
|
|
2077
|
-
pm_text: disposition === 'edit' ? pmText : undefined,
|
|
2078
|
-
reason, reason_note: reasonNote, // the PM's own word — never defaulted, never inferred
|
|
2079
|
-
run_id: rv.id, initiative_id: 'workspace-review-and-edit',
|
|
2080
|
-
};
|
|
2081
|
-
let out = {};
|
|
2082
|
-
try { out = await (await fetch('/api/review/dispose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json(); }
|
|
2083
|
-
catch { out = { ok: false, error: 'could not reach the workspace server' }; }
|
|
2084
|
-
if (!out.ok) { rvSay(out.error || 'refused', out.detail); return; }
|
|
2085
|
-
openSurface('review'); // re-read: the record is the source of truth
|
|
2086
|
-
}
|
|
2087
|
-
function rvSay(msg, detail) {
|
|
2088
|
-
const el = document.createElement('div');
|
|
2089
|
-
el.className = 'rv-say';
|
|
2090
|
-
el.textContent = detail ? `${msg} — ${detail}` : msg;
|
|
2091
|
-
$('docBody').querySelector('.rv') ? $('docBody').querySelector('.rv').prepend(el) : $('docBody').prepend(el);
|
|
2092
|
-
}
|
|
2093
|
-
|
|
2094
1993
|
function surfaceBody(name, payload) {
|
|
2095
1994
|
if (!payload || !payload.ok) return sfCantProduce(name, payload && payload.contract);
|
|
2096
1995
|
const s = payload.surface || {};
|
|
@@ -187,7 +187,10 @@
|
|
|
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="
|
|
190
|
+
<button class="mini docrev" id="docEdit" hidden title="Edit this document">edit</button>
|
|
191
|
+
<button class="mini docrev" id="docSave" hidden title="Save (⌘S)">save</button>
|
|
192
|
+
<button class="mini docrev" id="docCancel" hidden title="Discard changes">cancel</button>
|
|
193
|
+
<button class="mini docrev" id="docHistory" hidden title="Changes you have made to this document">history</button>
|
|
191
194
|
<button class="x" id="closeDoc" aria-label="Close document">✕</button>
|
|
192
195
|
</div>
|
|
193
196
|
<div class="pane-body doc-body" id="docBody" tabindex="0"></div>
|
|
@@ -257,9 +260,8 @@
|
|
|
257
260
|
<script src="/vendor/addon-web-links.js"></script>
|
|
258
261
|
<script src="/lib/preview-kind.js"></script>
|
|
259
262
|
<script src="/lib/layout.js"></script>
|
|
260
|
-
<!--
|
|
261
|
-
|
|
262
|
-
<script src="/lib/finding-contract.js"></script>
|
|
263
|
+
<!-- The editor computes its diff with the SAME module the server saves through, so what you see
|
|
264
|
+
while editing and what the server writes can never disagree. -->
|
|
263
265
|
<script src="/lib/apply-change.js"></script>
|
|
264
266
|
<script src="/app.js"></script>
|
|
265
267
|
</body>
|