bmad-method-test-architecture-enterprise 1.25.2 → 1.26.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.
Files changed (28) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +18 -0
  3. package/DESIGN-CRITERIA-REGISTRY.md +12 -6
  4. package/cli/lib/agent-adapters.js +41 -2
  5. package/cli/lib/build-prompt.js +46 -9
  6. package/cli/lib/convention-baseline.js +144 -21
  7. package/cli/lib/parse-report.js +60 -2
  8. package/cli/lib/resolve-tea-config.js +39 -3
  9. package/cli/test-review.js +60 -9
  10. package/docs/explanation/test-review-cli-architecture.md +1 -1
  11. package/docs/reference/tea-test-review-cli.md +19 -9
  12. package/package.json +1 -1
  13. package/src/workflows/testarch/bmad-testarch-test-review/resources/test-review.example.md +8 -6
  14. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/criteria-registry.md +1 -1
  15. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-02-discover-tests.md +36 -17
  16. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03-quality-evaluation.md +44 -2
  17. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03b-subagent-isolation.md +5 -0
  18. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03c-subagent-maintainability.md +5 -0
  19. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03e-subagent-performance.md +5 -0
  20. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03f-aggregate-scores.md +6 -2
  21. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-04-generate-report.md +4 -0
  22. package/src/workflows/testarch/bmad-testarch-test-review/test-review-template.md +2 -0
  23. package/test/contracts/test-review.contract.json +3 -1
  24. package/test/fixtures/test-review-cli/reports/convention-baseline-unavailable.md +2 -0
  25. package/test/fixtures/test-review-cli/reports/convention-fabricated.md +2 -0
  26. package/test/fixtures/test-review-cli/reports/convention-honest-absent.md +2 -0
  27. package/test/fixtures/test-review-cli/stub-agent.js +9 -0
  28. package/test/test-test-review-cli.js +143 -15
@@ -31,7 +31,7 @@
31
31
  "name": "bmad-method-test-architecture-enterprise",
32
32
  "source": "./",
33
33
  "description": "Master Test Architect module for quality strategy, test automation, CI/CD quality gates, and structured testing education. Part of the BMad Method ecosystem.",
34
- "version": "1.25.2",
34
+ "version": "1.26.0",
35
35
  "author": {
36
36
  "name": "Murat K Ozcan (TEA Creator) & Brian (BMad) Madison"
37
37
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.26.0] - 2026-09-09
11
+
12
+ ### Changed
13
+
14
+ - `tea-test-review` spent most of a small review reading files that were not in the pull request. `step-02-discover-tests.md` §2b's convention sample was capped at 40 files, and the agent runs with no shell, so each sampled file cost its own `Read` turn on every run whatever the size of the diff. The agent now reads 8. Measured on a 50-file corpus with a one-file review set: 40 files and ~233 KB before, 8 files and ~47 KB after, five times fewer reads and a fifth of the bytes. Eight clears §2b's `sampled < 4` "corpus too small to infer a house rule" floor with margin and leaves the 0.5 established/emerging ratio a denominator that means something. The sampled count is published in the report and cross-checked by `parse-report.js`, so every report states the corpus it was judged against.
15
+ - The mechanical detector scan keeps the corpus it always had. It is a separate budget from the read set: `cli/lib/convention-baseline.js` opens those files itself and pays no agent turn, so it still scans the closest 40 while the agent reads 8 of them. `mechanicalSignal: false` is what forbids a report from citing any adoption for a key, and a zero observed over 8 files is much weaker evidence of absence than a zero observed over 40. Measured on this repository before the two budgets were split, `networkFirst` went from 1 of 40 to 0 of 8, which turns `emerging` into `absent` and removes the deduction entirely.
16
+ - The 8 are spread evenly across the closest 40. Every file in the reviewed file's own directory ranks at distance 0, so among those the ranking is the alphabetical tie-break and nothing else. At a cap of 40 against a 45-file corpus that never showed; at 8 the tie-break became the selector. Measured on this repository, a review anchored in `test/` sampled `eval-all.js` through `test-contracts.js` and nothing named `test-e*` or later was reachable by any review anchored there, on any run. Taking every `floor(i × 40 / 8)`-th file keeps the count, the determinism and the neighborhood, and the sample now spans `test/`, `test/lib/`, `test/schema/` and `test/fixtures/`.
17
+ - The convention corpus counts a file only when its name says it is a test, and skips `fixtures`, `__fixtures__`, `__mocks__` and `testdata` trees. `isTestFile` is deliberately wider, because the review set has to catch a changed helper under a test directory, and a directory-only match was never right for a baseline: on this repository it admitted `stub-agent.js` and a React component under `trace-eval/clean/src/tokens/`, and it admitted the seeded-defect specs under `test/fixtures/test-review-eval/seeded/` that the evals score against, so the house convention was partly established from deliberate anti-patterns. This repository's own corpus is now empty and its self-review reports the baseline as unavailable, which is the truthful answer for a repository whose only spec files are eval fixtures.
18
+ - The four quality workers in `step-03-quality-evaluation.md` can now actually run in parallel. Three things had to change together. `cli/lib/build-prompt.js` pinned `tea_execution_mode=sequential`; the `claude` adapter's tool list carried no delegation tool, so step-03's capability probe found no launcher; and `tea_capability_probe` was never stated at all, which resolves `auto` to `sequential` on every run whatever the mode says. The prompt now states both orchestration keys, and `cli/lib/agent-adapters.js` grants `Task` from the `scoped-artifact-writes` tier upward in both `--tools` and `--allowedTools`. A launched subagent inherits that list exactly, verified live against claude 2.1.266: the child reported `Agent, Edit, Glob, Grep, Read, Write` and no shell, so "the shell only under command-execution" holds at the workers too. `read-only` runners get no delegation, since the workers' declared outputs cannot exist at that tier, and `codex exec` exposes no subagent launcher, so `auto` resolves to `sequential` there and that adapter's run is unchanged.
19
+ - `tea_execution_mode` and `tea_capability_probe` resolve through the same chain the fragment keys do: `--execution-mode` / `--capability-probe` / `--no-capability-probe`, then `_bmad/tea/config.yaml`, then `src/module.yaml`. Both were previously stated in the prompt as literals, and the prompt declares that stated values beat `config.yaml`, so `tea_execution_mode: sequential` in the file that `docs/reference/troubleshooting.md` tells users to edit was being ignored.
20
+ - The report carries a `**Execution Mode**:` line and the verdict JSON carries `executionMode`, required on any run that measured a convention baseline. step-03 resolves its mode from a runtime capability probe and printed the result only to the agent's stdout, which the CLI reads only when the report is missing, so a run that asked for parallel workers and silently got `sequential` was indistinguishable afterwards from one that got what it asked for. `step-03f-aggregate-scores.md` also stopped hardcoding `subagent_execution: 'PARALLEL (4 quality dimensions)'` and `performance_gain: '~60% faster than sequential'` regardless of what ran.
21
+ - The CLI mints a `tea_run_id` and the workflow uses it as the timestamp in its four worker output paths. step-03 asked the agent to generate one with `new Date().toISOString()`, which an agent with no clock and no shell cannot execute, so it emitted a plausible string; nothing cleans `/tmp/tea-test-review-*` and step-03 checks only that the four files exist, so two runs landing on the same invented value would aggregate each other's scores.
22
+ - The default agent wall-clock timeout scales with the review set: 20 minutes plus 2 minutes per reviewed file, capped at the 30 minutes it used to be flat at, with `--timeout-ms` still overriding it. Nothing bounds how many turns the agent takes and no supported vendor CLI offers a turn cap (`claude` 2.1.266 has `--max-budget-usd` and no `--max-turns`; `codex exec` has neither), so the wall clock is the only vendor-agnostic bound. A one-file review that stopped making progress used to be indistinguishable from a slow one for half an hour.
23
+
24
+ ### Fixed
25
+
26
+ - Three statements of the module defaults that disagreed with `src/module.yaml` and with `cli/lib/resolve-tea-config.js`'s `MODULE_DEFAULTS`, which a test already holds equal to each other. `--use-pactjs-utils`'s help said the default is `false` and `--pact-mcp`'s said `none`; both are `true` and `"mcp"`. `docs/reference/tea-test-review-cli.md` repeated the same two wrong values and then advised the opposite of what the CLI does, telling a contract-testing repo it gets `tea_use_pactjs_utils: false` by default and should pass `--use-pactjs-utils` to change it. A repo on raw `@pact-foundation/pact` needs `--no-use-pactjs-utils`.
27
+
10
28
  ## [1.25.2] - 2026-09-09
