@nathapp/nax 0.32.2 → 0.33.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 (38) hide show
  1. package/dist/nax.js +808 -104
  2. package/package.json +1 -1
  3. package/src/cli/analyze.ts +145 -0
  4. package/src/cli/config.ts +9 -0
  5. package/src/config/defaults.ts +8 -0
  6. package/src/config/schema.ts +1 -0
  7. package/src/config/schemas.ts +10 -0
  8. package/src/config/types.ts +18 -0
  9. package/src/context/elements.ts +13 -0
  10. package/src/context/greenfield.ts +1 -1
  11. package/src/decompose/apply.ts +44 -0
  12. package/src/decompose/builder.ts +181 -0
  13. package/src/decompose/index.ts +8 -0
  14. package/src/decompose/sections/codebase.ts +26 -0
  15. package/src/decompose/sections/constraints.ts +32 -0
  16. package/src/decompose/sections/index.ts +4 -0
  17. package/src/decompose/sections/sibling-stories.ts +25 -0
  18. package/src/decompose/sections/target-story.ts +31 -0
  19. package/src/decompose/types.ts +55 -0
  20. package/src/decompose/validators/complexity.ts +45 -0
  21. package/src/decompose/validators/coverage.ts +134 -0
  22. package/src/decompose/validators/dependency.ts +91 -0
  23. package/src/decompose/validators/index.ts +35 -0
  24. package/src/decompose/validators/overlap.ts +128 -0
  25. package/src/execution/escalation/tier-escalation.ts +9 -2
  26. package/src/execution/sequential-executor.ts +4 -3
  27. package/src/interaction/index.ts +1 -0
  28. package/src/interaction/triggers.ts +21 -0
  29. package/src/interaction/types.ts +7 -0
  30. package/src/pipeline/stages/review.ts +6 -0
  31. package/src/pipeline/stages/routing.ts +89 -0
  32. package/src/pipeline/types.ts +2 -0
  33. package/src/plugins/types.ts +33 -0
  34. package/src/prd/index.ts +5 -1
  35. package/src/prd/types.ts +11 -1
  36. package/src/review/orchestrator.ts +1 -0
  37. package/src/review/types.ts +2 -0
  38. package/src/tdd/isolation.ts +1 -1
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Decompose module types.
3
+ *
4
+ * DecomposeConfig, SubStory, DecomposeResult, ValidationResult.
5
+ */
6
+
7
+ /** Configuration for story decomposition */
8
+ export interface DecomposeConfig {
9
+ /** Maximum number of sub-stories to generate */
10
+ maxSubStories: number;
11
+ /** Maximum allowed complexity for any sub-story */
12
+ maxComplexity: "simple" | "medium" | "complex" | "expert";
13
+ /** Maximum number of retries on validation failure */
14
+ maxRetries?: number;
15
+ }
16
+
17
+ /** A single decomposed sub-story */
18
+ export interface SubStory {
19
+ /** Sub-story ID (e.g., "SD-001-1") */
20
+ id: string;
21
+ /** Parent story ID */
22
+ parentStoryId: string;
23
+ /** Sub-story title */
24
+ title: string;
25
+ /** Sub-story description */
26
+ description: string;
27
+ /** Acceptance criteria */
28
+ acceptanceCriteria: string[];
29
+ /** Tags for routing */
30
+ tags: string[];
31
+ /** Dependencies (story IDs) */
32
+ dependencies: string[];
33
+ /** Complexity classification */
34
+ complexity: "simple" | "medium" | "complex" | "expert";
35
+ /** Justification that this sub-story does not overlap with sibling stories */
36
+ nonOverlapJustification: string;
37
+ }
38
+
39
+ /** Validation result for decomposition output */
40
+ export interface ValidationResult {
41
+ valid: boolean;
42
+ errors: string[];
43
+ warnings: string[];
44
+ }
45
+
46
+ /** Result of decomposing a story */
47
+ export interface DecomposeResult {
48
+ subStories: SubStory[];
49
+ validation: ValidationResult;
50
+ }
51
+
52
+ /** Adapter interface for calling the decompose LLM */
53
+ export interface DecomposeAdapter {
54
+ decompose(prompt: string): Promise<string>;
55
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Complexity validator.
3
+ *
4
+ * Validates each substory complexity is <= config.maxSubstoryComplexity (default: medium).
5
+ * Reuses classifyComplexity() from src/routing/router.ts as a cross-check against LLM-assigned complexity.
6
+ */
7
+
8
+ import { classifyComplexity } from "../../routing";
9
+ import type { SubStory, ValidationResult } from "../types";
10
+
11
+ export type ComplexityLevel = "simple" | "medium" | "complex" | "expert";
12
+
13
+ const COMPLEXITY_ORDER: Record<ComplexityLevel, number> = {
14
+ simple: 0,
15
+ medium: 1,
16
+ complex: 2,
17
+ expert: 3,
18
+ };
19
+
20
+ export function validateComplexity(substories: SubStory[], maxComplexity: ComplexityLevel): ValidationResult {
21
+ const errors: string[] = [];
22
+ const warnings: string[] = [];
23
+ const maxOrder = COMPLEXITY_ORDER[maxComplexity];
24
+
25
+ for (const sub of substories) {
26
+ const assignedOrder = COMPLEXITY_ORDER[sub.complexity];
27
+
28
+ if (assignedOrder > maxOrder) {
29
+ errors.push(`Substory ${sub.id} complexity "${sub.complexity}" exceeds maxComplexity "${maxComplexity}"`);
30
+ }
31
+
32
+ // Cross-check with classifyComplexity
33
+ const classified = classifyComplexity(sub.title, sub.description, sub.acceptanceCriteria, sub.tags);
34
+ if (classified !== sub.complexity) {
35
+ const classifiedOrder = COMPLEXITY_ORDER[classified as ComplexityLevel] ?? 0;
36
+ if (classifiedOrder > assignedOrder) {
37
+ warnings.push(
38
+ `Substory ${sub.id} is assigned complexity "${sub.complexity}" but classifier estimates "${classified}" — may be underestimated`,
39
+ );
40
+ }
41
+ }
42
+ }
43
+
44
+ return { valid: errors.length === 0, errors, warnings };
45
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Coverage validator.
3
+ *
4
+ * Checks that the union of substory acceptance criteria covers
5
+ * the original story's AC using keyword matching.
6
+ * Warns on unmatched original criteria.
7
+ */
8
+
9
+ import type { UserStory } from "../../prd";
10
+ import type { SubStory, ValidationResult } from "../types";
11
+
12
+ const STOP_WORDS = new Set([
13
+ "a",
14
+ "an",
15
+ "the",
16
+ "and",
17
+ "or",
18
+ "but",
19
+ "is",
20
+ "are",
21
+ "was",
22
+ "were",
23
+ "be",
24
+ "been",
25
+ "being",
26
+ "have",
27
+ "has",
28
+ "had",
29
+ "do",
30
+ "does",
31
+ "did",
32
+ "will",
33
+ "would",
34
+ "could",
35
+ "should",
36
+ "may",
37
+ "might",
38
+ "can",
39
+ "to",
40
+ "of",
41
+ "in",
42
+ "on",
43
+ "at",
44
+ "for",
45
+ "with",
46
+ "by",
47
+ "from",
48
+ "as",
49
+ "it",
50
+ "its",
51
+ "that",
52
+ "this",
53
+ "these",
54
+ "those",
55
+ "not",
56
+ "no",
57
+ "so",
58
+ "if",
59
+ "then",
60
+ "than",
61
+ "when",
62
+ "which",
63
+ "who",
64
+ "what",
65
+ "how",
66
+ "all",
67
+ "each",
68
+ "any",
69
+ "up",
70
+ "out",
71
+ "about",
72
+ "into",
73
+ "through",
74
+ "after",
75
+ "before",
76
+ ]);
77
+
78
+ function extractKeywords(text: string): string[] {
79
+ return text
80
+ .toLowerCase()
81
+ .split(/[\s,.:;!?()\[\]{}"'`\-_/\\]+/)
82
+ .filter((w) => w.length > 2 && !STOP_WORDS.has(w));
83
+ }
84
+
85
+ function commonPrefixLength(a: string, b: string): number {
86
+ let i = 0;
87
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
88
+ return i;
89
+ }
90
+
91
+ /**
92
+ * Two keywords match if they are identical or share a common prefix of ≥5 chars.
93
+ * This handles morphological variants like "register" / "registration" (prefix "regist" = 6).
94
+ * It does NOT match unrelated words that start with "re" ("reset" vs "register" = 2 chars).
95
+ */
96
+ function keywordsMatch(a: string, b: string): boolean {
97
+ return a === b || commonPrefixLength(a, b) >= 5;
98
+ }
99
+
100
+ /**
101
+ * Returns true if the original AC is "covered" by the union of substory ACs.
102
+ * Covered means: strictly more than half of the original AC's keywords have a
103
+ * match (exact or common-prefix ≥5) in the substory AC keywords.
104
+ */
105
+ function isCovered(originalAc: string, substoryAcs: string[]): boolean {
106
+ const originalKw = extractKeywords(originalAc);
107
+ if (originalKw.length === 0) return true;
108
+
109
+ const substoryKwList = substoryAcs.flatMap(extractKeywords);
110
+
111
+ let matchCount = 0;
112
+ for (const kw of originalKw) {
113
+ if (substoryKwList.some((s) => keywordsMatch(kw, s))) {
114
+ matchCount++;
115
+ }
116
+ }
117
+
118
+ // Require strictly more than half of the original AC's keywords to match
119
+ return matchCount > originalKw.length / 2;
120
+ }
121
+
122
+ export function validateCoverage(originalStory: UserStory, substories: SubStory[]): ValidationResult {
123
+ const warnings: string[] = [];
124
+
125
+ const allSubstoryAcs = substories.flatMap((s) => s.acceptanceCriteria);
126
+
127
+ for (const ac of originalStory.acceptanceCriteria ?? []) {
128
+ if (!isCovered(ac, allSubstoryAcs)) {
129
+ warnings.push(`Original AC not covered by any substory: "${ac}"`);
130
+ }
131
+ }
132
+
133
+ return { valid: true, errors: [], warnings };
134
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Dependency validator.
3
+ *
4
+ * Validates:
5
+ * - No circular dependencies among substories
6
+ * - All referenced dependency IDs exist (in substories or existing PRD)
7
+ * - No ID collisions with existing PRD story IDs
8
+ */
9
+
10
+ import type { SubStory, ValidationResult } from "../types";
11
+
12
+ function detectCycles(substories: SubStory[]): string[] {
13
+ const errors: string[] = [];
14
+ const idSet = new Set(substories.map((s) => s.id));
15
+
16
+ // Build adjacency map (only edges within the substory set)
17
+ const adj: Map<string, string[]> = new Map();
18
+ for (const sub of substories) {
19
+ adj.set(
20
+ sub.id,
21
+ sub.dependencies.filter((d) => idSet.has(d)),
22
+ );
23
+ }
24
+
25
+ const WHITE = 0; // unvisited
26
+ const GRAY = 1; // in current path
27
+ const BLACK = 2; // done
28
+
29
+ const color: Map<string, number> = new Map();
30
+ for (const id of idSet) color.set(id, WHITE);
31
+
32
+ const reported = new Set<string>();
33
+
34
+ function dfs(id: string, path: string[]): void {
35
+ color.set(id, GRAY);
36
+ for (const dep of adj.get(id) ?? []) {
37
+ if (color.get(dep) === GRAY) {
38
+ // Found a cycle — report it once
39
+ const cycleKey = [...path, dep].sort().join(",");
40
+ if (!reported.has(cycleKey)) {
41
+ reported.add(cycleKey);
42
+ const cycleStart = path.indexOf(dep);
43
+ const cycleNodes = cycleStart >= 0 ? path.slice(cycleStart) : path;
44
+ const cycleStr = [...cycleNodes, dep].join(" -> ");
45
+ errors.push(`Circular dependency detected: ${cycleStr}`);
46
+ }
47
+ } else if (color.get(dep) === WHITE) {
48
+ dfs(dep, [...path, dep]);
49
+ }
50
+ }
51
+ color.set(id, BLACK);
52
+ }
53
+
54
+ for (const id of idSet) {
55
+ if (color.get(id) === WHITE) {
56
+ dfs(id, [id]);
57
+ }
58
+ }
59
+
60
+ return errors;
61
+ }
62
+
63
+ export function validateDependencies(substories: SubStory[], existingStoryIds: string[]): ValidationResult {
64
+ const errors: string[] = [];
65
+
66
+ const substoryIdSet = new Set(substories.map((s) => s.id));
67
+ const existingIdSet = new Set(existingStoryIds);
68
+ const allKnownIds = new Set([...substoryIdSet, ...existingIdSet]);
69
+
70
+ // ID collisions with existing PRD
71
+ for (const sub of substories) {
72
+ if (existingIdSet.has(sub.id)) {
73
+ errors.push(`Substory ID "${sub.id}" collides with existing PRD story — duplicate IDs are not allowed`);
74
+ }
75
+ }
76
+
77
+ // Non-existent dependency references
78
+ for (const sub of substories) {
79
+ for (const dep of sub.dependencies) {
80
+ if (!allKnownIds.has(dep)) {
81
+ errors.push(`Substory ${sub.id} references non-existent story ID "${dep}"`);
82
+ }
83
+ }
84
+ }
85
+
86
+ // Circular dependencies
87
+ const cycleErrors = detectCycles(substories);
88
+ errors.push(...cycleErrors);
89
+
90
+ return { valid: errors.length === 0, errors, warnings: [] };
91
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Validator orchestrator.
3
+ *
4
+ * runAllValidators() runs all validators in sequence and returns merged ValidationResult.
5
+ */
6
+
7
+ import type { UserStory } from "../../prd";
8
+ import type { DecomposeConfig, SubStory, ValidationResult } from "../types";
9
+ import type { ComplexityLevel } from "./complexity";
10
+ import { validateComplexity } from "./complexity";
11
+ import { validateCoverage } from "./coverage";
12
+ import { validateDependencies } from "./dependency";
13
+ import { validateOverlap } from "./overlap";
14
+
15
+ export function runAllValidators(
16
+ originalStory: UserStory,
17
+ substories: SubStory[],
18
+ existingStories: UserStory[],
19
+ config: DecomposeConfig,
20
+ ): ValidationResult {
21
+ const existingIds = existingStories.map((s) => s.id);
22
+ const maxComplexity = (config.maxComplexity ?? "medium") as ComplexityLevel;
23
+
24
+ const results = [
25
+ validateOverlap(substories, existingStories),
26
+ validateCoverage(originalStory, substories),
27
+ validateComplexity(substories, maxComplexity),
28
+ validateDependencies(substories, existingIds),
29
+ ];
30
+
31
+ const errors = results.flatMap((r) => r.errors);
32
+ const warnings = results.flatMap((r) => r.warnings);
33
+
34
+ return { valid: errors.length === 0, errors, warnings };
35
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Overlap validator.
3
+ *
4
+ * Checks keyword + tag similarity between each substory and all existing PRD stories.
5
+ * Uses Jaccard-like normalized keyword intersection over title + tags.
6
+ * Flags pairs with similarity > 0.6 as warnings, > 0.8 as errors.
7
+ */
8
+
9
+ import type { UserStory } from "../../prd";
10
+ import type { SubStory, ValidationResult } from "../types";
11
+
12
+ const STOP_WORDS = new Set([
13
+ "a",
14
+ "an",
15
+ "the",
16
+ "and",
17
+ "or",
18
+ "but",
19
+ "is",
20
+ "are",
21
+ "was",
22
+ "were",
23
+ "be",
24
+ "been",
25
+ "being",
26
+ "have",
27
+ "has",
28
+ "had",
29
+ "do",
30
+ "does",
31
+ "did",
32
+ "will",
33
+ "would",
34
+ "could",
35
+ "should",
36
+ "may",
37
+ "might",
38
+ "can",
39
+ "to",
40
+ "of",
41
+ "in",
42
+ "on",
43
+ "at",
44
+ "for",
45
+ "with",
46
+ "by",
47
+ "from",
48
+ "as",
49
+ "it",
50
+ "its",
51
+ "that",
52
+ "this",
53
+ "these",
54
+ "those",
55
+ "not",
56
+ "no",
57
+ "so",
58
+ "if",
59
+ "then",
60
+ "than",
61
+ "when",
62
+ "which",
63
+ "who",
64
+ "what",
65
+ "how",
66
+ "all",
67
+ "each",
68
+ "any",
69
+ "up",
70
+ "out",
71
+ "about",
72
+ "into",
73
+ "through",
74
+ "after",
75
+ "before",
76
+ ]);
77
+
78
+ function extractKeywords(texts: string[]): Set<string> {
79
+ const words = texts
80
+ .join(" ")
81
+ .toLowerCase()
82
+ .split(/[\s,.:;!?()\[\]{}"'`\-_/\\]+/)
83
+ .filter((w) => w.length > 2 && !STOP_WORDS.has(w) && !/^\d+$/.test(w));
84
+ return new Set(words);
85
+ }
86
+
87
+ function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
88
+ if (a.size === 0 && b.size === 0) return 0;
89
+ let intersectionSize = 0;
90
+ for (const word of a) {
91
+ if (b.has(word)) intersectionSize++;
92
+ }
93
+ const unionSize = a.size + b.size - intersectionSize;
94
+ return unionSize === 0 ? 0 : intersectionSize / unionSize;
95
+ }
96
+
97
+ // Use title + tags only for overlap detection to get stable, meaningful similarity scores
98
+ function substoryKeywords(s: SubStory): Set<string> {
99
+ return extractKeywords([s.title, ...s.tags]);
100
+ }
101
+
102
+ function storyKeywords(s: UserStory): Set<string> {
103
+ return extractKeywords([s.title, ...(s.tags ?? [])]);
104
+ }
105
+
106
+ export function validateOverlap(substories: SubStory[], existingStories: UserStory[]): ValidationResult {
107
+ const errors: string[] = [];
108
+ const warnings: string[] = [];
109
+
110
+ for (const sub of substories) {
111
+ const subKw = substoryKeywords(sub);
112
+ for (const existing of existingStories) {
113
+ const exKw = storyKeywords(existing);
114
+ const sim = jaccardSimilarity(subKw, exKw);
115
+ if (sim > 0.8) {
116
+ errors.push(
117
+ `Substory ${sub.id} overlaps with existing story ${existing.id} (similarity ${sim.toFixed(2)} > 0.8)`,
118
+ );
119
+ } else if (sim > 0.6) {
120
+ warnings.push(
121
+ `Substory ${sub.id} may overlap with existing story ${existing.id} (similarity ${sim.toFixed(2)} > 0.6)`,
122
+ );
123
+ }
124
+ }
125
+ }
126
+
127
+ return { valid: errors.length === 0, errors, warnings };
128
+ }
@@ -20,12 +20,17 @@ import { appendProgress } from "../progress";
20
20
  import { handleMaxAttemptsReached, handleNoTierAvailable } from "./tier-outcome";
21
21
 
22
22
  /** Build a StructuredFailure for tier escalation. */
23
- function buildEscalationFailure(story: UserStory, currentTier: string): StructuredFailure {
23
+ function buildEscalationFailure(
24
+ story: UserStory,
25
+ currentTier: string,
26
+ reviewFindings?: import("../../plugins/types").ReviewFinding[],
27
+ ): StructuredFailure {
24
28
  return {
25
29
  attempt: (story.attempts ?? 0) + 1,
26
30
  modelTier: currentTier,
27
31
  stage: "escalation" as const,
28
32
  summary: `Failed with tier ${currentTier}, escalating to next tier`,
33
+ reviewFindings: reviewFindings && reviewFindings.length > 0 ? reviewFindings : undefined,
29
34
  timestamp: new Date().toISOString(),
30
35
  };
31
36
  }
@@ -192,6 +197,7 @@ export interface EscalationHandlerContext {
192
197
  context: {
193
198
  retryAsLite?: boolean;
194
199
  tddFailureCategory?: FailureCategory;
200
+ reviewFindings?: import("../../plugins/types").ReviewFinding[];
195
201
  };
196
202
  };
197
203
  config: NaxConfig;
@@ -224,6 +230,7 @@ export async function handleTierEscalation(ctx: EscalationHandlerContext): Promi
224
230
  // Retrieve TDD-specific context flags set by executionStage
225
231
  const escalateRetryAsLite = ctx.pipelineResult.context.retryAsLite === true;
226
232
  const escalateFailureCategory = ctx.pipelineResult.context.tddFailureCategory;
233
+ const escalateReviewFindings = ctx.pipelineResult.context.reviewFindings;
227
234
  // S5: Auto-switch to test-after on greenfield-no-tests
228
235
  const escalateRetryAsTestAfter = escalateFailureCategory === "greenfield-no-tests";
229
236
  const routingMode = ctx.config.routing.llm?.mode ?? "hybrid";
@@ -288,7 +295,7 @@ export async function handleTierEscalation(ctx: EscalationHandlerContext): Promi
288
295
  const shouldResetAttempts = isChangingTier || shouldSwitchToTestAfter;
289
296
 
290
297
  // Build escalation failure
291
- const escalationFailure = buildEscalationFailure(s, currentStoryTier);
298
+ const escalationFailure = buildEscalationFailure(s, currentStoryTier, escalateReviewFindings);
292
299
 
293
300
  return {
294
301
  ...s,
@@ -14,7 +14,7 @@ import { wireReporters } from "../pipeline/subscribers/reporters";
14
14
  import type { PipelineContext } from "../pipeline/types";
15
15
  import { generateHumanHaltSummary, isComplete, isStalled, loadPRD } from "../prd";
16
16
  import type { PRD } from "../prd/types";
17
- import { startHeartbeat, stopHeartbeat, writeExitSummary } from "./crash-recovery";
17
+ import { startHeartbeat } from "./crash-recovery";
18
18
  import type { SequentialExecutionContext, SequentialExecutionResult } from "./executor-types";
19
19
  import { runIteration } from "./iteration-runner";
20
20
  import { selectNextStories } from "./story-selector";
@@ -181,7 +181,8 @@ export async function executeSequential(
181
181
 
182
182
  return buildResult("max-iterations");
183
183
  } finally {
184
- stopHeartbeat();
185
- writeExitSummary(ctx.logFilePath, totalCost, iterations, storiesCompleted, Date.now() - ctx.startTime);
184
+ // BUG-060: Do NOT stopHeartbeat or writeExitSummary here.
185
+ // runner.ts owns the full lifecycle (including deferred regression gate)
186
+ // and handles heartbeat + exit summary after all post-run work completes.
186
187
  }
187
188
  }
@@ -53,6 +53,7 @@ export {
53
53
  checkPreMerge,
54
54
  checkStoryAmbiguity,
55
55
  checkReviewGate,
56
+ checkStoryOversized,
56
57
  } from "./triggers";
57
58
  export type { TriggerContext } from "./triggers";
58
59
 
@@ -227,3 +227,24 @@ export async function checkReviewGate(
227
227
  const response = await executeTrigger("review-gate", context, config, chain);
228
228
  return response.action === "approve";
229
229
  }
230
+
231
+ /**
232
+ * Check story-oversized trigger (decompose, skip, or continue)
233
+ */
234
+ export async function checkStoryOversized(
235
+ context: TriggerContext,
236
+ config: NaxConfig,
237
+ chain: InteractionChain,
238
+ ): Promise<"decompose" | "skip" | "continue"> {
239
+ if (!isTriggerEnabled("story-oversized", config)) return "continue";
240
+
241
+ try {
242
+ const response = await executeTrigger("story-oversized", context, config, chain);
243
+ if (response.action === "approve") return "decompose";
244
+ if (response.action === "skip") return "skip";
245
+ return "continue";
246
+ } catch {
247
+ // No plugin registered or all plugins failed — apply default fallback
248
+ return "continue";
249
+ }
250
+ }
@@ -83,6 +83,7 @@ export type TriggerName =
83
83
  | "max-retries" // skip (yellow) — max retries reached
84
84
  | "pre-merge" // escalate (yellow) — before merging to main
85
85
  | "human-review" // skip (yellow) — human review required on max retries / critical failure
86
+ | "story-oversized" // continue (yellow) — story has too many acceptance criteria
86
87
  | "story-ambiguity" // continue (green) — story requirements unclear
87
88
  | "review-gate"; // continue (green) — code review checkpoint
88
89
 
@@ -150,6 +151,12 @@ export const TRIGGER_METADATA: Record<TriggerName, TriggerMetadata> = {
150
151
  safety: "yellow",
151
152
  defaultSummary: "Human review required for story {{storyId}} — skip and continue?",
152
153
  },
154
+ "story-oversized": {
155
+ defaultFallback: "continue",
156
+ safety: "yellow",
157
+ defaultSummary:
158
+ "Story {{storyId}} is oversized ({{criteriaCount}} acceptance criteria) — decompose into smaller stories?",
159
+ },
153
160
  "story-ambiguity": {
154
161
  defaultFallback: "continue",
155
162
  safety: "green",
@@ -30,6 +30,12 @@ export const reviewStage: PipelineStage = {
30
30
  ctx.reviewResult = result.builtIn;
31
31
 
32
32
  if (!result.success) {
33
+ // Collect structured findings from plugin reviewers for escalation context
34
+ const allFindings = result.builtIn.pluginReviewers?.flatMap((pr) => pr.findings ?? []) ?? [];
35
+ if (allFindings.length > 0) {
36
+ ctx.reviewFindings = allFindings;
37
+ }
38
+
33
39
  if (result.pluginFailed) {
34
40
  // security-review trigger: prompt before permanently failing
35
41
  if (ctx.interaction && isTriggerEnabled("security-review", ctx.config)) {