@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.
- package/README.md +191 -6
- package/dist/nax.js +1150 -382
- 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/crash-recovery.ts +8 -0
- package/src/execution/escalation/tier-escalation.ts +9 -2
- package/src/execution/iteration-runner.ts +2 -0
- package/src/execution/lifecycle/run-completion.ts +100 -15
- package/src/execution/parallel-executor.ts +20 -1
- package/src/execution/pipeline-result-handler.ts +5 -1
- package/src/execution/runner.ts +20 -0
- package/src/execution/sequential-executor.ts +2 -11
- package/src/hooks/types.ts +20 -10
- package/src/interaction/index.ts +1 -0
- package/src/interaction/triggers.ts +21 -0
- package/src/interaction/types.ts +7 -0
- package/src/metrics/tracker.ts +7 -0
- package/src/metrics/types.ts +2 -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 +7 -2
- package/src/prd/types.ts +17 -2
- package/src/review/orchestrator.ts +1 -0
- package/src/review/types.ts +2 -0
- package/src/tdd/isolation.ts +1 -1
package/package.json
CHANGED
package/src/cli/analyze.ts
CHANGED
|
@@ -12,7 +12,11 @@ import { getAgent } from "../agents/registry";
|
|
|
12
12
|
import { scanCodebase } from "../analyze/scanner";
|
|
13
13
|
import type { NaxConfig } from "../config";
|
|
14
14
|
import { resolveModel } from "../config/schema";
|
|
15
|
+
import { applyDecomposition } from "../decompose/apply";
|
|
16
|
+
import { DecomposeBuilder } from "../decompose/builder";
|
|
17
|
+
import type { DecomposeConfig as BuilderDecomposeConfig, DecomposeResult, SubStory } from "../decompose/types";
|
|
15
18
|
import { getLogger } from "../logger";
|
|
19
|
+
import { loadPRD, savePRD } from "../prd";
|
|
16
20
|
import type { PRD, UserStory } from "../prd";
|
|
17
21
|
import { routeTask } from "../routing";
|
|
18
22
|
import { NAX_VERSION } from "../version";
|
|
@@ -201,3 +205,144 @@ async function generateAcceptanceTestsForFeature(
|
|
|
201
205
|
logger.warn("cli", "Failed to generate acceptance tests", { error: (error as Error).message });
|
|
202
206
|
}
|
|
203
207
|
}
|
|
208
|
+
|
|
209
|
+
// ============================================================================
|
|
210
|
+
// SD-004: Story decompose CLI entry points
|
|
211
|
+
// ============================================================================
|
|
212
|
+
|
|
213
|
+
/** Default runDecompose implementation — replaced in tests via _decomposeCLIDeps. */
|
|
214
|
+
async function runDecomposeDefault(
|
|
215
|
+
story: UserStory,
|
|
216
|
+
prd: PRD,
|
|
217
|
+
config: NaxConfig,
|
|
218
|
+
_featureDir: string,
|
|
219
|
+
): Promise<DecomposeResult> {
|
|
220
|
+
const naxDecompose = config.decompose;
|
|
221
|
+
const builderConfig: BuilderDecomposeConfig = {
|
|
222
|
+
maxSubStories: naxDecompose?.maxSubstories ?? 5,
|
|
223
|
+
maxComplexity: naxDecompose?.maxSubstoryComplexity ?? "medium",
|
|
224
|
+
maxRetries: naxDecompose?.maxRetries ?? 2,
|
|
225
|
+
};
|
|
226
|
+
const adapter = {
|
|
227
|
+
async decompose(_prompt: string): Promise<string> {
|
|
228
|
+
throw new Error("[decompose] No LLM adapter configured for story decomposition");
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
return DecomposeBuilder.for(story).prd(prd).config(builderConfig).decompose(adapter);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Load PRD from featureDir and return both PRD and resolved path. */
|
|
235
|
+
async function loadPRDFromDir(featureDir: string): Promise<{ prd: PRD; prdPath: string }> {
|
|
236
|
+
const prdPath = join(featureDir, "prd.json");
|
|
237
|
+
const prd = await loadPRD(prdPath);
|
|
238
|
+
return { prd, prdPath };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Build a human-readable summary of decomposed substories. */
|
|
242
|
+
function buildSummaryLines(subStories: SubStory[]): string[] {
|
|
243
|
+
const lines: string[] = ["Decomposed substories:"];
|
|
244
|
+
for (const sub of subStories) {
|
|
245
|
+
lines.push(` ${sub.id} ${sub.title} [${sub.complexity}] parent: ${sub.parentStoryId}`);
|
|
246
|
+
}
|
|
247
|
+
return lines;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Default print implementation — writes lines to stdout. */
|
|
251
|
+
function printSummaryDefault(lines: string[]): void {
|
|
252
|
+
for (const line of lines) {
|
|
253
|
+
process.stdout.write(`${line}\n`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Swappable dependencies for CLI decompose functions.
|
|
259
|
+
* Tests override individual entries without using mock.module().
|
|
260
|
+
*/
|
|
261
|
+
export const _decomposeCLIDeps = {
|
|
262
|
+
loadPRD: loadPRDFromDir,
|
|
263
|
+
runDecompose: runDecomposeDefault,
|
|
264
|
+
applyDecomposition,
|
|
265
|
+
savePRD,
|
|
266
|
+
printSummary: printSummaryDefault,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Decompose a single story by ID via --decompose <storyId>.
|
|
271
|
+
*
|
|
272
|
+
* Loads the PRD, runs decomposition, applies result, and saves the updated PRD.
|
|
273
|
+
* Prints a summary table of the generated substories.
|
|
274
|
+
*/
|
|
275
|
+
export async function decomposeStory(
|
|
276
|
+
storyId: string,
|
|
277
|
+
options: { featureDir: string; config: NaxConfig },
|
|
278
|
+
): Promise<void> {
|
|
279
|
+
const { featureDir, config } = options;
|
|
280
|
+
const logger = getLogger();
|
|
281
|
+
|
|
282
|
+
const { prd, prdPath } = await _decomposeCLIDeps.loadPRD(featureDir);
|
|
283
|
+
|
|
284
|
+
const story = prd.userStories.find((s) => s.id === storyId);
|
|
285
|
+
if (!story) {
|
|
286
|
+
throw new Error(`Story ${storyId} not found in PRD`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const result = await _decomposeCLIDeps.runDecompose(story, prd, config, featureDir);
|
|
290
|
+
|
|
291
|
+
if (!result.validation.valid) {
|
|
292
|
+
logger.warn("cli", `Decompose failed for ${storyId}: ${result.validation.errors.join(", ")}`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_decomposeCLIDeps.applyDecomposition(prd, result);
|
|
297
|
+
await _decomposeCLIDeps.savePRD(prd, prdPath);
|
|
298
|
+
|
|
299
|
+
const lines = buildSummaryLines(result.subStories);
|
|
300
|
+
_decomposeCLIDeps.printSummary(lines);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Decompose all oversized stories via --decompose-oversized.
|
|
305
|
+
*
|
|
306
|
+
* Iterates all stories, decomposes any that exceed config.decompose.maxAcceptanceCriteria
|
|
307
|
+
* AND have complex/expert complexity. Saves the PRD once after all decompositions.
|
|
308
|
+
*/
|
|
309
|
+
export async function decomposeOversized(options: { featureDir: string; config: NaxConfig }): Promise<void> {
|
|
310
|
+
const { featureDir, config } = options;
|
|
311
|
+
const logger = getLogger();
|
|
312
|
+
|
|
313
|
+
const { prd, prdPath } = await _decomposeCLIDeps.loadPRD(featureDir);
|
|
314
|
+
|
|
315
|
+
const threshold = config.decompose?.maxAcceptanceCriteria ?? 6;
|
|
316
|
+
const oversized = prd.userStories.filter((s) => {
|
|
317
|
+
const complexity = s.routing?.complexity;
|
|
318
|
+
return s.acceptanceCriteria.length > threshold && (complexity === "complex" || complexity === "expert");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
if (oversized.length === 0) {
|
|
322
|
+
logger.info("cli", "No oversized stories found");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const allSubStories: SubStory[] = [];
|
|
327
|
+
let anyDecomposed = false;
|
|
328
|
+
|
|
329
|
+
for (const story of oversized) {
|
|
330
|
+
const result = await _decomposeCLIDeps.runDecompose(story, prd, config, featureDir);
|
|
331
|
+
if (result.validation.valid) {
|
|
332
|
+
_decomposeCLIDeps.applyDecomposition(prd, result);
|
|
333
|
+
allSubStories.push(...result.subStories);
|
|
334
|
+
anyDecomposed = true;
|
|
335
|
+
} else {
|
|
336
|
+
logger.warn("cli", `Decompose failed for ${story.id}: ${result.validation.errors.join(", ")}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (anyDecomposed) {
|
|
341
|
+
await _decomposeCLIDeps.savePRD(prd, prdPath);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (allSubStories.length > 0) {
|
|
345
|
+
const lines = buildSummaryLines(allSubStories);
|
|
346
|
+
_decomposeCLIDeps.printSummary(lines);
|
|
347
|
+
}
|
|
348
|
+
}
|
package/src/cli/config.ts
CHANGED
|
@@ -193,6 +193,15 @@ const FIELD_DESCRIPTIONS: Record<string, string> = {
|
|
|
193
193
|
"prompts.overrides.implementer": 'Path to custom implementer prompt (e.g., ".nax/prompts/implementer.md")',
|
|
194
194
|
"prompts.overrides.verifier": 'Path to custom verifier prompt (e.g., ".nax/prompts/verifier.md")',
|
|
195
195
|
"prompts.overrides.single-session": 'Path to custom single-session prompt (e.g., ".nax/prompts/single-session.md")',
|
|
196
|
+
|
|
197
|
+
// Decompose
|
|
198
|
+
decompose: "Story decomposition configuration (SD-003)",
|
|
199
|
+
"decompose.trigger": "Decomposition trigger mode: auto | confirm | disabled",
|
|
200
|
+
"decompose.maxAcceptanceCriteria": "Max acceptance criteria before flagging as oversized (default: 6)",
|
|
201
|
+
"decompose.maxSubstories": "Max number of substories to generate (default: 5)",
|
|
202
|
+
"decompose.maxSubstoryComplexity": "Max complexity for any generated substory (default: 'medium')",
|
|
203
|
+
"decompose.maxRetries": "Max retries on decomposition validation failure (default: 2)",
|
|
204
|
+
"decompose.model": "Model tier for decomposition LLM calls (default: 'balanced')",
|
|
196
205
|
};
|
|
197
206
|
|
|
198
207
|
/** Options for config command */
|
package/src/config/defaults.ts
CHANGED
|
@@ -163,4 +163,12 @@ export const DEFAULT_CONFIG: NaxConfig = {
|
|
|
163
163
|
},
|
|
164
164
|
},
|
|
165
165
|
prompts: {},
|
|
166
|
+
decompose: {
|
|
167
|
+
trigger: "auto",
|
|
168
|
+
maxAcceptanceCriteria: 6,
|
|
169
|
+
maxSubstories: 5,
|
|
170
|
+
maxSubstoryComplexity: "medium",
|
|
171
|
+
maxRetries: 2,
|
|
172
|
+
model: "balanced",
|
|
173
|
+
},
|
|
166
174
|
};
|
package/src/config/schema.ts
CHANGED
package/src/config/schemas.ts
CHANGED
|
@@ -300,6 +300,15 @@ const PromptsConfigSchema = z.object({
|
|
|
300
300
|
.optional(),
|
|
301
301
|
});
|
|
302
302
|
|
|
303
|
+
const DecomposeConfigSchema = z.object({
|
|
304
|
+
trigger: z.enum(["auto", "confirm", "disabled"]).default("auto"),
|
|
305
|
+
maxAcceptanceCriteria: z.number().int().min(1).default(6),
|
|
306
|
+
maxSubstories: z.number().int().min(1).default(5),
|
|
307
|
+
maxSubstoryComplexity: z.enum(["simple", "medium", "complex", "expert"]).default("medium"),
|
|
308
|
+
maxRetries: z.number().int().min(0).default(2),
|
|
309
|
+
model: z.string().min(1).default("balanced"),
|
|
310
|
+
});
|
|
311
|
+
|
|
303
312
|
export const NaxConfigSchema = z
|
|
304
313
|
.object({
|
|
305
314
|
version: z.number(),
|
|
@@ -321,6 +330,7 @@ export const NaxConfigSchema = z
|
|
|
321
330
|
interaction: InteractionConfigSchema.optional(),
|
|
322
331
|
precheck: PrecheckConfigSchema.optional(),
|
|
323
332
|
prompts: PromptsConfigSchema.optional(),
|
|
333
|
+
decompose: DecomposeConfigSchema.optional(),
|
|
324
334
|
})
|
|
325
335
|
.refine((data) => data.version === 1, {
|
|
326
336
|
message: "Invalid version: expected 1",
|
package/src/config/types.ts
CHANGED
|
@@ -412,6 +412,22 @@ export interface PromptsConfig {
|
|
|
412
412
|
overrides?: Partial<Record<"test-writer" | "implementer" | "verifier" | "single-session" | "tdd-simple", string>>;
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
+
/** Decompose config (SD-003) */
|
|
416
|
+
export interface DecomposeConfig {
|
|
417
|
+
/** Trigger mode: 'auto' = decompose automatically, 'confirm' = ask user, 'disabled' = skip */
|
|
418
|
+
trigger: "auto" | "confirm" | "disabled";
|
|
419
|
+
/** Max acceptance criteria before flagging a story as oversized (default: 6) */
|
|
420
|
+
maxAcceptanceCriteria: number;
|
|
421
|
+
/** Max number of substories to generate (default: 5) */
|
|
422
|
+
maxSubstories: number;
|
|
423
|
+
/** Max complexity for any generated substory (default: 'medium') */
|
|
424
|
+
maxSubstoryComplexity: Complexity;
|
|
425
|
+
/** Max retries on decomposition validation failure (default: 2) */
|
|
426
|
+
maxRetries: number;
|
|
427
|
+
/** Model tier for decomposition LLM calls (default: 'balanced') */
|
|
428
|
+
model: ModelTier;
|
|
429
|
+
}
|
|
430
|
+
|
|
415
431
|
/** Full nax configuration */
|
|
416
432
|
export interface NaxConfig {
|
|
417
433
|
/** Schema version */
|
|
@@ -452,6 +468,8 @@ export interface NaxConfig {
|
|
|
452
468
|
precheck?: PrecheckConfig;
|
|
453
469
|
/** Prompt override settings (PB-003) */
|
|
454
470
|
prompts?: PromptsConfig;
|
|
471
|
+
/** Decompose settings (SD-003) */
|
|
472
|
+
decompose?: DecomposeConfig;
|
|
455
473
|
}
|
|
456
474
|
|
|
457
475
|
/** Resolve a ModelEntry (string shorthand or full object) into a ModelDef */
|
package/src/context/elements.ts
CHANGED
|
@@ -83,6 +83,19 @@ export function formatPriorFailures(failures: StructuredFailure[]): string {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
if (failure.reviewFindings && failure.reviewFindings.length > 0) {
|
|
88
|
+
parts.push("\n**Review Findings (fix these issues):**");
|
|
89
|
+
for (const finding of failure.reviewFindings) {
|
|
90
|
+
const source = finding.source ? ` (${finding.source})` : "";
|
|
91
|
+
parts.push(`\n- **[${finding.severity}]** \`${finding.file}:${finding.line}\`${source}`);
|
|
92
|
+
parts.push(` **Rule:** ${finding.ruleId}`);
|
|
93
|
+
parts.push(` **Issue:** ${finding.message}`);
|
|
94
|
+
if (finding.url) {
|
|
95
|
+
parts.push(` **Docs:** ${finding.url}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
86
99
|
parts.push("");
|
|
87
100
|
}
|
|
88
101
|
|
|
@@ -69,7 +69,7 @@ function globToRegex(pattern: string): RegExp {
|
|
|
69
69
|
.replace(/\{([^}]+)\}/g, (_, group) => `(${group.replace(/,/g, "|")})`) // {a,b} = (a|b)
|
|
70
70
|
.replace(/\\\.\\\*/g, "\\.[^/]*"); // Fix escaped .* back to .\*
|
|
71
71
|
|
|
72
|
-
return new RegExp(`${regexStr}$`);
|
|
72
|
+
return new RegExp(`${regexStr}$`); // nosemgrep: detect-non-literal-regexp — pattern from internal .gitignore, not user input
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRD Mutation — Apply Decomposition (SD-003)
|
|
3
|
+
*
|
|
4
|
+
* Marks the original story as 'decomposed', inserts substories after the
|
|
5
|
+
* original with status 'pending' and parentStoryId.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PRD, UserStory } from "../prd";
|
|
9
|
+
import type { DecomposeResult } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Apply a decomposition result to a PRD:
|
|
13
|
+
* - Marks the original story as 'decomposed'
|
|
14
|
+
* - Inserts substories after the original with status 'pending' and parentStoryId
|
|
15
|
+
*/
|
|
16
|
+
export function applyDecomposition(prd: PRD, result: DecomposeResult): void {
|
|
17
|
+
const { subStories } = result;
|
|
18
|
+
if (subStories.length === 0) return;
|
|
19
|
+
|
|
20
|
+
const parentStoryId = subStories[0].parentStoryId;
|
|
21
|
+
const originalIndex = prd.userStories.findIndex((s) => s.id === parentStoryId);
|
|
22
|
+
if (originalIndex === -1) return;
|
|
23
|
+
|
|
24
|
+
// Mark original story as decomposed
|
|
25
|
+
prd.userStories[originalIndex].status = "decomposed";
|
|
26
|
+
|
|
27
|
+
// Convert substories to UserStory format with parentStoryId attached
|
|
28
|
+
const newStories = subStories.map((sub): UserStory & { parentStoryId: string } => ({
|
|
29
|
+
id: sub.id,
|
|
30
|
+
title: sub.title,
|
|
31
|
+
description: sub.description,
|
|
32
|
+
acceptanceCriteria: sub.acceptanceCriteria,
|
|
33
|
+
tags: sub.tags,
|
|
34
|
+
dependencies: sub.dependencies,
|
|
35
|
+
status: "pending",
|
|
36
|
+
passes: false,
|
|
37
|
+
escalations: [],
|
|
38
|
+
attempts: 0,
|
|
39
|
+
parentStoryId: sub.parentStoryId,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
// Insert substories immediately after the original story
|
|
43
|
+
prd.userStories.splice(originalIndex + 1, 0, ...newStories);
|
|
44
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DecomposeBuilder — fluent API for composing story decomposition prompts.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const result = await DecomposeBuilder.for(story)
|
|
6
|
+
* .prd(prd)
|
|
7
|
+
* .codebase(scan)
|
|
8
|
+
* .config(cfg)
|
|
9
|
+
* .decompose(adapter);
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { CodebaseScan } from "../analyze/types";
|
|
13
|
+
import type { PRD, UserStory } from "../prd";
|
|
14
|
+
import { buildCodebaseSection } from "./sections/codebase";
|
|
15
|
+
import { buildConstraintsSection } from "./sections/constraints";
|
|
16
|
+
import { buildSiblingStoriesSection } from "./sections/sibling-stories";
|
|
17
|
+
import { buildTargetStorySection } from "./sections/target-story";
|
|
18
|
+
import type { DecomposeAdapter, DecomposeConfig, DecomposeResult, SubStory } from "./types";
|
|
19
|
+
import { runAllValidators } from "./validators/index";
|
|
20
|
+
|
|
21
|
+
export const SECTION_SEP = "\n\n---\n\n";
|
|
22
|
+
|
|
23
|
+
export class DecomposeBuilder {
|
|
24
|
+
private _story: UserStory;
|
|
25
|
+
private _prd: PRD | undefined;
|
|
26
|
+
private _scan: CodebaseScan | undefined;
|
|
27
|
+
private _cfg: DecomposeConfig | undefined;
|
|
28
|
+
|
|
29
|
+
private constructor(story: UserStory) {
|
|
30
|
+
this._story = story;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static for(story: UserStory): DecomposeBuilder {
|
|
34
|
+
return new DecomposeBuilder(story);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
prd(prd: PRD): this {
|
|
38
|
+
this._prd = prd;
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
codebase(scan: CodebaseScan): this {
|
|
43
|
+
this._scan = scan;
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
config(cfg: DecomposeConfig): this {
|
|
48
|
+
this._cfg = cfg;
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
buildPrompt(errorFeedback?: string): string {
|
|
53
|
+
const sections: string[] = [];
|
|
54
|
+
|
|
55
|
+
sections.push(buildTargetStorySection(this._story));
|
|
56
|
+
|
|
57
|
+
if (this._prd) {
|
|
58
|
+
sections.push(buildSiblingStoriesSection(this._story, this._prd));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (this._scan) {
|
|
62
|
+
sections.push(buildCodebaseSection(this._scan));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (this._cfg) {
|
|
66
|
+
sections.push(buildConstraintsSection(this._cfg));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (errorFeedback) {
|
|
70
|
+
sections.push(
|
|
71
|
+
`## Validation Errors from Previous Attempt\n\nFix the following errors and try again:\n\n${errorFeedback}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return sections.join(SECTION_SEP);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async decompose(adapter: DecomposeAdapter): Promise<DecomposeResult> {
|
|
79
|
+
const cfg = this._cfg;
|
|
80
|
+
const maxRetries = cfg?.maxRetries ?? 0;
|
|
81
|
+
const existingStories = this._prd ? this._prd.userStories.filter((s) => s.id !== this._story.id) : [];
|
|
82
|
+
|
|
83
|
+
let lastResult: DecomposeResult | undefined;
|
|
84
|
+
let errorFeedback: string | undefined;
|
|
85
|
+
|
|
86
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
87
|
+
const prompt = this.buildPrompt(errorFeedback);
|
|
88
|
+
const raw = await adapter.decompose(prompt);
|
|
89
|
+
const parsed = parseSubStories(raw);
|
|
90
|
+
|
|
91
|
+
if (!parsed.validation.valid) {
|
|
92
|
+
lastResult = parsed;
|
|
93
|
+
errorFeedback = parsed.validation.errors.join("\n");
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Run post-parse validators
|
|
98
|
+
const config: DecomposeConfig = cfg ?? { maxSubStories: 5, maxComplexity: "medium" };
|
|
99
|
+
const validation = runAllValidators(this._story, parsed.subStories, existingStories, config);
|
|
100
|
+
|
|
101
|
+
if (!validation.valid) {
|
|
102
|
+
lastResult = { subStories: parsed.subStories, validation };
|
|
103
|
+
errorFeedback = validation.errors.join("\n");
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { subStories: parsed.subStories, validation };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
lastResult ?? {
|
|
112
|
+
subStories: [],
|
|
113
|
+
validation: { valid: false, errors: ["Decomposition failed after all retries"], warnings: [] },
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseSubStories(output: string): DecomposeResult {
|
|
120
|
+
// Extract JSON array (handles optional markdown code fences)
|
|
121
|
+
const fenceMatch = output.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/);
|
|
122
|
+
let jsonText = fenceMatch ? fenceMatch[1] : output;
|
|
123
|
+
|
|
124
|
+
if (!fenceMatch) {
|
|
125
|
+
const arrayMatch = output.match(/\[[\s\S]*\]/);
|
|
126
|
+
if (arrayMatch) {
|
|
127
|
+
jsonText = arrayMatch[0];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let parsed: unknown;
|
|
132
|
+
try {
|
|
133
|
+
parsed = JSON.parse(jsonText.trim());
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return {
|
|
136
|
+
subStories: [],
|
|
137
|
+
validation: { valid: false, errors: [`Failed to parse JSON: ${(err as Error).message}`], warnings: [] },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!Array.isArray(parsed)) {
|
|
142
|
+
return {
|
|
143
|
+
subStories: [],
|
|
144
|
+
validation: { valid: false, errors: ["Output is not a JSON array"], warnings: [] },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const errors: string[] = [];
|
|
149
|
+
const subStories: SubStory[] = [];
|
|
150
|
+
|
|
151
|
+
for (const [index, item] of parsed.entries()) {
|
|
152
|
+
if (typeof item !== "object" || item === null) {
|
|
153
|
+
errors.push(`Item at index ${index} is not an object`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const r = item as Record<string, unknown>;
|
|
157
|
+
subStories.push({
|
|
158
|
+
id: String(r.id ?? ""),
|
|
159
|
+
parentStoryId: String(r.parentStoryId ?? ""),
|
|
160
|
+
title: String(r.title ?? ""),
|
|
161
|
+
description: String(r.description ?? ""),
|
|
162
|
+
acceptanceCriteria: Array.isArray(r.acceptanceCriteria) ? (r.acceptanceCriteria as string[]) : [],
|
|
163
|
+
tags: Array.isArray(r.tags) ? (r.tags as string[]) : [],
|
|
164
|
+
dependencies: Array.isArray(r.dependencies) ? (r.dependencies as string[]) : [],
|
|
165
|
+
complexity: normalizeComplexity(r.complexity),
|
|
166
|
+
nonOverlapJustification: String(r.nonOverlapJustification ?? ""),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
subStories,
|
|
172
|
+
validation: { valid: errors.length === 0, errors, warnings: [] },
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeComplexity(value: unknown): "simple" | "medium" | "complex" | "expert" {
|
|
177
|
+
if (value === "simple" || value === "medium" || value === "complex" || value === "expert") {
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
return "medium";
|
|
181
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { DecomposeBuilder, SECTION_SEP } from "./builder";
|
|
2
|
+
export type { DecomposeConfig, DecomposeResult, SubStory, ValidationResult, DecomposeAdapter } from "./types";
|
|
3
|
+
export {
|
|
4
|
+
buildTargetStorySection,
|
|
5
|
+
buildSiblingStoriesSection,
|
|
6
|
+
buildCodebaseSection,
|
|
7
|
+
buildConstraintsSection,
|
|
8
|
+
} from "./sections";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codebase context section builder.
|
|
3
|
+
*
|
|
4
|
+
* Builds a prompt section from scanCodebase output.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CodebaseScan } from "../../analyze/types";
|
|
8
|
+
|
|
9
|
+
export function buildCodebaseSection(scan: CodebaseScan): string {
|
|
10
|
+
const deps = Object.entries(scan.dependencies)
|
|
11
|
+
.slice(0, 15)
|
|
12
|
+
.map(([k, v]) => ` ${k}: ${v}`)
|
|
13
|
+
.join("\n");
|
|
14
|
+
|
|
15
|
+
return [
|
|
16
|
+
"# Codebase Context",
|
|
17
|
+
"",
|
|
18
|
+
"**File Tree:**",
|
|
19
|
+
scan.fileTree,
|
|
20
|
+
"",
|
|
21
|
+
"**Dependencies:**",
|
|
22
|
+
deps || " (none)",
|
|
23
|
+
"",
|
|
24
|
+
`**Test Patterns:** ${scan.testPatterns.join(", ")}`,
|
|
25
|
+
].join("\n");
|
|
26
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constraints section builder.
|
|
3
|
+
*
|
|
4
|
+
* Builds a prompt section with decomposition constraints:
|
|
5
|
+
* max substories, max complexity, output JSON schema, nonOverlapJustification requirement.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DecomposeConfig } from "../types";
|
|
9
|
+
|
|
10
|
+
export function buildConstraintsSection(config: DecomposeConfig): string {
|
|
11
|
+
return [
|
|
12
|
+
"# Decomposition Constraints",
|
|
13
|
+
"",
|
|
14
|
+
`- **Max sub-stories:** ${config.maxSubStories}`,
|
|
15
|
+
`- **Max complexity per sub-story:** ${config.maxComplexity}`,
|
|
16
|
+
"",
|
|
17
|
+
"Respond with ONLY a JSON array (no markdown code fences):",
|
|
18
|
+
"[{",
|
|
19
|
+
` "id": "PARENT-ID-1",`,
|
|
20
|
+
` "parentStoryId": "PARENT-ID",`,
|
|
21
|
+
` "title": "Sub-story title",`,
|
|
22
|
+
` "description": "What to implement",`,
|
|
23
|
+
` "acceptanceCriteria": ["Criterion 1"],`,
|
|
24
|
+
` "tags": [],`,
|
|
25
|
+
` "dependencies": [],`,
|
|
26
|
+
` "complexity": "simple",`,
|
|
27
|
+
` "nonOverlapJustification": "Why this sub-story does not overlap with sibling stories"`,
|
|
28
|
+
"}]",
|
|
29
|
+
"",
|
|
30
|
+
"The nonOverlapJustification field is required for every sub-story.",
|
|
31
|
+
].join("\n");
|
|
32
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sibling stories section builder.
|
|
3
|
+
*
|
|
4
|
+
* Builds a prompt section with all other PRD stories (id, title, status, AC summary)
|
|
5
|
+
* to help the LLM avoid overlap.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PRD, UserStory } from "../../prd";
|
|
9
|
+
|
|
10
|
+
export function buildSiblingStoriesSection(targetStory: UserStory, prd: PRD): string {
|
|
11
|
+
const siblings = prd.userStories.filter((s) => s.id !== targetStory.id);
|
|
12
|
+
|
|
13
|
+
if (siblings.length === 0) {
|
|
14
|
+
return "# Sibling Stories\n\nNo other stories exist in this PRD.";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const entries = siblings
|
|
18
|
+
.map((s) => {
|
|
19
|
+
const acSummary = s.acceptanceCriteria.slice(0, 3).join("; ");
|
|
20
|
+
return `- **${s.id}** — ${s.title} [${s.status}]\n AC: ${acSummary}`;
|
|
21
|
+
})
|
|
22
|
+
.join("\n");
|
|
23
|
+
|
|
24
|
+
return ["# Sibling Stories", "", "Avoid overlapping with these existing stories in the PRD:", "", entries].join("\n");
|
|
25
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target story section builder.
|
|
3
|
+
*
|
|
4
|
+
* Builds a prompt section with full story details and decompose instruction.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { UserStory } from "../../prd";
|
|
8
|
+
|
|
9
|
+
export function buildTargetStorySection(story: UserStory): string {
|
|
10
|
+
const ac = story.acceptanceCriteria.map((c, i) => `${i + 1}. ${c}`).join("\n");
|
|
11
|
+
const tags = story.tags.length > 0 ? story.tags.join(", ") : "none";
|
|
12
|
+
const deps = story.dependencies.length > 0 ? story.dependencies.join(", ") : "none";
|
|
13
|
+
|
|
14
|
+
return [
|
|
15
|
+
"# Target Story to Decompose",
|
|
16
|
+
"",
|
|
17
|
+
`**ID:** ${story.id}`,
|
|
18
|
+
`**Title:** ${story.title}`,
|
|
19
|
+
"",
|
|
20
|
+
"**Description:**",
|
|
21
|
+
story.description,
|
|
22
|
+
"",
|
|
23
|
+
"**Acceptance Criteria:**",
|
|
24
|
+
ac,
|
|
25
|
+
"",
|
|
26
|
+
`**Tags:** ${tags}`,
|
|
27
|
+
`**Dependencies:** ${deps}`,
|
|
28
|
+
"",
|
|
29
|
+
"Decompose this story into smaller sub-stories that can each be implemented independently.",
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|