@verevoir/review 0.3.2 → 0.3.3

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
@@ -1,5 +1,8 @@
1
1
  # @verevoir/review
2
2
 
3
+ **Adopting it? Start with [docs/ADOPTING.md](docs/ADOPTING.md)**: review a change locally in one
4
+ command, or add it as a pre-push gate or a GitHub review stage.
5
+
3
6
  The review panel: lens-based diff review, fanned out across every declared
4
7
  lens with a shared cached prefix, aggregated into one verdict with a
5
8
  fail-closed outcome rule. One implementation, consumed by pump-internal
@@ -103,90 +106,20 @@ never folded into a generic failure.
103
106
 
104
107
  ## Consuming this library
105
108
 
106
- This is a **private** repository, consumed as a pinned git dependency --
107
- never via npm's registry, and never floating on a branch or tag.
108
-
109
- 1. **Pin the full commit sha**, in `git+https://` form, never the
110
- `github:owner/repo#sha` shorthand (npm's resolution of that
111
- shorthand's own transport -- ssh vs https -- isn't guaranteed, and
112
- the CI step below only works if npm goes over https):
113
-
114
- ```json
115
- "@verevoir/review": "git+https://github.com/verevoir/review.git#<full-sha>"
116
- ```
117
-
118
- A branch or tag is never acceptable here: this repo merges on green
119
- (see "Merge policy" below), with no human gate between a PR and
120
- `main` moving -- a consumer floating on `main` would pick up a change
121
- with no review of its own on this side of the boundary. Pin the exact
122
- sha you have actually run the ratchet against (or, for a mechanical
123
- bump with no behaviour change, the exact sha you have confirmed
124
- carries one).
125
-
126
- 2. **Add `REVIEW_READ_TOKEN` as a repository secret** in the consumer's
127
- own CI -- a fine-grained PAT scoped to `contents: read` on
128
- `verevoir/review` ONLY. This repository is private, and a consumer's
129
- ambient `GITHUB_TOKEN` cannot see a different private repository.
130
- `REVIEW_READ_TOKEN` is the operator's standing secret name for every
131
- consumer of this library -- used for nothing else, so it can stay
132
- scoped this narrowly without colliding with any other credential a
133
- consumer's CI might hold.
134
-
135
- 3. **Before `npm ci` (or equivalent), rewrite git URLs for this
136
- dependency's exact URL only** -- never every `https://github.com/`
137
- URL -- then run the install with NO lifecycle scripts, remove the
138
- rewrite immediately, and only then run the scripts that were skipped:
139
-
140
- ```yaml
141
- - run: |
142
- if [ -z "${{ secrets.REVIEW_READ_TOKEN }}" ]; then
143
- echo "::error::REVIEW_READ_TOKEN is not set (or is blank) -- required to install the private @verevoir/review git dependency over https."
144
- exit 1
145
- fi
146
- - run: git config --global url."https://x-access-token:${{ secrets.REVIEW_READ_TOKEN }}@github.com/verevoir/review.git".insteadOf "https://github.com/verevoir/review.git"
147
- - run: npm ci --ignore-scripts
148
- - run: git config --global --unset url."https://x-access-token:${{ secrets.REVIEW_READ_TOKEN }}@github.com/verevoir/review.git".insteadOf
149
- if: always()
150
- - run: npm rebuild
151
- - run: npm run build --prefix node_modules/@verevoir/review
152
- ```
153
-
154
- **Why `--ignore-scripts` plus the two steps after, not just the scoped
155
- rewrite and an unset** -- an earlier version of this guidance claimed
156
- scoping the rewrite to one URL meant the token was never exposed to
157
- "any other dependency's install script". That was wrong, caught by a
158
- lens-review round on a real consumer's cutover branch, scored
159
- blocking: URL scoping only limits which git operations the rewrite
160
- applies to. It does nothing to stop a DIFFERENT package's own
161
- install/postinstall script from reading `~/.gitconfig` directly, as a
162
- file -- any process in the job can do that regardless of which URL
163
- the rewrite names. `--ignore-scripts` is what actually closes this:
164
- no package's lifecycle script runs while the token is present. The
165
- rewrite is then unset (`if: always()`, so a failed `npm ci` does not
166
- leave it behind either), and only then do the deferred scripts run --
167
- `npm rebuild` for every other dependency's own install/postinstall
168
- (native modules among them, if your consumer has any), and an
169
- explicit build of **this package's own** `dist/` specifically,
170
- because it is gitignored here and normally produced by this
171
- package's `prepare` script -- which `--ignore-scripts` also skipped,
172
- and which `npm rebuild` does not run (rebuild covers preinstall/
173
- install/postinstall only, never prepare).
174
-
175
- **The precise claim this now supports**: the token exists in
176
- `~/.gitconfig` only across the url.insteadOf write and
177
- `npm ci --ignore-scripts` itself -- an install that runs no package
178
- script. Nothing after that point can read it. The one residual,
179
- accepted exposure is whatever npm does internally to fetch the git
180
- dependency during that one command -- unavoidable, since that is the
181
- minimum npm needs to resolve a private git dependency at all.
182
-
183
- Run this on every CI leg/matrix entry that installs dependencies -- a
184
- leg that skips it fails `npm ci` outright and loudly (a missing
185
- credential), never silently.
186
-
187
- This is the exact mechanism in place on `aigency-runtime-v2`'s own
188
- `verify.yml`, on its consumer-cutover branch (held for operator review,
189
- per that repository's own protected-paths rule).
109
+ `@verevoir/review` is **public on npm**. Install it from the registry, pinned to an exact version,
110
+ never a range or a dist-tag:
111
+
112
+ ```sh
113
+ npm install --save-exact @verevoir/review@0.3.2
114
+ ```
115
+
116
+ The package carries its own `dist/`, so no build step is needed. To run the command line without
117
+ installing anything, see "Command line" below and [docs/ADOPTING.md](docs/ADOPTING.md).
118
+
119
+ A pinned version matters because this repo merges on green (see "Merge policy" below), with no
120
+ human gate between a PR and `main` moving: a consumer floating on a range would pick up a change
121
+ with no review of its own on this side of the boundary. Pin the exact version you have actually
122
+ run the ratchet against.
190
123
 
191
124
  ## Round-aware review (opt-in)
192
125
 
@@ -304,8 +237,8 @@ blocking findings before calling `runPanel`/`runReviewNode` again.
304
237
  public one that cannot call governance's private reusable workflow:
305
238
 
306
239
  ```sh
307
- npx -y @verevoir/review aigency-review --base origin/main --title "My change"
308
- git diff origin/main...HEAD | npx -y @verevoir/review aigency-review
240
+ npx -y -p @verevoir/review@0.3.2 aigency-review --base origin/main --title "My change"
241
+ git diff origin/main...HEAD | npx -y -p @verevoir/review@0.3.2 aigency-review
309
242
  ```
310
243
 
311
244
  **Flags**
@@ -433,11 +366,17 @@ v2); no YAML library.
433
366
  - uses: actions/checkout@v4
