@nathapp/nax 0.49.1 → 0.49.6
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/CHANGELOG.md +14 -0
- package/README.md +282 -10
- package/dist/nax.js +257 -136
- package/package.json +1 -1
- package/src/agents/acp/adapter.ts +53 -23
- package/src/agents/acp/spawn-client.ts +0 -2
- package/src/agents/claude/execution.ts +14 -0
- package/src/agents/types.ts +7 -0
- package/src/cli/prompts-main.ts +4 -59
- package/src/cli/prompts-shared.ts +70 -0
- package/src/cli/prompts-tdd.ts +1 -1
- package/src/config/merge.ts +18 -0
- package/src/config/test-strategy.ts +4 -4
- package/src/execution/iteration-runner.ts +1 -1
- package/src/execution/pipeline-result-handler.ts +4 -1
- package/src/execution/story-selector.ts +2 -1
- package/src/interaction/plugins/webhook.ts +44 -25
- package/src/pipeline/stages/autofix.ts +26 -7
- package/src/pipeline/stages/routing.ts +1 -1
- package/src/review/runner.ts +15 -0
- package/src/tdd/cleanup.ts +15 -6
- package/src/tdd/isolation.ts +9 -2
- package/src/tdd/rectification-gate.ts +41 -10
- package/src/tdd/session-runner.ts +71 -38
- package/src/verification/executor.ts +4 -1
- package/src/verification/strategies/acceptance.ts +4 -1
package/package.json
CHANGED
|
@@ -261,23 +261,37 @@ function acpSessionsPath(workdir: string, featureName: string): string {
|
|
|
261
261
|
return join(workdir, "nax", "features", featureName, "acp-sessions.json");
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/** Sidecar entry — session name + agent name for correct sweep/close. */
|
|
265
|
+
type SidecarEntry = string | { sessionName: string; agentName: string };
|
|
266
|
+
|
|
267
|
+
/** Extract sessionName from a sidecar entry (handles legacy string format). */
|
|
268
|
+
function sidecarSessionName(entry: SidecarEntry): string {
|
|
269
|
+
return typeof entry === "string" ? entry : entry.sessionName;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Extract agentName from a sidecar entry (defaults to "claude" for legacy entries). */
|
|
273
|
+
function sidecarAgentName(entry: SidecarEntry): string {
|
|
274
|
+
return typeof entry === "string" ? "claude" : entry.agentName;
|
|
275
|
+
}
|
|
276
|
+
|
|
264
277
|
/** Persist a session name to the sidecar file. Best-effort — errors are swallowed. */
|
|
265
278
|
export async function saveAcpSession(
|
|
266
279
|
workdir: string,
|
|
267
280
|
featureName: string,
|
|
268
281
|
storyId: string,
|
|
269
282
|
sessionName: string,
|
|
283
|
+
agentName = "claude",
|
|
270
284
|
): Promise<void> {
|
|
271
285
|
try {
|
|
272
286
|
const path = acpSessionsPath(workdir, featureName);
|
|
273
|
-
let data: Record<string,
|
|
287
|
+
let data: Record<string, SidecarEntry> = {};
|
|
274
288
|
try {
|
|
275
289
|
const existing = await Bun.file(path).text();
|
|
276
290
|
data = JSON.parse(existing);
|
|
277
291
|
} catch {
|
|
278
292
|
// File doesn't exist yet — start fresh
|
|
279
293
|
}
|
|
280
|
-
data[storyId] = sessionName;
|
|
294
|
+
data[storyId] = { sessionName, agentName };
|
|
281
295
|
await Bun.write(path, JSON.stringify(data, null, 2));
|
|
282
296
|
} catch (err) {
|
|
283
297
|
getSafeLogger()?.warn("acp-adapter", "Failed to save session to sidecar", { error: String(err) });
|
|
@@ -307,8 +321,9 @@ export async function readAcpSession(workdir: string, featureName: string, story
|
|
|
307
321
|
try {
|
|
308
322
|
const path = acpSessionsPath(workdir, featureName);
|
|
309
323
|
const existing = await Bun.file(path).text();
|
|
310
|
-
const data: Record<string,
|
|
311
|
-
|
|
324
|
+
const data: Record<string, SidecarEntry> = JSON.parse(existing);
|
|
325
|
+
const entry = data[storyId];
|
|
326
|
+
return entry ? sidecarSessionName(entry) : null;
|
|
312
327
|
} catch {
|
|
313
328
|
return null;
|
|
314
329
|
}
|
|
@@ -326,10 +341,10 @@ const MAX_SESSION_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
|
326
341
|
*/
|
|
327
342
|
export async function sweepFeatureSessions(workdir: string, featureName: string): Promise<void> {
|
|
328
343
|
const path = acpSessionsPath(workdir, featureName);
|
|
329
|
-
let sessions: Record<string,
|
|
344
|
+
let sessions: Record<string, SidecarEntry>;
|
|
330
345
|
try {
|
|
331
346
|
const text = await Bun.file(path).text();
|
|
332
|
-
sessions = JSON.parse(text) as Record<string,
|
|
347
|
+
sessions = JSON.parse(text) as Record<string, SidecarEntry>;
|
|
333
348
|
} catch {
|
|
334
349
|
return; // No sidecar — nothing to sweep
|
|
335
350
|
}
|
|
@@ -340,24 +355,35 @@ export async function sweepFeatureSessions(workdir: string, featureName: string)
|
|
|
340
355
|
const logger = getSafeLogger();
|
|
341
356
|
logger?.info("acp-adapter", `[sweep] Closing ${entries.length} open sessions for feature: ${featureName}`);
|
|
342
357
|
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
358
|
+
// Group sessions by agent name so we create one client per agent
|
|
359
|
+
const byAgent = new Map<string, string[]>();
|
|
360
|
+
for (const [, entry] of entries) {
|
|
361
|
+
const agent = sidecarAgentName(entry);
|
|
362
|
+
const name = sidecarSessionName(entry);
|
|
363
|
+
if (!byAgent.has(agent)) byAgent.set(agent, []);
|
|
364
|
+
byAgent.get(agent)?.push(name);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (const [agentName, sessionNames] of byAgent) {
|
|
368
|
+
const cmdStr = `acpx ${agentName}`;
|
|
369
|
+
const client = _acpAdapterDeps.createClient(cmdStr, workdir);
|
|
370
|
+
try {
|
|
371
|
+
await client.start();
|
|
372
|
+
for (const sessionName of sessionNames) {
|
|
373
|
+
try {
|
|
374
|
+
if (client.loadSession) {
|
|
375
|
+
const session = await client.loadSession(sessionName, agentName, "approve-reads");
|
|
376
|
+
if (session) {
|
|
377
|
+
await session.close().catch(() => {});
|
|
378
|
+
}
|
|
353
379
|
}
|
|
380
|
+
} catch (err) {
|
|
381
|
+
logger?.warn("acp-adapter", `[sweep] Failed to close session ${sessionName}`, { error: String(err) });
|
|
354
382
|
}
|
|
355
|
-
} catch (err) {
|
|
356
|
-
logger?.warn("acp-adapter", `[sweep] Failed to close session ${sessionName}`, { error: String(err) });
|
|
357
383
|
}
|
|
384
|
+
} finally {
|
|
385
|
+
await client.close().catch(() => {});
|
|
358
386
|
}
|
|
359
|
-
} finally {
|
|
360
|
-
await client.close().catch(() => {});
|
|
361
387
|
}
|
|
362
388
|
|
|
363
389
|
// Clear sidecar after sweep
|
|
@@ -554,7 +580,7 @@ export class AcpAgentAdapter implements AgentAdapter {
|
|
|
554
580
|
|
|
555
581
|
// 4. Persist for plan→run continuity
|
|
556
582
|
if (options.featureName && options.storyId) {
|
|
557
|
-
await saveAcpSession(options.workdir, options.featureName, options.storyId, sessionName);
|
|
583
|
+
await saveAcpSession(options.workdir, options.featureName, options.storyId, sessionName, this.name);
|
|
558
584
|
}
|
|
559
585
|
|
|
560
586
|
let lastResponse: AcpSessionResponse | null = null;
|
|
@@ -635,13 +661,17 @@ export class AcpAgentAdapter implements AgentAdapter {
|
|
|
635
661
|
} finally {
|
|
636
662
|
// 6. Cleanup — close session and clear sidecar only on success.
|
|
637
663
|
// On failure, keep session open so retry can resume with full context.
|
|
638
|
-
|
|
664
|
+
// When keepSessionOpen=true (e.g. rectification loop), skip close even on success
|
|
665
|
+
// so all attempts share the same conversation context.
|
|
666
|
+
if (runState.succeeded && !options.keepSessionOpen) {
|
|
639
667
|
await closeAcpSession(session);
|
|
640
668
|
if (options.featureName && options.storyId) {
|
|
641
669
|
await clearAcpSession(options.workdir, options.featureName, options.storyId);
|
|
642
670
|
}
|
|
643
|
-
} else {
|
|
671
|
+
} else if (!runState.succeeded) {
|
|
644
672
|
getSafeLogger()?.info("acp-adapter", "Keeping session open for retry", { sessionName });
|
|
673
|
+
} else {
|
|
674
|
+
getSafeLogger()?.debug("acp-adapter", "Keeping session open (keepSessionOpen=true)", { sessionName });
|
|
645
675
|
}
|
|
646
676
|
await client.close().catch(() => {});
|
|
647
677
|
}
|
|
@@ -272,7 +272,6 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
272
272
|
private readonly model: string;
|
|
273
273
|
private readonly cwd: string;
|
|
274
274
|
private readonly timeoutSeconds: number;
|
|
275
|
-
private readonly permissionMode: string;
|
|
276
275
|
private readonly env: Record<string, string | undefined>;
|
|
277
276
|
private readonly pidRegistry?: PidRegistry;
|
|
278
277
|
|
|
@@ -289,7 +288,6 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
289
288
|
this.agentName = lastToken;
|
|
290
289
|
this.cwd = cwd || process.cwd();
|
|
291
290
|
this.timeoutSeconds = timeoutSeconds || 1800;
|
|
292
|
-
this.permissionMode = "approve-reads";
|
|
293
291
|
this.env = buildAllowedEnv();
|
|
294
292
|
this.pidRegistry = pidRegistry;
|
|
295
293
|
}
|
|
@@ -126,6 +126,20 @@ export async function executeOnce(
|
|
|
126
126
|
const cmd = _runOnceDeps.buildCmd(binary, options);
|
|
127
127
|
const startTime = Date.now();
|
|
128
128
|
|
|
129
|
+
// Log session-related options for traceability. CLI adapter doesn't use sessions,
|
|
130
|
+
// but the pipeline passes these uniformly. Logged so future CLI session support
|
|
131
|
+
// can verify they're threaded correctly.
|
|
132
|
+
if (options.sessionRole || options.acpSessionName || options.keepSessionOpen) {
|
|
133
|
+
const logger = getLogger();
|
|
134
|
+
logger.debug("agent", "CLI mode: session options received (unused)", {
|
|
135
|
+
sessionRole: options.sessionRole,
|
|
136
|
+
acpSessionName: options.acpSessionName,
|
|
137
|
+
keepSessionOpen: options.keepSessionOpen,
|
|
138
|
+
featureName: options.featureName,
|
|
139
|
+
storyId: options.storyId,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
129
143
|
const proc = Bun.spawn(cmd, {
|
|
130
144
|
cwd: options.workdir,
|
|
131
145
|
stdout: "pipe",
|
package/src/agents/types.ts
CHANGED
|
@@ -84,6 +84,13 @@ export interface AgentRunOptions {
|
|
|
84
84
|
pipelineStage?: import("../config/permissions").PipelineStage;
|
|
85
85
|
/** Full nax config — passed through so adapters can call resolvePermissions() */
|
|
86
86
|
config?: NaxConfig;
|
|
87
|
+
/**
|
|
88
|
+
* When true, the adapter will NOT close the session after a successful run.
|
|
89
|
+
* Use this for rectification loops where the same session must persist across
|
|
90
|
+
* multiple attempts so the agent retains full conversation context.
|
|
91
|
+
* The caller is responsible for closing the session when the loop is done.
|
|
92
|
+
*/
|
|
93
|
+
keepSessionOpen?: boolean;
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
/**
|
package/src/cli/prompts-main.ts
CHANGED
|
@@ -13,7 +13,11 @@ import type { PipelineContext } from "../pipeline";
|
|
|
13
13
|
import { constitutionStage, contextStage, promptStage, routingStage } from "../pipeline/stages";
|
|
14
14
|
import type { UserStory } from "../prd";
|
|
15
15
|
import { loadPRD } from "../prd";
|
|
16
|
+
// buildFrontmatter lives in prompts-shared to avoid circular import with prompts-tdd.
|
|
17
|
+
// Import for local use + re-export to preserve the public API via prompts.ts.
|
|
18
|
+
import { buildFrontmatter } from "./prompts-shared";
|
|
16
19
|
import { handleThreeSessionTddPrompts } from "./prompts-tdd";
|
|
20
|
+
export { buildFrontmatter };
|
|
17
21
|
|
|
18
22
|
export interface PromptsCommandOptions {
|
|
19
23
|
/** Feature name */
|
|
@@ -177,62 +181,3 @@ export async function promptsCommand(options: PromptsCommandOptions): Promise<st
|
|
|
177
181
|
|
|
178
182
|
return processedStories;
|
|
179
183
|
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Build YAML frontmatter for a story prompt.
|
|
183
|
-
*
|
|
184
|
-
* Uses actual token counts from BuiltContext elements (computed by context builder
|
|
185
|
-
* using CHARS_PER_TOKEN=3) rather than re-estimating independently.
|
|
186
|
-
*
|
|
187
|
-
* @param story - User story
|
|
188
|
-
* @param ctx - Pipeline context after running prompt assembly
|
|
189
|
-
* @param role - Optional role for three-session TDD (test-writer, implementer, verifier)
|
|
190
|
-
* @returns YAML frontmatter string (without delimiters)
|
|
191
|
-
*/
|
|
192
|
-
export function buildFrontmatter(story: UserStory, ctx: PipelineContext, role?: string): string {
|
|
193
|
-
const lines: string[] = [];
|
|
194
|
-
|
|
195
|
-
lines.push(`storyId: ${story.id}`);
|
|
196
|
-
lines.push(`title: "${story.title}"`);
|
|
197
|
-
lines.push(`testStrategy: ${ctx.routing.testStrategy}`);
|
|
198
|
-
lines.push(`modelTier: ${ctx.routing.modelTier}`);
|
|
199
|
-
|
|
200
|
-
if (role) {
|
|
201
|
-
lines.push(`role: ${role}`);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Use actual token counts from BuiltContext if available
|
|
205
|
-
const builtContext = ctx.builtContext;
|
|
206
|
-
const contextTokens = builtContext?.totalTokens ?? 0;
|
|
207
|
-
const promptTokens = ctx.prompt ? Math.ceil(ctx.prompt.length / 3) : 0;
|
|
208
|
-
|
|
209
|
-
lines.push(`contextTokens: ${contextTokens}`);
|
|
210
|
-
lines.push(`promptTokens: ${promptTokens}`);
|
|
211
|
-
|
|
212
|
-
// Dependencies
|
|
213
|
-
if (story.dependencies && story.dependencies.length > 0) {
|
|
214
|
-
lines.push(`dependencies: [${story.dependencies.join(", ")}]`);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Context elements breakdown from actual BuiltContext
|
|
218
|
-
lines.push("contextElements:");
|
|
219
|
-
|
|
220
|
-
if (builtContext) {
|
|
221
|
-
for (const element of builtContext.elements) {
|
|
222
|
-
lines.push(` - type: ${element.type}`);
|
|
223
|
-
if (element.storyId) {
|
|
224
|
-
lines.push(` storyId: ${element.storyId}`);
|
|
225
|
-
}
|
|
226
|
-
if (element.filePath) {
|
|
227
|
-
lines.push(` filePath: ${element.filePath}`);
|
|
228
|
-
}
|
|
229
|
-
lines.push(` tokens: ${element.tokens}`);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
if (builtContext?.truncated) {
|
|
234
|
-
lines.push("truncated: true");
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return `${lines.join("\n")}\n`;
|
|
238
|
-
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Prompts Utilities
|
|
3
|
+
*
|
|
4
|
+
* Functions shared between prompts-main and prompts-tdd to avoid circular imports.
|
|
5
|
+
* Both modules need buildFrontmatter; keeping it here breaks the cycle:
|
|
6
|
+
* prompts-main → prompts-tdd (was circular)
|
|
7
|
+
* now both → prompts-shared
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PipelineContext } from "../pipeline";
|
|
11
|
+
import type { UserStory } from "../prd";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build YAML frontmatter for a prompt file.
|
|
15
|
+
*
|
|
16
|
+
* Token counts use actual BuiltContext values (computed during pipeline execution,
|
|
17
|
+
* using CHARS_PER_TOKEN=3) rather than re-estimating independently.
|
|
18
|
+
*
|
|
19
|
+
* @param story - User story
|
|
20
|
+
* @param ctx - Pipeline context after running prompt assembly
|
|
21
|
+
* @param role - Optional role for three-session TDD (test-writer, implementer, verifier)
|
|
22
|
+
* @returns YAML frontmatter string (without delimiters)
|
|
23
|
+
*/
|
|
24
|
+
export function buildFrontmatter(story: UserStory, ctx: PipelineContext, role?: string): string {
|
|
25
|
+
const lines: string[] = [];
|
|
26
|
+
|
|
27
|
+
lines.push(`storyId: ${story.id}`);
|
|
28
|
+
lines.push(`title: "${story.title}"`);
|
|
29
|
+
lines.push(`testStrategy: ${ctx.routing.testStrategy}`);
|
|
30
|
+
lines.push(`modelTier: ${ctx.routing.modelTier}`);
|
|
31
|
+
|
|
32
|
+
if (role) {
|
|
33
|
+
lines.push(`role: ${role}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Use actual token counts from BuiltContext if available
|
|
37
|
+
const builtContext = ctx.builtContext;
|
|
38
|
+
const contextTokens = builtContext?.totalTokens ?? 0;
|
|
39
|
+
const promptTokens = ctx.prompt ? Math.ceil(ctx.prompt.length / 3) : 0;
|
|
40
|
+
|
|
41
|
+
lines.push(`contextTokens: ${contextTokens}`);
|
|
42
|
+
lines.push(`promptTokens: ${promptTokens}`);
|
|
43
|
+
|
|
44
|
+
// Dependencies
|
|
45
|
+
if (story.dependencies && story.dependencies.length > 0) {
|
|
46
|
+
lines.push(`dependencies: [${story.dependencies.join(", ")}]`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Context elements breakdown from actual BuiltContext
|
|
50
|
+
lines.push("contextElements:");
|
|
51
|
+
|
|
52
|
+
if (builtContext) {
|
|
53
|
+
for (const element of builtContext.elements) {
|
|
54
|
+
lines.push(` - type: ${element.type}`);
|
|
55
|
+
if (element.storyId) {
|
|
56
|
+
lines.push(` storyId: ${element.storyId}`);
|
|
57
|
+
}
|
|
58
|
+
if (element.filePath) {
|
|
59
|
+
lines.push(` filePath: ${element.filePath}`);
|
|
60
|
+
}
|
|
61
|
+
lines.push(` tokens: ${element.tokens}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (builtContext?.truncated) {
|
|
66
|
+
lines.push("truncated: true");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return `${lines.join("\n")}\n`;
|
|
70
|
+
}
|
package/src/cli/prompts-tdd.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { getLogger } from "../logger";
|
|
|
9
9
|
import type { PipelineContext } from "../pipeline";
|
|
10
10
|
import type { UserStory } from "../prd";
|
|
11
11
|
import { PromptBuilder } from "../prompts";
|
|
12
|
-
import { buildFrontmatter } from "./prompts-
|
|
12
|
+
import { buildFrontmatter } from "./prompts-shared";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Handle three-session TDD prompts by building separate prompts for each role.
|
package/src/config/merge.ts
CHANGED
|
@@ -55,6 +55,24 @@ export function mergePackageConfig(root: NaxConfig, packageOverride: Partial<Nax
|
|
|
55
55
|
...packageOverride.review,
|
|
56
56
|
commands: {
|
|
57
57
|
...root.review.commands,
|
|
58
|
+
// PKG-006: Bridge quality.commands → review.commands for per-package overrides.
|
|
59
|
+
// Users naturally put per-package commands in quality.commands (the intuitive
|
|
60
|
+
// place), but the review runner reads review.commands. Bridge them here so
|
|
61
|
+
// packages don't need to define the same commands in two places.
|
|
62
|
+
// Explicit review.commands still take precedence (applied after).
|
|
63
|
+
...(packageOverride.quality?.commands?.lint !== undefined && {
|
|
64
|
+
lint: packageOverride.quality.commands.lint,
|
|
65
|
+
}),
|
|
66
|
+
...(packageOverride.quality?.commands?.lintFix !== undefined && {
|
|
67
|
+
lintFix: packageOverride.quality.commands.lintFix,
|
|
68
|
+
}),
|
|
69
|
+
...(packageOverride.quality?.commands?.typecheck !== undefined && {
|
|
70
|
+
typecheck: packageOverride.quality.commands.typecheck,
|
|
71
|
+
}),
|
|
72
|
+
...(packageOverride.quality?.commands?.test !== undefined && {
|
|
73
|
+
test: packageOverride.quality.commands.test,
|
|
74
|
+
}),
|
|
75
|
+
// Explicit review.commands override bridged quality values
|
|
58
76
|
...packageOverride.review?.commands,
|
|
59
77
|
},
|
|
60
78
|
},
|
|
@@ -53,10 +53,10 @@ regardless of LOC count. These require at minimum "tdd-simple" test strategy.`;
|
|
|
53
53
|
|
|
54
54
|
export const TEST_STRATEGY_GUIDE = `## Test Strategy Guide
|
|
55
55
|
|
|
56
|
-
- test-after: Simple changes with well-understood behavior. Write tests after implementation.
|
|
57
|
-
- tdd-simple: Medium complexity. Write
|
|
58
|
-
- three-session-tdd: Complex stories.
|
|
59
|
-
- three-session-tdd-lite: Expert/high-risk stories.
|
|
56
|
+
- test-after: Simple changes with well-understood behavior. Write tests after implementation in a single session.
|
|
57
|
+
- tdd-simple: Medium complexity. Write failing tests first, then implement to pass them — all in one session.
|
|
58
|
+
- three-session-tdd: Complex stories. 3 sessions: (1) test-writer writes failing tests — no src/ changes allowed, (2) implementer makes them pass without modifying test files, (3) verifier confirms correctness.
|
|
59
|
+
- three-session-tdd-lite: Expert/high-risk stories. 3 sessions: (1) test-writer writes failing tests and may create minimal src/ stubs for imports, (2) implementer makes tests pass and may add missing coverage or replace stubs, (3) verifier confirms correctness.`;
|
|
60
60
|
|
|
61
61
|
export const GROUPING_RULES = `## Grouping Rules
|
|
62
62
|
|
|
@@ -102,6 +102,7 @@ export async function handlePipelineSuccess(
|
|
|
102
102
|
export interface PipelineFailureResult {
|
|
103
103
|
prd: PRD;
|
|
104
104
|
prdDirty: boolean;
|
|
105
|
+
costDelta: number;
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
export async function handlePipelineFailure(
|
|
@@ -111,6 +112,8 @@ export async function handlePipelineFailure(
|
|
|
111
112
|
const logger = getSafeLogger();
|
|
112
113
|
let prd = ctx.prd;
|
|
113
114
|
let prdDirty = false;
|
|
115
|
+
// Always capture cost even for failed stories — agent ran and spent tokens
|
|
116
|
+
const costDelta = pipelineResult.context.agentResult?.estimatedCost || 0;
|
|
114
117
|
|
|
115
118
|
switch (pipelineResult.finalAction) {
|
|
116
119
|
case "pause":
|
|
@@ -185,5 +188,5 @@ export async function handlePipelineFailure(
|
|
|
185
188
|
}
|
|
186
189
|
}
|
|
187
190
|
|
|
188
|
-
return { prd, prdDirty };
|
|
191
|
+
return { prd, prdDirty, costDelta };
|
|
189
192
|
}
|
|
@@ -11,8 +11,8 @@ import { z } from "zod";
|
|
|
11
11
|
import type { InteractionPlugin, InteractionRequest, InteractionResponse } from "../types";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Injectable sleep for
|
|
15
|
-
*
|
|
14
|
+
* Injectable sleep — kept for backward compat with existing tests that override it.
|
|
15
|
+
* No longer used internally by receive() (replaced by event-driven delivery).
|
|
16
16
|
* @internal
|
|
17
17
|
*/
|
|
18
18
|
export const _webhookPluginDeps = {
|
|
@@ -56,7 +56,10 @@ export class WebhookInteractionPlugin implements InteractionPlugin {
|
|
|
56
56
|
private config: WebhookConfig = {};
|
|
57
57
|
private server: Server | null = null;
|
|
58
58
|
private serverStartPromise: Promise<void> | null = null;
|
|
59
|
+
/** Legacy map for responses that arrive before receive() is called */
|
|
59
60
|
private pendingResponses = new Map<string, InteractionResponse>();
|
|
61
|
+
/** Event-driven callbacks: requestId → resolve fn (set by receive(), called by handleRequest) */
|
|
62
|
+
private receiveCallbacks = new Map<string, (response: InteractionResponse) => void>();
|
|
60
63
|
|
|
61
64
|
async init(config: Record<string, unknown>): Promise<void> {
|
|
62
65
|
const cfg = WebhookConfigSchema.parse(config);
|
|
@@ -117,33 +120,49 @@ export class WebhookInteractionPlugin implements InteractionPlugin {
|
|
|
117
120
|
// Start HTTP server to receive callback
|
|
118
121
|
await this.startServer();
|
|
119
122
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
while (Date.now() - startTime < timeout) {
|
|
126
|
-
const response = this.pendingResponses.get(requestId);
|
|
127
|
-
if (response) {
|
|
128
|
-
this.pendingResponses.delete(requestId);
|
|
129
|
-
return response;
|
|
130
|
-
}
|
|
131
|
-
await _webhookPluginDeps.sleep(backoffMs);
|
|
132
|
-
// Exponential backoff: double interval up to max
|
|
133
|
-
backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
|
|
123
|
+
// Check if a response already arrived before receive() was called
|
|
124
|
+
const early = this.pendingResponses.get(requestId);
|
|
125
|
+
if (early) {
|
|
126
|
+
this.pendingResponses.delete(requestId);
|
|
127
|
+
return early;
|
|
134
128
|
}
|
|
135
129
|
|
|
136
|
-
//
|
|
137
|
-
return {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
130
|
+
// Event-driven: resolve immediately when handleRequest delivers the response
|
|
131
|
+
return new Promise<InteractionResponse>((resolve) => {
|
|
132
|
+
const timer = setTimeout(() => {
|
|
133
|
+
this.receiveCallbacks.delete(requestId);
|
|
134
|
+
resolve({
|
|
135
|
+
requestId,
|
|
136
|
+
action: "skip",
|
|
137
|
+
respondedBy: "timeout",
|
|
138
|
+
respondedAt: Date.now(),
|
|
139
|
+
});
|
|
140
|
+
}, timeout);
|
|
141
|
+
|
|
142
|
+
this.receiveCallbacks.set(requestId, (response) => {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
this.receiveCallbacks.delete(requestId);
|
|
145
|
+
resolve(response);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
async cancel(requestId: string): Promise<void> {
|
|
146
151
|
this.pendingResponses.delete(requestId);
|
|
152
|
+
this.receiveCallbacks.delete(requestId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Deliver a response to a waiting receive() callback, or store for later pickup.
|
|
157
|
+
*/
|
|
158
|
+
private deliverResponse(requestId: string, response: InteractionResponse): void {
|
|
159
|
+
const cb = this.receiveCallbacks.get(requestId);
|
|
160
|
+
if (cb) {
|
|
161
|
+
cb(response);
|
|
162
|
+
} else {
|
|
163
|
+
// receive() hasn't been called yet — store for early-pickup path
|
|
164
|
+
this.pendingResponses.set(requestId, response);
|
|
165
|
+
}
|
|
147
166
|
}
|
|
148
167
|
|
|
149
168
|
/**
|
|
@@ -220,7 +239,7 @@ export class WebhookInteractionPlugin implements InteractionPlugin {
|
|
|
220
239
|
try {
|
|
221
240
|
const parsed = JSON.parse(body);
|
|
222
241
|
const response = InteractionResponseSchema.parse(parsed);
|
|
223
|
-
this.
|
|
242
|
+
this.deliverResponse(requestId, response);
|
|
224
243
|
} catch {
|
|
225
244
|
// Sanitize error - do not leak parse/validation details
|
|
226
245
|
return new Response("Bad Request: Invalid response format", { status: 400 });
|
|
@@ -230,7 +249,7 @@ export class WebhookInteractionPlugin implements InteractionPlugin {
|
|
|
230
249
|
try {
|
|
231
250
|
const parsed = await req.json();
|
|
232
251
|
const response = InteractionResponseSchema.parse(parsed);
|
|
233
|
-
this.
|
|
252
|
+
this.deliverResponse(requestId, response);
|
|
234
253
|
} catch {
|
|
235
254
|
// Sanitize error - do not leak parse/validation details
|
|
236
255
|
return new Response("Bad Request: Invalid response format", { status: 400 });
|
|
@@ -61,12 +61,22 @@ export const autofixStage: PipelineStage = {
|
|
|
61
61
|
// Effective workdir for running commands (scoped to package if monorepo)
|
|
62
62
|
const effectiveWorkdir = ctx.story.workdir ? join(ctx.workdir, ctx.story.workdir) : ctx.workdir;
|
|
63
63
|
|
|
64
|
-
//
|
|
65
|
-
|
|
64
|
+
// Identify which checks failed
|
|
65
|
+
const failedCheckNames = new Set((reviewResult.checks ?? []).filter((c) => !c.success).map((c) => c.check));
|
|
66
|
+
const hasLintFailure = failedCheckNames.has("lint");
|
|
67
|
+
|
|
68
|
+
logger.info("autofix", "Starting autofix", {
|
|
69
|
+
storyId: ctx.story.id,
|
|
70
|
+
failedChecks: [...failedCheckNames],
|
|
71
|
+
workdir: effectiveWorkdir,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Phase 1: Mechanical fix — only for lint failures (lintFix/formatFix cannot fix typecheck errors)
|
|
75
|
+
if (hasLintFailure && (lintFixCmd || formatFixCmd)) {
|
|
66
76
|
if (lintFixCmd) {
|
|
67
77
|
pipelineEventBus.emit({ type: "autofix:started", storyId: ctx.story.id, command: lintFixCmd });
|
|
68
78
|
const lintResult = await _autofixDeps.runCommand(lintFixCmd, effectiveWorkdir);
|
|
69
|
-
logger.debug("autofix", `lintFix exit=${lintResult.exitCode}`, { storyId: ctx.story.id });
|
|
79
|
+
logger.debug("autofix", `lintFix exit=${lintResult.exitCode}`, { storyId: ctx.story.id, command: lintFixCmd });
|
|
70
80
|
if (lintResult.exitCode !== 0) {
|
|
71
81
|
logger.warn("autofix", "lintFix command failed — may not have fixed all issues", {
|
|
72
82
|
storyId: ctx.story.id,
|
|
@@ -78,7 +88,10 @@ export const autofixStage: PipelineStage = {
|
|
|
78
88
|
if (formatFixCmd) {
|
|
79
89
|
pipelineEventBus.emit({ type: "autofix:started", storyId: ctx.story.id, command: formatFixCmd });
|
|
80
90
|
const fmtResult = await _autofixDeps.runCommand(formatFixCmd, effectiveWorkdir);
|
|
81
|
-
logger.debug("autofix", `formatFix exit=${fmtResult.exitCode}`, {
|
|
91
|
+
logger.debug("autofix", `formatFix exit=${fmtResult.exitCode}`, {
|
|
92
|
+
storyId: ctx.story.id,
|
|
93
|
+
command: formatFixCmd,
|
|
94
|
+
});
|
|
82
95
|
if (fmtResult.exitCode !== 0) {
|
|
83
96
|
logger.warn("autofix", "formatFix command failed — may not have fixed all issues", {
|
|
84
97
|
storyId: ctx.story.id,
|
|
@@ -91,10 +104,13 @@ export const autofixStage: PipelineStage = {
|
|
|
91
104
|
pipelineEventBus.emit({ type: "autofix:completed", storyId: ctx.story.id, fixed: recheckPassed });
|
|
92
105
|
|
|
93
106
|
if (recheckPassed) {
|
|
94
|
-
if (ctx.reviewResult) ctx.reviewResult = { ...ctx.reviewResult, success: true };
|
|
95
107
|
logger.info("autofix", "Mechanical autofix succeeded — retrying review", { storyId: ctx.story.id });
|
|
96
108
|
return { action: "retry", fromStage: "review" };
|
|
97
109
|
}
|
|
110
|
+
|
|
111
|
+
logger.info("autofix", "Mechanical autofix did not resolve all failures — proceeding to agent rectification", {
|
|
112
|
+
storyId: ctx.story.id,
|
|
113
|
+
});
|
|
98
114
|
}
|
|
99
115
|
|
|
100
116
|
// Phase 2: Agent rectification — spawn agent with review error context
|
|
@@ -134,8 +150,11 @@ async function recheckReview(ctx: PipelineContext): Promise<boolean> {
|
|
|
134
150
|
// Import reviewStage lazily to avoid circular deps
|
|
135
151
|
const { reviewStage } = await import("./review");
|
|
136
152
|
if (!reviewStage.enabled(ctx)) return true;
|
|
137
|
-
|
|
138
|
-
|
|
153
|
+
// reviewStage.execute updates ctx.reviewResult in place.
|
|
154
|
+
// We cannot use result.action here because review returns "continue" for BOTH
|
|
155
|
+
// pass and built-in-check-failure (to hand off to autofix). Check success directly.
|
|
156
|
+
await reviewStage.execute(ctx);
|
|
157
|
+
return ctx.reviewResult?.success === true;
|
|
139
158
|
}
|
|
140
159
|
|
|
141
160
|
function collectFailedChecks(ctx: PipelineContext): ReviewCheckResult[] {
|
|
@@ -196,7 +196,7 @@ export const routingStage: PipelineStage = {
|
|
|
196
196
|
|
|
197
197
|
// SD-004: Oversized story detection and decomposition
|
|
198
198
|
const decomposeConfig = ctx.config.decompose;
|
|
199
|
-
if (decomposeConfig) {
|
|
199
|
+
if (decomposeConfig && ctx.story.status !== "decomposed") {
|
|
200
200
|
const acCount = ctx.story.acceptanceCriteria.length;
|
|
201
201
|
const complexity = ctx.routing.complexity;
|
|
202
202
|
const isOversized =
|
package/src/review/runner.ts
CHANGED
|
@@ -99,6 +99,9 @@ const SIGKILL_GRACE_PERIOD_MS = 5_000;
|
|
|
99
99
|
*/
|
|
100
100
|
async function runCheck(check: ReviewCheckName, command: string, workdir: string): Promise<ReviewCheckResult> {
|
|
101
101
|
const startTime = Date.now();
|
|
102
|
+
const logger = getSafeLogger();
|
|
103
|
+
|
|
104
|
+
logger?.info("review", `Running ${check} check`, { check, command, workdir });
|
|
102
105
|
|
|
103
106
|
try {
|
|
104
107
|
// Parse command into executable and args
|
|
@@ -152,6 +155,18 @@ async function runCheck(check: ReviewCheckName, command: string, workdir: string
|
|
|
152
155
|
const stderr = await new Response(proc.stderr).text();
|
|
153
156
|
const output = [stdout, stderr].filter(Boolean).join("\n");
|
|
154
157
|
|
|
158
|
+
if (exitCode !== 0) {
|
|
159
|
+
logger?.warn("review", `${check} check failed`, {
|
|
160
|
+
check,
|
|
161
|
+
command,
|
|
162
|
+
workdir,
|
|
163
|
+
exitCode,
|
|
164
|
+
output: output.slice(0, 2000),
|
|
165
|
+
});
|
|
166
|
+
} else {
|
|
167
|
+
logger?.debug("review", `${check} check passed`, { check, command, durationMs: Date.now() - startTime });
|
|
168
|
+
}
|
|
169
|
+
|
|
155
170
|
return {
|
|
156
171
|
check,
|
|
157
172
|
command,
|