11
29
 
12
30
  ### Fixed
@@ -63,7 +63,7 @@ or label-based locator now satisfies L1 outright.**
63
63
  ### The baseline is measured before it is judged against
64
64
 
65
65
  `step-02-discover-tests` §2b samples the corpus **outside the review set** (a PR must
66
- not establish or dilute the convention it is judged against), capped at 40 files
66
+ not establish or dilute the convention it is judged against), capped at 8 files
67
67
  closest-first, and classifies each convention on a pinned threshold: `< 4` files is
68
68
  `unknown`, `0` adopted is `absent`, `>= 50%` is `established`, otherwise `emerging`.
69
69
  Unmeasurable means every Convention row passes as `n/a` and the report says so.
@@ -269,9 +269,13 @@ code path that looked for one.
269
269
  Same fix as the score and the recommendation before it: stop trusting the agent to
270
270
  compute a number the CLI can compute itself. `cli/lib/convention-baseline.js` now
271
271
  performs §2b's sampling deterministically — `git ls-files`, the review set excluded,
272
- ranked closest-first by directory distance, capped at 40 — and, for the five keys
272
+ ranked closest-first by directory distance, capped at 8 — and, for the six keys
273
273
  with a literal recognized form (`priorityMarkers`, `testIds`, `networkFirst`,
274
- `dataFactories`, `fixtures`), scans the real sampled files for it. `bddNaming` and
274
+ `dataFactories`, `fixtures`, `playwrightUtils`), scans the real file content for it.
275
+ The scan reads a wider slice of the same ranking than the agent does, 40 files
276
+ against 8, because the CLI opens them itself and the agent pays a turn per file.
277
+ The zero-signal floor below is only as strong as the corpus it observed nothing in,
278
+ so the read set got cheaper and the scanned corpus stayed where it was. `bddNaming` and
275
279
  `assertionStyle` get no mechanical signal (no single token separates "adopted" from
276
280
  "not" for a naming style or a dialect choice) and stay agent-judged; the
277
281
  sampled/corpusSize grounding still applies to them.
