@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.
- package/dist/nax.js +808 -104
- package/package.json +1 -1
- package/src/cli/analyze.ts +145 -0
- package/src/cli/config.ts +9 -0
- package/src/config/defaults.ts +8 -0
- package/src/config/schema.ts +1 -0
- package/src/config/schemas.ts +10 -0
- package/src/config/types.ts +18 -0
- package/src/context/elements.ts +13 -0
- package/src/context/greenfield.ts +1 -1
- package/src/decompose/apply.ts +44 -0
- package/src/decompose/builder.ts +181 -0
- package/src/decompose/index.ts +8 -0
- package/src/decompose/sections/codebase.ts +26 -0
- package/src/decompose/sections/constraints.ts +32 -0
- package/src/decompose/sections/index.ts +4 -0
- package/src/decompose/sections/sibling-stories.ts +25 -0
- package/src/decompose/sections/target-story.ts +31 -0
- package/src/decompose/types.ts +55 -0
- package/src/decompose/validators/complexity.ts +45 -0
- package/src/decompose/validators/coverage.ts +134 -0
- package/src/decompose/validators/dependency.ts +91 -0
- package/src/decompose/validators/index.ts +35 -0
- package/src/decompose/validators/overlap.ts +128 -0
- package/src/execution/escalation/tier-escalation.ts +9 -2
- package/src/execution/sequential-executor.ts +4 -3
- package/src/interaction/index.ts +1 -0
- package/src/interaction/triggers.ts +21 -0
- package/src/interaction/types.ts +7 -0
- package/src/pipeline/stages/review.ts +6 -0
- package/src/pipeline/stages/routing.ts +89 -0
- package/src/pipeline/types.ts +2 -0
- package/src/plugins/types.ts +33 -0
- package/src/prd/index.ts +5 -1
- package/src/prd/types.ts +11 -1
- package/src/review/orchestrator.ts +1 -0
- package/src/review/types.ts +2 -0
- 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(
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/interaction/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/interaction/types.ts
CHANGED
|
@@ -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)) {
|