@nathapp/nax 0.32.2 → 0.34.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 (48) hide show
  1. package/README.md +191 -6
  2. package/dist/nax.js +1150 -382
  3. package/package.json +1 -1
  4. package/src/cli/analyze.ts +145 -0
  5. package/src/cli/config.ts +9 -0
  6. package/src/config/defaults.ts +8 -0
  7. package/src/config/schema.ts +1 -0
  8. package/src/config/schemas.ts +10 -0
  9. package/src/config/types.ts +18 -0
  10. package/src/context/elements.ts +13 -0
  11. package/src/context/greenfield.ts +1 -1
  12. package/src/decompose/apply.ts +44 -0
  13. package/src/decompose/builder.ts +181 -0
  14. package/src/decompose/index.ts +8 -0
  15. package/src/decompose/sections/codebase.ts +26 -0
  16. package/src/decompose/sections/constraints.ts +32 -0
  17. package/src/decompose/sections/index.ts +4 -0
  18. package/src/decompose/sections/sibling-stories.ts +25 -0
  19. package/src/decompose/sections/target-story.ts +31 -0
  20. package/src/decompose/types.ts +55 -0
  21. package/src/decompose/validators/complexity.ts +45 -0
  22. package/src/decompose/validators/coverage.ts +134 -0
  23. package/src/decompose/validators/dependency.ts +91 -0
  24. package/src/decompose/validators/index.ts +35 -0
  25. package/src/decompose/validators/overlap.ts +128 -0
  26. package/src/execution/crash-recovery.ts +8 -0
  27. package/src/execution/escalation/tier-escalation.ts +9 -2
  28. package/src/execution/iteration-runner.ts +2 -0
  29. package/src/execution/lifecycle/run-completion.ts +100 -15
  30. package/src/execution/parallel-executor.ts +20 -1
  31. package/src/execution/pipeline-result-handler.ts +5 -1
  32. package/src/execution/runner.ts +20 -0
  33. package/src/execution/sequential-executor.ts +2 -11
  34. package/src/hooks/types.ts +20 -10
  35. package/src/interaction/index.ts +1 -0
  36. package/src/interaction/triggers.ts +21 -0
  37. package/src/interaction/types.ts +7 -0
  38. package/src/metrics/tracker.ts +7 -0
  39. package/src/metrics/types.ts +2 -0
  40. package/src/pipeline/stages/review.ts +6 -0
  41. package/src/pipeline/stages/routing.ts +89 -0
  42. package/src/pipeline/types.ts +2 -0
  43. package/src/plugins/types.ts +33 -0
  44. package/src/prd/index.ts +7 -2
  45. package/src/prd/types.ts +17 -2
  46. package/src/review/orchestrator.ts +1 -0
  47. package/src/review/types.ts +2 -0
  48. 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
+ }
@@ -409,3 +409,11 @@ export function resetCrashHandlers(): void {
409
409
  handlersInstalled = false;
410
410
  stopHeartbeat();
411
411
  }
412
+
413
+ /**
414
+ * Returns true if heartbeat timer is currently active.
415
+ * @internal - test use only.
416
+ */
417
+ export function _isHeartbeatActive(): boolean {
418
+ return heartbeatTimer !== null;
419
+ }
@@ -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,
@@ -58,6 +58,7 @@ export async function runIteration(
58
58
  };
59
59
  }
60
60
 
61
+ const storyStartTime = Date.now();
61
62
  const storyGitRef = await captureGitRef(ctx.workdir);
62
63
  const pipelineContext: PipelineContext = {
63
64
  config: ctx.config,
@@ -109,6 +110,7 @@ export async function runIteration(
109
110
  allStoryMetrics,
110
111
  storyGitRef,
111
112
  interactionChain: ctx.interactionChain,
113
+ storyStartTime,
112
114
  };
113
115
 
114
116
  if (pipelineResult.success) {