create-pmos 0.6.4 → 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 CHANGED
@@ -333,6 +333,43 @@ 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
+
336
373
  ## What's new in 0.6.4
337
374
 
338
375
  Workspace GUI session controls — two things the PM asked for while working in real terminals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.6.4",
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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@4ce6980
2
- assembled: 2026-07-27
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
+ });
@@ -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
- QUIET_AFTER_MS: 30_000,
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
  };