create-pmos 0.6.3 → 0.7.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 +54 -1
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/scripts/gate-initiative.sh +4 -1
- package/workspace/lib/apply-change.js +106 -0
- package/workspace/lib/correction-record.js +127 -0
- package/workspace/lib/finding-contract.js +162 -0
- package/workspace/lib/git-info.js +71 -0
- package/workspace/lib/harness-parse.js +5 -1
- package/workspace/lib/layout.js +171 -0
- package/workspace/lib/skill-store.js +28 -9
- package/workspace/lib/tmux-control.js +124 -0
- package/workspace/public/app.js +862 -89
- package/workspace/public/index.html +113 -23
- package/workspace/public/styles.css +232 -19
- package/workspace/server.js +384 -16
- package/workspace/skills/doc-review.skill +114 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "create-pmos"
|
|
4
|
-
timestamp: 2026-07-
|
|
4
|
+
timestamp: 2026-07-27
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# create-pmos
|
|
@@ -333,6 +333,59 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
|
|
|
333
333
|
`okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
|
|
334
334
|
exactly what you're running.
|
|
335
335
|
|
|
336
|
+
## What's new in 0.7.0
|
|
337
|
+
|
|
338
|
+
**The workspace GUI has layouts.** Until now it had exactly one arrangement: a fixed sidebar, the
|
|
339
|
+
terminal, and a right rail. Now it has four you can name, switch between in one act, and then change
|
|
340
|
+
however you like.
|
|
341
|
+
|
|
342
|
+
- **Named layouts — `focus` · `read` · `compare` · `review`.** Switch from the header or with
|
|
343
|
+
⌃1–⌃4. `focus` gives the terminal the window; `read` makes the document the primary column instead
|
|
344
|
+
of a 400px strip; `compare` puts two sessions side by side; `review` is the arrangement you already
|
|
345
|
+
had.
|
|
346
|
+
- **Adjust anything, and it's kept.** Drag a pane by its ⠿ grip to a different edge, drag a column's
|
|
347
|
+
edge to resize it, fold a pane away — whatever you change is written back into the layout you're
|
|
348
|
+
in. There is no save button, no "unsaved changes", and nothing to manage. Each stock layout has a
|
|
349
|
+
**restore** in the list for when you want the original back.
|
|
350
|
+
- **Two terminals at once.** `compare` (or the layout menu) splits the terminal into two groups, each
|
|
351
|
+
with its own tab strip and its own live session — so you can watch two agents work instead of
|
|
352
|
+
alternating tabs from memory. Closing the split doesn't kill anything; the second session goes back
|
|
353
|
+
to being a tab.
|
|
354
|
+
- **Make your own.** `+ new layout from this arrangement` names what you've built; custom layouts can
|
|
355
|
+
be renamed and deleted.
|
|
356
|
+
- **Layouts belong to the repo you opened.** Each workspace remembers its own, on your machine
|
|
357
|
+
(`workspace/state/layouts/`) — nothing is sent anywhere.
|
|
358
|
+
- **The sidebar caught up.** Its panes now fold and its width now drags, which only the right rail
|
|
359
|
+
could do before.
|
|
360
|
+
|
|
361
|
+
**Upgrade note:** the workspace app ships inside the package; `npx create-pmos@latest workspace`
|
|
362
|
+
installs the new build. If you had already dragged your rail width or hidden it, that carries into
|
|
363
|
+
this workspace's `review` layout on first run — you keep what you had. Your `state/`, `planning/`,
|
|
364
|
+
and `okf/` records are untouched. Phones are unchanged: the single-column switcher still owns the
|
|
365
|
+
layout there, and the layout control is hidden.
|
|
366
|
+
|
|
367
|
+
**Also in this build, and not yet finished:** a **summoned document review** surface (ask for a
|
|
368
|
+
review of a document, dispose of each finding by Apply / Edit / Dismiss, with every disposition
|
|
369
|
+
recorded as an attributed correction). It rides along because the package bundles the whole
|
|
370
|
+
workspace app — its own build is still open and it has not been through its review gate. Treat it as
|
|
371
|
+
a preview, not a finished feature.
|
|
372
|
+
|
|
373
|
+
## What's new in 0.6.4
|
|
374
|
+
|
|
375
|
+
Workspace GUI session controls — two things the PM asked for while working in real terminals.
|
|
376
|
+
|
|
377
|
+
- **Branch awareness + pick/create at session start.** The header now shows the active repo's current
|
|
378
|
+
git branch (dirty-marked), and the new-session form lets you start a session on an existing branch
|
|
379
|
+
or create a new one. It's honest about the shared working tree: switching a branch is repo-wide and
|
|
380
|
+
moves every session in that repo — the form says so rather than faking per-session isolation.
|
|
381
|
+
- **Scratchpad.** A collapsible drawer at the bottom of the terminal column (toggle from the header or
|
|
382
|
+
⌘J) to stage commands and notes before they hit the terminal — it never overlays the terminal or the
|
|
383
|
+
document reader, persists per workspace, and has **copy** and **→ terminal** (which pastes without
|
|
384
|
+
pressing enter, so nothing auto-runs).
|
|
385
|
+
|
|
386
|
+
**Upgrade note:** the workspace app ships inside the package; `npx create-pmos@latest workspace`
|
|
387
|
+
installs the new build. Your `state/`, `planning/`, and `okf/` records are untouched — purely additive.
|
|
388
|
+
|
|
336
389
|
## What's new in 0.6.1
|
|
337
390
|
|
|
338
391
|
A completeness fix: two skills the kit already *pointed at* but didn't *ship*.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-07-
|
|
1
|
+
kernel-source: pmos@a2227ff
|
|
2
|
+
assembled: 2026-07-28
|
|
3
3
|
mode: vendor
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
# chore -> a non-empty `intent:` line in the initiative file; no PRD/rubric
|
|
11
11
|
# incident -> prd_path = the incident record, set and exists (post-hoc is sanctioned)
|
|
12
12
|
# content -> prd_path = the content brief, set and exists; rubric recommended (D58)
|
|
13
|
+
# prototype -> prd_path = the quick prototype brief, set and exists; NO rubric BY
|
|
14
|
+
# DESIGN (leg A is grilled as dialogue, never scored — D60)
|
|
13
15
|
# Unknown/missing type = feature: the strictest lane. Lane variation is never a skip.
|
|
14
16
|
# Escape hatch (explicit, logged): the `no-initiative` label — anchor-free trivia ONLY, real work
|
|
15
17
|
# takes a lane (D56).
|
|
@@ -68,11 +70,12 @@ anchor="$(frontval parent_kr)"
|
|
|
68
70
|
# Gate); what varies per lane is the spec/eval artifact. In EVERY lane a rubric_path that is set
|
|
69
71
|
# must point at a real file — optional never means dangling.
|
|
70
72
|
case "$lane" in
|
|
71
|
-
discovery|incident|content)
|
|
73
|
+
discovery|incident|content|prototype)
|
|
72
74
|
# if-form, not `[ … ] && …`: under set -e a false test as a bare && would kill the script.
|
|
73
75
|
what="spike brief"
|
|
74
76
|
if [ "$lane" = incident ]; then what="incident record (post-hoc within 48h is the sanctioned lane rule)"; fi
|
|
75
77
|
if [ "$lane" = content ]; then what="content brief (rubric recommended, not required — D58)"; fi
|
|
78
|
+
if [ "$lane" = prototype ]; then what="quick prototype brief (no rubric BY DESIGN — leg A is grilled, never scored; D60)"; fi
|
|
76
79
|
[ -n "$prd" ] || fail "initiative '$id' (type $lane) has no prd_path in $init_file — set it to the $what (see $KIT_ROOT/okf/core/templates/)."
|
|
77
80
|
[ -f "$prd" ] || fail "prd_path '$prd' (from $init_file, type $lane) does not exist in the repo."
|
|
78
81
|
[ -z "$rubric" ] || [ -f "$rubric" ] || fail "rubric_path '$rubric' (from $init_file) does not exist in the repo (a rubric is optional in the $lane lane — set the path only to a real file)."
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/* apply-change — turn a proposed change into new file text, or refuse.
|
|
2
|
+
* (workspace-review-and-edit · R6/R9/R11)
|
|
3
|
+
*
|
|
4
|
+
* Pure: text in, text out. It never touches the filesystem, so the path guard stays where it
|
|
5
|
+
* already lives (insideRoot in server.js) and this module can be unit-tested directly.
|
|
6
|
+
*
|
|
7
|
+
* The rule that matters is REFUSE ON MISMATCH. A proposal is anchored to the exact text it
|
|
8
|
+
* expects to replace; if that text is not present exactly once, the change is rejected rather
|
|
9
|
+
* than merged, guessed at, or fuzzy-matched. This is what makes the file-changed-under-you case
|
|
10
|
+
* safe (rubric dim 4 grades it explicitly): the PM opened a finding, the file moved on disk, and
|
|
11
|
+
* Apply must fail loudly instead of writing a diff the PM never saw.
|
|
12
|
+
*
|
|
13
|
+
* It is also the whole of R9. There is no stored diff and no parallel history here — the change
|
|
14
|
+
* is computed from `before`/`after` at apply time and then it is over. `git log` remains the
|
|
15
|
+
* record of what changed; the correction record (correction-record.js) holds why and by whom,
|
|
16
|
+
* which is the part git cannot represent.
|
|
17
|
+
*
|
|
18
|
+
* Cancel needs no code: the only write is applyChange()'s caller. Not calling it writes nothing.
|
|
19
|
+
*/
|
|
20
|
+
'use strict';
|
|
21
|
+
(function (root, factory) {
|
|
22
|
+
const api = factory();
|
|
23
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
24
|
+
else root.ApplyChange = api;
|
|
25
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
26
|
+
|
|
27
|
+
/* Line endings are normalised for MATCHING only, never for writing. A CRLF file must come out
|
|
28
|
+
* CRLF — silently rewriting every line ending of a document would be a diff the PM never saw,
|
|
29
|
+
* which is the same defect as a fuzzy match wearing a different hat. */
|
|
30
|
+
function detectEol(text) {
|
|
31
|
+
return /\r\n/.test(text) ? '\r\n' : '\n';
|
|
32
|
+
}
|
|
33
|
+
const toLf = (s) => String(s).replace(/\r\n/g, '\n');
|
|
34
|
+
|
|
35
|
+
function countOccurrences(hay, needle) {
|
|
36
|
+
if (!needle) return 0;
|
|
37
|
+
let n = 0, i = 0;
|
|
38
|
+
for (;;) {
|
|
39
|
+
const at = hay.indexOf(needle, i);
|
|
40
|
+
if (at === -1) return n;
|
|
41
|
+
n++; i = at + needle.length;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Apply one proposal to the current text.
|
|
47
|
+
* current — the file as it is on disk right now
|
|
48
|
+
* proposal — { before, after }. Omit `before` for a pure insertion (requires `anchor`).
|
|
49
|
+
* opts — { anchor } text to insert after, when `before` is absent
|
|
50
|
+
* Returns { ok: true, text, eol } or { ok: false, reason, detail? }. Never throws.
|
|
51
|
+
*/
|
|
52
|
+
function applyChange(current, proposal, opts) {
|
|
53
|
+
if (typeof current !== 'string') return { ok: false, reason: 'current-not-text' };
|
|
54
|
+
if (!proposal || typeof proposal !== 'object') return { ok: false, reason: 'no-proposal' };
|
|
55
|
+
|
|
56
|
+
const eol = detectEol(current);
|
|
57
|
+
const cur = toLf(current);
|
|
58
|
+
const before = proposal.before == null ? null : toLf(proposal.before);
|
|
59
|
+
const after = proposal.after == null ? '' : toLf(proposal.after);
|
|
60
|
+
const anchor = opts && opts.anchor != null ? toLf(opts.anchor) : null;
|
|
61
|
+
|
|
62
|
+
// ── replacement / deletion ────────────────────────────────────────────────
|
|
63
|
+
if (before != null && before !== '') {
|
|
64
|
+
const n = countOccurrences(cur, before);
|
|
65
|
+
// 0 ⇒ the file moved under the PM. >1 ⇒ we cannot know which one they meant. Both refuse:
|
|
66
|
+
// an ambiguous apply is a silent wrong edit, and this surface's whole value is being trusted.
|
|
67
|
+
if (n === 0) return { ok: false, reason: 'stale', detail: 'the text this change replaces is no longer in the file' };
|
|
68
|
+
if (n > 1) return { ok: false, reason: 'ambiguous', detail: `the anchor text appears ${n} times` };
|
|
69
|
+
const out = cur.replace(before, after);
|
|
70
|
+
return { ok: true, text: eol === '\n' ? out : out.replace(/\n/g, eol), eol };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── pure insertion ────────────────────────────────────────────────────────
|
|
74
|
+
if (!after) return { ok: false, reason: 'empty-change' };
|
|
75
|
+
if (anchor == null || anchor === '') return { ok: false, reason: 'no-anchor', detail: 'an insertion must say what it follows' };
|
|
76
|
+
const n = countOccurrences(cur, anchor);
|
|
77
|
+
if (n === 0) return { ok: false, reason: 'stale', detail: 'the anchor this change follows is no longer in the file' };
|
|
78
|
+
if (n > 1) return { ok: false, reason: 'ambiguous', detail: `the anchor text appears ${n} times` };
|
|
79
|
+
const at = cur.indexOf(anchor) + anchor.length;
|
|
80
|
+
const out = cur.slice(0, at) + after + cur.slice(at);
|
|
81
|
+
return { ok: true, text: eol === '\n' ? out : out.replace(/\n/g, eol), eol };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Added/removed line counts for the change bar (`+3 −1`). Presentation only. */
|
|
85
|
+
function changeStat(proposal) {
|
|
86
|
+
const nl = (s) => (s == null || s === '' ? 0 : toLf(s).split('\n').length);
|
|
87
|
+
return { added: nl(proposal && proposal.after), removed: nl(proposal && proposal.before) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Guard for the write endpoint: the client must send back the exact diff it rendered, and the
|
|
92
|
+
* server re-derives it. If the two disagree the request is refused — this is what stops a
|
|
93
|
+
* tampered or stale client from applying something the PM never looked at (sprint criterion 4).
|
|
94
|
+
*/
|
|
95
|
+
function matchesRendered(current, proposal, renderedAfterText, opts) {
|
|
96
|
+
const r = applyChange(current, proposal, opts);
|
|
97
|
+
if (!r.ok) return { ok: false, reason: r.reason, detail: r.detail };
|
|
98
|
+
if (typeof renderedAfterText !== 'string') return { ok: false, reason: 'no-rendered-text' };
|
|
99
|
+
if (toLf(r.text) !== toLf(renderedAfterText)) {
|
|
100
|
+
return { ok: false, reason: 'diff-mismatch', detail: 'the result does not match what was shown to the PM' };
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, text: r.text, eol: r.eol };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { applyChange, changeStat, matchesRendered, detectEol };
|
|
106
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/* finding-contract — what a review finding must be, and what the PM may do with it.
|
|
2
|
+
* (workspace-review-and-edit · R3/R7)
|
|
3
|
+
*
|
|
4
|
+
* Used on BOTH sides, like preview-kind:
|
|
5
|
+
* - server.js requires it to validate a reviewer skill's output before it reaches the client
|
|
6
|
+
* - the browser loads it as /lib/finding-contract.js so the renderer asks the SAME question
|
|
7
|
+
* so the contract, the endpoint and the UI can never disagree about what is applyable.
|
|
8
|
+
*
|
|
9
|
+
* The one security-critical function is isApplyable(). It decides whether an Apply control
|
|
10
|
+
* exists at all. R7's rule is that a proposal which would close a recorded open question or
|
|
11
|
+
* overwrite a PM decision is surfaced as CONTEXT with no Apply affordance — the absence of the
|
|
12
|
+
* control is the mechanism, and a warning label is explicitly not a substitute. The rubric makes
|
|
13
|
+
* this a hard floor (dim 2 must = 1: zero Apply controls ever rendered for a withheld finding),
|
|
14
|
+
* so this boolean is the whole containment story and it is unit-tested directly.
|
|
15
|
+
*
|
|
16
|
+
* The second load-bearing rule is self_undetectable (R3). A review that emits lint findings
|
|
17
|
+
* becomes a linter the PM stops reading, and "judgement-grade" is not enforceable by inspecting
|
|
18
|
+
* the finding's text. So the contract inverts it: every finding must NAME what the artifact
|
|
19
|
+
* cannot see about itself. A reviewer that cannot fill that line has not found this class of
|
|
20
|
+
* defect, and the finding is rejected structurally rather than by taste.
|
|
21
|
+
*/
|
|
22
|
+
'use strict';
|
|
23
|
+
(function (root, factory) {
|
|
24
|
+
const api = factory();
|
|
25
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
26
|
+
else root.FindingContract = api;
|
|
27
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
28
|
+
|
|
29
|
+
/* Finding kinds the review must NOT emit (R3). These are owned by the Frontmatter Gate and the
|
|
30
|
+
* linters; re-reporting them here is how counsel decays into noise. Declared by the reviewer
|
|
31
|
+
* and rejected on sight — a blocklist catches the honest mistake, and self_undetectable below
|
|
32
|
+
* catches the dishonest one. */
|
|
33
|
+
const MECHANICAL_KINDS = new Set([
|
|
34
|
+
'frontmatter', 'schema', 'link', 'broken-link', 'missing-path', 'path',
|
|
35
|
+
'string-match', 'regex', 'count', 'occurrence', 'spelling', 'typo', 'formatting', 'lint',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const isStr = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
39
|
+
|
|
40
|
+
/** Reject a finding that a linter should have owned (R3). Kind is declared, not sniffed. */
|
|
41
|
+
function isMechanical(f) {
|
|
42
|
+
return !!f && isStr(f.kind) && MECHANICAL_KINDS.has(f.kind.trim().toLowerCase());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* THE guardrail (R7). An Apply control may exist only when the finding is not withheld AND
|
|
46
|
+
* actually carries a change to make. Both halves matter: `withheld` is the PM-decision guard,
|
|
47
|
+
* and a null proposal is the context class (nothing to apply). Anything unexpected — a missing
|
|
48
|
+
* field, a malformed proposal, a non-boolean withheld — resolves to NOT applyable. This
|
|
49
|
+
* function fails closed on purpose; an unparseable finding must never grow a button. */
|
|
50
|
+
function isApplyable(f) {
|
|
51
|
+
if (!f || typeof f !== 'object') return false;
|
|
52
|
+
if (f.withheld === true) return false;
|
|
53
|
+
if (f.withheld !== false) return false; // must be explicitly declared, never inferred
|
|
54
|
+
const p = f.proposal;
|
|
55
|
+
if (!p || typeof p !== 'object') return false;
|
|
56
|
+
return isStr(p.before) || isStr(p.after); // a pure insertion has no `before`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Validate one finding against the contract. Returns { ok, errors[] } — never throws. */
|
|
60
|
+
function validateFinding(f) {
|
|
61
|
+
const e = [];
|
|
62
|
+
if (!f || typeof f !== 'object') return { ok: false, errors: ['finding is not an object'] };
|
|
63
|
+
|
|
64
|
+
if (!isStr(f.title)) e.push('title is required');
|
|
65
|
+
if (!isStr(f.file)) e.push('file is required');
|
|
66
|
+
if (!isStr(f.argument)) e.push('argument is required — a finding without reasoning is an assertion');
|
|
67
|
+
|
|
68
|
+
// R3: name what the artifact cannot see about itself, or this is not the class of defect
|
|
69
|
+
// this review exists to surface.
|
|
70
|
+
if (!isStr(f.self_undetectable)) {
|
|
71
|
+
e.push('self_undetectable is required — name what this artifact cannot detect about itself (R3)');
|
|
72
|
+
}
|
|
73
|
+
if (isMechanical(f)) {
|
|
74
|
+
e.push(`kind '${f.kind}' is mechanically findable and belongs to lint, not to this review (R3)`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Evidence is file:line, plural, and cited — an un-sourced finding is a guess.
|
|
78
|
+
if (!Array.isArray(f.evidence) || f.evidence.length === 0) {
|
|
79
|
+
e.push('evidence[] is required — at least one file:line citation');
|
|
80
|
+
} else {
|
|
81
|
+
f.evidence.forEach((ev, i) => {
|
|
82
|
+
if (!ev || typeof ev !== 'object') { e.push(`evidence[${i}] is not an object`); return; }
|
|
83
|
+
if (!isStr(ev.file)) e.push(`evidence[${i}].file is required`);
|
|
84
|
+
if (!Number.isInteger(ev.line) || ev.line < 1) e.push(`evidence[${i}].line must be a positive integer`);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// withheld is declared, never inferred — the containment guard must be explicit (R7).
|
|
89
|
+
if (typeof f.withheld !== 'boolean') {
|
|
90
|
+
e.push('withheld must be an explicit boolean (R7) — it is never inferred');
|
|
91
|
+
}
|
|
92
|
+
// A withheld finding carries no proposal at all. Carrying one invites a later refactor to
|
|
93
|
+
// "just render it disabled", which is exactly the failure dim 2 forbids.
|
|
94
|
+
if (f.withheld === true && f.proposal != null) {
|
|
95
|
+
e.push('a withheld finding must carry proposal: null — it has no change to offer (R7)');
|
|
96
|
+
}
|
|
97
|
+
if (f.withheld === true && !isStr(f.withheld_reason)) {
|
|
98
|
+
e.push('withheld_reason is required — say why this is yours to settle (R7)');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (f.proposal != null) {
|
|
102
|
+
const p = f.proposal;
|
|
103
|
+
if (typeof p !== 'object') e.push('proposal must be an object or null');
|
|
104
|
+
else {
|
|
105
|
+
if (p.before != null && typeof p.before !== 'string') e.push('proposal.before must be a string or absent');
|
|
106
|
+
if (!isStr(p.after) && !isStr(p.before)) e.push('proposal must change something (before and/or after)');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { ok: e.length === 0, errors: e };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Validate a whole review payload. One bad finding fails the review — partial trust is worse
|
|
114
|
+
* than none on a surface whose only job is to be believed. */
|
|
115
|
+
function validateReview(r) {
|
|
116
|
+
const e = [];
|
|
117
|
+
if (!r || typeof r !== 'object') return { ok: false, errors: ['review is not an object'] };
|
|
118
|
+
if (!Array.isArray(r.read) || r.read.length === 0) {
|
|
119
|
+
e.push('read[] is required — the review states what it actually read (R2)');
|
|
120
|
+
}
|
|
121
|
+
if (!Array.isArray(r.findings)) {
|
|
122
|
+
e.push('findings[] is required (an empty array is a valid, and good, review — R10)');
|
|
123
|
+
} else {
|
|
124
|
+
r.findings.forEach((f, i) => {
|
|
125
|
+
const v = validateFinding(f);
|
|
126
|
+
if (!v.ok) v.errors.forEach((msg) => e.push(`findings[${i}]: ${msg}`));
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return { ok: e.length === 0, errors: e };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Quiet-rate over a scored corpus (R10, rubric dim 1). Documents read with zero findings
|
|
133
|
+
* against them, divided by documents believed clean. The rubric's binding number. */
|
|
134
|
+
function quietRate(reviewed, cleanPaths) {
|
|
135
|
+
const clean = new Set(cleanPaths || []);
|
|
136
|
+
if (clean.size === 0) return null; // no clean class ⇒ no valid precision number
|
|
137
|
+
let quiet = 0;
|
|
138
|
+
for (const p of clean) {
|
|
139
|
+
const noisy = (reviewed || []).some((f) => f && f.file === p);
|
|
140
|
+
if (!noisy) quiet++;
|
|
141
|
+
}
|
|
142
|
+
return quiet / clean.size;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Action rate (PRD success signal): Apply+Edit ÷ all dispositions. Dismiss-heavy means the
|
|
146
|
+
* review is noise the PM is politely declining, whatever it scored. */
|
|
147
|
+
function actionRate(dispositions) {
|
|
148
|
+
const d = (dispositions || []).filter((x) => x === 'apply' || x === 'edit' || x === 'dismiss');
|
|
149
|
+
if (d.length === 0) return null;
|
|
150
|
+
return d.filter((x) => x !== 'dismiss').length / d.length;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
MECHANICAL_KINDS,
|
|
155
|
+
isMechanical,
|
|
156
|
+
isApplyable,
|
|
157
|
+
validateFinding,
|
|
158
|
+
validateReview,
|
|
159
|
+
quietRate,
|
|
160
|
+
actionRate,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Read-mostly git helpers for the workspace (branch awareness + select/create at session start).
|
|
3
|
+
// Everything here shells `git` via spawnSync with an ARGS ARRAY — never a shell string — so no
|
|
4
|
+
// caller input is ever interpolated into a command line. A workspace's working tree is SHARED by
|
|
5
|
+
// every session in it (D62), so a branch is a property of the REPO, not a session: switching moves
|
|
6
|
+
// every session in that repo. Callers surface that truth; this module never pretends otherwise.
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { spawnSync } = require('child_process');
|
|
10
|
+
|
|
11
|
+
const GIT_TIMEOUT = 5000; // never let a slow/hung repo (huge tree, network remote) block the server
|
|
12
|
+
|
|
13
|
+
function isRepo(root) {
|
|
14
|
+
// a normal repo has a `.git` dir; a linked worktree has a `.git` FILE — both count.
|
|
15
|
+
try { return !!root && fs.existsSync(path.join(root, '.git')); } catch { return false; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function git(root, args) {
|
|
19
|
+
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: GIT_TIMEOUT });
|
|
20
|
+
return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim(), failed: !!r.error };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// git check-ref-format for a BRANCH name, re-implemented defensively so nothing dubious ever
|
|
24
|
+
// reaches `git` (defense in depth — `git` itself is the final authority and rejects the rest).
|
|
25
|
+
// Rejects: empty, whitespace/control, the special chars ~ ^ : ? * [ \ , the sequences .. and @{,
|
|
26
|
+
// a leading '-' (would be read as a flag), leading/trailing '/' or '.', doubled '/', a trailing
|
|
27
|
+
// '.lock', and the lone '@'. Allows the ordinary set A–Z a–z 0–9 . _ / -, e.g. initiative/foo-bar.
|
|
28
|
+
function validBranchName(name) {
|
|
29
|
+
if (typeof name !== 'string') return false;
|
|
30
|
+
const n = name.trim();
|
|
31
|
+
if (!n || n.length > 200) return false;
|
|
32
|
+
if (/[\x00-\x20~^:?*[\\\s]/.test(n)) return false; // control/space + git's forbidden set
|
|
33
|
+
if (n.includes('..') || n.includes('@{') || n.includes('//')) return false;
|
|
34
|
+
if (n === '@') return false;
|
|
35
|
+
if (n.startsWith('-') || n.startsWith('/') || n.startsWith('.')) return false;
|
|
36
|
+
if (n.endsWith('/') || n.endsWith('.') || n.endsWith('.lock')) return false;
|
|
37
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(n)) return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// { ok, current, branches[], detached, dirty }. A non-repo or a failed git returns ok:false with a
|
|
42
|
+
// reason — NEVER a fabricated branch (a wrong branch chip is worse than none; anti-drift).
|
|
43
|
+
function branchInfo(root) {
|
|
44
|
+
if (!isRepo(root)) return { ok: false, error: 'not a git repository' };
|
|
45
|
+
const head = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
46
|
+
if (head.failed) return { ok: false, error: 'git not available' };
|
|
47
|
+
if (head.code !== 0) return { ok: false, error: head.err || 'could not read HEAD' };
|
|
48
|
+
const detached = head.out === 'HEAD';
|
|
49
|
+
const current = detached ? null : head.out;
|
|
50
|
+
const refs = git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
|
|
51
|
+
const branches = refs.code === 0 && refs.out ? refs.out.split('\n').filter(Boolean) : [];
|
|
52
|
+
const status = git(root, ['status', '--porcelain']);
|
|
53
|
+
const dirty = status.code === 0 ? status.out.length > 0 : false;
|
|
54
|
+
return { ok: true, current, branches, detached, dirty };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check out an existing branch, or create one with -b. Confined to `root` by cwd; the name is
|
|
58
|
+
// validated first so nothing malformed reaches git. On any failure returns { ok:false, error } and
|
|
59
|
+
// leaves the caller to NOT spawn a session on the wrong branch (a failed checkout must never spawn).
|
|
60
|
+
function checkout(root, branch, { create = false } = {}) {
|
|
61
|
+
if (!isRepo(root)) return { ok: false, error: 'not a git repository' };
|
|
62
|
+
if (!validBranchName(branch)) return { ok: false, error: 'invalid branch name' };
|
|
63
|
+
const args = create ? ['checkout', '-b', branch, '--'] : ['checkout', branch, '--'];
|
|
64
|
+
const r = git(root, args);
|
|
65
|
+
if (r.failed) return { ok: false, error: 'git not available' };
|
|
66
|
+
if (r.code !== 0) return { ok: false, error: (r.err || r.out || 'checkout failed').split('\n')[0].slice(0, 200) };
|
|
67
|
+
const info = branchInfo(root);
|
|
68
|
+
return { ok: true, current: info.ok ? info.current : branch };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { isRepo, validBranchName, branchInfo, checkout };
|
|
@@ -216,7 +216,11 @@ module.exports = {
|
|
|
216
216
|
// 30s is a deliberate over-estimate: a false "quiet" is the failure that matters, because the
|
|
217
217
|
// whole feature exists to stop the PM mistaking blocked for busy. Needs one field measurement
|
|
218
218
|
// (the byte rate of a working session) to tighten — carried as an open question in the PRD.
|
|
219
|
-
|
|
219
|
+
// Env-overridable so a suite can reproduce a stale-liveness defect in seconds instead of thirty;
|
|
220
|
+
// the shipped value is unchanged. The SERVER publishes this number in `hello` and the client
|
|
221
|
+
// reads it from there — it used to be re-typed as a literal in app.js, which is a threshold that
|
|
222
|
+
// can silently drift out of agreement with the one actually deriving the state.
|
|
223
|
+
QUIET_AFTER_MS: Number(process.env.PMOS_QUIET_AFTER_MS || 30_000),
|
|
220
224
|
TAIL_BYTES,
|
|
221
225
|
_internal: { lastWords, toolCounts, modelInfo, contextPct, tailLines, isToolCall, toolName },
|
|
222
226
|
};
|