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
@@ -0,0 +1,230 @@
1
+ /**
2
+ * plan-text-hygiene.js — deterministic text-hygiene lints over draft Story
3
+ * bodies (Story #4599).
4
+ *
5
+ * The text analysis of Stories #4592–#4594 (and domio#1684) surfaced three
6
+ * body-defect classes no gate checks: dangling prose citations whose carrier
7
+ * document is unlocatable, operator-directed open questions persisted into
8
+ * tickets executed by non-interactive sub-agents, and `## Slicing` sections
9
+ * carrying more mass than the `## Spec` they are supposed to checkpoint.
10
+ * This module makes those classes checkable at the one point a re-author
11
+ * loop exists — the pre-persist critic gate (`plan-critics.js`).
12
+ *
13
+ * Advisory by contract: findings are deterministic text for the workflow's
14
+ * re-author round. They never gate persist, never flip a dispatch verdict,
15
+ * and spawn nothing.
16
+ *
17
+ * Heuristics are deliberately narrow (few false positives over recall):
18
+ *
19
+ * - **dangling-citation** — a sentence referencing a document section
20
+ * (`§`, "design note", "review doc") with no repo-relative path and no
21
+ * `#<digits>` issue anchor in the same sentence.
22
+ * - **open-question** — interrogative-to-operator phrasing ("Flag if",
23
+ * "TBD", "confirm with the operator", a trailing `?`) in Goal/Spec
24
+ * prose outside code spans. Bodies record decisions; unresolved
25
+ * unknowns belong in declarative Key Assumptions.
26
+ * - **slicing-mass** — `## Slicing` character mass exceeding `## Spec`
27
+ * character mass when both are present: checkpoints carrying
28
+ * Spec-grade detail the Spec then re-covers.
29
+ *
30
+ * Pure, synchronous, no I/O. Operates on the draft `stories.json` array,
31
+ * reusing `parse()` from `lib/story-body/story-body.js` for section access.
32
+ * An unparseable draft body is skipped, not failed — hygiene is advisory
33
+ * and the persist validators own structural rejection.
34
+ *
35
+ * @module lib/orchestration/plan-text-hygiene
36
+ */
37
+
38
+ import { parse } from '../story-body/story-body.js';
39
+
40
+ /** Truncation length for the `evidence` excerpt on each finding. */
41
+ const EVIDENCE_MAX_CHARS = 160;
42
+
43
+ /**
44
+ * Phrases the citation heuristic treats as a reference to an external
45
+ * carrier document. Matched case-insensitively within one sentence.
46
+ */
47
+ const CITATION_MARKERS = [/§/, /\bdesign note\b/i, /\breview doc\b/i];
48
+
49
+ /**
50
+ * Anchors that locate a citation: a `#<digits>` issue reference or a
51
+ * repo-relative path (a slash-joined token carrying a file-ish segment).
52
+ */
53
+ const ISSUE_ANCHOR = /#\d+/;
54
+ const REPO_PATH_ANCHOR = /[\w.-]+\/[\w./-]+/;
55
+
56
+ /**
57
+ * Operator-directed open-question phrasings. Each match is an instruction
58
+ * or question aimed at a human, which a non-interactive delivery sub-agent
59
+ * can never answer.
60
+ */
61
+ const OPEN_QUESTION_MARKERS = [
62
+ /\bflag if\b/i,
63
+ /\bTBD\b/,
64
+ /\bconfirm with the operator\b/i,
65
+ ];
66
+
67
+ /**
68
+ * @typedef {Object} TextHygieneFinding
69
+ * @property {'dangling-citation'|'open-question'|'slicing-mass'} kind
70
+ * @property {string} slug - The draft Story's slug ('' when absent).
71
+ * @property {string} evidence - Excerpt of the offending text.
72
+ * @property {string} message - Human-readable, re-author-actionable text.
73
+ */
74
+
75
+ /**
76
+ * Strip fenced code blocks and inline code spans so code content (shell
77
+ * snippets, grep patterns, JSON) never trips a prose heuristic.
78
+ *
79
+ * @param {string} text
80
+ * @returns {string}
81
+ */
82
+ function stripCodeSpans(text) {
83
+ return text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ');
84
+ }
85
+
86
+ /**
87
+ * Split prose into sentence-ish units. Newlines are boundaries too, so a
88
+ * bullet list yields one unit per bullet.
89
+ *
90
+ * @param {string} text
91
+ * @returns {string[]}
92
+ */
93
+ function splitSentences(text) {
94
+ return text
95
+ .split(/(?<=[.!?])\s+|\n+/)
96
+ .map((s) => s.trim())
97
+ .filter(Boolean);
98
+ }
99
+
100
+ /**
101
+ * Truncate an excerpt for the finding's `evidence` field.
102
+ *
103
+ * @param {string} text
104
+ * @returns {string}
105
+ */
106
+ function excerpt(text) {
107
+ const flat = text.replace(/\s+/g, ' ').trim();
108
+ return flat.length > EVIDENCE_MAX_CHARS
109
+ ? `${flat.slice(0, EVIDENCE_MAX_CHARS - 1)}…`
110
+ : flat;
111
+ }
112
+
113
+ /**
114
+ * dangling-citation: a citation-marker sentence with no locating anchor.
115
+ *
116
+ * @param {string} prose - Code-stripped body prose.
117
+ * @param {string} slug
118
+ * @returns {TextHygieneFinding[]}
119
+ */
120
+ function findDanglingCitations(prose, slug) {
121
+ const findings = [];
122
+ for (const sentence of splitSentences(prose)) {
123
+ const cites = CITATION_MARKERS.some((m) => m.test(sentence));
124
+ if (!cites) continue;
125
+ const anchored =
126
+ ISSUE_ANCHOR.test(sentence) || REPO_PATH_ANCHOR.test(sentence);
127
+ if (anchored) continue;
128
+ findings.push({
129
+ kind: 'dangling-citation',
130
+ slug,
131
+ evidence: excerpt(sentence),
132
+ message:
133
+ 'Citation names a document section but no repo-relative path or ' +
134
+ '#<issue> anchor locates it in the same sentence — the executing ' +
135
+ 'agent cannot follow it. Anchor the citation or inline the claim.',
136
+ });
137
+ }
138
+ return findings;
139
+ }
140
+
141
+ /**
142
+ * open-question: operator-directed phrasing (or a trailing `?`) in prose a
143
+ * non-interactive sub-agent executes.
144
+ *
145
+ * @param {string} prose - Code-stripped Goal/Spec prose.
146
+ * @param {string} slug
147
+ * @returns {TextHygieneFinding[]}
148
+ */
149
+ function findOpenQuestions(prose, slug) {
150
+ const findings = [];
151
+ for (const sentence of splitSentences(prose)) {
152
+ const marked =
153
+ OPEN_QUESTION_MARKERS.some((m) => m.test(sentence)) ||
154
+ sentence.endsWith('?');
155
+ if (!marked) continue;
156
+ findings.push({
157
+ kind: 'open-question',
158
+ slug,
159
+ evidence: excerpt(sentence),
160
+ message:
161
+ 'Body text carries an operator-directed open question; the Story ' +
162
+ 'is executed by a non-interactive sub-agent that cannot answer it. ' +
163
+ 'Record the decision, or restate the unknown as a declarative Key ' +
164
+ 'Assumption.',
165
+ });
166
+ }
167
+ return findings;
168
+ }
169
+
170
+ /**
171
+ * slicing-mass: `## Slicing` outweighing `## Spec` when both are present.
172
+ *
173
+ * @param {{ slicing?: string, spec?: string }} body - Parsed Story body.
174
+ * @param {string} slug
175
+ * @returns {TextHygieneFinding[]}
176
+ */
177
+ function findSlicingMass(body, slug) {
178
+ const slicing = typeof body.slicing === 'string' ? body.slicing : '';
179
+ const spec = typeof body.spec === 'string' ? body.spec : '';
180
+ if (slicing.length === 0 || spec.length === 0) return [];
181
+ if (slicing.length <= spec.length) return [];
182
+ return [
183
+ {
184
+ kind: 'slicing-mass',
185
+ slug,
186
+ evidence: excerpt(slicing),
187
+ message:
188
+ `## Slicing (${slicing.length} chars) outweighs ## Spec ` +
189
+ `(${spec.length} chars) — checkpoints are carrying Spec-grade ` +
190
+ 'detail. Keep each Slicing checkpoint to one line and move the ' +
191
+ 'detail into ## Spec.',
192
+ },
193
+ ];
194
+ }
195
+
196
+ /**
197
+ * Evaluate the three text-hygiene lints over a draft Story array.
198
+ *
199
+ * @param {{ draftStories?: Array<object>|null }} args - The draft
200
+ * `stories.json` array (raw Story objects with top-level `slug` /
201
+ * `body`). Null/absent evaluates to zero findings (the single-delivery
202
+ * shape authors no draft tickets).
203
+ * @returns {{ findings: TextHygieneFinding[] }}
204
+ */
205
+ export function evaluateTextHygiene({ draftStories = null } = {}) {
206
+ const stories = Array.isArray(draftStories) ? draftStories : [];
207
+ const findings = [];
208
+ for (const story of stories) {
209
+ const slug = typeof story?.slug === 'string' ? story.slug : '';
210
+ let body;
211
+ try {
212
+ body = parse(story?.body).body;
213
+ } catch {
214
+ // Advisory lint: an unparseable body is the persist validators'
215
+ // rejection to make, not this evaluator's.
216
+ continue;
217
+ }
218
+ const goal = typeof body.goal === 'string' ? body.goal : '';
219
+ const spec = typeof body.spec === 'string' ? body.spec : '';
220
+ const bodyProse = stripCodeSpans(
221
+ typeof story.body === 'string' ? story.body : [goal, spec].join('\n'),
222
+ );
223
+ findings.push(
224
+ ...findDanglingCitations(bodyProse, slug),
225
+ ...findOpenQuestions(stripCodeSpans([goal, spec].join('\n')), slug),
226
+ ...findSlicingMass(body, slug),
227
+ );
228
+ }
229
+ return { findings };
230
+ }
@@ -13,11 +13,10 @@ import { renderDecomposerSystemPrompt } from '../../templates/decomposer-prompts
13
13
 
