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
package/README.md
CHANGED
|
@@ -333,6 +333,89 @@ 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
|
+
|
|
380
|
+
## What's new in 0.7.3
|
|
381
|
+
|
|
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
|
|
383
|
+
than one repository open in the workspace.**
|
|
384
|
+
|
|
385
|
+
Reviews were filed into the **workspace app's own install directory** rather than into the repository
|
|
386
|
+
being reviewed. Two consequences, and the first is the one that matters:
|
|
387
|
+
|
|
388
|
+
- **Reviews from different repositories were pooled together.** Open two repos and the review you
|
|
389
|
+
filed against one appeared when you opened the review surface in the other. A review names the
|
|
390
|
+
documents it read and quotes their contents, so this put one project's material in front of
|
|
391
|
+
another — including in front of an agent session working in that second repo.
|
|
392
|
+
- **In a product repo using this kit, reviews were written nowhere useful.** The review skill was
|
|
393
|
+
written against PMOS's own layout and told the agent to write to `workspace/state/reviews/` and
|
|
394
|
+
read `workspace/state/needs.json`. **A product repo has no `workspace/` folder**, so the agent
|
|
395
|
+
either created a stray one or filed the review where nothing would ever read it.
|
|
396
|
+
|
|
397
|
+
**Both are fixed.** Reviews now resolve through the same per-workspace state directory that
|
|
398
|
+
`needs.json` and `acceptances.jsonl` already used — your repo's own `pmos/state/reviews/`. The skill
|
|
399
|
+
no longer assumes any layout: the paths it needs are passed to it as absolute facts.
|
|
400
|
+
|
|
401
|
+
**If you reviewed anything on 0.7.1 or 0.7.2**, look in the workspace app's install directory under
|
|
402
|
+
`workspace/state/reviews/` for reviews that belong to your repositories, and delete any you would
|
|
403
|
+
rather not have sitting there. Nothing was sent anywhere — this is local files in the wrong folder —
|
|
404
|
+
but if you had two repos open, they saw each other.
|
|
405
|
+
|
|
406
|
+
**And the reason you probably never ran one: there was no way to say what to review.** The skill could
|
|
407
|
+
only be launched from the scheduled lens, and a schedule carries no scope — so its own instruction to
|
|
408
|
+
read exactly the documents you named had nothing behind it.
|
|
409
|
+
|
|
410
|
+
- **A `review` control now sits on the open document.** You are reading a document; you click
|
|
411
|
+
`review`; an agent reviews *that* document. Nothing to type, no schedule to create. The scope is
|
|
412
|
+
the thing in front of you.
|
|
413
|
+
|
|
414
|
+
**The standing disclosure is unchanged**, and it still matters more than any of the above: the review
|
|
415
|
+
surface has **not** been through its own review gate, and its false-positive rate is unmeasured. The
|
|
416
|
+
guard that withholds any proposal which would close a recorded open question or overwrite a decision
|
|
417
|
+
is tested, including in a real browser. How often its findings are simply *wrong* is not yet known.
|
|
418
|
+
|
|
336
419
|
## What's new in 0.7.2
|
|
337
420
|
|
|
338
421
|
**A fix — read this if you use the Terminal Workspace on a machine with `tmux` installed.**
|
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,6 +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
|
+
edControls();
|
|
886
887
|
refit();
|
|
887
888
|
}
|
|
888
889
|
function markActiveDoc(pth) {
|
|
@@ -1807,6 +1808,123 @@ $('themeBtn').onclick = () => {
|
|
|
1807
1808
|
};
|
|
1808
1809
|
$('closeDoc').onclick = closeDoc;
|
|
1809
1810
|
|
|
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() {
|
|
1828
|
+
const pth = state.docPath;
|
|
1829
|
+
if (!pth) return;
|
|
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;
|
|
1885
|
+
let out = {};
|
|
1886
|
+
try {
|
|
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();
|
|
1889
|
+
} catch { out = { ok: false, error: 'could not reach the workspace server' }; }
|
|
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();
|
|
1922
|
+
};
|
|
1923
|
+
document.addEventListener('keydown', (e) => {
|
|
1924
|
+
if (ed.on && (e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); edSave(); }
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
|
|
1810
1928
|
// ── ⌘K / Ctrl-K jump palette (client-side: workspaces + sessions + initiatives + docs) ──
|
|
1811
1929
|
// ── on-demand surfaces (Plan · Deliver · Measure · Report) ─────────────────────────────────────
|
|
1812
1930
|
// Summoned from ⌘K, rendered in the reader, derived through the server's local state adapter and
|
|
@@ -1821,11 +1939,6 @@ const SURFACES = [
|
|
|
1821
1939
|
{ name: 'needs', label: 'needs you', phase: 'deliver', console: true },
|
|
1822
1940
|
{ name: 'radar', label: 'radar', phase: 'measure', console: true },
|
|
1823
1941
|
{ name: 'metrics', label: 'metrics', phase: 'measure', console: true },
|
|
1824
|
-
// The summoned document review (workspace-review-and-edit). Summoned like every surface here and
|
|
1825
|
-
// never resident — nothing reviews the PM's documents until they ask. The only INTERACTIVE surface:
|
|
1826
|
-
// each finding is disposed of by Apply / Edit / Dismiss, and each disposition writes a correction
|
|
1827
|
-
// record. Whether an Apply control exists at all is decided by the finding contract, never here.
|
|
1828
|
-
{ name: 'review', label: 'review', phase: 'deliver', review: true },
|
|
1829
1942
|
];
|
|
1830
1943
|
const SURFACE_BY_NAME = {}; SURFACES.forEach((v) => { SURFACE_BY_NAME[v.name] = v; });
|
|
1831
1944
|
const SF_STAT = { planning: ['○', 'planning'], building: ['◐', 'building'], blocked: ['◆', 'blocked'], landed: ['▲', 'landed'] };
|
|
@@ -1834,6 +1947,7 @@ async function openSurface(name) {
|
|
|
1834
1947
|
showRail();
|
|
1835
1948
|
$('paneDoc').hidden = false; $('railSplit').hidden = false; foldPane('doc', true);
|
|
1836
1949
|
state.surface = name; state.docPath = null;
|
|
1950
|
+
ed.on = false; edControls();
|
|
1837
1951
|
document.querySelectorAll('.doc').forEach((b) => b.classList.remove('on'));
|
|
1838
1952
|
$('crumb').innerHTML = `<bdi>surface · ${esc(name)}</bdi>`;
|
|
1839
1953
|
$('docBody').scrollTop = 0;
|
|
@@ -1842,18 +1956,15 @@ async function openSurface(name) {
|
|
|
1842
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>`;
|
|
1843
1957
|
wireSurface();
|
|
1844
1958
|
const meta = SURFACE_BY_NAME[name] || {};
|
|
1845
|
-
const endpoint = meta.
|
|
1846
|
-
? `/api/review?ws=${encodeURIComponent(state.wsId || '')}`
|
|
1847
|
-
: meta.console
|
|
1959
|
+
const endpoint = meta.console
|
|
1848
1960
|
? `/api/console?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`
|
|
1849
1961
|
: `/api/surface?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`;
|
|
1850
1962
|
try {
|
|
1851
1963
|
const r = await fetch(endpoint);
|
|
1852
1964
|
const payload = await r.json();
|
|
1853
|
-
const body = meta.
|
|
1965
|
+
const body = meta.console ? consoleBody(name, payload) : surfaceBody(name, payload);
|
|
1854
1966
|
const okFlag = payload.ok !== false;
|
|
1855
1967
|
$('docBody').innerHTML = `<div class="sf-wrap">${sfHead(name, okFlag)}<div class="sf-body">${body}</div></div>`;
|
|
1856
|
-
if (meta.review) { state.review = payload; wireReview(); }
|
|
1857
1968
|
} catch {
|
|
1858
1969
|
// A failed FETCH is a broken read — render RED, never a calm "nothing here" (honesty by construction).
|
|
1859
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>`;
|
|
@@ -1879,195 +1990,6 @@ function wireSurface() {
|
|
|
1879
1990
|
document.querySelectorAll('[data-open-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.openSurface));
|
|
1880
1991
|
$('docBody').querySelectorAll('.sf-art[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
|
|
1881
1992
|
}
|
|
1882
|
-
// ── the summoned review (workspace-review-and-edit) ───────────────────────────────────────────
|
|
1883
|
-
// Renders findings in the quiet register the PM validated on the prototype: no cards, no severity
|
|
1884
|
-
// colours, no taxonomy headers, no explanation of its own guardrails. Each finding is a title and a
|
|
1885
|
-
// source line; the argument and the actions appear on expansion. Detail is AVAILABLE, not ambient.
|
|
1886
|
-
//
|
|
1887
|
-
// The load-bearing line is `f.applyable`. It comes from the SERVER, which asks finding-contract.js —
|
|
1888
|
-
// the client never re-derives it. A withheld finding renders no Apply control at all: the missing
|
|
1889
|
-
// button IS the guard (R7), and a disabled-but-present button would be the failure it exists to stop.
|
|
1890
|
-
function reviewBody(p) {
|
|
1891
|
-
if (!p || p.ok === false) {
|
|
1892
|
-
return csError('review', (p && p.error) || 'the review could not be read')
|
|
1893
|
-
+ (p && p.errors ? `<ul class="rv-errs">${p.errors.map((e) => `<li>${esc(e)}</li>`).join('')}</ul>` : '');
|
|
1894
|
-
}
|
|
1895
|
-
if (p.empty) {
|
|
1896
|
-
return `<div class="rv-empty"><p>No review filed yet.</p>
|
|
1897
|
-
<p class="rv-dim">Run the <b>Review documents</b> skill on the documents you want read.
|
|
1898
|
-
Nothing reviews them until you ask.</p></div>`;
|
|
1899
|
-
}
|
|
1900
|
-
const open = p.findings.filter((f) => !f.settled);
|
|
1901
|
-
const lede = open.length === 0
|
|
1902
|
-
? 'Nothing here that these documents cannot already tell you about themselves.'
|
|
1903
|
-
: `${open.length} thing${open.length === 1 ? '' : 's'} ${open.length === 1 ? 'this document cannot' : 'these documents cannot'} tell you about ${open.length === 1 ? 'itself' : 'themselves'}.`;
|
|
1904
|
-
return `<div class="rv">
|
|
1905
|
-
<div class="rv-lede">${esc(lede)}</div>
|
|
1906
|
-
<div class="rv-sub">Read ${p.read.map((r) => `<span class="mono">${esc(r)}</span>`).join(', ')}.</div>
|
|
1907
|
-
${p.findings.map((f, i) => rvFinding(f, i)).join('')}
|
|
1908
|
-
<div class="rv-tail">Read ${p.read.length} document${p.read.length === 1 ? '' : 's'}. <b>Nothing written.</b>${rvStats(p.stats)}</div>
|
|
1909
|
-
</div>`;
|
|
1910
|
-
}
|
|
1911
|
-
// The PRD's success signal, on the face of the surface. Action rate comes straight off the correction
|
|
1912
|
-
// records. Quiet-rate CANNOT — it needs a corpus with a labelled clean class, which does not exist —
|
|
1913
|
-
// so it says "not measured" rather than rendering a flattering zero. An unmeasured number shown as a
|
|
1914
|
-
// number is the watermelon this whole surface exists to catch.
|
|
1915
|
-
function rvStats(s) {
|
|
1916
|
-
if (!s || !s.dispositions) return '';
|
|
1917
|
-
const pct = (v) => (v == null ? null : `${Math.round(v * 100)}%`);
|
|
1918
|
-
const bits = [
|
|
1919
|
-
`${s.dispositions} disposition${s.dispositions === 1 ? '' : 's'}`,
|
|
1920
|
-
`${s.corrections} correction${s.corrections === 1 ? '' : 's'}`,
|
|
1921
|
-
`acted on ${pct(s.action_rate)}`,
|
|
1922
|
-
`quiet on clean <i>not measured</i>`,
|
|
1923
|
-
];
|
|
1924
|
-
const warn = s.inferred_attributions
|
|
1925
|
-
? ` <b class="rv-bad">${s.inferred_attributions} inferred attribution${s.inferred_attributions === 1 ? '' : 's'}</b>`
|
|
1926
|
-
: '';
|
|
1927
|
-
return `<div class="rv-stats">${bits.join(' · ')}${warn}</div>`;
|
|
1928
|
-
}
|
|
1929
|
-
function rvFinding(f, i) {
|
|
1930
|
-
const ev = (f.evidence || []).map((e) => `${e.file}:${e.line}`).join(' · ');
|
|
1931
|
-
// Three states, and only one of them offers a write. `settled` is terminal for this review.
|
|
1932
|
-
const acts = f.settled
|
|
1933
|
-
? `<span class="rv-only">${esc(f.settled === 'dismiss' ? 'Dismissed.' : f.settled === 'edit' ? 'Edited and applied.' : 'Applied.')}</span>`
|
|
1934
|
-
: f.applyable
|
|
1935
|
-
? `<button data-rv-apply="${i}">Apply</button><button data-rv-edit="${i}">Edit</button><button class="q" data-rv-dismiss="${i}">Dismiss</button>`
|
|
1936
|
-
: `<span class="rv-only">${esc(f.withheld ? 'Yours to settle.' : 'Nothing to apply.')}</span><button class="q" data-rv-dismiss="${i}">Dismiss</button>`;
|
|
1937
|
-
return `<div class="rv-f${f.settled ? ' rv-done' : ''}${f.applyable ? '' : ' rv-quiet'}" data-rv="${i}">
|
|
1938
|
-
<div class="rv-h">${esc(f.title)}</div>
|
|
1939
|
-
<div class="rv-src">${esc(ev || f.file)}</div>
|
|
1940
|
-
<div class="rv-d">
|
|
1941
|
-
<p>${esc(f.argument)}</p>
|
|
1942
|
-
${f.withheld_reason ? `<p class="rv-why">${esc(f.withheld_reason)}</p>` : ''}
|
|
1943
|
-
<div class="rv-acts">${acts}</div>
|
|
1944
|
-
</div>
|
|
1945
|
-
<div class="rv-ed" data-rv-edbox="${i}"></div>
|
|
1946
|
-
</div>`;
|
|
1947
|
-
}
|
|
1948
|
-
// The edit surface: opens IN PLACE under the finding — no modal, no mode switch, no navigation.
|
|
1949
|
-
// Gutter change bars are the VS Code cue; the replaced lines sit struck through above the new ones.
|
|
1950
|
-
// Cancel needs no write path at all — it just closes, which is the whole of "Cancel writes nothing".
|
|
1951
|
-
// The reason taxonomy, deliberately short. A vocabulary nobody can hold in their head gets skipped,
|
|
1952
|
-
// and a skipped reason is a record that teaches nothing. Values match correction-record.js's REASONS;
|
|
1953
|
-
// the server rejects anything outside it, so this list can never drift into inventing one.
|
|
1954
|
-
const RV_REASONS = [
|
|
1955
|
-
['factually-wrong', 'factually wrong'], ['incomplete', 'incomplete'],
|
|
1956
|
-
['wrong-scope', 'wrong scope'], ['style', 'style'], ['other', 'other…'],
|
|
1957
|
-
];
|
|
1958
|
-
function rvEditor(f, i) {
|
|
1959
|
-
const before = (f.proposal && f.proposal.before) || '';
|
|
1960
|
-
const after = (f.proposal && f.proposal.after) || '';
|
|
1961
|
-
const nl = (s) => (s ? s.split('\n').length : 0);
|
|
1962
|
-
return `<div class="rv-edfile"><span class="mono">${esc(f.file)}</span>
|
|
1963
|
-
<span class="rv-n"><b>+${nl(after)}</b> <i>−${nl(before)}</i></span></div>
|
|
1964
|
-
<div class="rv-code">
|
|
1965
|
-
${before.split('\n').map((l) => `<div class="rv-l was"><span class="g"></span><span class="t">${esc(l)}</span></div>`).join('')}
|
|
1966
|
-
</div>
|
|
1967
|
-
<textarea class="rv-ta" data-rv-ta="${i}" spellcheck="false" rows="${Math.min(12, Math.max(3, nl(after) + 1))}">${esc(after)}</textarea>
|
|
1968
|
-
<div class="rv-reason" data-rv-reason="${i}">
|
|
1969
|
-
<span class="rv-only">What was wrong with it?</span>
|
|
1970
|
-
${RV_REASONS.map((r) => `<button class="rv-rz" data-rv-rz="${i}" data-val="${r[0]}">${esc(r[1])}</button>`).join('')}
|
|
1971
|
-
</div>
|
|
1972
|
-
<input class="rv-note" data-rv-note="${i}" hidden placeholder="in a few words…" aria-label="Why" autocomplete="off" />
|
|
1973
|
-
<div class="rv-acts">
|
|
1974
|
-
<button data-rv-save="${i}">Apply</button>
|
|
1975
|
-
<button class="q" data-rv-cancel="${i}">Cancel</button>
|
|
1976
|
-
<span class="rv-only">You are editing the change, not the file.</span>
|
|
1977
|
-
</div>`;
|
|
1978
|
-
}
|
|
1979
|
-
function wireReview() {
|
|
1980
|
-
const rv = state.review || {};
|
|
1981
|
-
const F = (n) => (rv.findings || [])[Number(n)];
|
|
1982
|
-
const box = (n) => $('docBody').querySelector(`[data-rv-edbox="${n}"]`);
|
|
1983
|
-
const card = (n) => $('docBody').querySelector(`[data-rv="${n}"]`);
|
|
1984
|
-
|
|
1985
|
-
$('docBody').querySelectorAll('.rv-f').forEach((el) => {
|
|
1986
|
-
el.addEventListener('click', (e) => {
|
|
1987
|
-
if (e.target.closest('button') || e.target.closest('textarea')) return;
|
|
1988
|
-
if (el.dataset.editing === '1') return; // never collapse an open editor by clicking it
|
|
1989
|
-
el.classList.toggle('on');
|
|
1990
|
-
});
|
|
1991
|
-
});
|
|
1992
|
-
$('docBody').querySelectorAll('[data-rv-edit]').forEach((b) => b.onclick = () => {
|
|
1993
|
-
const n = b.dataset.rvEdit;
|
|
1994
|
-
box(n).innerHTML = rvEditor(F(n), n);
|
|
1995
|
-
card(n).dataset.editing = '1';
|
|
1996
|
-
wireReview(); // re-wire the freshly rendered controls
|
|
1997
|
-
const ta = $('docBody').querySelector(`[data-rv-ta="${n}"]`); if (ta) ta.focus();
|
|
1998
|
-
});
|
|
1999
|
-
$('docBody').querySelectorAll('[data-rv-cancel]').forEach((b) => b.onclick = () => {
|
|
2000
|
-
const n = b.dataset.rvCancel;
|
|
2001
|
-
box(n).innerHTML = ''; card(n).dataset.editing = '0'; // closing IS the whole of "writes nothing"
|
|
2002
|
-
});
|
|
2003
|
-
$('docBody').querySelectorAll('[data-rv-apply]').forEach((b) => b.onclick = () => rvDispose(F(b.dataset.rvApply), 'apply'));
|
|
2004
|
-
$('docBody').querySelectorAll('[data-rv-dismiss]').forEach((b) => b.onclick = () => rvDispose(F(b.dataset.rvDismiss), 'dismiss'));
|
|
2005
|
-
// The reason tap. Required on an edit (R8 / correction-record.js) — an edit is where the agent was
|
|
2006
|
-
// wrong in a way worth knowing, and a record that cannot say HOW is the self-report this feature
|
|
2007
|
-
// exists to replace. Dismiss stays one click: friction belongs on the informative action, not the
|
|
2008
|
-
// common one (PRD open question 2).
|
|
2009
|
-
$('docBody').querySelectorAll('[data-rv-rz]').forEach((b) => b.onclick = () => {
|
|
2010
|
-
const n = b.dataset.rvRz;
|
|
2011
|
-
const row = $('docBody').querySelector(`[data-rv-reason="${n}"]`);
|
|
2012
|
-
row.querySelectorAll('.rv-rz').forEach((x) => x.classList.toggle('on', x === b));
|
|
2013
|
-
row.dataset.picked = b.dataset.val;
|
|
2014
|
-
const note = $('docBody').querySelector(`[data-rv-note="${n}"]`);
|
|
2015
|
-
note.hidden = b.dataset.val !== 'other'; // 'other' is only useful with words
|
|
2016
|
-
if (b.dataset.val === 'other') note.focus();
|
|
2017
|
-
const nudge = row.querySelector('.rv-nudge'); if (nudge) nudge.remove();
|
|
2018
|
-
});
|
|
2019
|
-
$('docBody').querySelectorAll('[data-rv-save]').forEach((b) => b.onclick = () => {
|
|
2020
|
-
const n = b.dataset.rvSave;
|
|
2021
|
-
const row = $('docBody').querySelector(`[data-rv-reason="${n}"]`);
|
|
2022
|
-
const picked = row && row.dataset.picked;
|
|
2023
|
-
if (!picked) {
|
|
2024
|
-
// An inline nudge, not a modal and not a silent default. Defaulting is what produced the bug
|
|
2025
|
-
// this replaces: every edit recorded "incomplete" whether or not it was.
|
|
2026
|
-
if (!row.querySelector('.rv-nudge')) {
|
|
2027
|
-
const s = document.createElement('span');
|
|
2028
|
-
s.className = 'rv-nudge'; s.textContent = 'Pick one — the record is worth nothing without it.';
|
|
2029
|
-
row.appendChild(s);
|
|
2030
|
-
}
|
|
2031
|
-
return;
|
|
2032
|
-
}
|
|
2033
|
-
const note = $('docBody').querySelector(`[data-rv-note="${n}"]`);
|
|
2034
|
-
const ta = $('docBody').querySelector(`[data-rv-ta="${n}"]`);
|
|
2035
|
-
rvDispose(F(n), 'edit', ta ? ta.value : '', picked, note && !note.hidden ? note.value : undefined);
|
|
2036
|
-
});
|
|
2037
|
-
}
|
|
2038
|
-
async function rvDispose(f, disposition, pmText, reason, reasonNote) {
|
|
2039
|
-
if (!f) return;
|
|
2040
|
-
const rv = state.review || {};
|
|
2041
|
-
// The client sends back the exact text it rendered; the server re-derives the diff and refuses if
|
|
2042
|
-
// they differ (sprint criterion 4). That is what makes a stale or tampered client safe.
|
|
2043
|
-
let renderedAfter = null;
|
|
2044
|
-
if (disposition !== 'dismiss') {
|
|
2045
|
-
try {
|
|
2046
|
-
const r = await fetch(`/api/doc?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(f.file)}`);
|
|
2047
|
-
const cur = await r.text();
|
|
2048
|
-
const prop = disposition === 'edit' ? { before: f.proposal.before, after: pmText } : f.proposal;
|
|
2049
|
-
renderedAfter = window.ApplyChange.applyChange(cur, prop).text;
|
|
2050
|
-
} catch { /* leave null — the server refuses rather than guessing */ }
|
|
2051
|
-
}
|
|
2052
|
-
const body = {
|
|
2053
|
-
ws: state.wsId, disposition, finding: f, rendered_after: renderedAfter,
|
|
2054
|
-
pm_text: disposition === 'edit' ? pmText : undefined,
|
|
2055
|
-
reason, reason_note: reasonNote, // the PM's own word — never defaulted, never inferred
|
|
2056
|
-
run_id: rv.id, initiative_id: 'workspace-review-and-edit',
|
|
2057
|
-
};
|
|
2058
|
-
let out = {};
|
|
2059
|
-
try { out = await (await fetch('/api/review/dispose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json(); }
|
|
2060
|
-
catch { out = { ok: false, error: 'could not reach the workspace server' }; }
|
|
2061
|
-
if (!out.ok) { rvSay(out.error || 'refused', out.detail); return; }
|
|
2062
|
-
openSurface('review'); // re-read: the record is the source of truth
|
|
2063
|
-
}
|
|
2064
|
-
function rvSay(msg, detail) {
|
|
2065
|
-
const el = document.createElement('div');
|
|
2066
|
-
el.className = 'rv-say';
|
|
2067
|
-
el.textContent = detail ? `${msg} — ${detail}` : msg;
|
|
2068
|
-
$('docBody').querySelector('.rv') ? $('docBody').querySelector('.rv').prepend(el) : $('docBody').prepend(el);
|
|
2069
|
-
}
|
|
2070
|
-
|
|
2071
1993
|
function surfaceBody(name, payload) {
|
|
2072
1994
|
if (!payload || !payload.ok) return sfCantProduce(name, payload && payload.contract);
|
|
2073
1995
|
const s = payload.surface || {};
|
|
@@ -187,6 +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="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>
|
|
190
194
|
<button class="x" id="closeDoc" aria-label="Close document">✕</button>
|
|
191
195
|
</div>
|
|
192
196
|
<div class="pane-body doc-body" id="docBody" tabindex="0"></div>
|
|
@@ -256,9 +260,8 @@
|
|
|
256
260
|
<script src="/vendor/addon-web-links.js"></script>
|
|
257
261
|
<script src="/lib/preview-kind.js"></script>
|
|
258
262
|
<script src="/lib/layout.js"></script>
|
|
259
|
-
<!--
|
|
260
|
-
|
|
261
|
-
<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. -->
|
|
262
265
|
<script src="/lib/apply-change.js"></script>
|
|
263
266
|
<script src="/app.js"></script>
|
|
264
267
|
</body>
|