@mrciphersmith/keryx 0.3.114 → 0.3.116

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/dist/core.js CHANGED
@@ -1078,6 +1078,11 @@ var init_catalog = __esm(() => {
1078
1078
  "Anchor class_scope on the callers that break, never on the changed line.",
1079
1079
  "Say when the radius could not answer \u2014 a non-code change has an empty one."
1080
1080
  ]),
1081
+ skill("review-pr-contract", "review", ["recommended", "full"], "Compare what the PR body claims (checklist boxes, stated changes) with the diff, and look for documentation outside the diff that the change made stale.", [
1082
+ "Work every checklist box in pr-context.md as a row: quoted box, state, quoted diff line, expectation.",
1083
+ "File a finding only where a quoted box or claim disagrees with a quoted diff or document line.",
1084
+ "On a shell host stay read-only and answer NEEDS_CONTEXT with named paths instead of guessing a document."
1085
+ ]),
1081
1086
  skill("review-core-boundaries", "review", ["recommended", "full"], "Review shared/core module coupling, public API stability, and dependency minimization.", [
1082
1087
  "Identify core/shared surfaces changed by the task.",
1083
1088
  "Check if feature-specific logic leaked into shared modules.",
@@ -9695,8 +9700,9 @@ var init_skill_length_ceilings = __esm(() => {
9695
9700
  "review/review-jev-scenarios": 187,
9696
9701
  "review/review-layout": 238,
9697
9702
  "review/review-logic": 370,
9698
- "review/review-orchestrator": 1616,
9703
+ "review/review-orchestrator": 1615,
9699
9704
  "review/review-performance": 359,
9705
+ "review/review-pr-contract": 191,
9700
9706
  "review/review-pr-feedback": 895,
9701
9707
  "review/review-regression": 214,
9702
9708
  "review/review-security-code": 364,
@@ -28072,6 +28078,7 @@ var NOT_ROSTER = new Set([
28072
28078
  "review-orchestrator",
28073
28079
  "review-verifier",
28074
28080
  "review-regression",
28081
+ "review-pr-contract",
28075
28082
  "review-pr-feedback",
28076
28083
  "review-greptile",
28077
28084
  "review-strict"
@@ -28125,6 +28132,10 @@ async function readSkill(root, relativePath) {
28125
28132
  }) : undefined;
28126
28133
  }
28127
28134
  var REGRESSION = "review-regression";
28135
+ var PR_CONTRACT = "review-pr-contract";
28136
+ function dispatchKnowsRole(dispatch, role) {
28137
+ return Array.isArray(dispatch.roles) && dispatch.roles.includes(role);
28138
+ }
28128
28139
  async function requiredReviewers(root, mode, files, options = {}) {
28129
28140
  const inventory = await collectReviewers(root);
28130
28141
  const stack = await detectProjectStack(root);
@@ -28180,6 +28191,12 @@ async function requiredReviewers(root, mode, files, options = {}) {
28180
28191
  else
28181
28192
  excluded.push({ reviewer: REGRESSION, reason: "no blast radius computed for the package: there is no scope B to review" });
28182
28193
  }
28194
+ if (inventory.bundled.some((r) => r.name === PR_CONTRACT)) {
28195
+ if (options.prContext === true)
28196
+ required.push(PR_CONTRACT);
28197
+ else
28198
+ excluded.push({ reviewer: PR_CONTRACT, reason: "no pr-context.md in the package: there is no PR body checklist to compare with the diff" });
28199
+ }
28183
28200
  return { mode, required: required.sort(), excluded, filesKnown };
28184
28201
  }
28185
28202
  function isAttendedRun(env = process.env) {
@@ -28206,6 +28223,9 @@ async function packageHasBlastRadius(packageDir, name = "blast-radius.json") {
28206
28223
  return false;
28207
28224
  }
28208
28225
  }
28226
+ function packageHasPrContext(packageDir, name = "pr-context.md") {
28227
+ return existsSync6(path46.join(packageDir, name));
28228
+ }
28209
28229
  async function coverageErrors(dispatch, options) {
28210
28230
  const record = dispatch && typeof dispatch === "object" && !Array.isArray(dispatch) ? dispatch : undefined;
28211
28231
  return [...await reviewerSetErrors(dispatch, options), ...record === undefined ? [] : mutationErrors(record, options)];
@@ -28226,7 +28246,10 @@ async function reviewerSetErrors(dispatch, options) {
28226
28246
  }
28227
28247
  if (record.mode === "diff" && options.files === undefined)
28228
28248
  return ["diff mode needs the scope-A file list: pass `--scope <scope.json>` to `keryx review start|ingest`, or run mode all"];
28229
- const need = await requiredReviewers(options.root, record.mode, options.files, { blastRadius: options.blastRadius });
28249
+ const need = await requiredReviewers(options.root, record.mode, options.files, {
28250
+ blastRadius: options.blastRadius,
28251
+ prContext: options.prContext === true && dispatchKnowsRole(record, PR_CONTRACT)
28252
+ });
28230
28253
  const selected = new Set(Array.isArray(record.selected) ? record.selected.filter(text) : []);
28231
28254
  const missing = need.required.filter((name) => !selected.has(name));
28232
28255
  if (!missing.length)
@@ -28657,6 +28680,7 @@ function dispatchErrors(value) {
28657
28680
  import { existsSync as existsSync8 } from "fs";
28658
28681
  import { cp, mkdir as mkdir9, mkdtemp, readFile as readFile23, readdir as readdir10, realpath as realpath12, rm as rm3, symlink, writeFile as writeFile6 } from "fs/promises";
28659
28682
  import path49 from "path";
28683
+ var MUTANT_KINDS = ["behavior", "presentation"];
28660
28684
  var MUTANTS_SPEC = "mutants.json";
28661
28685
  var EVIDENCE_DIR = "research-evidence";
28662
28686
  var SKIP_COPY = new Set([".git", "node_modules"]);
@@ -28676,9 +28700,39 @@ function parseMutantSpec(raw) {
28676
28700
  ids.add(id);
28677
28701
  if (m.anchor === m.replacement)
28678
28702
  throw new Error(`mutant ${id}: replacement equals the anchor, so nothing changes`);
28679
- return { id, file: m.file, anchor: m.anchor, replacement: m.replacement, note: typeof m.note === "string" ? m.note : undefined };
28703
+ if (m.kind !== undefined && !MUTANT_KINDS.includes(m.kind))
28704
+ throw new Error(`mutant ${id}: "kind" must be one of ${MUTANT_KINDS.join(", ")}`);
28705
+ return {
28706
+ id,
28707
+ file: m.file,
28708
+ anchor: m.anchor,
28709
+ replacement: m.replacement,
28710
+ note: typeof m.note === "string" ? m.note : undefined,
28711
+ ...m.kind === undefined ? {} : { kind: m.kind }
28712
+ };
28680
28713
  });
28681
28714
  }
28715
+ async function doneMutantIds(packageDir) {
28716
+ const dir = path49.join(packageDir, EVIDENCE_DIR);
28717
+ const done = new Set;
28718
+ if (!existsSync8(dir))
28719
+ return done;
28720
+ for (const name of (await readdir10(dir)).filter((n) => /^mutation-\d+\.json$/.test(n))) {
28721
+ try {
28722
+ const record = JSON.parse(await readFile23(path49.join(dir, name), "utf8"));
28723
+ for (const m of record.mutants)
28724
+ if (m.status !== "invalid")
28725
+ done.add(m.id);
28726
+ } catch {
28727
+ continue;
28728
+ }
28729
+ }
28730
+ return done;
28731
+ }
28732
+ async function unjudgedMutants(packageDir, specs) {
28733
+ const done = await doneMutantIds(packageDir);
28734
+ return specs.filter((s) => !done.has(s.id));
28735
+ }
28682
28736
  async function pendingMutants(packageDir) {
28683
28737
  const dir = path49.join(packageDir, EVIDENCE_DIR);
28684
28738
  const specFile = path49.join(dir, MUTANTS_SPEC);
@@ -28693,18 +28747,7 @@ async function pendingMutants(packageDir) {
28693
28747
  } catch {
28694
28748
  return [];
28695
28749
  }
28696
- const done = new Set;
28697
- for (const name of (await readdir10(dir)).filter((n) => /^mutation-\d+\.json$/.test(n))) {
28698
- try {
28699
- const record = JSON.parse(await readFile23(path49.join(dir, name), "utf8"));
28700
- for (const m of record.mutants)
28701
- if (m.status !== "invalid")
28702
- done.add(m.id);
28703
- } catch {
28704
- continue;
28705
- }
28706
- }
28707
- return specs.filter((s) => !done.has(s.id));
28750
+ return unjudgedMutants(packageDir, specs);
28708
28751
  }
28709
28752
 
28710
28753
  // src/review/research.ts
@@ -28768,6 +28811,7 @@ async function packageResearchErrors(packageDir, artifacts, findings, options =
28768
28811
  root: projectRootOfPackage(packageDir),
28769
28812
  files: await packageScopeFiles(packageDir),
28770
28813
  blastRadius: await packageHasBlastRadius(packageDir),
28814
+ prContext: packageHasPrContext(packageDir),
28771
28815
  pendingMutants: (await pendingMutants(packageDir)).length
28772
28816
  });
28773
28817
  const completeness = a.reviewerDispatch === undefined || options.coverage === false || options.completeness === false || dispatch.length ? [] : await coverageGateErrors(packageDir, ledger);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.3.114",
3
+ "version": "0.3.116",
4
4
  "description": "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -465,6 +465,7 @@
465
465
  "src/gdskills/bundled/skills/review/review-clean-code/**",
466
466
  "src/gdskills/bundled/skills/review/review-highload/**",
467
467
  "src/gdskills/bundled/skills/review/review-regression/**",
468
+ "src/gdskills/bundled/skills/review/review-pr-contract/**",
468
469
  "src/gdskills/bundled/skills/review/review-core-boundaries/**",
469
470
  "src/gdskills/bundled/skills/review/review-flow-graph/**",
470
471
  "src/gdskills/bundled/skills/review/review-layout/**",
@@ -234,5 +234,5 @@ Report done only once all of these hold:
234
234
  - No finding invents a severity: each lands under **Severity (canonical)** in
235
235
  `review-orchestrator/SKILL.md`.
236
236
  - The reply is the `REVIEW_RESULT` the Orchestrated Review Contract asks for, with
237
- finding ids prefixed `LY-`, or `NEEDS_CONTEXT` naming the context that was
238
- missing — never a guess in its place.
237
+ finding ids prefixed `LY-`, or `NEEDS_CONTEXT` naming the measurement that was missing.
238
+ Measurements supplied as `context_files` count as evidence when they name the command and the viewport.
@@ -697,6 +697,11 @@ each proposed mutant to `<review-dir>/research-evidence/mutants.json`:
697
697
  `{ "mutants": [{ "id", "file", "anchor", "replacement" }] }`. `anchor` is exact text that
698
698
  occurs once in `file`; `"runnable": false` at the top level says the suite cannot run here.
699
699
 
700
+ The testing reviewer's contract is `mutant_candidates` for **every changed branch**, including
701
+ code downstream of the diff that the diff makes reachable differently. The main session writes
702
+ that list to `mutants.json` unchanged. It may not narrow, drop or merge candidates to fit a
703
+ budget: a smaller pass is a cap (below), stated, never a smaller list.
704
+
700
705
  Run `keryx review mutate --test "<the nearest suite command>" --package <review-dir>`. It
701
706
  applies each mutant in a scratch copy of the worktree (never in the worktree), refuses to
702
707
  start when the baseline is red, and writes the table to `research-evidence/mutation-<n>.md`
@@ -708,6 +713,12 @@ A survivor is evidence for the finding that names it.
708
713
  `dispatch.mutationWaiver = { operatorQuote, reason }` carries the operator's own words.
709
714
  `keryx review run --autonomous` is unchanged: it records `Not run: mutation pass` as before.
710
715
 
716
+ `keryx review mutate --cap <n>` runs the first `n` candidates and records `cap` and `not_reached`
717
+ in `mutation-<n>.json`. The table then prints `Not run: N candidates` (and the report must carry
718
+ that line, see `keryx review report-parity`). A candidate the cap left out is still pending, so it
719
+ keeps blocking the coverage gate until a later pass runs it; a cap moves work to the next pass, it
720
+ never closes it.
721
+
711
722
  ### Severity audit — `keryx review severity-audit`
712
723
 
713
724
  A reviewer that argues a finding from a mutant writes `mutant:<id>` in the finding's `evidence`.
@@ -718,6 +729,34 @@ run executed stays minor (a major is capped at minor) and gets the label `unprov
718
729
  never deletes a finding and never touches a blocker; it is a separate pass from the verifier,
719
730
  which can only delete. `--dry-run` prints the changes without writing `findings.json`.
720
731
 
732
+ Each mutant in `mutants.json` carries a `kind`: `behavior` (the default, and what a record written
733
+ before the field reads as) or `presentation` (it changes only what a reader sees: a label, a
734
+ column, copy, spacing). A behavior survivor raises as above. When the only survivors a finding
735
+ cites are `presentation`, the severity stays, and the finding gets
736
+ `severity_audit {held: {severity, mutant, kind: "presentation", evidence, reason}}` instead of
737
+ `{from, to}`. A held note is not a raise and not a deletion; it is the record that a survivor was
738
+ seen and did not move the severity, so the report must say so.
739
+
740
+ `keryx review report-parity <review-dir>` prints what the round left behind as Markdown to paste
741
+ into `report.md`: the `Not run: N candidates` line, each held note, and each follow-up still open
742
+ as `Not verified: <text>`. The same lines show in `keryx review status`, in the shell
743
+ `/review parity` view and in the sidebar. `keryx review complete` warns, and does not refuse, when
744
+ any of them exists and the report names none.
745
+
746
+ ### Layout measurements on a shell host
747
+
748
+ A shell reviewer cannot start a browser. Before dispatching `review-layout` the main session runs
749
+ the browser measurements itself (Playwright, or Storybook with its test runner) for the changed
750
+ components and passes the output files as `context_files` in the **first** dispatch. A measurement
751
+ counts as evidence for `review-layout` when it names the command and the viewport it ran at; one
752
+ without them is context, not evidence.
753
+
754
+ `src/review/retry-plan.ts` treats `NEEDS_CONTEXT` as one refill: the first one re-dispatches the
755
+ role with what it asked for. A second `NEEDS_CONTEXT` from the same role that covered no new slice and
756
+ asks nothing new is `notRun`; the reason is `layout: no engine` for `review-layout` and `no engine` for any other
757
+ role when its questions ask for a browser or engine and name no route without one, else `no progress after one
758
+ context refill`, and the round goes on. A second one that covered a new slice or asked a new question still gets its refill.
759
+
721
760
  ### Pull request context — `keryx review context-collect`
722
761
 
723
762
  A pull-request review starts with `keryx review context-collect --repo <o/r> --pr <n> --package <review-dir>`.
@@ -87,6 +87,8 @@ A reviewer on this host cannot open a path: its file tool reads inside its worki
87
87
  3. **Check before dispatch.** `keryx review dispatch-check --payload <file> --manifest <dir>/manifest.json` for the wave; exit 1 means a payload names no slice, is over the wave ceiling (dispatch fewer slices, then the next wave), or breaks `reviewer-input.schema.json`. Fix it; do not dispatch around it.
88
88
  4. **One batch.** Dispatch the whole wave — up to 10 reviewers — in one message, not one at a time. Give each reviewer `max_rounds` of at least 40 (the default); a reviewer that spends its rounds reading the skill and the schema returns nothing. **Incomplete means the next wave while it makes progress, Not run when it stalls.** Every reviewer result lists the `slices` it covered, in its `REVIEW_RESULT` object, as ids (`"slices": ["slice-01", "slice-02"]`); an object with an `id` and an optional `coverage` note (`{"id": "slice-01", "coverage": "read in full"}`) is read the same way. An object with a `status` counts only when it is DONE, COMPLETE, COVERED or OK (any case); a slice marked NOT_READ, INCOMPLETE, SKIPPED or PARTIAL is not covered, so give a slice you did not read no `status` of that kind and leave it out. An entry with no id counts for nothing. **One driver file per dispatch.** Write `drivers/<reviewer>.json` next to `raw/` with `{"reviewer": "<name>", "slices": [<ids dispatched>], "result": "raw/<reviewer>.txt", "definition": "<path of the reviewer's SKILL.md>"}`: `ledger build` takes the reviewer's rule file from `definition` (a `skill_path` there, or inside a `payload` object, is read too) and the slices it was handed from `slices`. Only reviewer replies belong in `raw/`; a verification payload or a note filed there is reported as set aside and left in place, but it still costs a step. On `INCOMPLETE`, `BLOCKED` or `NEEDS_CONTEXT` (never accepted as a finished scope), and on a `DONE` that left slices open, `keryx review retry-plan --manifest <dir>/manifest.json --result <raw/reviewer.txt>` decides and hands out the next wave of only the slices still uncovered (`--next-wave` plans one without a result). A result that covered a new slice spends no attempt; two results in a row with nothing new, or six failed rounds, print the `- **Not run:** …` line to paste into the report, and a result with nothing new first has its open slices cut in half. Dispatch each wave, save its reply, and run `retry-plan` again until it says `accepted`. Only the operator can accept a smaller set than that, with their own quote. An unfinished reviewer is never counted as a clean pass. **Persist every result.** Pass each reviewer `save_result_to: .metaproject/data/review/raw/<reviewer>.txt`: the host writes its full final text there, so `review complete` has raw evidence even after the transcript is cut. Build `research.json` with `keryx review ledger build <input-dir> --package <review-dir>` — never by hand: it reads the saved results, `scope-files.json` and the retry state, marks a reviewer complete only when its result is finished and its payloads cover the scope, and prints each gap with the exact next command and the waves left (a `retry-plan --next-wave` call, or a `--dispositions` entry for a raw finding). Run `keryx review ingest --research <review-dir>/research.json` once it prints READY (exit 0). A refused `review complete` names what is missing: fix that with a command and run it again; it is never `blocked`. `--head` wants a SHA (`git rev-parse HEAD`), not `HEAD`.
89
89
  5. **Never print a scope or a slice.** The tool output of one call is cut at about 20 KB and `keryx review scope` of a large PR prints hundreds of KB. Run it as `keryx review scope --ref <base> --json > .metaproject/data/review/scope.json` and read the counts from the file (`keryx ctx read`, or `jq`); take `<base>` from `git symbolic-ref refs/remotes/origin/HEAD`, not a guessed `origin/main`. Create `.metaproject/data/review/` first (`mkdir -p`) — a missing directory is a command to run, not a blocker. **Machine artifacts by direct write.** Save slices, payloads and results by redirection or `keryx ctx run --raw -- <command>`; the summarized form of `keryx ctx run` drops the bytes a later step parses.
90
+ 6. **Write mutants whole.** A testing reviewer's `mutant_candidates` cover every changed branch, code downstream of the diff included. Write them to `research-evidence/mutants.json` unchanged; you may not narrow them, and a candidate no pass has run still blocks the coverage gate (`SKILL.detail.md`, mutation pass).
91
+ 7. **Measure before `review-layout`.** Run the browser measurements (Playwright/Storybook) yourself and pass them as `context_files` in its first dispatch; they count as evidence when the command and viewport are named. A second `NEEDS_CONTEXT` from one role is Not run (`layout: no engine`), not another refill (`SKILL.detail.md`).
90
92
 
91
93
  ## Control
92
94
 
@@ -871,6 +873,7 @@ If the repository has local convention docs such as `CLAUDE.md`, `AGENTS.md`,
871
873
  | `src/core/**`, `core/**`, `shared/**`, `foundation/**` | `review-core-boundaries` |
872
874
  | `src/core/flow/**`, `src/graph/**`, `src/shared/flow/**` | `review-flow-graph` |
873
875
  | A `.tsx`/`.jsx`/`.css`/`.scss` hunk that adds or changes a sizing or spacing utility on a rendered element — `w-*`, `min-w-*`, `max-w-*`, `flex-*`, `basis-*`, `grid-cols-*`, `truncate`, `line-clamp-*`, `overflow-*`, `p*`/`m*`/`gap-*` and their logical `ps-*`/`pe-*`/`ms-*`/`me-*` forms, or an inline `style` carrying a dimension | `review-layout` |
876
+ | `pr-context.md` is in the package | `review-pr-contract` |
874
877
 
875
878
  These convention reviewers are additive: keep the generic reviewers selected by normal detection,
876
879
  then add the matching convention pass. Deduplicate reviewer names before dispatch.
@@ -1057,6 +1060,7 @@ Skipped reviewers:
1057
1060
  | `--core-boundaries` | `review-core-boundaries` |
1058
1061
  | `--flow-graph` | `review-flow-graph` |
1059
1062
  | `--layout` | `review-layout` |
1063
+ | `--pr-contract` | `review-pr-contract` |
1060
1064
  | `--all` | all reviewers above (including `review-clean-code`, `review-highload`, applicable legacy/profile reviewers, and project convention reviewers when local convention docs exist) |
1061
1065
  | `--verify` | `review-verifier`, AFTER all others; checks the consolidated findings by running something. Delete-only. |
1062
1066
  | (auto) | detected from diff file extensions — see Auto-detection table |
@@ -1113,12 +1117,7 @@ Dispatch selected reviewers in parallel when independent — this engine dispatc
1113
1117
  3. Wave C - **verification**: `review-verifier` over the consolidated findings, when blockers/majors
1114
1118
  exist, `--verify` is set, or the PR is high-risk. See below.
1115
1119
 
1116
- **Execution belongs to both B and C, and they execute for opposite reasons.**
1117
- Wave C runs a command to *decide the fate of a finding that exists*; it is
1118
- delete-only and can produce nothing. Wave B runs commands to *make findings* —
1119
- `review-testing-practices` deletes each gate the diff added and records whether
1120
- the suite goes red, and `review-layout` measures rendered geometry in a real
1121
- engine.
1120
+ **Execution belongs to both B and C, and they execute for opposite reasons.** Wave C runs a command to *decide the fate of a finding that exists*; it is delete-only and can produce nothing. Wave B runs commands to *make findings* — `review-testing-practices` deletes each gate the diff added and records whether the suite goes red, and `review-layout` measures rendered geometry in a real engine.
1122
1121
 
1123
1122
  The consequence is directional and easy to get wrong: **a surviving mutation is
1124
1123
  not something Wave C can hand you.** If Wave B skips its mutation pass, that
@@ -184,6 +184,15 @@
184
184
  "type": "string"
185
185
  }
186
186
  },
187
+ "follow_ups": {
188
+ "title": "Follow-ups handed back to the parent",
189
+ "description": "Work the reviewer could not do itself and leaves to the parent. Each entry becomes a research obligation `<raw>:fu<N>` (N counts from 1, in this order; a `Parent follow-up:` line after the REVIEW_RESULT block is appended) with status `unresolved`. It closes only through a disposition (`finding`, `refuted`, `out-of-scope` or `narrowed` with a \"what remains\" text); until then the ledger is not reconciled and the report lists it as not verified.",
190
+ "type": "array",
191
+ "items": {
192
+ "type": "string",
193
+ "minLength": 1
194
+ }
195
+ },
187
196
  "slices": {
188
197
  "title": "Slices this result covered",
189
198
  "description": "The ids of the dispatched slices the reviewer read. An entry is an id string, or an object with an `id`, an optional `coverage` note and an optional `status`. `keryx review retry-plan` and `ledger build` read both. An object counts as covered only when its `status` is absent or one of DONE, COMPLETE, COMPLETED, COVERED, OK (any case); any other status (NOT_READ, INCOMPLETE, SKIPPED, PARTIAL, …) leaves the slice uncovered and is named in the plan reason or the ledger gap. An entry with no id counts for nothing.",
@@ -89,6 +89,10 @@
89
89
  "type": "boolean",
90
90
  "description": "Run this dispatch on the caller's own model. Present in place of `provider`/`model`, never alongside them, when `keryx review tier` could not resolve a concrete model."
91
91
  },
92
+ "routed": {
93
+ "type": "boolean",
94
+ "description": "True when the provider/model were set by the routing table's `review` category (`keryx review tier` emits it on a routed pick) rather than tier-ranked discovery. Additive to `tier_resolution`, which still records how the tier resolved a model; the two questions are independent."
95
+ },
92
96
  "tier_resolution": {
93
97
  "type": "string",
94
98
  "enum": [
@@ -0,0 +1,191 @@
1
+ ---
2
+ name: review-pr-contract
3
+ model_tier: standard
4
+ description: |
5
+ Use when a review package carries pr-context.md and the claims the author made there
6
+ need checking against the diff: a checklist box ("Documentation needed", "Tests
7
+ updated", "Breaking change") that the diff contradicts, and documentation outside the
8
+ diff that still describes the behaviour the change altered. Dispatched by
9
+ review-orchestrator for --pr-contract, --all, or whenever pr-context.md is in the
10
+ package.
11
+ NOT for: whether the code is correct (review-logic), whether the tests can fail
12
+ (review-testing-practices), or comments that the diff itself adds (review-jev-comments).
13
+ triggers:
14
+ - "pr contract"
15
+ - "review pr contract"
16
+ - "pr checklist"
17
+ - "documentation needed"
18
+ metadata:
19
+ author: "MrCipherSmith"
20
+ version: "1.0.0"
21
+ category: "review"
22
+ flags: "--pr-contract"
23
+ compatible_harnesses: "cursor,codex,zed,opencode,claude"
24
+ origin: "authored"
25
+ license: "MIT"
26
+ ---
27
+
28
+ # Review — PR Contract
29
+
30
+ Reviewer for what the pull request says about itself.
31
+
32
+ This lane exists because nobody else reads the PR body. `review-logic` reads the code,
33
+ `review-testing-practices` reads the tests, and a retrospective review of a merged
34
+ frontend PR missed a documentation gap that sat in plain view: the body left
35
+ **Documentation needed** unticked while the diff added a user-visible table row, and
36
+ the page that describes that table lives outside the diff. The box and the diff
37
+ disagreed, and no reviewer held both.
38
+
39
+ A finding here is a disagreement between two things you can quote: a line of
40
+ `pr-context.md` and a line of the diff (or of a document). Nothing else is filed.
41
+
42
+ ---
43
+
44
+ ## What you are given
45
+
46
+ - `pr-context.md` — the PR body, its checklist, the review comments and the round-1
47
+ intent, written by `keryx review context-collect`. Read it before the diff.
48
+ - the diff slices the orchestrator supplies, as text in the task or as `context_files`;
49
+ - documentation the orchestrator chose to supply with them.
50
+
51
+ ## The checklist form
52
+
53
+ Work each checklist box and each stated claim of the PR body as one row. A row has four
54
+ cells, and a row with an empty cell is not a finding:
55
+
56
+ | Box or claim (quoted) | State | What the diff shows (`file:line`, quoted) | Expectation |
57
+ |---|---|---|---|
58
+ | `Documentation needed` | unticked | `src/admin/ExamplePage.tsx:41` adds a user-visible row label | A user-visible string or row is added, so the box should be ticked and a document should describe it |
59
+ | `Tests updated` | ticked | no `*.test.*`, `*.spec.*` or `*.stories.*` file is in the diff | A ticked box names a test change; the diff has none |
60
+
61
+ Boxes this lane checks, and the diff signal that contradicts each:
62
+
63
+ - **Documentation** unticked, while the diff adds or changes a user-visible string, table
64
+ column or row, route, menu entry, command flag, config key, or error message.
65
+ - **Tests updated** ticked, while no test, spec or story file is in the diff; or unticked
66
+ while one is.
67
+ - **Breaking change** unticked, while the diff removes or renames an exported symbol, a
68
+ route, a flag, a schema field or a persisted key; or ticked with no such change.
69
+ - **Screenshots / visual evidence** ticked, while `pr-context.md` links none.
70
+ - Any other box or claim: quote it and apply the same test — what would the diff contain
71
+ if the box were true?
72
+
73
+ A box that is not applicable (marked `N/A`) is read as the author's statement; contradict
74
+ it only with a quoted diff line.
75
+
76
+ ## Documentation outside the diff
77
+
78
+ When the diff changes behaviour a reader sees, look for documentation that describes it
79
+ and was not touched:
80
+
81
+ - `keryx gdgraph find <symbol>` and `keryx gdgraph affected <file>` — what points at the
82
+ changed file; a `.md` or docs-site file in the answer is a candidate.
83
+ - `keryx wiki ask "<the changed behaviour>"` and `keryx wiki query` — the wiki pages that
84
+ name it.
85
+ - `keryx ctx rg "<the old label or flag>" docs README.md` — the string the change
86
+ replaced, wherever it is still quoted.
87
+
88
+ Name the document, the line that describes the old behaviour, and the diff line that
89
+ moved it. A document that is merely related is not a finding.
90
+
91
+ ### On a shell host
92
+
93
+ A reviewer on a shell host is read-only: its file tool reads inside its working directory
94
+ and it runs no `keryx` command. Work from `pr-context.md` and the slices you were
95
+ supplied. If answering needs a document you were not given, do not guess it: reply
96
+ `STATUS: NEEDS_CONTEXT` and name the paths (or the symbol and the wiki query) the
97
+ orchestrator should supply. Rows you could settle from what you hold are still reported.
98
+
99
+ ## Severity
100
+
101
+ A disagreement between the body and the diff names no runtime trigger, so it is `minor`:
102
+ the code is correct; the cost is a reader who trusts the box. A document that describes
103
+ behaviour the diff now contradicts, where following it gives a wrong result, names its
104
+ trigger and outcome and may be `major`. A box you cannot settle from what you hold is
105
+ `info`, marked unverified, with the paths that would settle it.
106
+
107
+ ---
108
+
109
+ ## Class scope — required for `blocker` and `major`
110
+
111
+ Every `blocker` and `major` finding must carry `class_scope`: **every** site that holds
112
+ the shape you found, and **how you enumerated them** — the search you ran, or the guard
113
+ that derives the set.
114
+
115
+ A finding anchored to one `file:line` is a claim about one site. A document gap usually
116
+ has siblings: the README, the docs-site page and the in-app help each quote the same
117
+ label.
118
+
119
+ ```yaml
120
+ class_scope:
121
+ sites: ["docs/admin/query-log.md:12", "README.md:88"]
122
+ enumeration_method: "keryx ctx rg for the old column label across docs and README; 2 documents still use it"
123
+ ```
124
+
125
+ "I checked the others" is not an enumeration method. A single-entry `sites` list is a
126
+ claim that the class has exactly one member — make it deliberately, because
127
+ `review-finding.schema.json` accepts it and the next round tests it.
128
+
129
+ `minor` and `info` may omit it.
130
+
131
+ ```markdown
132
+ ### [F-NNN] Title
133
+
134
+ - **Severity**: blocker | major | minor | info
135
+ - **File**: path/to/file:line
136
+ - **Problem**: the box or claim, quoted, and the diff or document line that contradicts it
137
+ - **Why it matters**: who trusts the box or the document, and what they get wrong
138
+ - **Fix**: tick the box and add the document, or correct the claim
139
+ ```
140
+
141
+ Severity comes from **Severity (canonical)** in `review-orchestrator/SKILL.md`; no table
142
+ here. When dispatched, follow the `reviewer-input.schema.json` payload and return a
143
+ `REVIEW_RESULT` object compatible with
144
+ `.metaproject/skills/gdskills/review/review-orchestrator/reviewer-finding.schema.json`,
145
+ finding ids prefixed `PC-`, then a short summary; `NEEDS_CONTEXT` rather than a guess.
146
+
147
+ ### Shared laws (every reviewer)
148
+
149
+ 1. **A claim of runtime harm with no reproducible path is `info`.** If you cannot name the input, call, or condition that reaches the code, you have an observation, not a finding. Report it as `info` and say what would settle it.
150
+ 2. **Never flag the theoretical.** The path you describe must exist in the code under review. Do not report a safe API because it could be misused, or a pattern because it is often wrong elsewhere.
151
+ 3. **One finding per class, not one per occurrence.** When the same shape appears at several sites, report it once and list every site. Ten findings that are one finding hide the other nine problems.
152
+
153
+ Severity levels are defined once, in `review-orchestrator/SKILL.md` → **Severity (canonical)**. This reviewer does not restate them: `blocker` is the four merge-blocking shapes named there and nothing else, and the `major`/`minor` boundary is the trigger-and-outcome test.
154
+
155
+ ---
156
+
157
+ ## Red Flags
158
+
159
+ | Rationalization | Why it is wrong |
160
+ |----------------|-----------------|
161
+ | "The box is unticked, so documentation is missing." | The box is the claim under review, not the evidence. Quote the diff line that adds the user-visible string; with no such line there is nothing to file. |
162
+ | "There is probably a docs page for this." | Probably is not a path. Find the page with `gdgraph`, `wiki ask` or `ctx rg`, or — on a shell host — return `NEEDS_CONTEXT` naming what you need. |
163
+ | "`Tests updated` is ticked and the author is trustworthy." | Trust is not what this lane checks. If no test file is in the diff, the row is a disagreement and gets filed. |
164
+ | "I could not open the docs, so I assume they are current." | An unread document is unverified, not clean. Say which paths were not read. |
165
+ | "The PR body is long, I checked the main boxes." | Every box is a row. A box you skipped is a box the next round finds. |
166
+
167
+ ---
168
+
169
+ ## Verification
170
+
171
+ Report done only once all of these hold:
172
+
173
+ - Every checklist box in `pr-context.md` appears as a row in the checklist form, ticked or not, with the state quoted.
174
+ - Every finding quotes both sides: the box or claim, and the diff or document line that contradicts it.
175
+ - Every document named was found by a command or was supplied; none is inferred.
176
+ - On a shell host, each document you needed and did not hold is named in a `NEEDS_CONTEXT` reply.
177
+ - Every `blocker` and `major` carries `class_scope` with `sites` and an `enumeration_method` naming the search.
178
+ - The reply is the `REVIEW_RESULT` the Orchestrated Review Contract asks for, or `NEEDS_CONTEXT` naming the context that was missing — never a guess in its place.
179
+
180
+ <!-- keryx:layers-block:start -->
181
+ ## Project layers: graph, tests, wiki, memory
182
+
183
+ Your payload may carry a `layers` block for your files: the files the graph says are affected, symbol neighbours, related tests, the wiki pages that describe the files and the memory entries for their module. It is bounded, and `truncated` names what was cut and the command that fetches the rest. When the diff alone does not settle a question, ask the project. Each command below is read-only and calls no model.
184
+
185
+ - `keryx gdgraph affected <file>`: who depends on the file. Use it before you call a change contained, and whenever a changed export, signature or behaviour may have callers outside your files.
186
+ - `keryx test related <file>`: the tests that cover the file. Use it before you say a behaviour is untested, and to judge whether a test would fail if the code broke.
187
+ - `keryx wiki ask "<q>"`: what the project wiki says a module is for and which rules bind it. Use it when the intent of the code is unclear and the diff does not state it.
188
+ - `keryx memory search "<q>"`: recorded lessons, decisions and known mistakes. Use it before you report a pattern the project may already have decided on, and when a failure looks like one it has seen.
189
+
190
+ In a review package, `keryx gdgraph affected <file> --data-dir <package>/layers/graph` reads the graph built at the PR head; the tracked graph may be from another commit. A finding still rests on the code you read: what a layer says is a lead, not evidence. If a layer is empty or a command fails, say so in the report instead of guessing.
191
+ <!-- keryx:layers-block:end -->
@@ -85,14 +85,14 @@ this pass the class is unreachable for the whole round.
85
85
 
86
86
  ### Bounds
87
87
 
88
- - Mutate only gates inside **scope A**, one at a time, reverting each before the
88
+ - Run mutants one at a time on gates inside **scope A**, reverting each before the
89
89
  next. Never leave a mutation in the tree.
90
90
  - Run the **nearest** suite, not the full one. This is not a `test:mutation`
91
91
  sweep — those are slow, opt-in, and answer a different question.
92
- - Cap it: the gates the diff added, plus any gate a previous round asked for. If
93
- that set is larger than your budget, take the previous-round gates first and say
94
- in your summary how many you did not reach. A stated cap is a result; silence
95
- reads as a clean sweep.
92
+ - Propose, do not cap: `mutant_candidates` in your `REVIEW_RESULT` names a mutant (`id`, `file`, `anchor`, `replacement`,
93
+ optional `kind`: `behavior` (default) or `presentation`, for copy or styling only) for **every changed branch**, code downstream of the diff included.
94
+ Run what your budget allows, previous-round gates first, and say how many you did not reach:
95
+ the main session writes your list to `mutants.json` unchanged and runs the rest. Silence reads as a clean sweep.
96
96
  - If you cannot run the suite at all, say so and skip the pass. Do **not** report
97
97
  a mutation you did not run — a predicted result is `info` at best, and this
98
98
  reviewer has no use for one.
@@ -145,6 +145,10 @@
145
145
  "inherit": {
146
146
  "description": "Adaptive: the host picks the model. `keryx review tier` writes it — in place of `provider`/`model`, never alongside them — whenever it does not name a model other than the session's: no session was named by the caller (flags or KERYX_SESSION_PROVIDER/KERYX_SESSION_MODEL), the catalogue or the session model could not be ranked, or the tier resolved to the session's own model. The host resolves the accompanying `tier` against the models its own dispatch tool offers: `standard` on its session model, `deep` on its most capable class, `light` on its lighter class — each falling back to the session model when the host offers no choice. The alternative, an empty or stale `provider`/`model` pair, is schema-invalid or pins a model the runner may not have.",
147
147
  "type": "boolean"
148
+ },
149
+ "routed": {
150
+ "description": "True when the provider/model were set by the routing table's `review` category (`keryx review tier` emits it on a routed pick) rather than tier-ranked discovery. Additive to `tier_resolution`, which still records how the tier resolved a model; the two questions are independent.",
151
+ "type": "boolean"
148
152
  }
149
153
  }
150
154
  },