@nathapp/nax 0.40.1 → 0.42.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 +1 -0
- package/bin/nax.ts +130 -11
- package/dist/nax.js +1520 -424
- package/package.json +8 -7
- package/src/acceptance/fix-generator.ts +4 -35
- package/src/acceptance/generator.ts +4 -27
- package/src/agents/acp/adapter.ts +642 -0
- package/src/agents/acp/cost.ts +79 -0
- package/src/agents/acp/index.ts +9 -0
- package/src/agents/acp/interaction-bridge.ts +126 -0
- package/src/agents/acp/parser.ts +166 -0
- package/src/agents/acp/spawn-client.ts +309 -0
- package/src/agents/acp/types.ts +22 -0
- package/src/agents/claude-complete.ts +3 -3
- package/src/agents/claude.ts +12 -2
- package/src/agents/registry.ts +83 -0
- package/src/agents/types-extended.ts +23 -0
- package/src/agents/types.ts +17 -0
- package/src/analyze/scanner.ts +16 -20
- package/src/cli/analyze.ts +6 -2
- package/src/cli/plan.ts +218 -129
- package/src/commands/precheck.ts +1 -1
- package/src/config/defaults.ts +1 -0
- package/src/config/runtime-types.ts +10 -0
- package/src/config/schema.ts +1 -0
- package/src/config/schemas.ts +6 -0
- package/src/config/types.ts +1 -0
- package/src/execution/executor-types.ts +6 -0
- package/src/execution/iteration-runner.ts +2 -0
- package/src/execution/lifecycle/acceptance-loop.ts +5 -2
- package/src/execution/lifecycle/run-initialization.ts +16 -4
- package/src/execution/lifecycle/run-setup.ts +4 -0
- package/src/execution/runner-completion.ts +11 -1
- package/src/execution/runner-execution.ts +8 -0
- package/src/execution/runner-setup.ts +4 -0
- package/src/execution/runner.ts +10 -0
- package/src/interaction/plugins/webhook.ts +10 -1
- package/src/pipeline/stages/execution.ts +33 -1
- package/src/pipeline/stages/routing.ts +18 -7
- package/src/pipeline/types.ts +10 -0
- package/src/prd/schema.ts +249 -0
- package/src/tdd/orchestrator.ts +7 -0
- package/src/tdd/rectification-gate.ts +6 -0
- package/src/tdd/session-runner.ts +15 -2
- package/src/utils/git.ts +30 -0
- package/src/verification/runners.ts +10 -1
|
@@ -11,9 +11,11 @@ import { fireHook } from "../hooks";
|
|
|
11
11
|
import { getSafeLogger } from "../logger";
|
|
12
12
|
import type { StoryMetrics } from "../metrics";
|
|
13
13
|
import type { PipelineEventEmitter } from "../pipeline/events";
|
|
14
|
+
import type { AgentGetFn } from "../pipeline/types";
|
|
14
15
|
import type { PluginRegistry } from "../plugins/registry";
|
|
15
16
|
import { isComplete } from "../prd";
|
|
16
17
|
import type { PRD } from "../prd";
|
|
18
|
+
import { autoCommitIfDirty } from "../utils/git";
|
|
17
19
|
import { stopHeartbeat, writeExitSummary } from "./crash-recovery";
|
|
18
20
|
import { hookCtx } from "./story-context";
|
|
19
21
|
|
|
@@ -42,6 +44,10 @@ export interface RunnerCompletionOptions {
|
|
|
42
44
|
statusWriter: any;
|
|
43
45
|
pluginRegistry: PluginRegistry;
|
|
44
46
|
eventEmitter?: PipelineEventEmitter;
|
|
47
|
+
/** Protocol-aware agent resolver */
|
|
48
|
+
agentGetFn?: AgentGetFn;
|
|
49
|
+
/** Path to prd.json — required for acceptance fix story writes */
|
|
50
|
+
prdPath: string;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/**
|
|
@@ -67,7 +73,7 @@ export async function runCompletionPhase(options: RunnerCompletionOptions): Prom
|
|
|
67
73
|
const acceptanceResult = await runAcceptanceLoop({
|
|
68
74
|
config: options.config,
|
|
69
75
|
prd: options.prd,
|
|
70
|
-
prdPath:
|
|
76
|
+
prdPath: options.prdPath,
|
|
71
77
|
workdir: options.workdir,
|
|
72
78
|
featureDir: options.featureDir,
|
|
73
79
|
hooks: options.hooks,
|
|
@@ -79,6 +85,7 @@ export async function runCompletionPhase(options: RunnerCompletionOptions): Prom
|
|
|
79
85
|
pluginRegistry: options.pluginRegistry,
|
|
80
86
|
eventEmitter: options.eventEmitter,
|
|
81
87
|
statusWriter: options.statusWriter,
|
|
88
|
+
agentGetFn: options.agentGetFn,
|
|
82
89
|
});
|
|
83
90
|
|
|
84
91
|
Object.assign(options, {
|
|
@@ -153,6 +160,9 @@ export async function runCompletionPhase(options: RunnerCompletionOptions): Prom
|
|
|
153
160
|
durationMs,
|
|
154
161
|
);
|
|
155
162
|
|
|
163
|
+
// Commit status.json and any other nax runtime files left dirty at run end
|
|
164
|
+
await autoCommitIfDirty(options.workdir, "run.complete", "run-summary", options.feature);
|
|
165
|
+
|
|
156
166
|
return {
|
|
157
167
|
durationMs,
|
|
158
168
|
runCompletedAt,
|
|
@@ -10,6 +10,7 @@ import type { LoadedHooksConfig } from "../hooks";
|
|
|
10
10
|
import { getSafeLogger } from "../logger";
|
|
11
11
|
import type { StoryMetrics } from "../metrics";
|
|
12
12
|
import type { PipelineEventEmitter } from "../pipeline/events";
|
|
13
|
+
import type { AgentGetFn } from "../pipeline/types";
|
|
13
14
|
import type { PluginRegistry } from "../plugins/registry";
|
|
14
15
|
import type { PRD } from "../prd";
|
|
15
16
|
import { tryLlmBatchRoute } from "../routing/batch-route";
|
|
@@ -17,6 +18,7 @@ import { clearCache as clearLlmCache, routeBatch as llmRouteBatch } from "../rou
|
|
|
17
18
|
import { precomputeBatchPlan } from "./batching";
|
|
18
19
|
import { getAllReadyStories } from "./helpers";
|
|
19
20
|
import type { ParallelExecutorOptions, ParallelExecutorResult } from "./parallel-executor";
|
|
21
|
+
import type { PidRegistry } from "./pid-registry";
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Options for the execution phase.
|
|
@@ -42,6 +44,10 @@ export interface RunnerExecutionOptions {
|
|
|
42
44
|
headless: boolean;
|
|
43
45
|
parallel?: number;
|
|
44
46
|
runParallelExecution?: (options: ParallelExecutorOptions, prd: PRD) => Promise<ParallelExecutorResult>;
|
|
47
|
+
/** Protocol-aware agent resolver — created once in runner.ts from createAgentRegistry(config) */
|
|
48
|
+
agentGetFn?: AgentGetFn;
|
|
49
|
+
/** PID registry for crash recovery — passed to agent.run() to register child processes. */
|
|
50
|
+
pidRegistry?: PidRegistry;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/**
|
|
@@ -198,6 +204,8 @@ export async function runExecutionPhase(
|
|
|
198
204
|
runId: options.runId,
|
|
199
205
|
startTime: options.startTime,
|
|
200
206
|
batchPlan,
|
|
207
|
+
agentGetFn: options.agentGetFn,
|
|
208
|
+
pidRegistry: options.pidRegistry,
|
|
201
209
|
},
|
|
202
210
|
prd,
|
|
203
211
|
);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { NaxConfig } from "../config";
|
|
9
9
|
import type { LoadedHooksConfig } from "../hooks";
|
|
10
|
+
import type { AgentGetFn } from "../pipeline/types";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Options for the setup phase.
|
|
@@ -31,6 +32,8 @@ export interface RunnerSetupOptions {
|
|
|
31
32
|
getIterations: () => number;
|
|
32
33
|
getStoriesCompleted: () => number;
|
|
33
34
|
getTotalStories: () => number;
|
|
35
|
+
/** Protocol-aware agent resolver — created from createAgentRegistry(config) in runner.ts */
|
|
36
|
+
agentGetFn?: AgentGetFn;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
/**
|
|
@@ -76,6 +79,7 @@ export async function runSetupPhase(options: RunnerSetupOptions): Promise<Runner
|
|
|
76
79
|
// BUG-017: Pass getters for run.complete event on SIGTERM
|
|
77
80
|
getStoriesCompleted: options.getStoriesCompleted,
|
|
78
81
|
getTotalStories: options.getTotalStories,
|
|
82
|
+
agentGetFn: options.agentGetFn,
|
|
79
83
|
});
|
|
80
84
|
|
|
81
85
|
return setupResult;
|
package/src/execution/runner.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* - runner-completion.ts: Acceptance loop, hooks, metrics
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { createAgentRegistry } from "../agents/registry";
|
|
16
17
|
import type { NaxConfig } from "../config";
|
|
17
18
|
import type { LoadedHooksConfig } from "../hooks";
|
|
18
19
|
import { fireHook } from "../hooks";
|
|
@@ -116,6 +117,10 @@ export async function run(options: RunOptions): Promise<RunResult> {
|
|
|
116
117
|
|
|
117
118
|
const logger = getSafeLogger();
|
|
118
119
|
|
|
120
|
+
// Create protocol-aware agent registry (ACP wiring — ACP-003/registry-wiring)
|
|
121
|
+
const registry = createAgentRegistry(config);
|
|
122
|
+
const agentGetFn = registry.getAgent.bind(registry);
|
|
123
|
+
|
|
119
124
|
// Declare prd before crash handler setup to avoid TDZ if SIGTERM arrives during setup
|
|
120
125
|
// biome-ignore lint/suspicious/noExplicitAny: PRD type initialized during setup
|
|
121
126
|
let prd: any | undefined;
|
|
@@ -137,6 +142,7 @@ export async function run(options: RunOptions): Promise<RunResult> {
|
|
|
137
142
|
skipPrecheck,
|
|
138
143
|
headless,
|
|
139
144
|
formatterMode,
|
|
145
|
+
agentGetFn,
|
|
140
146
|
getTotalCost: () => totalCost,
|
|
141
147
|
getIterations: () => iterations,
|
|
142
148
|
// BUG-017: Pass getters for run.complete event on SIGTERM
|
|
@@ -170,6 +176,8 @@ export async function run(options: RunOptions): Promise<RunResult> {
|
|
|
170
176
|
headless,
|
|
171
177
|
parallel,
|
|
172
178
|
runParallelExecution: _runnerDeps.runParallelExecution ?? undefined,
|
|
179
|
+
agentGetFn,
|
|
180
|
+
pidRegistry,
|
|
173
181
|
},
|
|
174
182
|
prd,
|
|
175
183
|
pluginRegistry,
|
|
@@ -198,6 +206,7 @@ export async function run(options: RunOptions): Promise<RunResult> {
|
|
|
198
206
|
hooks,
|
|
199
207
|
feature,
|
|
200
208
|
workdir,
|
|
209
|
+
prdPath,
|
|
201
210
|
statusFile,
|
|
202
211
|
logFilePath,
|
|
203
212
|
runId,
|
|
@@ -214,6 +223,7 @@ export async function run(options: RunOptions): Promise<RunResult> {
|
|
|
214
223
|
statusWriter,
|
|
215
224
|
pluginRegistry,
|
|
216
225
|
eventEmitter,
|
|
226
|
+
agentGetFn,
|
|
217
227
|
});
|
|
218
228
|
|
|
219
229
|
const { durationMs } = completionResult;
|
|
@@ -10,6 +10,15 @@ import type { Server } from "node:http";
|
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import type { InteractionPlugin, InteractionRequest, InteractionResponse } from "../types";
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Injectable sleep for WebhookInteractionPlugin.receive() polling loop.
|
|
15
|
+
* Replace in tests to avoid real backoff delays.
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
export const _webhookPluginDeps = {
|
|
19
|
+
sleep: (ms: number): Promise<void> => Bun.sleep(ms),
|
|
20
|
+
};
|
|
21
|
+
|
|
13
22
|
/** Webhook plugin configuration */
|
|
14
23
|
interface WebhookConfig {
|
|
15
24
|
/** Webhook URL to POST requests to */
|
|
@@ -119,7 +128,7 @@ export class WebhookInteractionPlugin implements InteractionPlugin {
|
|
|
119
128
|
this.pendingResponses.delete(requestId);
|
|
120
129
|
return response;
|
|
121
130
|
}
|
|
122
|
-
await
|
|
131
|
+
await _webhookPluginDeps.sleep(backoffMs);
|
|
123
132
|
// Exponential backoff: double interval up to max
|
|
124
133
|
backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
|
|
125
134
|
}
|
|
@@ -107,7 +107,7 @@ export const executionStage: PipelineStage = {
|
|
|
107
107
|
const logger = getLogger();
|
|
108
108
|
|
|
109
109
|
// HARD FAILURE: No agent available — cannot proceed without an agent
|
|
110
|
-
const agent = _executionDeps.getAgent(ctx.config.autoMode.defaultAgent);
|
|
110
|
+
const agent = (ctx.agentGetFn ?? _executionDeps.getAgent)(ctx.config.autoMode.defaultAgent);
|
|
111
111
|
if (!agent) {
|
|
112
112
|
return {
|
|
113
113
|
action: "fail",
|
|
@@ -133,6 +133,7 @@ export const executionStage: PipelineStage = {
|
|
|
133
133
|
config: ctx.config,
|
|
134
134
|
workdir: ctx.workdir,
|
|
135
135
|
modelTier: ctx.routing.modelTier,
|
|
136
|
+
featureName: ctx.prd.feature,
|
|
136
137
|
contextMarkdown: ctx.contextMarkdown,
|
|
137
138
|
constitution: ctx.constitution?.content,
|
|
138
139
|
dryRun: false,
|
|
@@ -217,6 +218,37 @@ export const executionStage: PipelineStage = {
|
|
|
217
218
|
modelDef: resolveModel(ctx.config.models[ctx.routing.modelTier]),
|
|
218
219
|
timeoutSeconds: ctx.config.execution.sessionTimeoutSeconds,
|
|
219
220
|
dangerouslySkipPermissions: ctx.config.execution.dangerouslySkipPermissions,
|
|
221
|
+
pidRegistry: ctx.pidRegistry,
|
|
222
|
+
featureName: ctx.prd.feature,
|
|
223
|
+
storyId: ctx.story.id,
|
|
224
|
+
// No sessionRole for single-session strategies (no role suffix in session name)
|
|
225
|
+
interactionBridge: (() => {
|
|
226
|
+
const plugin = ctx.interaction?.getPrimary();
|
|
227
|
+
if (!plugin) return undefined;
|
|
228
|
+
const QUESTION_PATTERNS = [/\?/, /\bwhich\b/i, /\bshould i\b/i, /\bunclear\b/i, /\bplease clarify\b/i];
|
|
229
|
+
return {
|
|
230
|
+
detectQuestion: async (text: string) => QUESTION_PATTERNS.some((p) => p.test(text)),
|
|
231
|
+
onQuestionDetected: async (text: string) => {
|
|
232
|
+
const requestId = `ix-acp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
233
|
+
await plugin.send({
|
|
234
|
+
id: requestId,
|
|
235
|
+
type: "input",
|
|
236
|
+
featureName: ctx.prd.feature,
|
|
237
|
+
storyId: ctx.story.id,
|
|
238
|
+
stage: "execution",
|
|
239
|
+
summary: text,
|
|
240
|
+
fallback: "continue",
|
|
241
|
+
createdAt: Date.now(),
|
|
242
|
+
});
|
|
243
|
+
try {
|
|
244
|
+
const response = await plugin.receive(requestId, 120_000);
|
|
245
|
+
return response.value ?? "continue";
|
|
246
|
+
} catch {
|
|
247
|
+
return "continue";
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
})(),
|
|
220
252
|
});
|
|
221
253
|
|
|
222
254
|
ctx.agentResult = result;
|
|
@@ -44,7 +44,13 @@ import type { PipelineContext, PipelineStage, RoutingResult, StageResult } from
|
|
|
44
44
|
* Used as the default implementation in _routingDeps.runDecompose.
|
|
45
45
|
* In production, replace with an LLM-backed adapter.
|
|
46
46
|
*/
|
|
47
|
-
async function runDecompose(
|
|
47
|
+
async function runDecompose(
|
|
48
|
+
story: UserStory,
|
|
49
|
+
prd: PRD,
|
|
50
|
+
config: NaxConfig,
|
|
51
|
+
_workdir: string,
|
|
52
|
+
agentGetFn?: import("../types").AgentGetFn,
|
|
53
|
+
): Promise<DecomposeResult> {
|
|
48
54
|
const naxDecompose = config.decompose;
|
|
49
55
|
const builderConfig: BuilderDecomposeConfig = {
|
|
50
56
|
maxSubStories: naxDecompose?.maxSubstories ?? 5,
|
|
@@ -52,10 +58,15 @@ async function runDecompose(story: UserStory, prd: PRD, config: NaxConfig, _work
|
|
|
52
58
|
maxRetries: naxDecompose?.maxRetries ?? 2,
|
|
53
59
|
};
|
|
54
60
|
|
|
55
|
-
//
|
|
61
|
+
// Resolve the default agent adapter for LLM-backed decompose.
|
|
62
|
+
// Falls back to agent.complete() with JSON mode — works with both CLI and ACP adapters.
|
|
63
|
+
const agent = (agentGetFn ?? getAgent)(config.autoMode.defaultAgent);
|
|
64
|
+
if (!agent) {
|
|
65
|
+
throw new Error(`[decompose] Agent "${config.autoMode.defaultAgent}" not found — cannot decompose`);
|
|
66
|
+
}
|
|
56
67
|
const adapter = {
|
|
57
|
-
async decompose(
|
|
58
|
-
|
|
68
|
+
async decompose(prompt: string): Promise<string> {
|
|
69
|
+
return agent.complete(prompt, { jsonMode: true });
|
|
59
70
|
},
|
|
60
71
|
};
|
|
61
72
|
|
|
@@ -71,7 +82,7 @@ export const routingStage: PipelineStage = {
|
|
|
71
82
|
|
|
72
83
|
// Resolve agent adapter for LLM routing (shared with execution)
|
|
73
84
|
const agentName = ctx.config.execution?.agent ?? "claude";
|
|
74
|
-
const adapter = _routingDeps.getAgent(agentName);
|
|
85
|
+
const adapter = (ctx.agentGetFn ?? _routingDeps.getAgent)(agentName);
|
|
75
86
|
|
|
76
87
|
// Staleness detection (RRP-003):
|
|
77
88
|
// - story.routing absent → cache miss (no prior routing)
|
|
@@ -173,7 +184,7 @@ export const routingStage: PipelineStage = {
|
|
|
173
184
|
`Story ${ctx.story.id} is oversized (${acCount} ACs) but decompose is disabled — continuing with original`,
|
|
174
185
|
);
|
|
175
186
|
} else if (decomposeConfig.trigger === "auto") {
|
|
176
|
-
const result = await _routingDeps.runDecompose(ctx.story, ctx.prd, ctx.config, ctx.workdir);
|
|
187
|
+
const result = await _routingDeps.runDecompose(ctx.story, ctx.prd, ctx.config, ctx.workdir, ctx.agentGetFn);
|
|
177
188
|
if (result.validation.valid) {
|
|
178
189
|
_routingDeps.applyDecomposition(ctx.prd, result);
|
|
179
190
|
if (ctx.prdPath) {
|
|
@@ -193,7 +204,7 @@ export const routingStage: PipelineStage = {
|
|
|
193
204
|
ctx.interaction!,
|
|
194
205
|
);
|
|
195
206
|
if (action === "decompose") {
|
|
196
|
-
const result = await _routingDeps.runDecompose(ctx.story, ctx.prd, ctx.config, ctx.workdir);
|
|
207
|
+
const result = await _routingDeps.runDecompose(ctx.story, ctx.prd, ctx.config, ctx.workdir, ctx.agentGetFn);
|
|
197
208
|
if (result.validation.valid) {
|
|
198
209
|
_routingDeps.applyDecomposition(ctx.prd, result);
|
|
199
210
|
if (ctx.prdPath) {
|
package/src/pipeline/types.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { AgentResult } from "../agents/types";
|
|
|
8
8
|
import type { NaxConfig } from "../config/schema";
|
|
9
9
|
import type { ConstitutionResult } from "../constitution/types";
|
|
10
10
|
import type { BuiltContext } from "../context/types";
|
|
11
|
+
import type { PidRegistry } from "../execution/pid-registry";
|
|
11
12
|
import type { HooksConfig } from "../hooks/types";
|
|
12
13
|
import type { InteractionChain } from "../interaction/chain";
|
|
13
14
|
import type { StoryMetrics } from "../metrics/types";
|
|
@@ -52,6 +53,8 @@ export interface RoutingResult {
|
|
|
52
53
|
* };
|
|
53
54
|
* ```
|
|
54
55
|
*/
|
|
56
|
+
export type AgentGetFn = (name: string) => import("../agents/types").AgentAdapter | undefined;
|
|
57
|
+
|
|
55
58
|
export interface PipelineContext {
|
|
56
59
|
/** Ngent configuration */
|
|
57
60
|
config: NaxConfig;
|
|
@@ -73,6 +76,13 @@ export interface PipelineContext {
|
|
|
73
76
|
hooks: HooksConfig;
|
|
74
77
|
/** Plugin registry (optional, for plugin-provided extensions) */
|
|
75
78
|
plugins?: PluginRegistry;
|
|
79
|
+
/**
|
|
80
|
+
* Protocol-aware agent resolver. When set (ACP mode), returns AcpAgentAdapter;
|
|
81
|
+
* falls back to standalone getAgent (CLI mode) when absent.
|
|
82
|
+
*/
|
|
83
|
+
agentGetFn?: AgentGetFn;
|
|
84
|
+
/** PID registry for crash recovery — passed through to agent.run() for child process registration. */
|
|
85
|
+
pidRegistry?: PidRegistry;
|
|
76
86
|
/** Interaction chain (optional, for human-in-the-loop triggers) */
|
|
77
87
|
interaction?: InteractionChain;
|
|
78
88
|
/** Constitution result (set by constitutionStage) */
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRD JSON Validation and Schema Enforcement
|
|
3
|
+
*
|
|
4
|
+
* Validates and normalizes LLM-generated PRD JSON output before writing to disk.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Complexity, TestStrategy } from "../config";
|
|
8
|
+
import type { PRD, UserStory } from "./types";
|
|
9
|
+
import { validateStoryId } from "./validate";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Constants
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const VALID_COMPLEXITY: Complexity[] = ["simple", "medium", "complex", "expert"];
|
|
16
|
+
const VALID_TEST_STRATEGIES: TestStrategy[] = [
|
|
17
|
+
"test-after",
|
|
18
|
+
"tdd-simple",
|
|
19
|
+
"three-session-tdd",
|
|
20
|
+
"three-session-tdd-lite",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** Pattern matching ST001 → ST-001 style IDs (prefix letters + digits, no separator) */
|
|
24
|
+
const STORY_ID_NO_SEPARATOR = /^([A-Za-z]+)(\d+)$/;
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Public API
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extract JSON from a markdown code block.
|
|
32
|
+
*
|
|
33
|
+
* Handles:
|
|
34
|
+
* ```json ... ```
|
|
35
|
+
* ``` ... ```
|
|
36
|
+
*
|
|
37
|
+
* Returns the input unchanged if no code block is detected.
|
|
38
|
+
*/
|
|
39
|
+
export function extractJsonFromMarkdown(text: string): string {
|
|
40
|
+
const match = text.match(/```(?:json)?\s*\n([\s\S]*?)\n?\s*```/);
|
|
41
|
+
if (match) {
|
|
42
|
+
return match[1] ?? text;
|
|
43
|
+
}
|
|
44
|
+
return text;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Strip trailing commas before closing braces/brackets to handle a common LLM quirk.
|
|
49
|
+
* e.g. `{"a":1,}` → `{"a":1}`
|
|
50
|
+
*/
|
|
51
|
+
function stripTrailingCommas(text: string): string {
|
|
52
|
+
return text.replace(/,\s*([}\]])/g, "$1");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Normalize a story ID: convert e.g. ST001 → ST-001.
|
|
57
|
+
* Leaves IDs that already have separators unchanged.
|
|
58
|
+
*/
|
|
59
|
+
function normalizeStoryId(id: string): string {
|
|
60
|
+
const match = id.match(STORY_ID_NO_SEPARATOR);
|
|
61
|
+
if (match) {
|
|
62
|
+
return `${match[1]}-${match[2]}`;
|
|
63
|
+
}
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Normalize complexity string (case-insensitive) to a valid Complexity value.
|
|
69
|
+
* Returns null if no match found.
|
|
70
|
+
*/
|
|
71
|
+
function normalizeComplexity(raw: string): Complexity | null {
|
|
72
|
+
const lower = raw.toLowerCase() as Complexity;
|
|
73
|
+
if ((VALID_COMPLEXITY as string[]).includes(lower)) {
|
|
74
|
+
return lower;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validate a single story from raw LLM output.
|
|
81
|
+
* Returns a normalized UserStory or throws with field-level error.
|
|
82
|
+
*/
|
|
83
|
+
function validateStory(raw: unknown, index: number, allIds: Set<string>): UserStory {
|
|
84
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
85
|
+
throw new Error(`[schema] story[${index}] must be an object`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const s = raw as Record<string, unknown>;
|
|
89
|
+
|
|
90
|
+
// id
|
|
91
|
+
const rawId = s.id;
|
|
92
|
+
if (rawId === undefined || rawId === null || rawId === "") {
|
|
93
|
+
throw new Error(`[schema] story[${index}].id is required and must be non-empty`);
|
|
94
|
+
}
|
|
95
|
+
if (typeof rawId !== "string") {
|
|
96
|
+
throw new Error(`[schema] story[${index}].id must be a string`);
|
|
97
|
+
}
|
|
98
|
+
const id = normalizeStoryId(rawId);
|
|
99
|
+
validateStoryId(id);
|
|
100
|
+
|
|
101
|
+
// title
|
|
102
|
+
const title = s.title;
|
|
103
|
+
if (!title || typeof title !== "string" || title.trim() === "") {
|
|
104
|
+
throw new Error(`[schema] story[${index}].title is required and must be non-empty`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// description
|
|
108
|
+
const description = s.description;
|
|
109
|
+
if (!description || typeof description !== "string" || description.trim() === "") {
|
|
110
|
+
throw new Error(`[schema] story[${index}].description is required and must be non-empty`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// acceptanceCriteria
|
|
114
|
+
const ac = s.acceptanceCriteria;
|
|
115
|
+
if (!Array.isArray(ac) || ac.length === 0) {
|
|
116
|
+
throw new Error(`[schema] story[${index}].acceptanceCriteria is required and must be a non-empty array`);
|
|
117
|
+
}
|
|
118
|
+
for (let i = 0; i < ac.length; i++) {
|
|
119
|
+
if (typeof ac[i] !== "string") {
|
|
120
|
+
throw new Error(`[schema] story[${index}].acceptanceCriteria[${i}] must be a string`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// complexity — accept from routing.complexity (PRD format) or top-level complexity (legacy)
|
|
125
|
+
const routing = typeof s.routing === "object" && s.routing !== null ? (s.routing as Record<string, unknown>) : {};
|
|
126
|
+
const rawComplexity = routing.complexity ?? s.complexity;
|
|
127
|
+
if (rawComplexity === undefined || rawComplexity === null) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`[schema] story[${index}] missing complexity. Set routing.complexity to one of: ${VALID_COMPLEXITY.join(", ")}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (typeof rawComplexity !== "string") {
|
|
133
|
+
throw new Error(`[schema] story[${index}].routing.complexity must be a string`);
|
|
134
|
+
}
|
|
135
|
+
const complexity = normalizeComplexity(rawComplexity);
|
|
136
|
+
if (complexity === null) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`[schema] story[${index}].routing.complexity "${rawComplexity}" is invalid. Valid values: ${VALID_COMPLEXITY.join(", ")}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// testStrategy — accept from routing.testStrategy or top-level testStrategy
|
|
143
|
+
const rawTestStrategy = routing.testStrategy ?? s.testStrategy;
|
|
144
|
+
const testStrategy: TestStrategy =
|
|
145
|
+
rawTestStrategy !== undefined && (VALID_TEST_STRATEGIES as unknown[]).includes(rawTestStrategy)
|
|
146
|
+
? (rawTestStrategy as TestStrategy)
|
|
147
|
+
: "tdd-simple";
|
|
148
|
+
|
|
149
|
+
// dependencies
|
|
150
|
+
const rawDeps = s.dependencies;
|
|
151
|
+
const dependencies: string[] = Array.isArray(rawDeps) ? (rawDeps as string[]) : [];
|
|
152
|
+
|
|
153
|
+
// Validate dependency references (against already-known IDs)
|
|
154
|
+
for (const dep of dependencies) {
|
|
155
|
+
if (!allIds.has(dep)) {
|
|
156
|
+
throw new Error(`[schema] story[${index}].dependencies references unknown story ID "${dep}"`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// tags
|
|
161
|
+
const rawTags = s.tags;
|
|
162
|
+
const tags: string[] = Array.isArray(rawTags) ? (rawTags as string[]) : [];
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
id,
|
|
166
|
+
title: title.trim(),
|
|
167
|
+
description: description.trim(),
|
|
168
|
+
acceptanceCriteria: ac as string[],
|
|
169
|
+
tags,
|
|
170
|
+
dependencies,
|
|
171
|
+
// Force runtime state — never trust LLM output
|
|
172
|
+
status: "pending",
|
|
173
|
+
passes: false,
|
|
174
|
+
attempts: 0,
|
|
175
|
+
escalations: [],
|
|
176
|
+
routing: {
|
|
177
|
+
complexity,
|
|
178
|
+
testStrategy,
|
|
179
|
+
reasoning: "validated from LLM output",
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Parse raw string input, handling markdown wrapping and trailing commas.
|
|
186
|
+
* Throws with parse error context on failure.
|
|
187
|
+
*/
|
|
188
|
+
function parseRawString(text: string): unknown {
|
|
189
|
+
const extracted = extractJsonFromMarkdown(text);
|
|
190
|
+
const cleaned = stripTrailingCommas(extracted);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(cleaned);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
const parseErr = err as SyntaxError;
|
|
196
|
+
throw new Error(`[schema] Failed to parse JSON: ${parseErr.message}`, { cause: parseErr });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Validate and normalize the JSON output from the planning LLM.
|
|
202
|
+
*
|
|
203
|
+
* @param raw - Raw LLM output (string or already-parsed object)
|
|
204
|
+
* @param feature - Feature name for auto-fill
|
|
205
|
+
* @param branch - Branch name for auto-fill
|
|
206
|
+
* @returns Validated PRD object
|
|
207
|
+
*/
|
|
208
|
+
export function validatePlanOutput(raw: unknown, feature: string, branch: string): PRD {
|
|
209
|
+
// Parse string input
|
|
210
|
+
const parsed: unknown = typeof raw === "string" ? parseRawString(raw) : raw;
|
|
211
|
+
|
|
212
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
213
|
+
throw new Error("[schema] PRD output must be a JSON object");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const obj = parsed as Record<string, unknown>;
|
|
217
|
+
|
|
218
|
+
// Validate top-level userStories
|
|
219
|
+
const rawStories = obj.userStories;
|
|
220
|
+
if (!Array.isArray(rawStories) || rawStories.length === 0) {
|
|
221
|
+
throw new Error("[schema] userStories is required and must be a non-empty array");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// First pass: collect all story IDs (after normalization) for dependency validation
|
|
225
|
+
const allIds = new Set<string>();
|
|
226
|
+
for (const story of rawStories) {
|
|
227
|
+
if (typeof story === "object" && story !== null && !Array.isArray(story)) {
|
|
228
|
+
const s = story as Record<string, unknown>;
|
|
229
|
+
const rawId = s.id;
|
|
230
|
+
if (typeof rawId === "string" && rawId !== "") {
|
|
231
|
+
allIds.add(normalizeStoryId(rawId));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Second pass: full validation
|
|
237
|
+
const userStories: UserStory[] = rawStories.map((story, index) => validateStory(story, index, allIds));
|
|
238
|
+
|
|
239
|
+
const now = new Date().toISOString();
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
project: typeof obj.project === "string" && obj.project !== "" ? obj.project : feature,
|
|
243
|
+
feature,
|
|
244
|
+
branchName: branch,
|
|
245
|
+
createdAt: typeof obj.createdAt === "string" ? obj.createdAt : now,
|
|
246
|
+
updatedAt: now,
|
|
247
|
+
userStories,
|
|
248
|
+
};
|
|
249
|
+
}
|
package/src/tdd/orchestrator.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface ThreeSessionTddOptions {
|
|
|
28
28
|
config: NaxConfig;
|
|
29
29
|
workdir: string;
|
|
30
30
|
modelTier: ModelTier;
|
|
31
|
+
/** Feature name — used for ACP session naming (nax-<hash>-<feature>-<story>-<role>) */
|
|
32
|
+
featureName?: string;
|
|
31
33
|
contextMarkdown?: string;
|
|
32
34
|
constitution?: string;
|
|
33
35
|
dryRun?: boolean;
|
|
@@ -45,6 +47,7 @@ export async function runThreeSessionTdd(options: ThreeSessionTddOptions): Promi
|
|
|
45
47
|
config,
|
|
46
48
|
workdir,
|
|
47
49
|
modelTier,
|
|
50
|
+
featureName,
|
|
48
51
|
contextMarkdown,
|
|
49
52
|
constitution,
|
|
50
53
|
dryRun = false,
|
|
@@ -135,6 +138,7 @@ export async function runThreeSessionTdd(options: ThreeSessionTddOptions): Promi
|
|
|
135
138
|
lite,
|
|
136
139
|
lite,
|
|
137
140
|
constitution,
|
|
141
|
+
featureName,
|
|
138
142
|
);
|
|
139
143
|
sessions.push(session1);
|
|
140
144
|
}
|
|
@@ -240,6 +244,7 @@ export async function runThreeSessionTdd(options: ThreeSessionTddOptions): Promi
|
|
|
240
244
|
lite,
|
|
241
245
|
lite,
|
|
242
246
|
constitution,
|
|
247
|
+
featureName,
|
|
243
248
|
);
|
|
244
249
|
sessions.push(session2);
|
|
245
250
|
|
|
@@ -269,6 +274,7 @@ export async function runThreeSessionTdd(options: ThreeSessionTddOptions): Promi
|
|
|
269
274
|
contextMarkdown,
|
|
270
275
|
lite,
|
|
271
276
|
logger,
|
|
277
|
+
featureName,
|
|
272
278
|
);
|
|
273
279
|
|
|
274
280
|
// Session 3: Verifier
|
|
@@ -286,6 +292,7 @@ export async function runThreeSessionTdd(options: ThreeSessionTddOptions): Promi
|
|
|
286
292
|
false,
|
|
287
293
|
false,
|
|
288
294
|
constitution,
|
|
295
|
+
featureName,
|
|
289
296
|
);
|
|
290
297
|
sessions.push(session3);
|
|
291
298
|
|
|
@@ -34,6 +34,7 @@ export async function runFullSuiteGate(
|
|
|
34
34
|
contextMarkdown: string | undefined,
|
|
35
35
|
lite: boolean,
|
|
36
36
|
logger: ReturnType<typeof getLogger>,
|
|
37
|
+
featureName?: string,
|
|
37
38
|
): Promise<boolean> {
|
|
38
39
|
const rectificationEnabled = config.execution.rectification?.enabled ?? false;
|
|
39
40
|
if (!rectificationEnabled) return false;
|
|
@@ -67,6 +68,7 @@ export async function runFullSuiteGate(
|
|
|
67
68
|
rectificationConfig,
|
|
68
69
|
testCmd,
|
|
69
70
|
fullSuiteTimeout,
|
|
71
|
+
featureName,
|
|
70
72
|
);
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -118,6 +120,7 @@ async function runRectificationLoop(
|
|
|
118
120
|
rectificationConfig: NonNullable<NaxConfig["execution"]["rectification"]>,
|
|
119
121
|
testCmd: string,
|
|
120
122
|
fullSuiteTimeout: number,
|
|
123
|
+
featureName?: string,
|
|
121
124
|
): Promise<boolean> {
|
|
122
125
|
const rectificationState: RectificationState = {
|
|
123
126
|
attempt: 0,
|
|
@@ -156,6 +159,9 @@ async function runRectificationLoop(
|
|
|
156
159
|
modelDef: resolveModel(config.models[implementerTier]),
|
|
157
160
|
timeoutSeconds: config.execution.sessionTimeoutSeconds,
|
|
158
161
|
dangerouslySkipPermissions: config.execution.dangerouslySkipPermissions,
|
|
162
|
+
featureName,
|
|
163
|
+
storyId: story.id,
|
|
164
|
+
sessionRole: "implementer",
|
|
159
165
|
});
|
|
160
166
|
|
|
161
167
|
if (!rectifyResult.success && rectifyResult.pid) {
|