mandrel 2.1.0 → 2.2.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 (40) hide show
  1. package/.agents/agents/acceptance-critic.md +11 -2
  2. package/.agents/agents/story-worker.md +4 -2
  3. package/.agents/docs/SDLC.md +11 -4
  4. package/.agents/docs/configuration.md +1 -1
  5. package/.agents/docs/quality-gates.md +3 -3
  6. package/.agents/rules/gherkin-standards.md +10 -0
  7. package/.agents/schemas/acceptance-eval-verdict.schema.json +2 -2
  8. package/.agents/schemas/agentrc.schema.json +1 -1
  9. package/.agents/scripts/acceptance-eval.js +2 -2
  10. package/.agents/scripts/lib/config/acceptance-eval.js +2 -2
  11. package/.agents/scripts/lib/config-settings-schema-delivery.js +3 -3
  12. package/.agents/scripts/lib/orchestration/change-set.js +103 -0
  13. package/.agents/scripts/lib/orchestration/code-review.js +24 -35
  14. package/.agents/scripts/lib/orchestration/plan-context.js +2 -9
  15. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +17 -16
  16. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +28 -15
  17. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +0 -25
  18. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +230 -0
  19. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +1 -2
  20. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
  21. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +97 -255
  22. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +191 -0
  23. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +120 -0
  24. package/.agents/scripts/lib/story-body/story-body.js +75 -8
  25. package/.agents/scripts/lib/templates/decomposer-prompts.js +8 -13
  26. package/.agents/scripts/lib/wave-runner/live-probe.js +315 -0
  27. package/.agents/scripts/plan-context.js +0 -1
  28. package/.agents/scripts/plan-critics.js +203 -0
  29. package/.agents/scripts/quality-preview.js +13 -6
  30. package/.agents/scripts/stories-wave-tick.js +307 -55
  31. package/.agents/workflows/deliver.md +50 -15
  32. package/.agents/workflows/helpers/acceptance-self-eval.md +14 -5
  33. package/.agents/workflows/helpers/code-quality-guardrails.md +7 -4
  34. package/.agents/workflows/helpers/code-review.md +2 -2
  35. package/.agents/workflows/helpers/deliver-story.md +22 -6
  36. package/.agents/workflows/plan.md +55 -0
  37. package/docs/CHANGELOG.md +22 -0
  38. package/lib/migrations/index.js +6 -1
  39. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +154 -0
  40. package/package.json +2 -2
@@ -34,7 +34,15 @@ verdicts, their commit-message justifications-as-proof, or any prior verdict
34
34
  file they authored. You grade the **work product**, not the homework the maker
35
35
  turned in about it. Your only trusted inputs are:
36
36
 