@@ -282,7 +286,7 @@ already does, and `parse-report.js`'s `verifyConventionBaseline` binds every
282
286
  `**Convention Baseline**:` line to it, one direction strictly (the sampled/corpusSize
283
287
  counts must match exactly — they're 100% mechanical) and one direction only
284
288
  downward (a citation claiming nonzero adoption for a key the CLI's own scan found
285
- zero real occurrences of anywhere in the sampled corpus is rejected outright; a lower
289
+ zero real occurrences of anywhere in the scanned corpus is rejected outright; a lower
286
290
  or judgment-based count is left alone, because a regex cannot know intent and was
287
291
  never used to force a number up). Applied to #106's actual corpus, this scan finds
288
292
  zero `priorityMarkers` signal too — the same zero grep found — so the fabricated
@@ -292,8 +296,10 @@ Suite 8 reproduce this end-to-end against a real temp git repo built to the same
292
296
  shape (real neighbor test files, zero real priority markers anywhere).
293
297
 
294
298
  Not fixed here, and worth naming: the "seven keys" §2b measures don't map onto the
295
- registry's Convention gate class evenly. Only three published criteria are actually
296
- Convention-gated (`priorityMarkers` → L2, `testIds` → L3, `bddNaming` → L5);
299
+ registry's Convention gate class evenly. Four published criteria are
300
+ Convention-gated (`priorityMarkers` → L2, `testIds` → L3, `bddNaming` → L5, and
301
+ `playwrightUtils` → M9 and L9, added later and the only one whose classification
302
+ moves the score: M9 is MEDIUM at `established` and LOW at `emerging`);
297
303
  `networkFirst`/`dataFactories`/`fixtures` deliberately became Applicability-gated
298
304
  instead (see "The baseline is measured before it is judged against" above —
299
305
  popularity shouldn't demote a navigation race), and `assertionStyle` → L7 has no
@@ -72,6 +72,25 @@ const DEFAULT_CAPABILITIES = ['scoped-artifact-writes'];
72
72
 
73
73
  const WRITE_TOOLS = ['Write', 'Edit'];
74
74
  const COMMAND_TOOLS = ['Bash'];
75
+ // Delegation, which claude spells `Task` on the command line and exposes to the
76
+ // model as `Agent`. step-03-quality-evaluation.md dispatches four quality workers
77
+ // and resolves its execution mode from a runtime capability probe, so without this
78
+ // the probe finds no launcher and every headless run collapses to `sequential` no
79
+ // matter what tea_execution_mode says.
80
+ //
81
+ // A launched subagent inherits this exact list, verified live against claude
82
+ // 2.1.266 under the argv below: the child reported `Agent, Edit, Glob, Grep, Read,
83
+ // Write` and no shell. So "the shell only under command-execution" holds at the four
84
+ // workers as well as at the parent, and granting delegation does not widen the tool
85
+ // surface by the back door.
86
+ //
87
+ // It is granted from `scoped-artifact-writes` upward, that being the first tier
88
+ // at which the workers' declared outputs can exist: each writes
89
+ // /tmp/tea-test-review-<dimension>-<timestamp>.json, and step-03 section 5 aborts
90
+ // the workflow when one of them is missing. A `read-only` runner cannot finish
91
+ // that step in any mode, so handing it a launcher would widen the tool surface
92
+ // while enabling nothing.
93
+ const DELEGATE_TOOLS = ['Task'];
75
94
 
76
95
  /** The tier a capability list resolves to: the strongest one named. */
77
96
  function strongestCapability(capabilities = DEFAULT_CAPABILITIES) {
@@ -90,6 +109,7 @@ function claudeTools(capabilities) {
90
109
  ...(tier === 'read-only' ? [] : WRITE_TOOLS),
91
110
  'Glob',
92
111
  'Grep',
112
+ ...(tier === 'read-only' ? [] : DELEGATE_TOOLS),
93
113
  ...(tier === 'command-execution' ? COMMAND_TOOLS : []),
94
114
  ].join(',');
95
115
  }
@@ -196,8 +216,19 @@ const AGENT_ADAPTERS = {
196
216
  modelFlags: ['--model'],
197
217
  // --safe-mode strips repo customizations for the review run; --tools/
198
218
  // --allowedTools scope the run to the tool surface the caller's declared
199
- // capabilities allow: search and read always, write only above read-only,
200
- // the shell only under command-execution.
219
+ // capabilities allow: search and read always, write and delegation only
220
+ // above read-only, the shell only under command-execution. Both flags carry
221
+ // the same list because --tools decides what exists and --allowedTools
222
+ // decides what runs without a prompt; naming a tool in one and not the other
223
+ // either hides it or stops the headless run to ask about it.
224
+ //
225
+ // claude 2.1.266 has no turn cap to pair with these. `--max-budget-usd`
226
+ // is the nearest vendor bound and is reachable through the passthrough
227
+ // (--agent-arg --max-budget-usd --agent-arg 2.00); it stays out of this
228
+ // table for the same reason codex's reasoning effort does, since it is one
229
+ // vendor's flag and the other adapters cannot honor it. The vendor-agnostic
230
+ // bound is the wall-clock timeout in run-agent.js, which the CLI scales to
231
+ // the size of the review set.
201
232
  buildArgv: (extra = [], model, capabilities = DEFAULT_CAPABILITIES) => [
202
233
  '-p',
203
234
  '--output-format',
@@ -226,6 +257,14 @@ const AGENT_ADAPTERS = {
226
257
  // --skip-git-repo-check matters under --isolate, where the agent's cwd
227
258
  // is a fresh tmpdir with no .git.
228
259
  //
260
+ // No parallel-worker argv here, deliberately. `codex exec` exposes no
261
+ // subagent launcher for step-03's four quality workers to run on, so the
262
+ // prompt's tea_execution_mode=auto resolves through the capability probe to
263
+ // sequential on this adapter, which is the same run codex did before. The
264
+ // output contract, the aggregation, and the score are identical in either
265
+ // mode (step-03: "Mode changes orchestration only"), so codex loses the
266
+ // wall-clock gain and nothing else.
267
+ //
229
268
  // Reasoning effort is deliberately not pinned here. It is a second
230
269
  // unstated input (a local model_reasoning_effort = "max" costs ~10s even
231
270
  // on a one-word prompt, measured 2026-08-03), but it is codex-only, so
@@ -10,9 +10,11 @@
10
10
  * context_files stays an invocation-only wire so PR evidence can never become
11
11
  * a persistent user preference.
12
12
  *
13
- * It also states every TEA config key that step-01 branches on, resolved by
14
- * resolve-tea-config. An unstated key is one the agent decides for itself, which
15
- * makes knowledge loading differ between runs over identical files.
13
+ * It also states every TEA config key the workflow branches on: the fragment-loading
14
+ * keys step-01 reads, resolved by resolve-tea-config, and the orchestration pair
15
+ * step-03 reads (tea_execution_mode, tea_capability_probe). An unstated key is one
16
+ * the agent decides for itself. Two runs over identical files would then load
17
+ * different knowledge, or dispatch a different number of workers.
16
18
  *
17
19
  * Two file lists travel in the prompt, each as a JSON array inside its own
18
20
  * delimiters so paths are unambiguously data: the review set, which is scored,
@@ -67,20 +69,25 @@ function conventionBaselinePromptLines(conventionBaseline) {
67
69
  }
68
70
  return measured.mechanicalSignal
69
71
  ? [
70
- `- ${key}: mechanically scanned; at least one sampled file contains a recognized form. Read the sampled files`,
71
- ` yourself to judge the true adopted count (0-${conventionBaseline.sampled}) and record the observed form.`,
72
+ `- ${key}: mechanically scanned; at least one file in the wider scanned corpus contains a recognized form, so`,
73
+ ' this key is NOT ruled out. That corpus is larger than the sampled files below, so the form may well live in a',
74
+ ` file you were not asked to read. Judge the adopted count (0-${conventionBaseline.sampled}) from the sampled files alone and`,
75
+ ' record the observed form; 0 is a legitimate answer here and does not contradict the scan.',
72
76
  ]
73
77
  : [
74
- `- ${key}: mechanically scanned across all ${conventionBaseline.sampled} sampled files; zero occurrences of any`,
78
+ `- ${key}: mechanically scanned across all ${conventionBaseline.scanned} scanned files; zero occurrences of any`,
75
79
  ' recognized form were found. This convention MUST be reported as absent: adopted = 0. A report claiming ANY',
76
- ` nonzero adoption for ${key} will be rejected — the CLI already read every sampled file and found nothing.`,
80
+ ` nonzero adoption for ${key} will be rejected — the CLI already read every scanned file and found nothing.`,
77
81
  ];
78
82
  });
79
83
  return [
80
84
  "step-02-discover-tests.md §2b's convention baseline has already been computed for this run. Do not sample, glob,",
81
85
  'or guess this yourself — the corpus and the counts below came from actually reading the files named, not the',
82
86
  'reviewed files themselves (sampling the review set to judge the review set would be circular).',
83
- `- corpusSize: ${conventionBaseline.corpusSize}, sampled: ${conventionBaseline.sampled}`,
87
+ `- corpusSize: ${conventionBaseline.corpusSize}, sampled: ${conventionBaseline.sampled}, scanned: ${conventionBaseline.scanned}`,
88
+ 'sampled is the list below, the files you read. scanned is how many files the CLI ran its own mechanical detectors',
89
+ 'over, which is wider because it costs the CLI a file read and costs you nothing. Cite sampled, never scanned:',
90
+ 'the scanned corpus only decides which conventions the CLI has already ruled out for you.',
84
91
  `The "**Convention Baseline**:" line must read exactly: ${conventionBaseline.sampled} test files sampled outside the review set`,
85
92
  'Sampled files (read exactly these; do not substitute, add, or drop any):',
86
93
  '---BEGIN CONVENTION CORPUS---',
@@ -126,6 +133,14 @@ function conventionBaselinePromptLines(conventionBaseline) {
126
133
  * only --test-glob put there and that no built-in rule recognizes. The CLI
127
134
  * cannot know whether a registry row attached, so it names them and the agent
128
135
  * applies criteria-registry rule 4 rather than publishing 100 - 0 = 100.
136
+ * @param {string} [options.runId] - Unique id for this run, minted by the CLI. The
137
+ * workflow's step-03 uses it verbatim as the `timestamp` in its worker output
138
+ * paths. step-03 asks the agent to generate one with `new Date().toISOString()`,
139
+ * which a headless agent with no shell cannot execute: it emits a plausible
140
+ * string instead, and two runs on one machine can land on the same one. Nothing
141
+ * cleans /tmp/tea-test-review-*, and step-03 section 5 checks existence only, so
142
+ * a repeat would aggregate a previous run's scores. A real unique value from the
143
+ * caller removes the class.
129
144
  * @param {object} [options.conventionBaseline] - step-02-discover-tests.md §2b's
130
145
  * "convention baseline", pre-computed by cli/lib/convention-baseline.js instead of
131
146
  * left to the agent to sample. `{ baselineUnavailable: true, reason }` or
@@ -149,6 +164,7 @@ function buildPrompt({
149
164
  unscorableTestArtifacts = [],
150
165
  forcedUnscorableCandidates = [],
151
166
  conventionBaseline,
167
+ runId = '',
152
168
  }) {
153
169
  const absoluteSkillRoot = path.resolve(skillRoot);
154
170
  const absoluteOutputPath = path.resolve(outputPath);
@@ -186,7 +202,9 @@ function buildPrompt({
186
202
  `review_scope=${reviewScope}`,
187
203
  `test_dir=${testDir}`,
188
204
  'tea_browser_automation=none',
189
- 'tea_execution_mode=sequential',
205
+ `tea_execution_mode=${teaConfig.tea_execution_mode}`,
206
+ `tea_capability_probe=${teaConfig.tea_capability_probe}`,
207
+ ...(runId ? [`tea_run_id=${runId}`] : []),
190
208
  `tea_use_playwright_utils=${teaConfig.tea_use_playwright_utils}`,
191
209
  `tea_use_pactjs_utils=${teaConfig.tea_use_pactjs_utils}`,
192
210
  `tea_pact_mcp=${teaConfig.tea_pact_mcp}`,
@@ -197,6 +215,25 @@ function buildPrompt({
197
215
  'fragment set, Pact MCP) instead of inferring the flags.',
198
216
  'The two *_installed values above were read from the project manifest by the CLI. Do not re-derive them, and do',
199
217
  'not open package.json: they are the second half of each mandate gate, stated here for the same reason the flags are.',
218
+ 'tea_execution_mode and tea_capability_probe are the orchestration pair step-03-quality-evaluation.md branches on,',
219
+ 'resolved the same way the fragment keys above are: an explicit CLI flag, then _bmad/tea/config.yaml, then',
220
+ 'src/module.yaml\'s default. Both are stated because step-03 reads both, and "auto" with probing off resolves to',
221
+ 'sequential on every run.',
222
+ 'Whichever mode resolves, dispatch every quality worker with the full subagentContext step-03 section 1 assembles,',
223
+ 'written out in the launch prompt itself: the review set, the criteria-registry path resolved to an absolute path,',
224
+ 'the convention baseline block stated above verbatim, and the playwright_utils_installed / pactjs_utils_installed',
225
+ 'values. A worker that has to guess any of those scores rows it cannot see, which silently removes deductions.',
226
+ ...(runId
227
+ ? [
228
+ `Use tea_run_id (${runId}) verbatim wherever step-03-quality-evaluation.md section 1 says to generate a`,
229
+ '`timestamp`, and pass that same value to every worker and to step 3F. Do not generate one: you have no clock,',
230
+ 'nothing cleans /tmp/tea-test-review-*, and step-03 section 5 checks only that the files exist, so an invented',
231
+ "value that collided with an earlier run's would aggregate that run's scores into this report.",
232
+ ]
233
+ : []),
234
+ 'Report the mode you actually ran in as exactly one "**Execution Mode**: <mode>" line in the Executive Summary,',
235
+ 'where <mode> is agent-team, subagent, or sequential. Never write "auto" there: auto is the request, and this line',
236
+ 'records what the capability probe resolved it to.',
200
237
  'playwrightUtilsActive = tea_use_playwright_utils AND playwright_utils_installed; when true, load',
201
238
  'playwright-utils-mandate.md and score registry rows M9 and L9. pactjsUtilsActive = tea_use_pactjs_utils AND',
202
239
  'pactjs_utils_installed; when true, load pactjs-utils-mandate.md and score registry row M10.',
@@ -18,19 +18,24 @@
18
18
  * is never asked to produce a number this module can produce itself.
19
19
  *
20
20
  * Two tiers of grounding:
21
- * - corpusSize / sampled / sampledFiles: 100% mechanical (git ls-files, isTestFile,
22
- * directory-distance ranking, a 40-file cap) — this is exactly step-02 §2b's
23
- * sampling rules, just executed by code instead of described in prose. A report
24
- * that cites a different sampled count, or a different corpus, is provably wrong
25
- * and rejected outright.
21
+ * - corpusSize / sampled / sampledFiles: 100% mechanical (git ls-files, a real test
22
+ * filename with fixture trees excluded, directory-distance ranking, then a stride
23
+ * of 8 across the closest 40) this is exactly step-02 §2b's sampling rules, just
24
+ * executed by code instead of described in prose. A report
25
+ * that cites a different sampled count is provably wrong and rejected outright.
26
+ * (verifyConventionBaseline checks the count; the corpus itself travels in the
27
+ * prompt as a do-not-substitute list and is not re-derivable from the report.)
26
28
  * - adopted counts for the six keys with a concrete, literal recognized form
27
29
  * (priorityMarkers, testIds, networkFirst, dataFactories, fixtures, playwrightUtils): a generous,
28
30
  * high-recall regex scan over the real sampled files' real content. This is not
29
31
  * claimed to be a precise semantic judgment — a regex cannot know intent — but it
30
32
  * gives a safe one-directional floor: when the scan finds ZERO occurrences of any
31
- * recognized form anywhere in the entire sampled corpus, the true adopted count is
33
+ * recognized form anywhere in the entire scanned corpus, the true adopted count is
32
34
  * zero with very high confidence (a looser scan finding nothing all but rules out
33
35
  * a careful reader finding something), so the report may never claim otherwise.
36
+ * That scan reads up to 40 files, wider than the 8 the agent is asked to read,
37
+ * because this module opens them itself and pays no agent turn for any of them.
38
+ * The floor is only as strong as the corpus it observed nothing in.
34
39
  * The two keys with no literal recognized form (bddNaming — a naming *style*, not
35
40
  * a token; assertionStyle — dialect consistency) get no mechanical signal and stay
36
41
  * fully agent-judged; only their sampled/corpusSize grounding applies. This is an
@@ -59,7 +64,31 @@ const CONVENTION_KEYS = [
59
64
  'playwrightUtils',
60
65
  ];
61
66
 
62
- const MAX_SAMPLED_FILES = 40;
67
+ // The sample is the review's single largest input, and it is paid on every run
68
+ // regardless of how small the pull request is. The agent has no shell (see
69
+ // agent-adapters.js's claude tool list), so a sampled file can only be opened one
70
+ // Read at a time: the cap is a turn budget as much as a byte budget. At 40, and
71
+ // measured on this repository against a one-file review set, the sample was
72
+ // ~609 KB of files that were not in the diff; at 8 it is ~289 KB, over five times
73
+ // fewer reads.
74
+ //
75
+ // Eight keeps the two properties the cap has to keep. It stays above
76
+ // step-02-discover-tests.md §2b's `sampled < 4` "corpus too small to infer a
77
+ // house rule" floor with margin, and it leaves enough files for §2b's 0.5
78
+ // established/emerging ratio to mean something. Changing this changes the number
79
+ // every report cites, so step-02 §2b's "Sampling rules" states the same figure.
80
+ const MAX_SAMPLED_FILES = 8;
81
+
82
+ // The mechanical scan is a different budget from the read set, so it gets its own
83
+ // cap. Nothing the agent does bounds it: this module opens the files itself with
84
+ // fs.readFileSync and runs six regexes, which costs milliseconds and no turns at
85
+ // all. Shrinking it alongside the read set would have quietly weakened the one
86
+ // claim the scan exists to make. `mechanicalSignal: false` is what forbids the
87
+ // report from citing any adoption for a key (see conventionBaselinePromptLines
88
+ // and parse-report.js's verifyConventionBaseline), and a zero observed over 8
89
+ // files is much weaker evidence of absence than a zero observed over 40. The
90
+ // floor keeps its old corpus; only the agent's reading got cheaper.
91
+ const MAX_SCANNED_FILES = 40;
63
92
  const MIN_CORPUS_TO_ATTEMPT = 1; // 0 eligible files outside the review set: nothing to sample at all.
64
93
 
65
94
  // One regex per mechanically-recognizable key, matched against a sampled file's raw
@@ -85,6 +114,48 @@ const MECHANICAL_DETECTORS = {
85
114
  playwrightUtils: /(?:from|require\s*\(|import\s*\()\s*['"`]@seontechnologies\/playwright-utils/i,
86
115
  };
87
116
 
117
+ // §2b says "Sample test files". `isTestFile` is deliberately wider than that,
118
+ // because the review set has to catch a changed helper sitting under a test
119
+ // directory, and a directory-only match is enough there. It is not enough here. A
120
+ // convention baseline built from files that are not tests measures the wrong thing:
121
+ // on this repository the directory rule pulled in `stub-agent.js` and a React
122
+ // component under `trace-eval/clean/src/tokens/`, and the house Playwright
123
+ // convention was being established partly from those.
124
+ const NAMED_TEST_FILE_PATTERNS = [
125
+ /^test_.*\.py$/,
126
+ /_test\.py$/,
127
+ /_test\.go$/,
128
+ /_spec\.rb$/,
129
+ /\.cy\.[^.]+$/,
130
+ /Test\.php$/,
131
+ /Tests?\.cs$/,
132
+ /Tests?\.java$/,
133
+ /\.pacttest\.[^.]+$/,
134
+ /\.test\.[^.]+$/,
135
+ /\.spec\.[^.]+$/,
136
+ /\.test-[^.]+\.[^.]+$/,
137
+ ];
138
+
139
+ // A repository's test-tooling fixtures are not its test suite. A spec under a
140
+ // directory that declares itself fixture data is an input to some other test, and
141
+ // the sharpest case is a seeded-defect corpus: this repository keeps deliberately
142
+ // bad specs under `test/fixtures/test-review-eval/seeded/` for its own evals to
143
+ // score against, and sampling those to establish "what this repo does" would
144
+ // establish the anti-patterns. Costs a consumer repository nothing, since spec
145
+ // files rarely live under a fixtures directory there, and when the exclusion empties
146
+ // the corpus the baseline reports itself unavailable rather than guessing.
147
+ const FIXTURE_TREE_PATTERN = /(^|\/)(fixtures|__fixtures__|__mocks__|testdata)(\/|$)/;
148
+
149
+ /** Whether a path is a real test file for baseline purposes: named like one, and not fixture data. */
150
+ function isConventionCorpusFile(filePath) {
151
+ const normalized = filePath.replaceAll('\\', '/');
152
+ if (FIXTURE_TREE_PATTERN.test(normalized)) {
153
+ return false;
154
+ }
155
+ const basename = normalized.slice(normalized.lastIndexOf('/') + 1);
156
+ return NAMED_TEST_FILE_PATTERNS.some((pattern) => pattern.test(basename));
157
+ }
158
+
88
159
  const MECHANICAL_CONVENTION_KEYS = Object.keys(MECHANICAL_DETECTORS);
89
160
  // bddNaming and assertionStyle: no literal token distinguishes "adopted" from "not",
90
161
  // so no mechanical signal is offered for them. Grounded on sampled/corpusSize only.
@@ -138,16 +209,18 @@ function closestDistance(candidateDir, reviewedDirs) {
138
209
  }
139
210
 
140
211
  /**
141
- * Mechanically measure adoption of the five literal-form conventions across the
142
- * already-sampled files' real content.
212
+ * Mechanically measure adoption of the six literal-form conventions across the
213
+ * scanned files' real content.
143
214
  *
144
215
  * @param {object} options
145
216
  * @param {string} options.projectRoot
146
217
  * @param {string[]} options.sampledFiles - Repo-relative paths, already capped/ranked.
218
+ * This is the scanned corpus, which is wider than the agent's read set; see
219
+ * computeConventionBaseline's `scanned` field.
147
220
  * @returns {object} One entry per CONVENTION_KEYS, `{ mechanical: false }` for the
148
221
  * two judgment-only keys, `{ mechanical: true, adopted, mechanicalSignal }` for
149
222
  * the rest. `mechanicalSignal` is `adopted > 0`, i.e. whether the scan found the
150
- * convention anywhere at all in the sampled corpus.
223
+ * convention anywhere at all in the scanned corpus.
151
224
  */
152
225
  function measureConventions({ projectRoot, sampledFiles }) {
153
226
  const conventions = {};
@@ -174,36 +247,82 @@ function measureConventions({ projectRoot, sampledFiles }) {
174
247
  return conventions;
175
248
  }
176
249
 
250
+ /**
251
+ * Take `count` entries spread evenly across `ranked`, always including its head.
252
+ *
253
+ * Taking the first `count` instead would be wrong in a way that hides itself.
254
+ * Every file in the reviewed file's own directory has distance 0, so within that
255
+ * directory the ranking is the alphabetical tie-break and nothing else. At a cap of
256
+ * 40 against a 45-file corpus that never showed, because the sample was almost the
257
+ * whole corpus. At a cap of 8 the tie-break becomes the selector: measured on this
258
+ * repository, a review anchored in `test/` sampled `eval-all.js` through
259
+ * `test-contracts.js` and nothing named `test-e*` or later was reachable by any
260
+ * review, ever. That is a systematic wrong draw rather than a noisy one, and a
261
+ * systematic draw has no error bars to argue about.
262
+ *
263
+ * Striding keeps every property the head had. It is deterministic (same ranking,
264
+ * same indices, same files on every machine), it stays inside the reviewed file's
265
+ * neighborhood (the whole stride runs over the closest `scanCap` files), and it
266
+ * costs the same `count` reads. What it adds is that the sample spans the corpus
267
+ * it claims to describe.
268
+ *
269
+ * @param {string[]} ranked - Ranked file list, closest-first.
270
+ * @param {number} count - How many to take.
271
+ * @returns {string[]} Up to `count` files, in ranked order.
272
+ */
273
+ function strideSelect(ranked, count) {
274
+ if (count >= ranked.length) {
275
+ return [...ranked];
276
+ }
277
+ const picked = [];
278
+ for (let index = 0; index < count; index++) {
279
+ picked.push(ranked[Math.floor((index * ranked.length) / count)]);
280
+ }
281
+ return picked;
282
+ }
283
+
177
284
  function unavailable(reason) {
178
- return { baselineUnavailable: true, reason, corpusSize: 0, sampled: 0, sampledFiles: [], conventions: {} };
285
+ return { baselineUnavailable: true, reason, corpusSize: 0, sampled: 0, scanned: 0, sampledFiles: [], conventions: {} };
179
286
  }
180
287
 
181
288
  /**
182
289
  * Compute the convention baseline for a review run: the real corpus outside the
183
- * review set, ranked closest-first by directory distance and capped at 40 (per
184
- * step-02-discover-tests.md §2b's sampling rules), plus a mechanical adoption scan
185
- * over the sampled files' real content.
290
+ * review set, ranked closest-first by directory distance, plus a mechanical
291
+ * adoption scan over real file content.
292
+ *
293
+ * Two corpora come out of the one ranking, and they are different sizes on purpose.
294
+ * `scanned` (the closest 40) is how many files this module opened itself for the
295
+ * mechanical detectors. `sampledFiles` (8, per step-02-discover-tests.md §2b's
296
+ * sampling rules) is a stride across those 40, and it is what the agent is told to
297
+ * read and the denominator every report cites. The first is not a turn budget and
298
+ * the second is, so keeping the scan wide costs milliseconds and keeps the
299
+ * zero-signal floor standing on the evidence it always had.
186
300
  *
187
301
  * @param {object} options
188
302
  * @param {string} options.projectRoot - Repo root (git ls-files runs here).
189
303
  * @param {string[]} options.reviewFiles - The review set; excluded from sampling and
190
304
  * used as the distance anchor.
191
- * @param {number} [options.cap] - Sample size cap (default 40, matching step-02).
305
+ * @param {number} [options.cap] - Agent read-set cap (default 8, matching step-02).
306
+ * @param {number} [options.scanCap] - Mechanical scan cap (default 40). Raised to
307
+ * `cap` when it is smaller, since the read set is always drawn from the scanned
308
+ * corpus.
192
309
  * @returns {object} `{ baselineUnavailable: true, reason, ... }` when no corpus
193
310
  * exists outside the review set (or git ls-files failed), otherwise
194
- * `{ baselineUnavailable: false, corpusSize, sampled, sampledFiles, conventions }`.
311
+ * `{ baselineUnavailable: false, corpusSize, sampled, scanned, sampledFiles, conventions }`.
195
312
  */
196
- function computeConventionBaseline({ projectRoot, reviewFiles, cap = MAX_SAMPLED_FILES }) {
313
+ function computeConventionBaseline({ projectRoot, reviewFiles, cap = MAX_SAMPLED_FILES, scanCap = MAX_SCANNED_FILES }) {
197
314
  const tracked = listGitTrackedFiles(projectRoot);
198
315
  if (tracked === null) {
199
316
  return unavailable('could not list repository files (git ls-files failed; not a git repo, or a git error)');
200
317
  }
201
318
 
202
319
  const reviewSet = new Set(reviewFiles.map((file) => file.replaceAll('\\', '/')));
203
- const eligible = tracked.filter((file) => isTestFile(file) && !reviewSet.has(file));
320
+ const eligible = tracked.filter((file) => isTestFile(file) && isConventionCorpusFile(file) && !reviewSet.has(file));
204
321
  const corpusSize = eligible.length;
205
322
  if (corpusSize < MIN_CORPUS_TO_ATTEMPT) {
206
- return unavailable('no test files exist outside the review set to measure a house convention against');
323
+ return unavailable(
324
+ 'no test files exist outside the review set to measure a house convention against (fixture and test-data trees are excluded)',
325
+ );
207
326
  }
208
327
 
209
328
  const reviewedDirs = reviewFiles.length > 0 ? reviewFiles.map(directoryOf) : [''];
@@ -213,15 +332,17 @@ function computeConventionBaseline({ projectRoot, reviewFiles, cap = MAX_SAMPLED
213
332
  // the same sampled set on every machine regardless of the runtime's ICU/locale
214
333
  // data, or "deterministic sampling" would itself be an environment-dependent claim.
215
334
  .sort((a, b) => a.distance - b.distance || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
216
- const sampledFiles = ranked.slice(0, cap).map((entry) => entry.file);
335
+ const scannedFiles = ranked.slice(0, Math.max(cap, scanCap)).map((entry) => entry.file);
336
+ const sampledFiles = strideSelect(scannedFiles, cap);
217
337
 
218
338
  return {
219
339
  baselineUnavailable: false,
220
340
  reason: null,
221
341
  corpusSize,
222
342
  sampled: sampledFiles.length,
343
+ scanned: scannedFiles.length,
223
344
  sampledFiles,
224
- conventions: measureConventions({ projectRoot, sampledFiles }),
345
+ conventions: measureConventions({ projectRoot, sampledFiles: scannedFiles }),
225
346
  };
226
347
  }
227
348
 
@@ -234,4 +355,6 @@ module.exports = {
234
355
  directoryDistance,
235
356
  directoryOf,
236
357
  measureConventions,
358
+ strideSelect,
359
+ isConventionCorpusFile,
237
360
  };
@@ -71,6 +71,11 @@ const { SEVERITY_ENUM } = require('./registry-rows');
71
71
  const RECOMMENDATION_ENUM = ['Approve', 'Approve with Comments', 'Request Changes', 'Block'];
72
72
  const RECOMMENDATION_LINE = /^[ \t]*(?:\*\*Recommendation\*\*:|\*\*Recommendation:\*\*|Recommendation:)[ \t]*([^\r\n]+?)[ \t]*$/m;
73
73
  const CONTEXT_BASIS_ENUM = ['none', 'pr_diff', 'pr_diff_truncated'];
74
+ // step-03-quality-evaluation.md's own three resolved modes. `auto` is a request,
75
+ // never a resolution, so it is deliberately absent: a report that says `auto` never
76
+ // ran the probe.
77
+ const EXECUTION_MODE_ENUM = ['agent-team', 'subagent', 'sequential'];
78
+ const EXECUTION_MODE_LINE_SOURCE = String.raw`^[ \t]*\*\*Execution Mode:?\*\*:?[ \t]*([^\r\n]+?)[ \t]*$`;
74
79
  const CONTEXT_BASIS_LINE_SOURCE = String.raw`^[ \t]*\*\*Context Basis:?\*\*:?[ \t]*([^\r\n]+)[ \t]*$`;
75
80
  const CONTEXT_WAIVERS_LINE_SOURCE = String.raw`^[ \t]*\*\*Context Waivers Applied:?\*\*:?[ \t]*([^\r\n]+)[ \t]*$`;
76
81
  const SCORE_PATTERN = /\*\*Quality Score\*\*:\s*(\d+)\s*\/\s*100(?:[ \t]*\([ \t]*([A-F])(?=[ \t)-]))?/;
@@ -155,6 +160,7 @@ const PARSED_VERDICT_KEYS = {
155
160
  },
156
161
  conditional: {
157
162
  conventionBaseline: 'object',
163
+ executionMode: 'string',
158
164
  reportedQualityScore: 'number',
159
165
  reportedRecommendation: 'string',
160
166
  },
@@ -696,6 +702,51 @@ function parseConventionCitations(text) {
696
702
  }));
697
703
  }
698
704
 
705
+ /**
706
+ * Parse the at-most-one "**Execution Mode**:" line, required on any run that
707
+ * measured a convention baseline.
708
+ *
709
+ * `step-03-quality-evaluation.md` resolves its own execution mode from a runtime
710
+ * capability probe and prints the result to the agent's stdout, which
711
+ * `cli/lib/run-agent.js` captures and `cli/test-review.js` reads only when the
712
+ * report is missing. So a run that asked for parallel workers and silently got
713
+ * `sequential` was indistinguishable, after the fact, from one that got what it
714
+ * asked for. That made "this change made the review faster" unfalsifiable: the
715
+ * gain could have come from anywhere. The mode is a run input the same way the
716
+ * model and the convention baseline are, so it travels in the verdict with them.
717
+ *
718
+ * Required exactly when the run supplied a convention baseline, which every real
719
+ * CLI run does. Making it unconditionally optional would have left the hole it
720
+ * exists to close: a report that omits the line is as silent about its mode as one
721
+ * written before the line existed, and the failure gradient would run the wrong way,
722
+ * with an omission passing and a malformed value exiting 3. A bare `parseReport`
723
+ * call in a unit test supplies no baseline and needs no mode.
724
+ *
725
+ * @param {string} text - Full report.
726
+ * @param {boolean} required - Whether this run has to state a mode.
727
+ * @returns {string|null} The resolved mode, or null when none is stated and none is required.
728
+ */
729
+ function parseExecutionMode(text, required) {
730
+ const matches = [...text.matchAll(new RegExp(EXECUTION_MODE_LINE_SOURCE, 'gm'))];
731
+ if (matches.length === 0) {
732
+ if (required) {
733
+ unparseable(
734
+ 'Report is missing the "**Execution Mode**:" line; state the mode step-03 actually resolved ' +
735
+ `(${EXECUTION_MODE_ENUM.join(' | ')}), so a run that fell back to sequential says so in its own artifact`,
736
+ );
737
+ }
738
+ return null;
739
+ }
740
+ if (matches.length > 1) {
741
+ unparseable(`Report must contain at most one "**Execution Mode**:" line; found ${matches.length}`);
742
+ }
743
+ const cleaned = stripWrappers(matches[0][1].replaceAll(/\s+/g, ' ')).toLowerCase();
744
+ if (!EXECUTION_MODE_ENUM.includes(cleaned)) {
745
+ unparseable(`Report Execution Mode "${cleaned}" is not one of: ${EXECUTION_MODE_ENUM.join(' | ')}`);
746
+ }
747
+ return cleaned;
748
+ }
749
+
699
750
  /** Extract the optional, at-most-one "**Convention Baseline**:" line's raw value. */
700
751
  function parseConventionBaselineLine(text) {
701
752
  const matches = [...text.matchAll(new RegExp(CONVENTION_BASELINE_LINE_SOURCE, 'gm'))];
@@ -795,8 +846,9 @@ function verifyConventionBaseline(text, conventionBaselineContract) {
795
846
  if (measured && measured.mechanical && measured.mechanicalSignal === false && citation.adopted > 0) {
796
847
  unparseable(
797
848
  `Report Convention citation "${citation.key} (${citation.adopted} of ${citation.sampled} sampled)" claims adoption, but this run ` +
798
- `scanned every one of the ${conventionBaselineContract.sampled} sampled files for the recognized forms and found zero occurrences; ` +
799
- 'a convention with no real evidence in the sampled corpus must be reported as absent (0 adopted), never a fabricated nonzero count',
849
+ `scanned every one of the ${conventionBaselineContract.scanned ?? conventionBaselineContract.sampled} files in its scanned corpus ` +
850
+ 'for the recognized forms and found zero occurrences; a convention with no real evidence anywhere in that corpus must be ' +
851
+ 'reported as absent (0 adopted), never a fabricated nonzero count',
800
852
  );
801
853
  }
802
854
  }
@@ -1465,6 +1517,7 @@ function parseReport(reportText, runContract = {}) {
1465
1517
 
1466
1518
  verifyRunContract({ reviewedFiles, contextBasis, contextFiles, excludedFiles }, runContract);
1467
1519
  verifyConventionBaseline(text, runContract.conventionBaseline);
1520
+ const executionMode = parseExecutionMode(text, Boolean(runContract.conventionBaseline));
1468
1521
 
1469
1522
  const executiveSection = extractSection(text, 'Executive Summary');
1470
1523
  const keyStrengths = extractBullets(extractSubsection(executiveSection, 'Key Strengths'), '✅');
@@ -1500,6 +1553,9 @@ function parseReport(reportText, runContract = {}) {
1500
1553
  if (runContract.conventionBaseline) {
1501
1554
  setConditionalKey(parsed, 'conventionBaseline', runContract.conventionBaseline);
1502
1555
  }
1556
+ if (executionMode !== null) {
1557
+ setConditionalKey(parsed, 'executionMode', executionMode);
1558
+ }
1503
1559
  if (
1504
1560
  reportedQualityScore !== qualityScore ||
1505
1561
  (reportedQualityGrade !== undefined && reportedQualityGrade !== gradeForScore(qualityScore))
@@ -1575,6 +1631,8 @@ module.exports = {
1575
1631
  CONTEXT_BASIS_ENUM,
1576
1632
  verifyConventionBaseline,
1577
1633
  parseConventionCitations,
1634
+ parseExecutionMode,
1635
+ EXECUTION_MODE_ENUM,
1578
1636
  verifyFindingSeverityCounts,
1579
1637
  extractFindings,
1580
1638
  FINDING_KEYS,