@nathapp/nax 0.49.3 → 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/README.md +2 -0
- package/dist/nax.js +217 -121
- 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/interaction/plugins/webhook.ts +44 -25
- 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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { AgentAdapter } from "../agents";
|
|
10
|
+
import { buildSessionName } from "../agents/acp/adapter";
|
|
10
11
|
import type { ModelTier, NaxConfig } from "../config";
|
|
11
12
|
import { resolveModel } from "../config";
|
|
12
13
|
import { resolvePermissions } from "../config/permissions";
|
|
@@ -14,15 +15,23 @@ import type { getLogger } from "../logger";
|
|
|
14
15
|
import type { UserStory } from "../prd";
|
|
15
16
|
import { autoCommitIfDirty, captureGitRef } from "../utils/git";
|
|
16
17
|
import {
|
|
18
|
+
type parseBunTestOutput as ParseBunTestOutputType,
|
|
17
19
|
type RectificationState,
|
|
18
|
-
executeWithTimeout,
|
|
19
|
-
parseBunTestOutput,
|
|
20
|
-
shouldRetryRectification,
|
|
20
|
+
executeWithTimeout as _executeWithTimeout,
|
|
21
|
+
parseBunTestOutput as _parseBunTestOutput,
|
|
22
|
+
shouldRetryRectification as _shouldRetryRectification,
|
|
21
23
|
} from "../verification";
|
|
22
24
|
import { cleanupProcessTree } from "./cleanup";
|
|
23
25
|
import { verifyImplementerIsolation } from "./isolation";
|
|
24
26
|
import { buildImplementerRectificationPrompt } from "./prompts";
|
|
25
27
|
|
|
28
|
+
/** Injectable deps for testability — avoids mock.module() contamination */
|
|
29
|
+
export const _rectificationGateDeps = {
|
|
30
|
+
executeWithTimeout: _executeWithTimeout,
|
|
31
|
+
parseBunTestOutput: _parseBunTestOutput,
|
|
32
|
+
shouldRetryRectification: _shouldRetryRectification,
|
|
33
|
+
};
|
|
34
|
+
|
|
26
35
|
/**
|
|
27
36
|
* Run full test suite gate before verifier session (v0.11 Rectification).
|
|
28
37
|
*/
|
|
@@ -49,11 +58,13 @@ export async function runFullSuiteGate(
|
|
|
49
58
|
timeout: fullSuiteTimeout,
|
|
50
59
|
});
|
|
51
60
|
|
|
52
|
-
const fullSuiteResult = await executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
|
|
61
|
+
const fullSuiteResult = await _rectificationGateDeps.executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
|
|
62
|
+
cwd: workdir,
|
|
63
|
+
});
|
|
53
64
|
const fullSuitePassed = fullSuiteResult.success && fullSuiteResult.exitCode === 0;
|
|
54
65
|
|
|
55
66
|
if (!fullSuitePassed && fullSuiteResult.output) {
|
|
56
|
-
const testSummary = parseBunTestOutput(fullSuiteResult.output);
|
|
67
|
+
const testSummary = _rectificationGateDeps.parseBunTestOutput(fullSuiteResult.output);
|
|
57
68
|
|
|
58
69
|
if (testSummary.failed > 0) {
|
|
59
70
|
return await runRectificationLoop(
|
|
@@ -117,7 +128,7 @@ async function runRectificationLoop(
|
|
|
117
128
|
contextMarkdown: string | undefined,
|
|
118
129
|
lite: boolean,
|
|
119
130
|
logger: ReturnType<typeof getLogger>,
|
|
120
|
-
testSummary: ReturnType<typeof
|
|
131
|
+
testSummary: ReturnType<typeof _parseBunTestOutput>,
|
|
121
132
|
rectificationConfig: NonNullable<NaxConfig["execution"]["rectification"]>,
|
|
122
133
|
testCmd: string,
|
|
123
134
|
fullSuiteTimeout: number,
|
|
@@ -135,9 +146,19 @@ async function runRectificationLoop(
|
|
|
135
146
|
passedTests: testSummary.passed,
|
|
136
147
|
});
|
|
137
148
|
|
|
138
|
-
|
|
149
|
+
// Build session name once so all rectification attempts share the same ACP session.
|
|
150
|
+
// This preserves full conversation context across retries (the agent knows what it already tried).
|
|
151
|
+
const rectificationSessionName = buildSessionName(workdir, featureName, story.id, "implementer");
|
|
152
|
+
logger.debug("tdd", "Rectification session name (shared across all attempts)", {
|
|
153
|
+
storyId: story.id,
|
|
154
|
+
sessionName: rectificationSessionName,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
while (_rectificationGateDeps.shouldRetryRectification(rectificationState, rectificationConfig)) {
|
|
139
158
|
rectificationState.attempt++;
|
|
140
159
|
|
|
160
|
+
const isLastAttempt = rectificationState.attempt >= rectificationConfig.maxRetries;
|
|
161
|
+
|
|
141
162
|
logger.info(
|
|
142
163
|
"tdd",
|
|
143
164
|
`-> Implementer rectification attempt ${rectificationState.attempt}/${rectificationConfig.maxRetries}`,
|
|
@@ -166,6 +187,12 @@ async function runRectificationLoop(
|
|
|
166
187
|
featureName,
|
|
167
188
|
storyId: story.id,
|
|
168
189
|
sessionRole: "implementer",
|
|
190
|
+
// Reuse the same ACP session across all rectification attempts so the agent
|
|
191
|
+
// retains full conversation context (knows what it already tried).
|
|
192
|
+
acpSessionName: rectificationSessionName,
|
|
193
|
+
// Keep session open until the last attempt — the session sweep at run end
|
|
194
|
+
// will handle cleanup. On the last attempt, let the adapter close normally.
|
|
195
|
+
keepSessionOpen: !isLastAttempt,
|
|
169
196
|
});
|
|
170
197
|
|
|
171
198
|
if (!rectifyResult.success && rectifyResult.pid) {
|
|
@@ -201,7 +228,9 @@ async function runRectificationLoop(
|
|
|
201
228
|
break;
|
|
202
229
|
}
|
|
203
230
|
|
|
204
|
-
const retryFullSuite = await executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
|
|
231
|
+
const retryFullSuite = await _rectificationGateDeps.executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
|
|
232
|
+
cwd: workdir,
|
|
233
|
+
});
|
|
205
234
|
const retrySuitePassed = retryFullSuite.success && retryFullSuite.exitCode === 0;
|
|
206
235
|
|
|
207
236
|
if (retrySuitePassed) {
|
|
@@ -213,7 +242,7 @@ async function runRectificationLoop(
|
|
|
213
242
|
}
|
|
214
243
|
|
|
215
244
|
if (retryFullSuite.output) {
|
|
216
|
-
const newTestSummary = parseBunTestOutput(retryFullSuite.output);
|
|
245
|
+
const newTestSummary = _rectificationGateDeps.parseBunTestOutput(retryFullSuite.output);
|
|
217
246
|
rectificationState.currentFailures = newTestSummary.failed;
|
|
218
247
|
testSummary.failures = newTestSummary.failures;
|
|
219
248
|
testSummary.failed = newTestSummary.failed;
|
|
@@ -227,7 +256,9 @@ async function runRectificationLoop(
|
|
|
227
256
|
});
|
|
228
257
|
}
|
|
229
258
|
|
|
230
|
-
const finalFullSuite = await executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
|
|
259
|
+
const finalFullSuite = await _rectificationGateDeps.executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
|
|
260
|
+
cwd: workdir,
|
|
261
|
+
});
|
|
231
262
|
const finalSuitePassed = finalFullSuite.success && finalFullSuite.exitCode === 0;
|
|
232
263
|
|
|
233
264
|
if (!finalSuitePassed) {
|
|
@@ -12,17 +12,39 @@ import { getLogger } from "../logger";
|
|
|
12
12
|
import type { UserStory } from "../prd";
|
|
13
13
|
import { PromptBuilder } from "../prompts";
|
|
14
14
|
import { autoCommitIfDirty as _autoCommitIfDirtyFn } from "../utils/git";
|
|
15
|
-
import {
|
|
16
|
-
|
|
15
|
+
import { captureGitRef as _captureGitRef } from "../utils/git";
|
|
16
|
+
import { cleanupProcessTree as _cleanupProcessTree } from "./cleanup";
|
|
17
17
|
/**
|
|
18
18
|
* Injectable dependencies for session-runner — allows tests to mock
|
|
19
19
|
* autoCommitIfDirty without going through internal git deps.
|
|
20
20
|
* @internal
|
|
21
21
|
*/
|
|
22
|
+
import {
|
|
23
|
+
getChangedFiles as _getChangedFiles,
|
|
24
|
+
verifyImplementerIsolation as _verifyImplementerIsolation,
|
|
25
|
+
verifyTestWriterIsolation as _verifyTestWriterIsolation,
|
|
26
|
+
} from "./isolation";
|
|
27
|
+
|
|
22
28
|
export const _sessionRunnerDeps = {
|
|
23
29
|
autoCommitIfDirty: _autoCommitIfDirtyFn,
|
|
30
|
+
spawn: Bun.spawn as typeof Bun.spawn,
|
|
31
|
+
getChangedFiles: _getChangedFiles,
|
|
32
|
+
verifyTestWriterIsolation: _verifyTestWriterIsolation,
|
|
33
|
+
verifyImplementerIsolation: _verifyImplementerIsolation,
|
|
34
|
+
captureGitRef: _captureGitRef,
|
|
35
|
+
cleanupProcessTree: _cleanupProcessTree,
|
|
36
|
+
buildPrompt: null as
|
|
37
|
+
| null
|
|
38
|
+
| ((
|
|
39
|
+
role: TddSessionRole,
|
|
40
|
+
config: NaxConfig,
|
|
41
|
+
story: UserStory,
|
|
42
|
+
workdir: string,
|
|
43
|
+
contextMarkdown?: string,
|
|
44
|
+
lite?: boolean,
|
|
45
|
+
constitution?: string,
|
|
46
|
+
) => Promise<string>),
|
|
24
47
|
};
|
|
25
|
-
import { getChangedFiles, verifyImplementerIsolation, verifyTestWriterIsolation } from "./isolation";
|
|
26
48
|
import type { IsolationCheck } from "./types";
|
|
27
49
|
import type { TddSessionResult, TddSessionRole } from "./types";
|
|
28
50
|
|
|
@@ -53,7 +75,7 @@ export async function rollbackToRef(workdir: string, ref: string): Promise<void>
|
|
|
53
75
|
const logger = getLogger();
|
|
54
76
|
logger.warn("tdd", "Rolling back git changes", { ref });
|
|
55
77
|
|
|
56
|
-
const resetProc =
|
|
78
|
+
const resetProc = _sessionRunnerDeps.spawn(["git", "reset", "--hard", ref], {
|
|
57
79
|
cwd: workdir,
|
|
58
80
|
stdout: "pipe",
|
|
59
81
|
stderr: "pipe",
|
|
@@ -66,7 +88,7 @@ export async function rollbackToRef(workdir: string, ref: string): Promise<void>
|
|
|
66
88
|
throw new Error(`Git rollback failed: ${stderr}`);
|
|
67
89
|
}
|
|
68
90
|
|
|
69
|
-
const cleanProc =
|
|
91
|
+
const cleanProc = _sessionRunnerDeps.spawn(["git", "clean", "-fd"], {
|
|
70
92
|
cwd: workdir,
|
|
71
93
|
stdout: "pipe",
|
|
72
94
|
stderr: "pipe",
|
|
@@ -98,41 +120,51 @@ export async function runTddSession(
|
|
|
98
120
|
): Promise<TddSessionResult> {
|
|
99
121
|
const startTime = Date.now();
|
|
100
122
|
|
|
101
|
-
// Build prompt
|
|
123
|
+
// Build prompt — use injectable buildPrompt if set, otherwise default PromptBuilder
|
|
102
124
|
let prompt: string;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
if (_sessionRunnerDeps.buildPrompt) {
|
|
126
|
+
prompt = await _sessionRunnerDeps.buildPrompt(role, config, story, workdir, contextMarkdown, lite, constitution);
|
|
127
|
+
} else {
|
|
128
|
+
switch (role) {
|
|
129
|
+
case "test-writer":
|
|
130
|
+
prompt = await PromptBuilder.for("test-writer", { isolation: lite ? "lite" : "strict" })
|
|
131
|
+
.withLoader(workdir, config)
|
|
132
|
+
.story(story)
|
|
133
|
+
.context(contextMarkdown)
|
|
134
|
+
.constitution(constitution)
|
|
135
|
+
.testCommand(config.quality?.commands?.test)
|
|
136
|
+
.build();
|
|
137
|
+
break;
|
|
138
|
+
case "implementer":
|
|
139
|
+
prompt = await PromptBuilder.for("implementer", { variant: lite ? "lite" : "standard" })
|
|
140
|
+
.withLoader(workdir, config)
|
|
141
|
+
.story(story)
|
|
142
|
+
.context(contextMarkdown)
|
|
143
|
+
.constitution(constitution)
|
|
144
|
+
.testCommand(config.quality?.commands?.test)
|
|
145
|
+
.build();
|
|
146
|
+
break;
|
|
147
|
+
case "verifier":
|
|
148
|
+
prompt = await PromptBuilder.for("verifier")
|
|
149
|
+
.withLoader(workdir, config)
|
|
150
|
+
.story(story)
|
|
151
|
+
.context(contextMarkdown)
|
|
152
|
+
.constitution(constitution)
|
|
153
|
+
.testCommand(config.quality?.commands?.test)
|
|
154
|
+
.build();
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
131
157
|
}
|
|
132
158
|
|
|
133
159
|
const logger = getLogger();
|
|
134
160
|
logger.info("tdd", `-> Session: ${role}`, { role, storyId: story.id, lite });
|
|
135
161
|
|
|
162
|
+
// When rectification is enabled, keep the implementer session open after it finishes.
|
|
163
|
+
// The rectification gate uses the same session name (buildSessionName + "implementer")
|
|
164
|
+
// and will resume it directly — so the implementer retains full context of what it built.
|
|
165
|
+
// The session sweep (or the last rectification attempt) handles final cleanup.
|
|
166
|
+
const keepSessionOpen = role === "implementer" && (config.execution.rectification?.enabled ?? false);
|
|
167
|
+
|
|
136
168
|
// Run the agent
|
|
137
169
|
const result = await agent.run({
|
|
138
170
|
prompt,
|
|
@@ -147,11 +179,12 @@ export async function runTddSession(
|
|
|
147
179
|
featureName,
|
|
148
180
|
storyId: story.id,
|
|
149
181
|
sessionRole: role,
|
|
182
|
+
keepSessionOpen,
|
|
150
183
|
});
|
|
151
184
|
|
|
152
185
|
// BUG-21 Fix: Clean up orphaned child processes if agent failed
|
|
153
186
|
if (!result.success && result.pid) {
|
|
154
|
-
await cleanupProcessTree(result.pid);
|
|
187
|
+
await _sessionRunnerDeps.cleanupProcessTree(result.pid);
|
|
155
188
|
}
|
|
156
189
|
|
|
157
190
|
if (result.success) {
|
|
@@ -178,14 +211,14 @@ export async function runTddSession(
|
|
|
178
211
|
if (!skipIsolation) {
|
|
179
212
|
if (role === "test-writer") {
|
|
180
213
|
const allowedPaths = config.tdd.testWriterAllowedPaths ?? ["src/index.ts", "src/**/index.ts"];
|
|
181
|
-
isolation = await verifyTestWriterIsolation(workdir, beforeRef, allowedPaths);
|
|
214
|
+
isolation = await _sessionRunnerDeps.verifyTestWriterIsolation(workdir, beforeRef, allowedPaths);
|
|
182
215
|
} else if (role === "implementer" || role === "verifier") {
|
|
183
|
-
isolation = await verifyImplementerIsolation(workdir, beforeRef);
|
|
216
|
+
isolation = await _sessionRunnerDeps.verifyImplementerIsolation(workdir, beforeRef);
|
|
184
217
|
}
|
|
185
218
|
}
|
|
186
219
|
|
|
187
220
|
// Get changed files
|
|
188
|
-
const filesChanged = await getChangedFiles(workdir, beforeRef);
|
|
221
|
+
const filesChanged = await _sessionRunnerDeps.getChangedFiles(workdir, beforeRef);
|
|
189
222
|
|
|
190
223
|
const durationMs = Date.now() - startTime;
|
|
191
224
|
|
|
@@ -9,6 +9,9 @@ import type { Subprocess } from "bun";
|
|
|
9
9
|
import { errorMessage } from "../utils/errors";
|
|
10
10
|
import type { TestExecutionResult } from "./types";
|
|
11
11
|
|
|
12
|
+
/** Injectable deps for testability — mock _executorDeps.spawn instead of global Bun.spawn */
|
|
13
|
+
export const _executorDeps = { spawn: Bun.spawn as typeof Bun.spawn };
|
|
14
|
+
|
|
12
15
|
/**
|
|
13
16
|
* Drain stdout+stderr from a killed Bun subprocess with a hard deadline.
|
|
14
17
|
*
|
|
@@ -92,7 +95,7 @@ export async function executeWithTimeout(
|
|
|
92
95
|
const gracePeriodMs = options?.gracePeriodMs ?? 5000;
|
|
93
96
|
const drainTimeoutMs = options?.drainTimeoutMs ?? 2000;
|
|
94
97
|
|
|
95
|
-
const proc =
|
|
98
|
+
const proc = _executorDeps.spawn([shell, "-c", command], {
|
|
96
99
|
stdout: "pipe",
|
|
97
100
|
stderr: "pipe",
|
|
98
101
|
env: env || normalizeEnvironment(process.env as Record<string, string | undefined>),
|
|
@@ -11,6 +11,9 @@ import { getLogger } from "../../logger";
|
|
|
11
11
|
import type { IVerificationStrategy, VerifyContext, VerifyResult } from "../orchestrator-types";
|
|
12
12
|
import { makeFailResult, makePassResult, makeSkippedResult } from "../orchestrator-types";
|
|
13
13
|
|
|
14
|
+
/** Injectable deps for testability */
|
|
15
|
+
export const _acceptanceDeps = { spawn: Bun.spawn as typeof Bun.spawn };
|
|
16
|
+
|
|
14
17
|
function parseFailedACs(output: string): string[] {
|
|
15
18
|
const failed: string[] = [];
|
|
16
19
|
for (const line of output.split("\n")) {
|
|
@@ -51,7 +54,7 @@ export class AcceptanceStrategy implements IVerificationStrategy {
|
|
|
51
54
|
|
|
52
55
|
const start = Date.now();
|
|
53
56
|
const timeoutMs = ctx.timeoutSeconds * 1000;
|
|
54
|
-
const proc =
|
|
57
|
+
const proc = _acceptanceDeps.spawn(["bun", "test", testPath], {
|
|
55
58
|
cwd: ctx.workdir,
|
|
56
59
|
stdout: "pipe",
|
|
57
60
|
stderr: "pipe",
|