37
- - the working **diff** (`git diff origin/<baseBranch>...HEAD`),
37
+ - the **change set** your caller hands you: the list of files this Story
38
+ touched, computed **once** per delivery by the shared `computeChangeSet`
39
+ enumerator (`.agents/scripts/lib/orchestration/change-set.js`) and threaded
40
+ into your spawn context. Read those files and inspect their changes to see
41
+ the work product. Do **not** re-derive the set yourself — re-enumerating it
42
+ can pick up commits that landed after your caller routed the ceremony, and
43
+ then you would be scoring a different change than the one you were dispatched
44
+ for (Story #4593). If no change set reached you, say so in your verdict
45
+ rather than substituting your own enumeration.
38
46
  - the Story's inline `acceptance[]` and `verify[]` arrays, read from the
39
47
  **Story body itself** (`gh issue view <storyId> --json body`) — its `##
40
48
  Acceptance` / `## Verify` sections are the SSOT. The `story-init` structured
@@ -58,7 +66,8 @@ given.
58
66
 
59
67
  For each acceptance item in your cluster:
60
68
 
61
- 1. **Inspect the diff** for the change that would satisfy the criterion.
69
+ 1. **Inspect the change set** read the files your caller named and look for
70
+ the change that would satisfy the criterion.
62
71
  2. **Run the relevant `verify[]` commands** and consume their output as
63
72
  **required evidence**. A criterion cannot be scored `met` without the
64
73
  supporting `verify[]` evidence where a `verify[]` command is relevant to it.
@@ -101,8 +101,10 @@ self-eval loop (below) may share `lint` / `typecheck` evidence with close via
101
101
  After the implementation commits land and **before** flipping to `closing`, run
102
102
  the bounded acceptance self-eval loop (see
103
103
  [`acceptance-self-eval.md`](../workflows/helpers/acceptance-self-eval.md)). It
104
- scores the working diff against **each** `acceptance[]` item and consumes the
105
- `verify[]` command output as **required evidence**. The gate returns one of:
104
+ scores the change set you computed once and injected into the critic — never
105
+ one the critic re-derives (Story #4593) against **each** `acceptance[]` item,
106
+ and consumes the `verify[]` command output as **required evidence**. The gate
107
+ returns one of:
106
108
 
107
109
  - **`proceed`** (every criterion met) → flip to `closing` and close.
108
110
  - **`redraft`** (rounds remaining) → fix the flagged criteria, commit, re-eval.
@@ -40,8 +40,9 @@ From zero to shipped:
40
40
 
41
41
  `/plan` is a **single path** — there is no Epic/Story router, no
42
42
  scope-triage `epic|story` verdict, and no `deliveryShape`. All GitHub
43
- reads happen in `plan-context.js`, all writes in `plan-persist.js`, and
44
- two HITL gates bracket the authoring middle. Duplicate search targets
43
+ reads happen in `plan-context.js`, the issue-creating writes in
44
+ `plan-persist.js`, and two HITL gates bracket the authoring middle.
45
+ Duplicate search targets
45
46
  open **Stories** (`type::story`), never Epics.
46
47
 
47
48
  1. **Interrogate** — `plan-context.js` emits the single authoring
@@ -54,6 +55,12 @@ From zero to shipped:
54
55
  Binding criteria live in top-level `acceptance[]` / `verify[]`;
55
56
  changes/references are `{ path, assumption }` objects. Split into
56
57
  N>1 only under the default-single split policy.
58
+ 2.5. **Critics** — `plan-critics.js` evaluates the consolidation +
59
+ pre-mortem dispatch conditions against the authored draft and ledgers
60
+ every skip. This is the **only** critic gate (#4592 moved it out of
61
+ `plan-persist.js` into workflow prose), so skipping it silently skips
62
+ both critics: run it before Persist, per
63
+ [`/plan`](../workflows/plan.md) step 2.5.
57
64
  3. **Persist** — **gate #2** (raised only by an explicit `--force-review`)
58
65
  then `plan-persist.js` runs every deterministic gate and
59
66
  creates Story issue(s) with `type::story` + `agent::ready`, writing
@@ -76,8 +83,8 @@ From zero to shipped:
76
83
  2. **Implement** — the agent delivers the Story in one guarded session
77
84
  against its inline `acceptance[]` / `verify[]` contract (optional
78
85
  `## Slicing` intra-session checkpoints).
79
- 3. **Acceptance self-eval** — a bounded critic loop scores the working
80
- diff against each acceptance item before close (see
86
+ 3. **Acceptance self-eval** — a bounded critic loop scores the
87
+ caller-injected change set against each acceptance item before close (see
81
88
  [`helpers/acceptance-self-eval`](../workflows/helpers/acceptance-self-eval.md)).
82
89
  4. **Ceremony** — acceptance critic mode and review depth, both routed off
83
90
  the change level derived from the Story's own diff
@@ -263,7 +263,7 @@ top-level keys are validation errors.
263
263
  | `codeReview.autoFixSeverity` | No | `"high"` \| `"medium"` | `"medium"` | Severity threshold for on-branch remediation in /deliver Phase 5 (code-review). `medium` (default) routes 🔴/🟠/🟡 findings into the host-LLM focused-fix routing (Mediums batched per lens: one commit per lens, a single validation + rescan at the end) while 🟢 suggestions still graduate to follow-up issues; `high` reproduces the pre-4399 Critical/High-only routing. Hard cutover — no back-compat flag. |
264
264
  | `refactorStage` | No | `object` | — | Opt-in, config-gated post-green refactor checkpoint wired into story-deliver (Story #3430, Epic #3418). Strictly additive and default-OFF: when disabled, story-deliver behaves exactly as before. Advisory only — never changes existing close-validation gate semantics. |
265
265
  | `refactorStage.enabled` | No | `boolean` | `false` | When true, story-deliver runs an advisory post-green refactor stage (core/code-review-and-quality skill, Post-Green Refactor Pass) after the suite is green. Default false — when unset the stage is skipped and close-validation gate semantics are unchanged. |
266
- | `acceptanceEval` | No | `object` | — | Story #3819. Bounded per-Story acceptance self-eval loop. After the implementation commits land and before the Story-implementation phase flips to `closing`, an independent (fresh-context) critic pass scores the working diff against each inline `acceptance[]` item, redrafts the unmet items, and re-evaluates — capped at `maxRounds` redraft rounds, then escalates to `agent::blocked` when criteria remain unmet. There is no `enabled` flag: the loop is a hard cutover (always on). |
266
+ | `acceptanceEval` | No | `object` | — | Story #3819. Bounded per-Story acceptance self-eval loop. After the implementation commits land and before the Story-implementation phase flips to `closing`, an independent (fresh-context) critic pass scores the caller-injected change set against each inline `acceptance[]` item, redrafts the unmet items, and re-evaluates — capped at `maxRounds` redraft rounds, then escalates to `agent::blocked` when criteria remain unmet. There is no `enabled` flag: the loop is a hard cutover (always on). |
267
267
  | `acceptanceEval.maxRounds` | No | `integer` | — | Maximum number of redraft rounds before escalation. Default 2; clamped into [1, hard ceiling] by lib/config/acceptance-eval.js so the cap can never be disabled (maxRounds: 0 clamps up to 1). |
268
268
  | `acceptanceEval.clusterCeiling` | No | `integer` | — | Epic #4475 (M4-B). Max acceptance criteria one single-delivery acceptance critic scores in a single fresh-context pass. Single delivery clusters the Epic ## Acceptance Table ACs into ceil(totalACs / clusterCeiling) groups and spawns one maker-blind critic per cluster, restoring the distributed acceptance coverage the per-Story critic fan-out gave for free. Default 4; clamped into [1, 8] by lib/config/acceptance-eval.js so a large value cannot collapse the fan-out to a single diluted critic. Ignored on the fan-out route. |
269
269
  | `ci` | No | `object` | — | Nested configuration block. |
@@ -260,9 +260,9 @@ per-round mechanic lives in
260
260
  with the gate CLI at
261
261
  [`.agents/scripts/acceptance-eval.js`](../scripts/acceptance-eval.js)).
262
262
  Each round, a fresh-context **critic pass** — independent of the
263
- implementing agent — scores the working diff against every inline
264
- `acceptance[]` item, using `verify[]` output as evidence, and yields one
265
- of three decisions:
263
+ implementing agent — scores the change set its caller injected (never one
264
+ it re-derives) against every inline `acceptance[]` item, using `verify[]`
265
+ output as evidence, and yields one of three decisions:
266
266
 
267
267
  - **proceed** — all criteria met; the Story continues to close.
268
268
  - **redraft** — unmet criteria are redrafted and re-implemented, then
@@ -23,6 +23,16 @@ scenarios. Use the canonical set below; do not invent ad-hoc tags.
23
23
  - `@flaky` — operational quarantine tag. Scenarios carrying this tag are
24
24
  excluded from the gating suite and run in a dedicated non-blocking job
25
25
  until stabilized. Treat `@flaky` as a debt marker, not a permanent label.
26
+ - `@skip` — scaffold-gating tag. Applied to scenarios scaffolded ahead of
27
+ their implementation (e.g. by a wave-0 BDD scaffold Story); the scenario
28
+ is excluded from the gating suite until the implementing Story removes
29
+ the tag (the "de-skip" edit). Unlike `@flaky`, `@skip` marks planned
30
+ not-yet-implemented behavior, never a stability problem.
31
+
32
+ Retired: the `@epic-<id>-ac-N` namespaced AC tag. Its consumer
33
+ (`acceptance-spec-reconciler.js`) was deleted in the v2 Epic removal, so
34
+ the tag is inert — do not apply it to new scenarios. The `mandrel update`
35
+ migration strips surviving instances from consumer feature files.
26
36
 
27
37
  Rules:
28
38
 
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "acceptance-eval-verdict",
4
4
  "title": "Acceptance Self-Eval Verdict",
5
- "description": "Structured output of one independent (fresh-context) acceptance self-eval round (Story #3819). Produced by the critic pass in the Story-implementation phase (helpers/deliver-story.md Step 1a), written under the gitignored temp tree, and consumed by acceptance-eval.js to enforce the bounded round cap and decide whether the Story proceeds to `closing`, redrafts, or escalates to `agent::blocked`. The author MUST NOT be the evaluator — the verdict is an independent judgment of the working diff against each inline acceptance[] item.",
5
+ "description": "Structured output of one independent (fresh-context) acceptance self-eval round (Story #3819). Produced by the critic pass in the Story-implementation phase (helpers/deliver-story.md Step 1a), written under the gitignored temp tree, and consumed by acceptance-eval.js to enforce the bounded round cap and decide whether the Story proceeds to `closing`, redrafts, or escalates to `agent::blocked`. The author MUST NOT be the evaluator — the verdict is an independent judgment of the caller-injected change set against each inline acceptance[] item.",
6
6
  "type": "object",
7
7
  "required": ["storyId", "schemaVersion", "round", "criteria"],
8
8
  "properties": {
@@ -52,7 +52,7 @@
52
52
  "verdict": {
53
53
  "type": "string",
54
54
  "enum": ["met", "partial", "unmet"],
55
- "description": "Per-criterion judgment of the working diff. `met` clears the criterion; `partial` and `unmet` both trigger a redraft round (subject to the cap)."
55
+ "description": "Per-criterion judgment of the caller-injected change set. `met` clears the criterion; `partial` and `unmet` both trigger a redraft round (subject to the cap)."
56
56
  },
57
57
  "evidence": {
58
58
  "type": "string",
@@ -1246,7 +1246,7 @@
1246
1246
  },
1247
1247
  "acceptanceEval": {
1248
1248
  "type": "object",
1249
- "description": "Story #3819. Bounded per-Story acceptance self-eval loop. After the implementation commits land and before the Story-implementation phase flips to `closing`, an independent (fresh-context) critic pass scores the working diff against each inline `acceptance[]` item, redrafts the unmet items, and re-evaluates — capped at `maxRounds` redraft rounds, then escalates to `agent::blocked` when criteria remain unmet. There is no `enabled` flag: the loop is a hard cutover (always on).",
1249
+ "description": "Story #3819. Bounded per-Story acceptance self-eval loop. After the implementation commits land and before the Story-implementation phase flips to `closing`, an independent (fresh-context) critic pass scores the caller-injected change set against each inline `acceptance[]` item, redrafts the unmet items, and re-evaluates — capped at `maxRounds` redraft rounds, then escalates to `agent::blocked` when criteria remain unmet. There is no `enabled` flag: the loop is a hard cutover (always on).",
1250
1250
  "properties": {
1251
1251
  "maxRounds": {
1252
1252
  "type": "integer",
@@ -4,8 +4,8 @@
4
4
  * acceptance-eval.js — bounded per-Story acceptance self-eval gate (Story #3819).
5
5
  *
6
6
  * The Story-implementation phase runs an independent (fresh-context)
7
- * critic pass that scores the working diff against each inline
8
- * `acceptance[]` item, emitting one verdict file per round
7
+ * critic pass that scores the caller-injected change set against each
8
+ * inline `acceptance[]` item, emitting one verdict file per round
9
9
  * (`.agents/schemas/acceptance-eval-verdict.schema.json`). This CLI is the
10
10
  * deterministic substrate that turns that verdict into the loop's next
11
11
  * action:
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Resolves `.agentrc.json → delivery.acceptanceEval` into the canonical
5
5
  * shape the per-Story acceptance self-eval loop consumes. The loop scores
6
- * the working diff against each inline `acceptance[]` item, redrafts the
7
- * unmet items, and re-evaluates — capped at `maxRounds` redraft rounds,
6
+ * the caller-injected change set against each inline `acceptance[]` item,
7
+ * redrafts the unmet items, and re-evaluates — capped at `maxRounds` rounds,
8
8
  * then escalates to `agent::blocked` when criteria remain unmet.
9
9
  *
10
10
  * ## The undisableable cap
@@ -249,9 +249,9 @@ const REFACTOR_STAGE_SCHEMA = {
249
249
  * `delivery.acceptanceEval` — bounded per-Story acceptance self-eval loop
250
250
  * (Story #3819). After the implementation commits land and before the
251
251
  * Story-implementation phase flips to `closing`, an independent
252
- * (fresh-context) critic pass scores the working diff against each inline
253
- * `acceptance[]` item, redrafts the unmet items, and re-evaluates — capped
254
- * at `maxRounds` redraft rounds.
252
+ * (fresh-context) critic pass scores the caller-injected change set against
253
+ * each inline `acceptance[]` item, redrafts the unmet items, and
254
+ * re-evaluates — capped at `maxRounds` redraft rounds.
255
255
  *
256
256
  * `maxRounds` is the operator-tunable redraft ceiling (default 2 via
257
257
  * `lib/config/acceptance-eval.js`). It is a soft knob inside an
@@ -0,0 +1,103 @@
1
+ /**
2
+ * lib/orchestration/change-set.js — the **one** Story change-set enumerator
3
+ * (Story #4593).
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * A single Story delivery used to enumerate `git diff --name-only
8
+ * <base>...<head>` three to four separate times: once for the ceremony
9
+ * derivation in `helpers/deliver-story`, once inside `runCodeReview` (to derive
10
+ * the review depth), once for the Story-scope local-lens roster, and once more
11
+ * per fresh acceptance critic. Every one of those consumers must agree about
12
+ * *what changed* — ceremony level and review depth both flow from
13
+ * `deriveChangeLevel`, so two enumerations straddling a commit could route the
14
+ * same Story two different ways. Computing the set once and injecting it closes
15
+ * that window and drops the redundant git calls.
16
+ *
17
+ * ## Contract
18
+ *
19
+ * {@link computeChangeSet} is **total**: it never throws. A diff it cannot
20
+ * enumerate (git failure, missing ref, spawn error) yields
21
+ * `{ files: null, enumerated: false }` — the neutral "diff unknown" signal that
22
+ * `deriveChangeLevel` / `resolveDepth` already fail safe on (`standard` depth, a
23
+ * `fresh` critic). `null` is deliberately distinct from `[]`: an empty array is
24
+ * the *fact* that nothing changed, whereas `null` is the *absence* of evidence,
25
+ * and only the latter must never buy a change less checking.
26
+ *
27
+ * The returned `files` are trimmed, de-duplicated, and sorted, so two consumers
28
+ * comparing the same change set compare byte-identical lists. The refs the set
29
+ * was computed against ride along on the envelope so a downstream consumer can
30
+ * report (or assert) its provenance rather than re-deriving it.
31
+ *
32
+ * @typedef {{
33
+ * baseRef: string,
34
+ * headRef: string,
35
+ * files: string[]|null,
36
+ * enumerated: boolean,
37
+ * }} ChangeSet
38
+ *
39
+ * @typedef {typeof gitSpawn} GitSpawnFn
40
+ */
41
+
42
+ import { gitSpawn } from '../git-utils.js';
43
+
44
+ /**
45
+ * Trim, drop empties, de-duplicate, and sort raw `git diff --name-only` output.
46
+ * Pure.
47
+ *
48
+ * @param {string} stdout
49
+ * @returns {string[]}
50
+ */
51
+ function normalizeFileList(stdout) {
52
+ const seen = new Set();
53
+ for (const line of stdout.split('\n')) {
54
+ const file = line.trim();
55
+ if (file.length > 0) seen.add(file);
56
+ }
57
+ return [...seen].sort();
58
+ }
59
+
60
+ /**
61
+ * Compute the change set for the `baseRef...headRef` diff **once**, for every
62
+ * consumer that needs to know what a Story touched.
63
+ *
64
+ * Total — never throws; see the module header for the `null` vs `[]` contract.
65
+ *
66
+ * @param {{
67
+ * baseRef: string,
68
+ * headRef: string,
69
+ * cwd?: string,
70
+ * gitSpawnFn?: typeof gitSpawn,
71
+ * }} args
72
+ * @returns {ChangeSet}
73
+ */
74
+ export function computeChangeSet({
75
+ baseRef,
76
+ headRef,
77
+ cwd = process.cwd(),
78
+ gitSpawnFn = gitSpawn,
79
+ } = {}) {
80
+ const unknown = { baseRef, headRef, files: null, enumerated: false };
81
+ if (typeof baseRef !== 'string' || baseRef.length === 0) return unknown;
82
+ if (typeof headRef !== 'string' || headRef.length === 0) return unknown;
83
+
84
+ try {
85
+ const result = gitSpawnFn(
86
+ cwd,
87
+ 'diff',
88
+ '--name-only',
89
+ `${baseRef}...${headRef}`,
90
+ );
91
+ if (!result || result.status !== 0 || typeof result.stdout !== 'string') {
92
+ return unknown;
93
+ }
94
+ return {
95
+ baseRef,
96
+ headRef,
97
+ files: normalizeFileList(result.stdout),
98
+ enumerated: true,
99
+ };
100
+ } catch {
101
+ return unknown;
102
+ }
103
+ }
@@ -41,7 +41,7 @@
41
41
 
42
42
  import { hasSurvivingCritical } from '../audit-suite/findings.js';
43
43
  import { resolveConfig } from '../config-resolver.js';
44
- import { gitSpawn } from '../git-utils.js';
44
+ import { computeChangeSet } from './change-set.js';
45
45
  import { deriveChangeLevel, resolveDepth } from './review-depth.js';
46
46
  import {
47
47
  countBySeverity,
@@ -65,36 +65,6 @@ import { upsertStructuredComment } from './ticketing.js';
65
65
  * @typedef {import('./review-depth.js').ReviewDepth} ReviewDepth
66
66
  */
67
67
 
68
- /**
69
- * Enumerate the files changed in the `baseRef...headRef` diff via
70
- * `git diff --name-only`. Returns the file list, or `null` when the diff
71
- * cannot be enumerated (git failure, missing ref). A `null` list is the neutral
72
- * "diff unknown" signal both {@link deriveChangeLevel} and {@link resolveDepth}
73
- * tolerate by failing safe to `standard`. Best-effort — never throws.
74
- *
75
- * @param {{ baseRef: string, headRef: string, gitSpawnFn?: typeof gitSpawn }} args
76
- * @returns {string[]|null}
77
- */
78
- function listChangedFiles({ baseRef, headRef, gitSpawnFn = gitSpawn }) {
79
- try {
80
- const result = gitSpawnFn(
81
- process.cwd(),
82
- 'diff',
83
- `${baseRef}...${headRef}`,
84
- '--name-only',
85
- );
86
- if (!result || result.status !== 0 || typeof result.stdout !== 'string') {
87
- return null;
88
- }
89
- return result.stdout
90
- .split('\n')
91
- .map((line) => line.trim())
92
- .filter((line) => line.length > 0);
93
- } catch {
94
- return null;
95
- }
96
- }
97
-
98
68
  /**
99
69
  * Resolve the project base branch fallback used when a caller omits
100
70
  * `baseRef`.
@@ -192,7 +162,7 @@ function resolveScopeEnvelope(opts, config) {
192
162
  * changedFileCount?: number|null,
193
163
  * storyId?: number|null,
194
164
  * reviewProvider?: { runReview: Function },
195
- * gitSpawnFn?: typeof gitSpawn,
165
+ * gitSpawnFn?: import('./change-set.js').GitSpawnFn,
196
166
  * resolveConfigFn?: typeof resolveConfig,
197
167
  * createReviewProviderFn?: typeof createReviewProvider,
198
168
  * upsertCommentFn?: typeof upsertStructuredComment,
@@ -229,6 +199,27 @@ function resolveProviderName(codeReviewConfig) {
229
199
  return 'native';
230
200
  }
231
201
 
202
+ /**
203
+ * Resolve the change set the depth derivation reads (Story #4593).
204
+ *
205
+ * `opts.changedFiles` is an injection with three distinct states,
206
+ * and the difference is load-bearing: an **array** is the change set to use
207
+ * verbatim; an explicit **null** is a caller (`runStoryReviewCore`) reporting
208
+ * that it already tried and the diff is unenumerable — re-running git here would
209
+ * only fail again, so it degrades straight to the fail-safe tier; **absent**
210
+ * means no caller enumerated at all, so the shared {@link computeChangeSet}
211
+ * enumerator runs as the fallback (standalone CLI use). On the close path the
212
+ * spine always injects, so the diff is enumerated exactly once per delivery and
213
+ * this pillar can never disagree with the lens pass about what changed.
214
+ */
215
+ function resolveInjectedChangedFiles({ opts, baseRef, headRef }) {
216
+ if (opts.changedFiles === undefined) {
217
+ return computeChangeSet({ baseRef, headRef, gitSpawnFn: opts.gitSpawnFn })
218
+ .files;
219
+ }
220
+ return Array.isArray(opts.changedFiles) ? opts.changedFiles : null;
221
+ }
222
+
232
223
  /**
233
224
  * Build the provider `runReview` input, resolving the review depth from the
234
225
  * diff under review: its changed files derive the change level (sensitive path
@@ -238,9 +229,7 @@ function resolveProviderName(codeReviewConfig) {
238
229
  * Story #4075 — extracted from `runCodeReview`.
239
230
  */
240
231
  function buildReviewInput({ opts, scope, ticketId, baseRef, headRef }) {
241
- const changedFiles = Array.isArray(opts.changedFiles)
242
- ? opts.changedFiles
243
- : listChangedFiles({ baseRef, headRef, gitSpawnFn: opts.gitSpawnFn });
232
+ const changedFiles = resolveInjectedChangedFiles({ opts, baseRef, headRef });
244
233
  const changedFileCount =
245
234
  typeof opts.changedFileCount === 'number'
246
235
  ? opts.changedFileCount
@@ -361,17 +361,12 @@ export function buildDeliveryShapeSignal({ body } = {}) {
361
361
  * authoritative from day one); the decompose prompt reuses the existing
362
362
  * Story #4162 carrier including the risk-heuristics suffix.
363
363
  *
364
- * @param {{ heuristics?: string[], maxTickets?: number, epicId?: number|null }} args
364
+ * @param {{ heuristics?: string[], maxTickets?: number }} args
365
365
  * @returns {{ spec: string, acceptance: string, decompose: string }}
366
366
  */
367
- export function buildSystemPrompts({
368
- heuristics = [],
369
- maxTickets,
370
- epicId = null,
371
- } = {}) {
367
+ export function buildSystemPrompts({ heuristics = [], maxTickets } = {}) {
372
368
  const decompose = buildDecomposerSystemPrompt(heuristics, {
373
369
  maxTickets,
374
- epicId,
375
370
  });
376
371
  return {
377
372
  spec: renderTechSpecSystemPrompt(),
@@ -500,7 +495,6 @@ async function buildSeedFileModeEnvelope({
500
495
  systemPrompts: buildSystemPrompts({
501
496
  heuristics,
502
497
  maxTickets: limits.maxTickets,
503
- epicId: null,
504
498
  }),
505
499
  planState: null,
506
500
  // N=1 default: author one Story; skip Epic-scale decompose ceremony.
@@ -654,7 +648,6 @@ async function buildTicketsModeEnvelope({
654
648
  systemPrompts: buildSystemPrompts({
655
649
  heuristics,
656
650
  maxTickets: limits.maxTickets,
657
- epicId: null,
658
651
  }),
659
652
  planState: null,
660
653
  planProfile:
@@ -2,15 +2,15 @@
2
2
  * plan-critic-conditions.js — size/heuristic-conditional dispatch decisions for
3
3
  * the /plan author-step critics (Epic #4474 PR6, design §4).
4
4
  *
5
- * The collapsed plan flow keeps the consolidation (8.3) and pre-mortem
6
- * (8.5) critics as fresh-context sub-agent dispatches, but makes each
7
- * dispatch **conditional** instead of unconditional — the dominant plan
8
- * cost is turns × standing context, and an unconditional critic pays a
9
- * full sub-agent spawn even when it provably has nothing to find. This
10
- * module computes those decisions deterministically so the workflow never
11
- * judges its own dispatch conditions:
5
+ * The collapsed plan flow keeps the consolidation and pre-mortem critics as
6
+ * fresh-context sub-agent dispatches, but makes each dispatch
7
+ * **conditional** instead of unconditional — the dominant plan cost is
8
+ * turns × standing context, and an unconditional critic pays a full
9
+ * sub-agent spawn even when it provably has nothing to find. This module
10
+ * computes those decisions deterministically so the workflow never judges
11
+ * its own dispatch conditions:
12
12
  *
13
- * - **Consolidation (8.3)**: dispatch only when the existing
13
+ * - **Consolidation**: dispatch only when the existing
14
14
  * `evaluateConsolidationPrecondition` gate says `dispatch: true` AND
15
15
  * (the draft has more than `CONSOLIDATION_STORY_THRESHOLD` stories OR
16
16
  * the precondition confirmed a divergence from the Delivery Slicing
@@ -18,7 +18,7 @@
18
18
  * small draft is NOT a confirmed divergence — it skips, because a
19
19
  * ≤-threshold draft is small enough for gate #2's single-view review
20
20
  * to catch a distorted shape without a dedicated sub-agent.
21
- * - **Pre-mortem (8.5)**: dispatch when the ticket count is at least half
21
+ * - **Pre-mortem**: dispatch when the ticket count is at least half
22
22
  * of `maxTickets`, OR any configured `planning.riskHeuristics` phrase
23
23
  * matches the plan text (case-insensitive substring). Story #4542 removed
24
24
  * its third condition — the authored risk verdict's overall level — along
@@ -28,18 +28,19 @@
28
28
  * Under-firing risk (design PR6 note): the persist validators are
29
29
  * unchanged hard gates and G2's cohort re-measures plan quality; every
30
30
  * skip decision this module produces is logged to the plan-metrics ledger
31
- * (`appendCriticSkip`) by the callers so under-firing is auditable.
31
+ * (`appendCriticSkip`) by the caller so under-firing is auditable.
32
32
  *
33
- * Pure, synchronous, no I/O the folded pre-write phase inside
34
- * `plan-persist.js` owns reading the authored artifacts and the resolved
35
- * config.
33
+ * Pure, synchronous, no I/O. The single caller is `plan-critics-evaluate.js`,
34
+ * driven by the `plan-critics.js` CLI that `/plan` runs between Author and
35
+ * Persist (Story #4592); the CLI owns reading the authored artifacts and the
36
+ * resolved config.
36
37
  */
37
38
 
38
39
  import { evaluateConsolidationPrecondition } from './consolidation-precondition.js';
39
40
 
40
41
  /**
41
42
  * Draft-story count above which the consolidation critic fires even
42
- * without a confirmed slicing divergence (design §6 PR6: "> 5 stories").
43
+ * without a confirmed slicing divergence (#4474 PR6: "> 5 stories").
43
44
  */
44
45
  export const CONSOLIDATION_STORY_THRESHOLD = 5;
45
46
 
@@ -53,7 +54,7 @@ export const CONSOLIDATION_STORY_THRESHOLD = 5;
53
54
  */
54
55
 
55
56
  /**
56
- * Decide the 8.3 consolidation dispatch: precondition AND size/divergence.
57
+ * Decide the consolidation dispatch: precondition AND size/divergence.
57
58
  *
58
59
  * @param {object} input
59
60
  * @param {object[]} input.draftStories - The draft `tickets.json` array
@@ -106,7 +107,7 @@ export function evaluateConsolidationDispatch({ draftStories, specText }) {
106
107
  }
107
108
 
108
109
  /**
109
- * Decide the 8.5 pre-mortem dispatch: size ≥ ½ budget, or a risk-heuristic
110
+ * Decide the pre-mortem dispatch: size ≥ ½ budget, or a risk-heuristic
110
111
  * phrase match.
111
112
  *
112
113
  * @param {object} input
@@ -1,20 +1,22 @@
1
1
  /**
2
2
  * plan-critics-evaluate.js — shared critic-dispatch evaluation for the
3
- * collapsed /plan flow (#4496 fix 6; extracted from the `plan-critics.js`
4
- * CLI so the persist surface folds the same evaluation in as a pre-write
5
- * phase).
3
+ * collapsed /plan flow (#4496 fix 6).
6
4
  *
7
- * Two consumers:
8
- * - `plan-persist.js` (via `runPlanPersist`) evaluates the dispatch
9
- * conditions as a deterministic pre-write phase, prints the verdicts,
10
- * and records every skip on the plan-metrics ledger, so the headless
11
- * path never pays a standalone CLI turn for the same decision.
12
- * - `plan-critics.js` the standalone CLI survives one release as a
13
- * thin shim over this module for the attended pre-gate evaluation
14
- * (the verdict folds into gate #2's view before the persist runs).
5
+ * One consumer: the `plan-critics.js` CLI, which `/plan` runs between its
6
+ * Author and Persist steps. The CLI loads the draft artifacts, calls this
7
+ * module, prints the verdict as JSON, and records every skip on the
8
+ * plan-metrics ledger; the workflow dispatches a fresh-context critic
9
+ * sub-agent on a `dispatch: true` verdict and folds the findings into a
10
+ * re-author round before persist.
11
+ *
12
+ * Story #4592 moved that evaluation here from `run-plan-persist.js`, which
13
+ * ran it after authoring was finished and immediately before
14
+ * `createStoryIssues` — the one point where a `dispatch: true` verdict has
15
+ * no re-author loop to route to. Persist no longer evaluates critics; this
16
+ * module has exactly one evaluation point.
15
17
  *
16
18
  * Pure evaluation: no file I/O, no GitHub calls, no ledger writes — the
17
- * callers own artifact loading and skip recording.
19
+ * caller owns artifact loading and skip recording.
18
20
  *
19
21
  * @module lib/orchestration/plan-critics-evaluate
20
22
  */
@@ -24,6 +26,7 @@ import {
24
26
  evaluateConsolidationDispatch,
25
27
  evaluatePremortemDispatch,
26
28
  } from './plan-critic-conditions.js';
29
+ import { evaluateTextHygiene } from './plan-text-hygiene.js';
27
30
 
28
31
  /**
29
32
  * Resolve the planning risk heuristics list from the canonical config
@@ -41,8 +44,7 @@ function resolveRiskHeuristics(config = {}) {
41
44
 
42
45
  /**
43
46
  * Evaluate the consolidation + pre-mortem critic dispatch conditions over
44
- * the authored planning artifacts (design §4 / #4474 PR6 conditions,
45
- * unchanged):
47
+ * the authored planning artifacts (#4474 PR6 conditions, unchanged):
46
48
  *
47
49
  * - Consolidation: skipped outright when `tickets` is null/absent (the
48
50
  * single-delivery shape authors no draft tickets); otherwise the
@@ -50,6 +52,11 @@ function resolveRiskHeuristics(config = {}) {
50
52
  * - Pre-mortem: ticket count at least half `maxTickets`, OR any
51
53
  * `planning.riskHeuristics` phrase matching the plan text. Story #4542
52
54
  * retired its authored-risk-level condition with the verdict itself.
55
+ * - Text hygiene (Story #4599, advisory-only): deterministic body lints
56
+ * (dangling-citation / open-question / slicing-mass) over the draft
57
+ * stories. It has no `dispatch` semantics and spawns nothing — its
58
+ * `findings[]` are re-author-round input, and the consolidation /
59
+ * premortem dispatch verdicts are untouched by it.
53
60
  *
54
61
  * @param {{
55
62
  * techSpecContent: string,
@@ -59,6 +66,7 @@ function resolveRiskHeuristics(config = {}) {
59
66
  * @returns {{
60
67
  * consolidation: { critic: string, dispatch: boolean, reasons: string[] },
61
68
  * premortem: { critic: string, dispatch: boolean, reasons: string[] },
69
+ * textHygiene: { critic: string, findings: Array<object> },
62
70
  * }}
63
71
  */
64
72
  export function evaluatePlanCritics({
@@ -91,5 +99,10 @@ export function evaluatePlanCritics({
91
99
  ].join('\n'),
92
100
  });
93
101
 
94
- return { consolidation, premortem };
102
+ const textHygiene = {
103
+ critic: 'text-hygiene',
104
+ findings: evaluateTextHygiene({ draftStories: ticketList }).findings,
105
+ };
106
+
107
+ return { consolidation, premortem, textHygiene };
95
108
  }
@@ -44,7 +44,6 @@ import { anchorTempRoot, tempRootFrom } from '../../config/temp-paths.js';
44
44
  import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
45
45
  import { gitSpawn } from '../../git-utils.js';
46
46
  import { Logger } from '../../Logger.js';
47
- import { evaluatePlanCritics } from '../plan-critics-evaluate.js';
48
47
  import {
49
48
  appendCriticSkip,
50
49
  readPlanMetrics,
@@ -438,29 +437,6 @@ export async function runPlanPersist({
438
437
  );
439
438
  }
440
439
 
441
- const critics = evaluatePlanCritics({
442
- techSpecContent: techSpecContent ?? '',
443
- tickets: rawStories,
444
- config,
445
- });
446
- for (const decision of [critics.consolidation, critics.premortem]) {
447
- Logger.info(
448
- `[plan-persist] critic ${decision.critic}: ` +
449
- `${decision.dispatch ? 'dispatch' : 'skip'} — ` +
450
- decision.reasons.join('; '),
451
- );
452
- if (!decision.dispatch) {
453
- await appendCriticSkip(
454
- {
455
- critic: decision.critic,
456
- reasons: decision.reasons,
457
- cli: 'plan-persist',
458
- },
459
- config,
460
- );
461
- }
462
- }
463
-
464
440
  // Split policy + inline Spec fold (over-budget Specs fail closed — no docs/).
465
441
  const { stories } = assemblePlanStories(rawStories, {
466
442
  sharedSpec: techSpecContent,
@@ -580,7 +556,6 @@ export async function runPlanPersist({
580
556
  stories: created,
581
557
  primaryStoryId: primary.id,
582
558
  forceReview,
583
- critics,
584
559
  reachability,
585
560
  freshness,
586
561
  waveTable,