434
367
  with:
435
368
  fetch-depth: 0
436
- - run: npx -y @verevoir/review@<next> aigency-review --base origin/${{ github.base_ref }} --title "${{ github.event.pull_request.title }}"
369
+ - run: npx -y -p @verevoir/review@0.3.2 aigency-review --base "origin/${BASE_REF}" --title "${PR_TITLE}"
437
370
  env:
438
371
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
372
+ BASE_REF: ${{ github.base_ref }}
373
+ PR_TITLE: ${{ github.event.pull_request.title }}
439
374
  ```
440
375
 
376
+ Values reach the shell through `env:`, never through `${{ }}` inside `run:`. For the hardened,
377
+ copy-and-use version (SHA-pinned actions, minimal permissions, check results, a corpus) see
378
+ [docs/ADOPTING.md](docs/ADOPTING.md) and `examples/github/review.yml`.
379
+
441
380
  ## Merge policy
442
381
 
443
382
  Merges on green, same as this organisation's other rebuild repos. "Green"
@@ -6,11 +6,23 @@ export declare function modelText(requested: string, calls: readonly RawCall[]):
6
6
  export declare function tierLine(downgraded: number): string | undefined;
7
7
  /** The one line saying how many smells tier enforcement promoted to [blocking]; none for 0. */
8
8
  export declare function promotedLine(promoted: number): string | undefined;
9
+ /** Said when Reject promotion was switched off because every changed file is documentation. */
10
+ export declare const PROMOTION_OFF_LINE = "Tier: promotion off: every changed file is documentation";
11
+ /** Said when the spec framing was added but there is no tier, so no promotion to switch off. */
12
+ export declare const SPEC_FRAMING_LINE = "Spec framing: every changed file is documentation (no tier signals, so promotion is not in play)";
13
+ /** Said when a changed path could not be determined, so the diff is not treated as documentation. */
14
+ export declare const PATH_UNDETERMINED_LINE = "Documentation check: a changed path could not be determined, so the diff is not treated as documentation (promotion stays on)";
15
+ /** The one line saying how the documentation check affected this review; none when it did not. */
16
+ export declare function docsLine(result: {
17
+ readonly promotionOff: boolean;
18
+ readonly docsOnly: boolean;
19
+ readonly pathUndetermined: boolean;
20
+ }): string | undefined;
9
21
  /**
10
22
  * The temperature the judging used. The claude-cli route has no temperature option
11
23
  * (`claude -p` documents no flag for it), so a requested one is said not to apply.
12
24
  */
13
25
  export declare function temperatureLine(route: RouteName, temperature: number | undefined): string;
14
- export declare function renderFooter(requested: string, route: RouteName, calls: readonly RawCall[], downgraded?: number, promoted?: number, temperature?: number): string;
26
+ export declare function renderFooter(requested: string, route: RouteName, calls: readonly RawCall[], downgraded?: number, promoted?: number, temperature?: number, docsNote?: string): string;
15
27
  /** One stderr line per lens. */
16
28
  export declare function usageLines(calls: readonly RawCall[]): string[];
@@ -42,6 +42,20 @@ export function promotedLine(promoted) {
42
42
  ? `Tier: ${String(promoted)} finding(s) promoted: quotes a Reject signal`
43
43
  : undefined;
44
44
  }
45
+ /** Said when Reject promotion was switched off because every changed file is documentation. */
46
+ export const PROMOTION_OFF_LINE = 'Tier: promotion off: every changed file is documentation';
47
+ /** Said when the spec framing was added but there is no tier, so no promotion to switch off. */
48
+ export const SPEC_FRAMING_LINE = 'Spec framing: every changed file is documentation (no tier signals, so promotion is not in play)';
49
+ /** Said when a changed path could not be determined, so the diff is not treated as documentation. */
50
+ export const PATH_UNDETERMINED_LINE = 'Documentation check: a changed path could not be determined, so the diff is not treated as documentation (promotion stays on)';
51
+ /** The one line saying how the documentation check affected this review; none when it did not. */
52
+ export function docsLine(result) {
53
+ if (result.promotionOff)
54
+ return PROMOTION_OFF_LINE;
55
+ if (result.docsOnly)
56
+ return SPEC_FRAMING_LINE;
57
+ return result.pathUndetermined ? PATH_UNDETERMINED_LINE : undefined;
58
+ }
45
59
  /**
46
60
  * The temperature the judging used. The claude-cli route has no temperature option
47
61
  * (`claude -p` documents no flag for it), so a requested one is said not to apply.
@@ -53,7 +67,7 @@ export function temperatureLine(route, temperature) {
53
67
  ? 'temperature: not supported on the claude-cli route'
54
68
  : `temperature: ${String(temperature)}`;
55
69
  }
56
- export function renderFooter(requested, route, calls, downgraded = 0, promoted = 0, temperature) {
70
+ export function renderFooter(requested, route, calls, downgraded = 0, promoted = 0, temperature, docsNote) {
57
71
  const lines = [
58
72
  '---',
59
73
  '',
@@ -67,6 +81,8 @@ export function renderFooter(requested, route, calls, downgraded = 0, promoted =
67
81
  const promotion = promotedLine(promoted);
68
82
  if (promotion !== undefined)
69
83
  lines.push('', promotion);
84
+ if (docsNote !== undefined)
85
+ lines.push('', docsNote);
70
86
  if (calls.length > 0) {
71
87
  lines.push('', 'Per-lens usage and wall time:', '');
72
88
  for (const call of byLens(calls)) {
@@ -24,7 +24,7 @@ import { buildPrompt, renderPrompt } from '../prompt.js';
24
24
  import { deriveLensSet, extractBar } from '../practice-lenses.js';
25
25
  import { buildFileContext, NO_FILE_CONTEXT_NOTE } from '../context-files.js';
26
26
  import { buildReferences } from '../references.js';
27
- import { promotedLine, renderFooter, temperatureLine, tierLine, usageLines } from './footer.js';
27
+ import { docsLine, promotedLine, renderFooter, temperatureLine, tierLine, usageLines, } from './footer.js';
28
28
  /** Governance's pinned default, sent to both routes. */
29
29
  export const DEFAULT_MODEL = 'claude-opus-5-5';
30
30
  /** Governance's default output-token budget (billed route only). */
@@ -515,10 +515,13 @@ export async function main(argv, env, io, deps) {
515
515
  const promotion = promotedLine(result.promoted);
516
516
  if (promotion !== undefined)
517
517
  io.stderr(`${promotion}\n`);
518
+ const docsNote = docsLine(result);
519
+ if (docsNote !== undefined)
520
+ io.stderr(`${docsNote}\n`);
518
521
  // Audit trail for tier decisions behind merged groups: stderr, not the report (see dedup.ts).
519
522
  for (const line of result.dedupAudit)
520
523
  io.stderr(line);
521
- const footer = `\n\n${renderFooter(model, route, calls, result.downgraded, result.promoted, temperature)}`;
524
+ const footer = `\n\n${renderFooter(model, route, calls, result.downgraded, result.promoted, temperature, docsNote)}`;
522
525
  const note = derived !== undefined && derived.untagged.length > 0
523
526
  ? `\n\n> ${String(derived.untagged.length)} practice(s) had no "lens:" tag and were ` +
524
527
  `not reviewed: ${derived.untagged.join(', ')}`
@@ -29,6 +29,32 @@ export interface DiffPath {
29
29
  * path so the caller can say it was skipped.
30
30
  */
31
31
  export declare function diffPaths(diff: string): DiffPath[];
32
+ /**
33
+ * EVERY path a diff changes, for deciding what kind of change it is (not for reading files).
34
+ * Unlike diffPaths it also sees what git writes WITHOUT `---`/`+++` headers: a pure rename
35
+ * (`rename from` / `rename to`), a copy, a binary change (`Binary files A and B differ`) and a
36
+ * mode-only change (the `diff --git` line itself). Both sides of a rename and the old path of a
37
+ * deletion are included, so moving code into a .md file is not a documentation change. A path
38
+ * that cannot be determined is reported as '' so the caller treats the diff as not understood.
39
+ * A diff with no `diff --git` lines (plain unified) falls back to diffPaths.
40
+ */
41
+ export declare function changedPaths(diff: string): string[];
42
+ /** Documentation by FILE TYPE (.md .mdx .rst .adoc, any depth; not .txt, which also names requirements.txt and CMakeLists.txt); a directory name alone is not enough. */
43
+ export declare function isDocumentationPath(path: string): boolean;
44
+ /**
45
+ * True when the diff changes at least one file and EVERY changed path is documentation. Drives
46
+ * the tier (promotion off) and the spec framing. Paths come from changedPaths; one that cannot
47
+ * be determined makes the diff NOT docs-only, so promotion stays on.
48
+ */
49
+ export declare function isDocumentationOnly(diff: string): boolean;
50
+ export interface DiffClassification {
51
+ /** At least one changed file, and every one is documentation. */
52
+ readonly documentationOnly: boolean;
53
+ /** At least one changed path could not be determined (so the diff is never docs-only). */
54
+ readonly undeterminedPath: boolean;
55
+ }
56
+ /** The single classification of a diff: callers compute it once and pass the answer down. */
57
+ export declare function classifyDiff(diff: string): DiffClassification;
32
58
  /** Why a diff-supplied path must not be read, or undefined if it is acceptable. */
33
59
  export declare function unsafePathReason(path: string): string | undefined;
34
60
  /**
@@ -69,6 +69,119 @@ export function diffPaths(diff) {
69
69
  }
70
70
  return out;
71
71
  }
72
+ function stripEither(path) {
73
+ return path.startsWith('a/') || path.startsWith('b/') ? path.slice(2) : path;
74
+ }
75
+ /** The `a/X b/X` header of a `diff --git` line, when both halves are the same path. */
76
+ function gitHeaderPath(line) {
77
+ const rest = line.slice('diff --git '.length);
78
+ if (rest.startsWith('"'))
79
+ return undefined;
80
+ const mid = (rest.length - 1) / 2;
81
+ if (!Number.isInteger(mid) || rest[mid] !== ' ')
82
+ return undefined;
83
+ const left = rest.slice(0, mid);
84
+ const right = rest.slice(mid + 1);
85
+ if (!left.startsWith('a/') || !right.startsWith('b/'))
86
+ return undefined;
87
+ return left.slice(2) === right.slice(2) ? left.slice(2) : undefined;
88
+ }
89
+ /**
90
+ * EVERY path a diff changes, for deciding what kind of change it is (not for reading files).
91
+ * Unlike diffPaths it also sees what git writes WITHOUT `---`/`+++` headers: a pure rename
92
+ * (`rename from` / `rename to`), a copy, a binary change (`Binary files A and B differ`) and a
93
+ * mode-only change (the `diff --git` line itself). Both sides of a rename and the old path of a
94
+ * deletion are included, so moving code into a .md file is not a documentation change. A path
95
+ * that cannot be determined is reported as '' so the caller treats the diff as not understood.
96
+ * A diff with no `diff --git` lines (plain unified) falls back to diffPaths.
97
+ */
98
+ export function changedPaths(diff) {
99
+ const lines = diff.split(/\r?\n/);
100
+ if (!lines.some((l) => l.startsWith('diff --git '))) {
101
+ return diffPaths(diff).map((p) => p.path);
102
+ }
103
+ const out = new Set();
104
+ let block = [];
105
+ const flush = () => {
106
+ if (block.length === 0)
107
+ return;
108
+ const found = [];
109
+ let bad = false;
110
+ const take = (raw, strip) => {
111
+ const value = unquote(raw);
112
+ if (value === undefined || value === '')
113
+ bad = true;
114
+ else if (value !== '/dev/null')
115
+ found.push(strip ? stripEither(value) : value);
116
+ };
117
+ for (const line of block.slice(1)) {
118
+ if (line.startsWith('@@'))
119
+ break;
120
+ if (line.startsWith('rename from '))
121
+ take(line.slice(12), false);
122
+ else if (line.startsWith('rename to '))
123
+ take(line.slice(10), false);
124
+ else if (line.startsWith('copy from '))
125
+ take(line.slice(10), false);
126
+ else if (line.startsWith('copy to '))
127
+ take(line.slice(8), false);
128
+ else if (line.startsWith('--- ') || line.startsWith('+++ '))
129
+ take(line.slice(4), true);
130
+ else {
131
+ const binary = /^Binary files (.+) and (.+) differ$/.exec(line);
132
+ if (binary !== null) {
133
+ take(binary[1] ?? '', true);
134
+ take(binary[2] ?? '', true);
135
+ }
136
+ }
137
+ }
138
+ if (found.length === 0 && !bad) {
139
+ const header = gitHeaderPath(block[0] ?? '');
140
+ if (header === undefined)
141
+ bad = true;
142
+ else
143
+ found.push(header);
144
+ }
145
+ if (bad)
146
+ out.add('');
147
+ for (const p of found)
148
+ out.add(p);
149
+ };
150
+ for (const line of lines) {
151
+ if (line.startsWith('diff --git ')) {
152
+ flush();
153
+ block = [line];
154
+ }
155
+ else if (block.length > 0) {
156
+ block.push(line);
157
+ }
158
+ }
159
+ flush();
160
+ return [...out];
161
+ }
162
+ /** A documentation file type, at any depth. BACKLOG.md is covered by .md. */
163
+ const DOCUMENTATION_FILE = /\.(?:md|mdx|rst|adoc)$/i;
164
+ /** Documentation by FILE TYPE (.md .mdx .rst .adoc, any depth; not .txt, which also names requirements.txt and CMakeLists.txt); a directory name alone is not enough. */
165
+ export function isDocumentationPath(path) {
166
+ return DOCUMENTATION_FILE.test(path);
167
+ }
168
+ /**
169
+ * True when the diff changes at least one file and EVERY changed path is documentation. Drives
170
+ * the tier (promotion off) and the spec framing. Paths come from changedPaths; one that cannot
171
+ * be determined makes the diff NOT docs-only, so promotion stays on.
172
+ */
173
+ export function isDocumentationOnly(diff) {
174
+ return classifyDiff(diff).documentationOnly;
175
+ }
176
+ /** The single classification of a diff: callers compute it once and pass the answer down. */
177
+ export function classifyDiff(diff) {
178
+ const paths = changedPaths(diff);
179
+ const undeterminedPath = paths.includes('');
180
+ return {
181
+ documentationOnly: paths.length > 0 && !undeterminedPath && paths.every((p) => isDocumentationPath(p)),
182
+ undeterminedPath,
183
+ };
184
+ }
72
185
  /** Why a diff-supplied path must not be read, or undefined if it is acceptable. */
73
186
  export function unsafePathReason(path) {
74
187
  if (path === '')
package/dist/prompt.d.ts CHANGED
@@ -76,6 +76,12 @@ export interface Subject {
76
76
  * `buildPrompt` below for exactly how that slice is picked out.
77
77
  */
78
78
  readonly priorFindings?: ReadonlyArray<PriorFinding>;
79
+ /**
80
+ * Whether every changed file is documentation (context-files.ts classifyDiff). A caller that
81
+ * has already classified the diff passes the answer so it is decided once; absent, buildPrompt
82
+ * classifies `diff` itself. True adds SPEC_FRAMING to the shared prefix.
83
+ */
84
+ readonly docsOnly?: boolean;
79
85
  }
80
86
  /**
81
87
  * The two halves of what a reviewer is shown.
@@ -116,6 +122,8 @@ export declare const NO_PRACTICES_NOTICE = "No practices were supplied for this
116
122
  * disclosed, not silently unnoticed.
117
123
  */
118
124
  export declare const NO_REQUIREMENT_NOTICE: string;
125
+ /** Added to the shared prefix when every changed file is documentation (see below). */
126
+ export declare const SPEC_FRAMING = "This change is a specification or plan. Block only where its text commits to behaviour a practice rejects. Where it merely doesn't rule out a case, raise a smell.";
119
127
  /** Heading of the shared check-results section (`Subject.checks`). */
120
128
  export declare const CHECKS_HEADING = "## Checks on this change";
121
129
  /** Heading of the shared resolved-references section (`Subject.references`). */
package/dist/prompt.js CHANGED
@@ -38,6 +38,7 @@
38
38
  // tests/review-round-aware.test.ts's own byte-identical pin, which
39
39
  // reproduces this file's PRE-feature template independently and asserts
40
40
  // equality against it.
41
+ import { isDocumentationOnly } from './context-files.js';
41
42
  import { REPLY_CONTRACT, RULING_MARKER, RULING_RESOLVED_WORD, RULING_UNRESOLVED_WORD, } from './replyContract.js';
42
43
  /** The two halves in the one order that preserves the shared prefix. */
43
44
  export function renderPrompt(prompt) {
@@ -63,6 +64,8 @@ export const NO_REQUIREMENT_NOTICE = 'No requirement text was supplied for this
63
64
  "context -- is this run's own account of its own work; there is nothing " +
64
65
  'from outside the run to check it against. Say so plainly in your ' +
65
66
  'reasoning, and judge only whether the change is coherent on its own terms.';
67
+ /** Added to the shared prefix when every changed file is documentation (see below). */
68
+ export const SPEC_FRAMING = "This change is a specification or plan. Block only where its text commits to behaviour a practice rejects. Where it merely doesn't rule out a case, raise a smell.";
66
69
  /** Heading of the shared check-results section (`Subject.checks`). */
67
70
  export const CHECKS_HEADING = '## Checks on this change';
68
71
  /** Heading of the shared resolved-references section (`Subject.references`). */
@@ -173,9 +176,11 @@ export function buildPrompt(lens, subject, practices, placement = 'shared') {
173
176
  const priorFindingsSection = hasPriorFindings
174
177
  ? ['', PRIOR_FINDINGS_HEADING, '', ...rulingSection, '', ...independentReviewSection]
175
178
  : [];
179
+ const specification = subject.docsOnly ?? isDocumentationOnly(subject.diff);
176
180
  const shared = [
177
181
  'You are one lens of a review panel. Several reviewers are reading this same change, each along a different dimension, and their verdicts are combined. Review only your own dimension: another lens is covering the rest, and a finding outside your dimension is noise in someone else’s report.',
178
182
  '',
183
+ ...(specification ? [SPEC_FRAMING, ''] : []),
179
184
  ...barInShared,
180
185
  '## Requirement',
181
186
  '',
@@ -68,6 +68,12 @@ export interface ReviewNodeResult {
68
68
  readonly downgraded: number;
69
69
  /** How many smells tier enforcement promoted to [blocking] (0 when none). */
70
70
  readonly promoted: number;
71
+ /** True when Reject promotion was switched off because every changed file is documentation. */
72
+ readonly promotionOff: boolean;
73
+ /** True when every changed file is documentation, whether or not a tier applied. */
74
+ readonly docsOnly: boolean;
75
+ /** True when a changed path could not be determined, so the diff was not treated as docs-only. */
76
+ readonly pathUndetermined: boolean;
71
77
  /** Stderr lines, one per member of every merged dedup group (empty when none merged). */
72
78
  readonly dedupAudit: readonly string[];
73
79
  }
@@ -29,6 +29,7 @@
29
29
  // - `citedContext` (a caller-curated prose field) is accepted and
30
30
  // forwarded if a caller supplies it, but nothing here computes one --
31
31
  // unchanged from v5, where this was also true.
32
+ import { classifyDiff } from './context-files.js';
32
33
  import { runPanel } from './panel.js';
33
34
  import { exitCodeFor, renderReport } from './report.js';
34
35
  import { selectReviewer } from './reviewer-selection.js';
@@ -113,12 +114,20 @@ export async function runReviewNode(input, onCall) {
113
114
  const dispatched = input.warmFirst === true && input.lensSet.lenses.length >= 2
114
115
  ? warmFirst(observedReviewer)
115
116
  : observedReviewer;
116
- const panel = await runPanel(input.lensSet, input.subject, dispatched, input.practices);
117
+ // The docs-only decision is made ONCE, here (context-files.ts classifyDiff). It reaches the
118
+ // prompt through the subject (spec framing, every mode) and the tier below (promotion off).
119
+ const docs = classifyDiff(input.subject.diff);
120
+ const subject = { ...input.subject, docsOnly: docs.documentationOnly };
121
+ const panel = await runPanel(input.lensSet, subject, dispatched, input.practices);
117
122
  const prompts = panel.prompts;
123
+ // Promotion is off when every changed file in the parsed diff is documentation
124
+ // (classified above by context-files.ts); with no tier signals there is no promotion to turn off.
125
+ const promotionOff = input.tierSignals !== undefined && docs.documentationOnly;
118
126
  // C1 runs before C2: de-duplication sees the tiers as enforced.
119
127
  const tiered = input.tierSignals === undefined
120
128
  ? { verdict: panel.verdict, downgraded: 0, promoted: 0 }
121
129
  : enforceTier(panel.verdict, input.tierSignals, {
130
+ ...(promotionOff ? { promote: false } : {}),
122
131
  ...(input.tierRejects === undefined ? {} : { rejects: input.tierRejects }),
123
132
  ...(input.practiceIds === undefined ? {} : { practiceIds: input.practiceIds }),
124
133
  });
@@ -151,6 +160,9 @@ export async function runReviewNode(input, onCall) {
151
160
  prompts,
152
161
  downgraded: tiered.downgraded,
153
162
  promoted: tiered.promoted,
163
+ promotionOff,
164
+ docsOnly: docs.documentationOnly,
165
+ pathUndetermined: docs.undeterminedPath,
154
166
  dedupAudit,
155
167
  };
156
168
  }
package/dist/tier.d.ts CHANGED
@@ -16,6 +16,11 @@ export interface TierContext {
16
16
  readonly rejects?: Readonly<Record<string, readonly string[]>>;
17
17
  /** Every practice id in the corpus, ignored when matching. */
18
18
  readonly practiceIds?: readonly string[];
19
+ /**
20
+ * False turns Reject-quote promotion off (every changed file is documentation; see
21
+ * context-files.ts isDocumentationOnly). The C1 downgrade is unaffected. Default true.
22
+ */
23
+ readonly promote?: boolean;
19
24
  }
20
25
  export interface TierResult {
21
26
  readonly verdict: Verdict;
package/dist/tier.js CHANGED
@@ -122,7 +122,8 @@ export function enforceTier(verdict, signals, context = {}) {
122
122
  if (!Object.prototype.hasOwnProperty.call(signals, result.lens))
123
123
  return result;
124
124
  const own = signals[result.lens] ?? [];
125
- const rejectLines = context.rejects !== undefined &&
125
+ const rejectLines = context.promote !== false &&
126
+ context.rejects !== undefined &&
126
127
  Object.prototype.hasOwnProperty.call(context.rejects, result.lens)
127
128
  ? (context.rejects[result.lens] ?? [])
128
129
  : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verevoir/review",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "The review panel: lens-based diff review. One implementation, consumed by pump-internal review, local preflight, eval-review, and CI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -18,7 +18,6 @@
18
18
  "files": [
19
19
  "dist",
20
20
  "lenses.default.json",
21
- "npm-shrinkwrap.json",
22
21
  "src",
23
22
  "tsconfig.json",
24
23
  "tsconfig.build.json",
package/src/cli/footer.ts CHANGED
@@ -53,6 +53,28 @@ export function promotedLine(promoted: number): string | undefined {
53
53
  : undefined;
54
54
  }
55
55
 
56
+ /** Said when Reject promotion was switched off because every changed file is documentation. */
57
+ export const PROMOTION_OFF_LINE = 'Tier: promotion off: every changed file is documentation';
58
+
59
+ /** Said when the spec framing was added but there is no tier, so no promotion to switch off. */
60
+ export const SPEC_FRAMING_LINE =
61
+ 'Spec framing: every changed file is documentation (no tier signals, so promotion is not in play)';
62
+
63
+ /** Said when a changed path could not be determined, so the diff is not treated as documentation. */
64
+ export const PATH_UNDETERMINED_LINE =
65
+ 'Documentation check: a changed path could not be determined, so the diff is not treated as documentation (promotion stays on)';
66
+
67
+ /** The one line saying how the documentation check affected this review; none when it did not. */
68
+ export function docsLine(result: {
69
+ readonly promotionOff: boolean;
70
+ readonly docsOnly: boolean;
71
+ readonly pathUndetermined: boolean;
72
+ }): string | undefined {
73
+ if (result.promotionOff) return PROMOTION_OFF_LINE;
74
+ if (result.docsOnly) return SPEC_FRAMING_LINE;
75
+ return result.pathUndetermined ? PATH_UNDETERMINED_LINE : undefined;
76
+ }
77
+
56
78
  /**
57
79
  * The temperature the judging used. The claude-cli route has no temperature option
58
80
  * (`claude -p` documents no flag for it), so a requested one is said not to apply.
@@ -71,6 +93,7 @@ export function renderFooter(
71
93
  downgraded = 0,
72
94
  promoted = 0,
73
95
  temperature?: number,
96
+ docsNote?: string,
74
97
  ): string {
75
98
  const lines = [
76
99
  '---',
@@ -83,6 +106,7 @@ export function renderFooter(
83
106
  if (tier !== undefined) lines.push('', tier);
84
107
  const promotion = promotedLine(promoted);
85
108
  if (promotion !== undefined) lines.push('', promotion);
109
+ if (docsNote !== undefined) lines.push('', docsNote);
86
110
  if (calls.length > 0) {
87
111
  lines.push('', 'Per-lens usage and wall time:', '');
88
112
  for (const call of byLens(calls)) {
package/src/cli/review.ts CHANGED
@@ -31,7 +31,14 @@ import { buildFileContext, NO_FILE_CONTEXT_NOTE } from '../context-files.js';
31
31
  import { buildReferences } from '../references.js';
32
32
  import type { DirEntry } from '../references.js';
33
33
  import type { RawCall } from '../raw-call.js';
34
- import { promotedLine, renderFooter, temperatureLine, tierLine, usageLines } from './footer.js';
34
+ import {
35
+ docsLine,
36
+ promotedLine,
37
+ renderFooter,
38
+ temperatureLine,
39
+ tierLine,
40
+ usageLines,
41
+ } from './footer.js';
35
42
  import type { RouteName } from './footer.js';
36
43
  import type { DerivedLenses } from '../practice-lenses.js';
37
44
 
@@ -642,9 +649,11 @@ export async function main(
642
649
  if (tier !== undefined) io.stderr(`${tier}\n`);
643
650
  const promotion = promotedLine(result.promoted);
644
651
  if (promotion !== undefined) io.stderr(`${promotion}\n`);
652
+ const docsNote = docsLine(result);
653
+ if (docsNote !== undefined) io.stderr(`${docsNote}\n`);
645
654
  // Audit trail for tier decisions behind merged groups: stderr, not the report (see dedup.ts).
646
655
  for (const line of result.dedupAudit) io.stderr(line);
647
- const footer = `\n\n${renderFooter(model, route, calls, result.downgraded, result.promoted, temperature)}`;
656
+ const footer = `\n\n${renderFooter(model, route, calls, result.downgraded, result.promoted, temperature, docsNote)}`;
648
657
  const note =
649
658
  derived !== undefined && derived.untagged.length > 0
650
659
  ? `\n\n> ${String(derived.untagged.length)} practice(s) had no "lens:" tag and were ` +