14
14
  export function buildDecomposerSystemPrompt(
15
15
  heuristics = [],
16
- { maxTickets, epicId } = {},
16
+ { maxTickets } = {},
17
17
  ) {
18
18
  const base = renderDecomposerSystemPrompt({
19
19
  maxTickets,
20
- epicId,
21
20
  });
22
21
  const heuristicsStr =
23
22
  heuristics.length > 0
@@ -28,7 +28,7 @@
28
28
  */
29
29
 
30
30
  import { parsePrNumberFromUrl } from '../../../github-url.js';
31
- import { runStoryReviewCore } from '../../story-close/phases/code-review.js';
31
+ import { runStoryReviewCore } from '../../story-close/phases/review-core.js';
32
32
  import { postStructuredComment } from '../../ticketing/state.js';
33
33
 
34
34
  /**
@@ -28,248 +28,111 @@
28
28
  * critical-halt path so the Epic-scoped lifecycle ledger still sees
29
29
  * the Story drop out.
30
30
  *
31
- * `runStoryReviewCore` is exported as the shared spine that the
32
- * `single-story-close` path imports, so both close paths call `runCodeReview`
33
- * through a single implementation rather than each maintaining its own
34
- * invocation pattern (Story #3653).
31
+ * The shared spine both close paths call `runCodeReview` through
32
+ * (`runStoryReviewCore`, Story #3653) and the shift-left local-lens pass
33
+ * (Epic #4405) live in `review-core.js` and `local-lens-review.js`
34
+ * respectively (extracted by Story #4603). This module is the Epic-attached
35
+ * phase entry point: it owns the advisory error posture and the
36
+ * critical-halt → blocked-envelope translation, and nothing else.
35
37
  */
36
38
 
37
- import {
38
- runAuditSuite,
39
- selectLocalLenses,
40
- } from '../../../audit-suite/index.js';
41
- import { gitSpawn } from '../../../git-utils.js';
42
39
  import { Logger } from '../../../Logger.js';
43
40
  import { runCodeReview } from '../../code-review.js';
44
41
  import { emitBlockedCloseResult } from '../emit-blocked.js';
42
+ import { runLocalLensReview } from './local-lens-review.js';
43
+ import { runStoryReviewCore } from './review-core.js';
45
44
 
46
45
  /**
47
- * The review depth the Story-scope local-lens pass runs at. Shift-left
48
- * (Epic #4405): local concerns are cheap to decide on a single Story's diff, so
49
- * the maker-blind Story-scope review runs its matched local lenses at `light`
50
- * depth here rather than paying a deeper pass at Epic close. Fixed for this
51
- * tier — it is not risk-scaled like the code-review pillar depth.
52
- */
53
- export const STORY_SCOPE_LENS_DEPTH = 'light';
54
-
55
- /**
56
- * Enumerate the files changed in the `baseRef...headRef` diff via
57
- * `git diff --name-only`. Best-effort: returns `[]` when the diff cannot be
58
- * enumerated (git failure, missing ref) and never throws, mirroring the
59
- * advisory posture of the surrounding review phase. Synchronous `gitSpawn`
60
- * (returns `{ status, stdout }`) is the same seam `code-review.js#countChangedFiles`
61
- * uses.
46
+ * Read a review envelope's severity counts, tolerating the partial envelope a
47
+ * misbehaving provider adapter can return. Pure.
62
48
  *
63
- * @param {{ baseRef: string, headRef: string, gitSpawnFn?: typeof gitSpawn }} args
64
- * @returns {string[]} Changed file paths, or `[]` on any failure.
49
+ * @param {object|null|undefined} reviewResult
50
+ * @returns {{ critical: number, high: number, medium: number, suggestion: number }}
65
51
  */
66
- export function enumerateChangedFiles({
67
- baseRef,
68
- headRef,
69
- gitSpawnFn = gitSpawn,
70
- }) {
71
- try {
72
- const result = gitSpawnFn(
73
- process.cwd(),
74
- 'diff',
75
- '--name-only',
76
- `${baseRef}...${headRef}`,
77
- );
78
- if (!result || result.status !== 0 || typeof result.stdout !== 'string') {
79
- return [];
52
+ function resolveSeverity(reviewResult) {
53
+ return (
54
+ reviewResult?.severity ?? {
55
+ critical: 0,
56
+ high: 0,
57
+ medium: 0,
58
+ suggestion: 0,
80
59
  }
81
- return result.stdout
82
- .split('\n')
83
- .map((f) => f.trim())
84
- .filter(Boolean);
85
- } catch {
86
- return [];
87
- }
60
+ );
88
61
  }
89
62
 
90
63
  /**
91
- * Run the Story-scope local-lens pass: select the LOCAL-tier lenses whose
92
- * `filePatterns` match the actual Story diff (`baseRef...headRef`) and
93
- * materialize their lens-prompt bodies at `light` depth. This is the
94
- * shift-left tier from Epic #4405 — it runs **inside** the story-close
95
- * subprocess spine (called only from {@link runStoryReviewCore}), never in the
96
- * delivering child's (maker's) context, so a maker never grades its own work.
97
- *
98
- * A diff that matches no local lens adds **no** lens work: the roster is empty
99
- * and `runAuditSuite` is never invoked. Best-effort and total — a git or
100
- * materialization failure degrades to `{ skipped: true, lenses: [] }` and is
101
- * logged via `progress`, matching the advisory posture the review phase already
102
- * takes for provider/transport failures.
64
+ * Collect the extra fields for the code-review-critical blocked envelope. Pure.
103
65
  *
104
- * @param {{
105
- * baseRef: string,
106
- * headRef: string,
107
- * progress: (tag: string, msg: string) => void,
108
- * progressTag?: string,
109
- * gitSpawnFn?: typeof gitSpawn,
110
- * selectLocalLensesFn?: typeof selectLocalLenses,
111
- * runAuditSuiteFn?: typeof runAuditSuite,
112
- * }} args
113
- * @returns {Promise<{
114
- * depth: 'light',
115
- * lenses: string[],
116
- * skipped: boolean,
117
- * materialized: object|null,
118
- * }>}
119
- */
120
- export async function runLocalLensReview({
121
- baseRef,
122
- headRef,
123
- progress,
124
- progressTag = 'CODE-REVIEW',
125
- gitSpawnFn = gitSpawn,
126
- selectLocalLensesFn = selectLocalLenses,
127
- runAuditSuiteFn = runAuditSuite,
128
- }) {
129
- const empty = {
130
- depth: STORY_SCOPE_LENS_DEPTH,
131
- lenses: [],
132
- skipped: true,
133
- materialized: null,
134
- };
135
- let lenses;
136
- try {
137
- const changedFiles = enumerateChangedFiles({
138
- baseRef,
139
- headRef,
140
- gitSpawnFn,
141
- });
142
- lenses = selectLocalLensesFn({ changedFiles });
143
- if (lenses.length === 0) {
144
- progress(
145
- progressTag,
146
- 'No local lens matched the Story diff — skipping the lens pass.',
147
- );
148
- return empty;
149
- }
150
- const materialized = await runAuditSuiteFn({ auditWorkflows: lenses });
151
- progress(
152
- progressTag,
153
- `Ran ${lenses.length} local lens(es) at ${STORY_SCOPE_LENS_DEPTH} depth: ${lenses.join(', ')}.`,
154
- );
155
- return {
156
- depth: STORY_SCOPE_LENS_DEPTH,
157
- lenses,
158
- skipped: false,
159
- materialized,
160
- };
161
- } catch (err) {
162
- // The lens pass is advisory: a git or materialization failure must not
163
- // fail the close. Log and degrade to a skipped envelope.
164
- progress(
165
- progressTag,
166
- `⚠️ local lens pass failed (continuing without it): ${err?.message ?? err}`,
167
- );
168
- return empty;
169
- }
170
- }
171
-
172
- /**
173
- * Collect the extra fields for the code-review-critical blocked envelope.
174
- * Pure; used by `runStoryCodeReview` to populate the `extra` argument of
175
- * `emitBlockedCloseResult`.
66
+ * @param {{ storyId: number, reviewResult: object }} args
67
+ * @returns {object}
176
68
  */
177
69
  function buildCodeReviewBlockedExtra({ storyId, reviewResult }) {
178
- const severity = reviewResult?.severity ?? {
179
- critical: 0,
180
- high: 0,
181
- medium: 0,
182
- suggestion: 0,
183
- };
184
70
  return {
185
- storyId: Number(storyId),
71
+ storyId,
186
72
  blockerReason: reviewResult?.blockerReason ?? null,
187
- severity,
73
+ severity: resolveSeverity(reviewResult),
188
74
  posted: reviewResult?.posted ?? false,
189
75
  exitCode: 1,
190
76
  };
191
77
  }
192
78
 
193
79
  /**
194
- * Invoke `runCodeReviewFn` with the canonical Story-scope envelope and return
195
- * the raw result. Shared by both the Epic-attached close path
196
- * (`runStoryCodeReview`) and the standalone close path
197
- * (`single-story-close/phases/code-review.js#runStoryScopeReview`) so the
198
- * invocation pattern lives in one place (Story #3653).
199
- *
200
- * The caller is responsible for error handling and result interpretation —
201
- * this function propagates throws rather than swallowing them, because the
202
- * two callers have different advisory postures:
80
+ * Render the operator-facing one-line summary of a completed (non-halting)
81
+ * review. Pure.
203
82
  *
204
- * - Epic-attached close: swallows throws (non-blocking advisory, same as
205
- * `refresh.js`).
206
- * - Standalone close: propagates throws (a review failure stops the close).
83
+ * @param {object} reviewResult
84
+ * @returns {string}
85
+ */
86
+ function formatReviewSummary(reviewResult) {
87
+ const { high, medium, suggestion } = resolveSeverity(reviewResult);
88
+ const posted = reviewResult?.posted ?? false;
89
+ return `Review complete — high=${high} medium=${medium} suggestion=${suggestion} (posted=${posted}).`;
90
+ }
91
+
92
+ /**
93
+ * Run the shared review spine, absorbing an adapter / wiring failure into this
94
+ * phase's advisory posture: the review is best-effort when the provider cannot
95
+ * complete, and the gates already vouched for the diff at this point.
207
96
  *
208
- * Review depth is not passed in: `runCodeReview` derives it entirely from the
209
- * `baseRef...headRef` diff it enumerates itself the changed files' sensitive-
210
- * path intersection plus their count (Story #4542, which retired the
211
- * planner-authored risk envelope this spine used to forward). Depth remains an
212
- * **input-only** signal: it tells the provider how thorough to be and never
213
- * alters the review's output envelope or the posted structured-comment body.
97
+ * @param {object} args Spine arguments (see {@link runStoryReviewCore}).
98
+ * @returns {Promise<object|null>} The review envelope, or `null` when the
99
+ * review threw and the close should proceed unblocked.
100
+ */
101
+ async function invokeReviewCore(args) {
102
+ try {
103
+ return await runStoryReviewCore(args);
104
+ } catch (err) {
105
+ Logger.warn?.(
106
+ `[story-close] ⚠️ code-review phase failed (continuing without blocker): ${err?.message ?? err}`,
107
+ );
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Translate a halting (critical-findings) review into the blocked envelope the
114
+ * caller returns verbatim, emitting `story.blocked` onto the bus on the way.
214
115
  *
215
116
  * @param {{
216
- * storyId: number|string,
217
- * baseRef: string,
218
- * headRef: string,
219
- * commentTargetId?: number|null,
220
- * provider: object,
117
+ * storyId: number,
118
+ * reviewResult: object,
119
+ * bus: { emit: Function }|null,
221
120
  * progress: (tag: string, msg: string) => void,
222
- * progressTag?: string,
223
- * runCodeReviewFn?: typeof runCodeReview,
224
- * runLocalLensReviewFn?: typeof runLocalLensReview,
225
121
  * }} args
226
- * @returns {Promise<object>} Raw result envelope from `runCodeReview`, augmented
227
- * with a `localLensReview` field carrying the Story-scope local-lens pass
228
- * outcome (Epic #4405, Story #4409). Both close entry points reach the lens
229
- * pass through this single spine, so it runs on the Epic-attached and
230
- * standalone paths alike and always inside the close subprocess.
122
+ * @returns {Promise<object>} The blocked envelope.
231
123
  */
232
- export async function runStoryReviewCore({
233
- storyId,
234
- baseRef,
235
- headRef,
236
- commentTargetId = null,
237
- provider,
238
- progress,
239
- progressTag = 'CODE-REVIEW',
240
- runCodeReviewFn = runCodeReview,
241
- runLocalLensReviewFn = runLocalLensReview,
242
- }) {
243
- const storyIdNum = Number(storyId);
244
- const opts = {
245
- scope: 'story',
246
- ticketId: storyIdNum,
247
- baseRef,
248
- headRef,
249
- provider,
250
- logger: {
251
- info: (m) => progress(progressTag, m),
252
- warn: (m) => progress(progressTag, `⚠️ ${m}`),
253
- },
254
- };
255
- if (commentTargetId != null) {
256
- opts.commentTargetId = commentTargetId;
257
- }
258
-
259
- // Shift-left local-lens pass (Epic #4405). Runs matched local lenses at
260
- // `light` depth against the actual Story diff, inside this close-subprocess
261
- // spine so the maker never grades its own work. Advisory — it never blocks
262
- // the close and its outcome rides on the returned envelope for downstream
263
- // consumers.
264
- const localLensReview = await runLocalLensReviewFn({
265
- baseRef,
266
- headRef,
124
+ async function emitCriticalBlock({ storyId, reviewResult, bus, progress }) {
125
+ const { critical } = resolveSeverity(reviewResult);
126
+ return emitBlockedCloseResult({
127
+ storyId,
128
+ phase: 'closing',
129
+ reason: 'code-review-critical',
130
+ extra: buildCodeReviewBlockedExtra({ storyId, reviewResult }),
131
+ bus,
267
132
  progress,
268
- progressTag,
133
+ blockedMessage: `Story #${storyId} blocked: code-review reported ${critical} critical blocker(s).`,
134
+ logger: Logger,
269
135
  });
270
-
271
- const result = await runCodeReviewFn(opts);
272
- return { ...result, localLensReview };
273
136
  }
274
137
 
275
138
  /**
@@ -301,65 +164,44 @@ export async function runStoryReviewCore({
301
164
  * (Epic #4405, Story #4409) when the review completed; it is absent only when
302
165
  * the whole review phase threw (advisory failure).
303
166
  */
304
- export async function runStoryCodeReview(args) {
305
- const {
306
- storyId,
307
- baseBranch,
308
- storyBranch,
309
- provider,
310
- bus,
311
- progress,
312
- runCodeReviewFn = runCodeReview,
313
- runLocalLensReviewFn = runLocalLensReview,
314
- } = args;
315
-
167
+ export async function runStoryCodeReview({
168
+ storyId,
169
+ baseBranch,
170
+ storyBranch,
171
+ provider,
172
+ bus,
173
+ progress,
174
+ runCodeReviewFn = runCodeReview,
175
+ runLocalLensReviewFn = runLocalLensReview,
176
+ }) {
316
177
  const storyIdNum = Number(storyId);
317
178
  progress(
318
179
  'CODE-REVIEW',
319
180
  `Running Story-scope review (${baseBranch}…${storyBranch})...`,
320
181
  );
321
182
 
322
- let reviewResult;
323
- try {
324
- reviewResult = await runStoryReviewCore({
325
- storyId: storyIdNum,
326
- baseRef: baseBranch,
327
- headRef: storyBranch,
328
- provider,
329
- progress,
330
- runCodeReviewFn,
331
- runLocalLensReviewFn,
332
- });
333
- } catch (err) {
334
- // Adapter / wiring failure — log and proceed. The review is advisory
335
- // when the provider cannot complete; the gates already vouched for
336
- // the diff at this point.
337
- Logger.warn?.(
338
- `[story-close] ⚠️ code-review phase failed (continuing without blocker): ${err?.message ?? err}`,
339
- );
340
- return { blocked: null };
341
- }
342
-
343
- const localLensReview = reviewResult?.localLensReview;
183
+ const reviewResult = await invokeReviewCore({
184
+ storyId: storyIdNum,
185
+ baseRef: baseBranch,
186
+ headRef: storyBranch,
187
+ provider,
188
+ progress,
189
+ runCodeReviewFn,
190
+ runLocalLensReviewFn,
191
+ });
192
+ if (reviewResult === null) return { blocked: null };
344
193
 
345
- if (reviewResult?.halted) {
346
- const blocked = await emitBlockedCloseResult({
194
+ const localLensReview = reviewResult.localLensReview;
195
+ if (reviewResult.halted) {
196
+ const blocked = await emitCriticalBlock({
347
197
  storyId: storyIdNum,
348
- phase: 'closing',
349
- reason: 'code-review-critical',
350
- extra: buildCodeReviewBlockedExtra({ storyId: storyIdNum, reviewResult }),
198
+ reviewResult,
351
199
  bus,
352
200
  progress,
353
- blockedMessage: `Story #${storyIdNum} blocked: code-review reported ${reviewResult.severity.critical} critical blocker(s).`,
354
- logger: Logger,
355
201
  });
356
202
  return { blocked, localLensReview };
357
203
  }
358
204
 
359
- const counts = reviewResult?.severity ?? {};
360
- progress(
361
- 'CODE-REVIEW',
362
- `Review complete — high=${counts.high ?? 0} medium=${counts.medium ?? 0} suggestion=${counts.suggestion ?? 0} (posted=${reviewResult?.posted ?? false}).`,
363
- );
205
+ progress('CODE-REVIEW', formatReviewSummary(reviewResult));
364
206
  return { blocked: null, localLensReview };
365
207
  }