@nathapp/nax 0.23.0 → 0.25.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/bin/nax.ts +20 -2
- package/docs/ROADMAP.md +33 -15
- package/docs/specs/trigger-completion.md +145 -0
- package/nax/features/central-run-registry/prd.json +105 -0
- package/nax/features/trigger-completion/prd.json +150 -0
- package/nax/features/trigger-completion/progress.txt +7 -0
- package/nax/status.json +14 -24
- package/package.json +2 -2
- package/src/commands/index.ts +1 -0
- package/src/commands/logs.ts +87 -17
- package/src/commands/runs.ts +220 -0
- package/src/config/types.ts +3 -1
- package/src/execution/crash-recovery.ts +11 -0
- package/src/execution/executor-types.ts +1 -1
- package/src/execution/lifecycle/run-setup.ts +4 -0
- package/src/execution/sequential-executor.ts +49 -7
- package/src/interaction/plugins/auto.ts +10 -1
- package/src/pipeline/event-bus.ts +14 -1
- package/src/pipeline/stages/completion.ts +20 -0
- package/src/pipeline/stages/execution.ts +62 -0
- package/src/pipeline/stages/review.ts +25 -1
- package/src/pipeline/subscribers/events-writer.ts +121 -0
- package/src/pipeline/subscribers/hooks.ts +32 -0
- package/src/pipeline/subscribers/interaction.ts +36 -1
- package/src/pipeline/subscribers/registry.ts +73 -0
- package/src/routing/router.ts +3 -2
- package/src/routing/strategies/keyword.ts +2 -1
- package/src/routing/strategies/llm-prompts.ts +29 -28
- package/src/utils/git.ts +21 -0
- package/test/integration/cli/cli-logs.test.ts +40 -17
- package/test/integration/routing/plugin-routing-core.test.ts +1 -1
- package/test/unit/commands/logs.test.ts +63 -22
- package/test/unit/commands/runs.test.ts +303 -0
- package/test/unit/execution/sequential-executor.test.ts +235 -0
- package/test/unit/interaction/auto-plugin.test.ts +162 -0
- package/test/unit/interaction-plugins.test.ts +308 -1
- package/test/unit/pipeline/stages/completion-review-gate.test.ts +218 -0
- package/test/unit/pipeline/stages/execution-ambiguity.test.ts +311 -0
- package/test/unit/pipeline/stages/execution-merge-conflict.test.ts +218 -0
- package/test/unit/pipeline/stages/review.test.ts +201 -0
- package/test/unit/pipeline/subscribers/events-writer.test.ts +227 -0
- package/test/unit/pipeline/subscribers/hooks.test.ts +43 -4
- package/test/unit/pipeline/subscribers/interaction.test.ts +284 -2
- package/test/unit/pipeline/subscribers/registry.test.ts +149 -0
- package/test/unit/prd-auto-default.test.ts +2 -2
- package/test/unit/routing/routing-stability.test.ts +1 -1
- package/test/unit/routing-core.test.ts +5 -5
- package/test/unit/routing-strategies.test.ts +1 -3
- package/test/unit/utils/git.test.ts +50 -0
|
@@ -32,11 +32,33 @@
|
|
|
32
32
|
|
|
33
33
|
import { getAgent, validateAgentForTier } from "../../agents";
|
|
34
34
|
import { resolveModel } from "../../config";
|
|
35
|
+
import { checkMergeConflict, checkStoryAmbiguity, isTriggerEnabled } from "../../interaction/triggers";
|
|
35
36
|
import { getLogger } from "../../logger";
|
|
36
37
|
import type { FailureCategory } from "../../tdd";
|
|
37
38
|
import { runThreeSessionTdd } from "../../tdd";
|
|
39
|
+
import { detectMergeConflict } from "../../utils/git";
|
|
38
40
|
import type { PipelineContext, PipelineStage, StageResult } from "../types";
|
|
39
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Detect if agent output contains ambiguity signals
|
|
44
|
+
* Checks for keywords that indicate the agent is unsure about the implementation
|
|
45
|
+
*/
|
|
46
|
+
export function isAmbiguousOutput(output: string): boolean {
|
|
47
|
+
if (!output) return false;
|
|
48
|
+
|
|
49
|
+
const ambiguityKeywords = [
|
|
50
|
+
"unclear",
|
|
51
|
+
"ambiguous",
|
|
52
|
+
"need clarification",
|
|
53
|
+
"please clarify",
|
|
54
|
+
"which one",
|
|
55
|
+
"not sure which",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const lowerOutput = output.toLowerCase();
|
|
59
|
+
return ambiguityKeywords.some((keyword) => lowerOutput.includes(keyword));
|
|
60
|
+
}
|
|
61
|
+
|
|
40
62
|
/**
|
|
41
63
|
* Determine the pipeline action for a failed TDD result, based on its failureCategory.
|
|
42
64
|
*
|
|
@@ -172,6 +194,42 @@ export const executionStage: PipelineStage = {
|
|
|
172
194
|
|
|
173
195
|
ctx.agentResult = result;
|
|
174
196
|
|
|
197
|
+
// merge-conflict trigger: detect CONFLICT markers in agent output
|
|
198
|
+
const combinedOutput = (result.output ?? "") + (result.stderr ?? "");
|
|
199
|
+
if (
|
|
200
|
+
_executionDeps.detectMergeConflict(combinedOutput) &&
|
|
201
|
+
ctx.interaction &&
|
|
202
|
+
isTriggerEnabled("merge-conflict", ctx.config)
|
|
203
|
+
) {
|
|
204
|
+
const shouldProceed = await _executionDeps.checkMergeConflict(
|
|
205
|
+
{ featureName: ctx.prd.feature, storyId: ctx.story.id },
|
|
206
|
+
ctx.config,
|
|
207
|
+
ctx.interaction,
|
|
208
|
+
);
|
|
209
|
+
if (!shouldProceed) {
|
|
210
|
+
logger.error("execution", "Merge conflict detected — aborting story", { storyId: ctx.story.id });
|
|
211
|
+
return { action: "fail", reason: "Merge conflict detected" };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// story-ambiguity trigger: detect ambiguity signals in agent output
|
|
216
|
+
if (
|
|
217
|
+
result.success &&
|
|
218
|
+
_executionDeps.isAmbiguousOutput(combinedOutput) &&
|
|
219
|
+
ctx.interaction &&
|
|
220
|
+
isTriggerEnabled("story-ambiguity", ctx.config)
|
|
221
|
+
) {
|
|
222
|
+
const shouldContinue = await _executionDeps.checkStoryAmbiguity(
|
|
223
|
+
{ featureName: ctx.prd.feature, storyId: ctx.story.id, reason: "Agent output suggests ambiguity" },
|
|
224
|
+
ctx.config,
|
|
225
|
+
ctx.interaction,
|
|
226
|
+
);
|
|
227
|
+
if (!shouldContinue) {
|
|
228
|
+
logger.warn("execution", "Story ambiguity detected — escalating story", { storyId: ctx.story.id });
|
|
229
|
+
return { action: "escalate", reason: "Story ambiguity detected — needs clarification" };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
175
233
|
if (!result.success) {
|
|
176
234
|
logger.error("execution", "Agent session failed", {
|
|
177
235
|
exitCode: result.exitCode,
|
|
@@ -199,4 +257,8 @@ export const executionStage: PipelineStage = {
|
|
|
199
257
|
export const _executionDeps = {
|
|
200
258
|
getAgent,
|
|
201
259
|
validateAgentForTier,
|
|
260
|
+
detectMergeConflict,
|
|
261
|
+
checkMergeConflict,
|
|
262
|
+
isAmbiguousOutput,
|
|
263
|
+
checkStoryAmbiguity,
|
|
202
264
|
};
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* @returns
|
|
7
7
|
* - `continue`: Review passed
|
|
8
8
|
* - `escalate`: Built-in check failed (lint/typecheck) — autofix stage handles retry
|
|
9
|
-
* - `
|
|
9
|
+
* - `escalate`: Plugin reviewer failed and security-review trigger responded non-abort
|
|
10
|
+
* - `fail`: Plugin reviewer hard-failed (no trigger, or trigger responded abort)
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
// RE-ARCH: rewrite
|
|
14
|
+
import { checkSecurityReview, isTriggerEnabled } from "../../interaction/triggers";
|
|
13
15
|
import { getLogger } from "../../logger";
|
|
14
16
|
import { reviewOrchestrator } from "../../review/orchestrator";
|
|
15
17
|
import type { PipelineContext, PipelineStage, StageResult } from "../types";
|
|
@@ -29,6 +31,21 @@ export const reviewStage: PipelineStage = {
|
|
|
29
31
|
|
|
30
32
|
if (!result.success) {
|
|
31
33
|
if (result.pluginFailed) {
|
|
34
|
+
// security-review trigger: prompt before permanently failing
|
|
35
|
+
if (ctx.interaction && isTriggerEnabled("security-review", ctx.config)) {
|
|
36
|
+
const shouldContinue = await _reviewDeps.checkSecurityReview(
|
|
37
|
+
{ featureName: ctx.prd.feature, storyId: ctx.story.id },
|
|
38
|
+
ctx.config,
|
|
39
|
+
ctx.interaction,
|
|
40
|
+
);
|
|
41
|
+
if (!shouldContinue) {
|
|
42
|
+
logger.error("review", `Plugin reviewer failed: ${result.failureReason}`, { storyId: ctx.story.id });
|
|
43
|
+
return { action: "fail", reason: `Review failed: ${result.failureReason}` };
|
|
44
|
+
}
|
|
45
|
+
logger.warn("review", "Security-review trigger escalated — retrying story", { storyId: ctx.story.id });
|
|
46
|
+
return { action: "escalate", reason: `Review failed: ${result.failureReason}` };
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
logger.error("review", `Plugin reviewer failed: ${result.failureReason}`, { storyId: ctx.story.id });
|
|
33
50
|
return { action: "fail", reason: `Review failed: ${result.failureReason}` };
|
|
34
51
|
}
|
|
@@ -47,3 +64,10 @@ export const reviewStage: PipelineStage = {
|
|
|
47
64
|
return { action: "continue" };
|
|
48
65
|
},
|
|
49
66
|
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Swappable dependencies for testing (avoids mock.module() which leaks in Bun 1.x).
|
|
70
|
+
*/
|
|
71
|
+
export const _reviewDeps = {
|
|
72
|
+
checkSecurityReview,
|
|
73
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Events Writer Subscriber
|
|
3
|
+
*
|
|
4
|
+
* Appends one JSON line per pipeline lifecycle event to
|
|
5
|
+
* ~/.nax/events/<project>/events.jsonl. Provides a machine-readable
|
|
6
|
+
* signal that nax exited gracefully (run:completed → event=on-complete),
|
|
7
|
+
* fixing watchdog false crash reports.
|
|
8
|
+
*
|
|
9
|
+
* Design:
|
|
10
|
+
* - Best-effort: all writes are wrapped in try/catch; never throws or blocks
|
|
11
|
+
* - Directory is created on first write via mkdir recursive
|
|
12
|
+
* - Returns UnsubscribeFn matching wireHooks/wireReporters pattern
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { basename, join } from "node:path";
|
|
18
|
+
import { getSafeLogger } from "../../logger";
|
|
19
|
+
import type { PipelineEventBus } from "../event-bus";
|
|
20
|
+
import type { UnsubscribeFn } from "./hooks";
|
|
21
|
+
|
|
22
|
+
interface EventLine {
|
|
23
|
+
ts: string;
|
|
24
|
+
event: string;
|
|
25
|
+
runId: string;
|
|
26
|
+
feature: string;
|
|
27
|
+
project: string;
|
|
28
|
+
storyId?: string;
|
|
29
|
+
data?: object;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Wire events file writer to the pipeline event bus.
|
|
34
|
+
*
|
|
35
|
+
* Listens to run:started, story:started, story:completed, story:failed,
|
|
36
|
+
* run:completed, run:paused and appends one JSONL entry per event.
|
|
37
|
+
*
|
|
38
|
+
* @param bus - The pipeline event bus
|
|
39
|
+
* @param feature - Feature name
|
|
40
|
+
* @param runId - Current run ID
|
|
41
|
+
* @param workdir - Working directory (project name derived via basename)
|
|
42
|
+
* @returns Unsubscribe function
|
|
43
|
+
*/
|
|
44
|
+
export function wireEventsWriter(
|
|
45
|
+
bus: PipelineEventBus,
|
|
46
|
+
feature: string,
|
|
47
|
+
runId: string,
|
|
48
|
+
workdir: string,
|
|
49
|
+
): UnsubscribeFn {
|
|
50
|
+
const logger = getSafeLogger();
|
|
51
|
+
const project = basename(workdir);
|
|
52
|
+
const eventsDir = join(homedir(), ".nax", "events", project);
|
|
53
|
+
const eventsFile = join(eventsDir, "events.jsonl");
|
|
54
|
+
let dirReady = false;
|
|
55
|
+
|
|
56
|
+
const write = (line: EventLine): void => {
|
|
57
|
+
(async () => {
|
|
58
|
+
try {
|
|
59
|
+
if (!dirReady) {
|
|
60
|
+
await mkdir(eventsDir, { recursive: true });
|
|
61
|
+
dirReady = true;
|
|
62
|
+
}
|
|
63
|
+
await appendFile(eventsFile, `${JSON.stringify(line)}\n`);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
logger?.warn("events-writer", "Failed to write event line (non-fatal)", {
|
|
66
|
+
event: line.event,
|
|
67
|
+
error: String(err),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const unsubs: UnsubscribeFn[] = [];
|
|
74
|
+
|
|
75
|
+
unsubs.push(
|
|
76
|
+
bus.on("run:started", (_ev) => {
|
|
77
|
+
write({ ts: new Date().toISOString(), event: "run:started", runId, feature, project });
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
unsubs.push(
|
|
82
|
+
bus.on("story:started", (ev) => {
|
|
83
|
+
write({ ts: new Date().toISOString(), event: "story:started", runId, feature, project, storyId: ev.storyId });
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
unsubs.push(
|
|
88
|
+
bus.on("story:completed", (ev) => {
|
|
89
|
+
write({ ts: new Date().toISOString(), event: "story:completed", runId, feature, project, storyId: ev.storyId });
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
unsubs.push(
|
|
94
|
+
bus.on("story:failed", (ev) => {
|
|
95
|
+
write({ ts: new Date().toISOString(), event: "story:failed", runId, feature, project, storyId: ev.storyId });
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
unsubs.push(
|
|
100
|
+
bus.on("run:completed", (_ev) => {
|
|
101
|
+
write({ ts: new Date().toISOString(), event: "on-complete", runId, feature, project });
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
unsubs.push(
|
|
106
|
+
bus.on("run:paused", (ev) => {
|
|
107
|
+
write({
|
|
108
|
+
ts: new Date().toISOString(),
|
|
109
|
+
event: "run:paused",
|
|
110
|
+
runId,
|
|
111
|
+
feature,
|
|
112
|
+
project,
|
|
113
|
+
...(ev.storyId !== undefined && { storyId: ev.storyId }),
|
|
114
|
+
});
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return () => {
|
|
119
|
+
for (const u of unsubs) u();
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -127,6 +127,38 @@ export function wireHooks(
|
|
|
127
127
|
}),
|
|
128
128
|
);
|
|
129
129
|
|
|
130
|
+
// run:resumed → on-resume
|
|
131
|
+
unsubs.push(
|
|
132
|
+
bus.on("run:resumed", (ev) => {
|
|
133
|
+
safe("on-resume", () => fireHook(hooks, "on-resume", hookCtx(feature, { status: "running" }), workdir));
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// story:completed → on-session-end (passed)
|
|
138
|
+
unsubs.push(
|
|
139
|
+
bus.on("story:completed", (ev) => {
|
|
140
|
+
safe("on-session-end (completed)", () =>
|
|
141
|
+
fireHook(hooks, "on-session-end", hookCtx(feature, { storyId: ev.storyId, status: "passed" }), workdir),
|
|
142
|
+
);
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// story:failed → on-session-end (failed)
|
|
147
|
+
unsubs.push(
|
|
148
|
+
bus.on("story:failed", (ev) => {
|
|
149
|
+
safe("on-session-end (failed)", () =>
|
|
150
|
+
fireHook(hooks, "on-session-end", hookCtx(feature, { storyId: ev.storyId, status: "failed" }), workdir),
|
|
151
|
+
);
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// run:errored → on-error
|
|
156
|
+
unsubs.push(
|
|
157
|
+
bus.on("run:errored", (ev) => {
|
|
158
|
+
safe("on-error", () => fireHook(hooks, "on-error", hookCtx(feature, { reason: ev.reason }), workdir));
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
|
|
130
162
|
return () => {
|
|
131
163
|
for (const u of unsubs) u();
|
|
132
164
|
};
|
|
@@ -19,7 +19,7 @@ import type { NaxConfig } from "../../config";
|
|
|
19
19
|
import type { InteractionChain } from "../../interaction/chain";
|
|
20
20
|
import { executeTrigger, isTriggerEnabled } from "../../interaction/triggers";
|
|
21
21
|
import { getSafeLogger } from "../../logger";
|
|
22
|
-
import type { PipelineEventBus } from "../event-bus";
|
|
22
|
+
import type { PipelineEventBus, StoryFailedEvent } from "../event-bus";
|
|
23
23
|
import type { UnsubscribeFn } from "./hooks";
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -62,6 +62,41 @@ export function wireInteraction(
|
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// story:failed (countsTowardEscalation=true) → executeTrigger("max-retries")
|
|
66
|
+
if (interactionChain && isTriggerEnabled("max-retries", config)) {
|
|
67
|
+
unsubs.push(
|
|
68
|
+
bus.on("story:failed", (ev: StoryFailedEvent) => {
|
|
69
|
+
if (!ev.countsTowardEscalation) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
executeTrigger(
|
|
74
|
+
"max-retries",
|
|
75
|
+
{
|
|
76
|
+
featureName: ev.feature ?? "",
|
|
77
|
+
storyId: ev.storyId,
|
|
78
|
+
iteration: ev.attempts ?? 0,
|
|
79
|
+
},
|
|
80
|
+
config,
|
|
81
|
+
interactionChain,
|
|
82
|
+
)
|
|
83
|
+
.then((response) => {
|
|
84
|
+
if (response.action === "abort") {
|
|
85
|
+
logger?.warn("interaction-subscriber", "max-retries abort requested", {
|
|
86
|
+
storyId: ev.storyId,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
.catch((err) => {
|
|
91
|
+
logger?.warn("interaction-subscriber", "max-retries trigger failed", {
|
|
92
|
+
storyId: ev.storyId,
|
|
93
|
+
error: String(err),
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
65
100
|
return () => {
|
|
66
101
|
for (const u of unsubs) u();
|
|
67
102
|
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry Writer Subscriber
|
|
3
|
+
*
|
|
4
|
+
* Creates ~/.nax/runs/<project>-<feature>-<runId>/meta.json on run:started.
|
|
5
|
+
* Provides a persistent record of each run with paths for status and events.
|
|
6
|
+
*
|
|
7
|
+
* Design:
|
|
8
|
+
* - Best-effort: all writes wrapped in try/catch; never throws or blocks
|
|
9
|
+
* - Directory created on first write via mkdir recursive
|
|
10
|
+
* - Written once on run:started, never updated
|
|
11
|
+
* - Returns UnsubscribeFn matching wireHooks/wireEventsWriter pattern
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { basename, join } from "node:path";
|
|
17
|
+
import { getSafeLogger } from "../../logger";
|
|
18
|
+
import type { PipelineEventBus } from "../event-bus";
|
|
19
|
+
import type { UnsubscribeFn } from "./hooks";
|
|
20
|
+
|
|
21
|
+
export interface MetaJson {
|
|
22
|
+
runId: string;
|
|
23
|
+
project: string;
|
|
24
|
+
feature: string;
|
|
25
|
+
workdir: string;
|
|
26
|
+
statusPath: string;
|
|
27
|
+
eventsDir: string;
|
|
28
|
+
registeredAt: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Wire registry writer to the pipeline event bus.
|
|
33
|
+
*
|
|
34
|
+
* Listens to run:started and writes meta.json to
|
|
35
|
+
* ~/.nax/runs/<project>-<feature>-<runId>/meta.json.
|
|
36
|
+
*
|
|
37
|
+
* @param bus - The pipeline event bus
|
|
38
|
+
* @param feature - Feature name
|
|
39
|
+
* @param runId - Current run ID
|
|
40
|
+
* @param workdir - Working directory (project name derived via basename)
|
|
41
|
+
* @returns Unsubscribe function
|
|
42
|
+
*/
|
|
43
|
+
export function wireRegistry(bus: PipelineEventBus, feature: string, runId: string, workdir: string): UnsubscribeFn {
|
|
44
|
+
const logger = getSafeLogger();
|
|
45
|
+
const project = basename(workdir);
|
|
46
|
+
const runDir = join(homedir(), ".nax", "runs", `${project}-${feature}-${runId}`);
|
|
47
|
+
const metaFile = join(runDir, "meta.json");
|
|
48
|
+
|
|
49
|
+
const unsub = bus.on("run:started", (_ev) => {
|
|
50
|
+
(async () => {
|
|
51
|
+
try {
|
|
52
|
+
await mkdir(runDir, { recursive: true });
|
|
53
|
+
const meta: MetaJson = {
|
|
54
|
+
runId,
|
|
55
|
+
project,
|
|
56
|
+
feature,
|
|
57
|
+
workdir,
|
|
58
|
+
statusPath: join(workdir, "nax", "features", feature, "status.json"),
|
|
59
|
+
eventsDir: join(workdir, "nax", "features", feature, "runs"),
|
|
60
|
+
registeredAt: new Date().toISOString(),
|
|
61
|
+
};
|
|
62
|
+
await writeFile(metaFile, JSON.stringify(meta, null, 2));
|
|
63
|
+
} catch (err) {
|
|
64
|
+
logger?.warn("registry-writer", "Failed to write meta.json (non-fatal)", {
|
|
65
|
+
path: metaFile,
|
|
66
|
+
error: String(err),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return unsub;
|
|
73
|
+
}
|
package/src/routing/router.ts
CHANGED
|
@@ -152,7 +152,7 @@ const LITE_TAGS = ["ui", "layout", "cli", "integration", "polyglot"];
|
|
|
152
152
|
* - 'auto' → existing heuristic logic, plus:
|
|
153
153
|
* if tags include ui/layout/cli/integration/polyglot → three-session-tdd-lite
|
|
154
154
|
* if security/public-api/complex/expert → three-session-tdd
|
|
155
|
-
*
|
|
155
|
+
* simple → test-after, medium → three-session-tdd-lite (BUG-045)
|
|
156
156
|
*
|
|
157
157
|
* @param complexity - Pre-classified complexity level
|
|
158
158
|
* @param title - Story title
|
|
@@ -201,7 +201,8 @@ export function determineTestStrategy(
|
|
|
201
201
|
return hasLiteTag ? "three-session-tdd-lite" : "three-session-tdd";
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
//
|
|
204
|
+
// BUG-045: simple → test-after (low overhead), medium → tdd-lite (sweet spot)
|
|
205
|
+
if (complexity === "simple") return "test-after";
|
|
205
206
|
return "three-session-tdd-lite";
|
|
206
207
|
}
|
|
207
208
|
|
|
@@ -117,7 +117,8 @@ function determineTestStrategy(
|
|
|
117
117
|
return "three-session-tdd";
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
//
|
|
120
|
+
// BUG-045: simple → test-after (low overhead), medium → tdd-lite (sweet spot)
|
|
121
|
+
if (complexity === "simple") return "test-after";
|
|
121
122
|
return "three-session-tdd-lite";
|
|
122
123
|
}
|
|
123
124
|
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* for LLM-based routing decisions.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { Complexity, ModelTier, NaxConfig, TestStrategy } from "../../config";
|
|
8
|
+
import type { Complexity, ModelTier, NaxConfig, TddStrategy, TestStrategy } from "../../config";
|
|
9
9
|
import type { UserStory } from "../../prd/types";
|
|
10
|
+
import { determineTestStrategy } from "../router";
|
|
10
11
|
import type { RoutingDecision } from "../strategy";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -34,18 +35,13 @@ Tags: ${tags.join(", ")}
|
|
|
34
35
|
- balanced: Standard features, moderate logic, straightforward tests. 30-90 min.
|
|
35
36
|
- powerful: Complex architecture, security-critical, multi-file refactors, novel algorithms. >90 min.
|
|
36
37
|
|
|
37
|
-
## Available Test Strategies
|
|
38
|
-
- test-after: Write implementation first, add tests after. For straightforward work.
|
|
39
|
-
- three-session-tdd: Separate test-writer → implementer → verifier sessions. For complex/critical work where test design matters.
|
|
40
|
-
|
|
41
38
|
## Rules
|
|
42
|
-
- Default to the CHEAPEST
|
|
43
|
-
-
|
|
44
|
-
- Simple barrel exports, re-exports, or index files are ALWAYS test-after + fast, regardless of keywords.
|
|
39
|
+
- Default to the CHEAPEST tier that will succeed.
|
|
40
|
+
- Simple barrel exports, re-exports, or index files are ALWAYS simple + fast.
|
|
45
41
|
- A story touching many files doesn't automatically mean complex — copy-paste refactors are simple.
|
|
46
42
|
|
|
47
43
|
Respond with ONLY this JSON (no markdown, no explanation):
|
|
48
|
-
{"complexity":"simple|medium|complex|expert","modelTier":"fast|balanced|powerful","
|
|
44
|
+
{"complexity":"simple|medium|complex|expert","modelTier":"fast|balanced|powerful","reasoning":"<one line>"}`;
|
|
49
45
|
}
|
|
50
46
|
|
|
51
47
|
/**
|
|
@@ -77,18 +73,13 @@ ${storyBlocks}
|
|
|
77
73
|
- balanced: Standard features, moderate logic, straightforward tests. 30-90 min.
|
|
78
74
|
- powerful: Complex architecture, security-critical, multi-file refactors, novel algorithms. >90 min.
|
|
79
75
|
|
|
80
|
-
## Available Test Strategies
|
|
81
|
-
- test-after: Write implementation first, add tests after. For straightforward work.
|
|
82
|
-
- three-session-tdd: Separate test-writer → implementer → verifier sessions. For complex/critical work where test design matters.
|
|
83
|
-
|
|
84
76
|
## Rules
|
|
85
|
-
- Default to the CHEAPEST
|
|
86
|
-
-
|
|
87
|
-
- Simple barrel exports, re-exports, or index files are ALWAYS test-after + fast, regardless of keywords.
|
|
77
|
+
- Default to the CHEAPEST tier that will succeed.
|
|
78
|
+
- Simple barrel exports, re-exports, or index files are ALWAYS simple + fast.
|
|
88
79
|
- A story touching many files doesn't automatically mean complex — copy-paste refactors are simple.
|
|
89
80
|
|
|
90
81
|
Respond with ONLY a JSON array (no markdown, no explanation):
|
|
91
|
-
[{"id":"US-001","complexity":"simple|medium|complex|expert","modelTier":"fast|balanced|powerful","
|
|
82
|
+
[{"id":"US-001","complexity":"simple|medium|complex|expert","modelTier":"fast|balanced|powerful","reasoning":"<one line>"}]`;
|
|
92
83
|
}
|
|
93
84
|
|
|
94
85
|
/**
|
|
@@ -99,33 +90,43 @@ Respond with ONLY a JSON array (no markdown, no explanation):
|
|
|
99
90
|
* @returns Validated routing decision
|
|
100
91
|
* @throws Error if validation fails
|
|
101
92
|
*/
|
|
102
|
-
export function validateRoutingDecision(
|
|
103
|
-
|
|
104
|
-
|
|
93
|
+
export function validateRoutingDecision(
|
|
94
|
+
parsed: Record<string, unknown>,
|
|
95
|
+
config: NaxConfig,
|
|
96
|
+
story?: UserStory,
|
|
97
|
+
): RoutingDecision {
|
|
98
|
+
// Validate required fields (testStrategy no longer required from LLM — derived via BUG-045)
|
|
99
|
+
if (!parsed.complexity || !parsed.modelTier || !parsed.reasoning) {
|
|
105
100
|
throw new Error(`Missing required fields in LLM response: ${JSON.stringify(parsed)}`);
|
|
106
101
|
}
|
|
107
102
|
|
|
108
103
|
// Validate field values
|
|
109
104
|
const validComplexities: Complexity[] = ["simple", "medium", "complex", "expert"];
|
|
110
|
-
const validTestStrategies: TestStrategy[] = ["test-after", "three-session-tdd"];
|
|
111
105
|
|
|
112
106
|
if (!validComplexities.includes(parsed.complexity as Complexity)) {
|
|
113
107
|
throw new Error(`Invalid complexity: ${parsed.complexity}`);
|
|
114
108
|
}
|
|
115
109
|
|
|
116
|
-
if (!validTestStrategies.includes(parsed.testStrategy as TestStrategy)) {
|
|
117
|
-
throw new Error(`Invalid testStrategy: ${parsed.testStrategy}`);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
110
|
// Validate modelTier exists in config
|
|
121
111
|
if (!config.models[parsed.modelTier as string]) {
|
|
122
112
|
throw new Error(`Invalid modelTier: ${parsed.modelTier} (not in config.models)`);
|
|
123
113
|
}
|
|
124
114
|
|
|
115
|
+
// BUG-045: Derive testStrategy from determineTestStrategy() — single source of truth.
|
|
116
|
+
// LLM decides complexity; testStrategy is a policy decision, not a judgment call.
|
|
117
|
+
const tddStrategy: TddStrategy = config.tdd?.strategy ?? "auto";
|
|
118
|
+
const testStrategy = determineTestStrategy(
|
|
119
|
+
parsed.complexity as Complexity,
|
|
120
|
+
story?.title ?? "",
|
|
121
|
+
story?.description ?? "",
|
|
122
|
+
story?.tags ?? [],
|
|
123
|
+
tddStrategy,
|
|
124
|
+
);
|
|
125
|
+
|
|
125
126
|
return {
|
|
126
127
|
complexity: parsed.complexity as Complexity,
|
|
127
128
|
modelTier: parsed.modelTier as ModelTier,
|
|
128
|
-
testStrategy
|
|
129
|
+
testStrategy,
|
|
129
130
|
reasoning: parsed.reasoning as string,
|
|
130
131
|
};
|
|
131
132
|
}
|
|
@@ -155,7 +156,7 @@ export function stripCodeFences(text: string): string {
|
|
|
155
156
|
export function parseRoutingResponse(output: string, story: UserStory, config: NaxConfig): RoutingDecision {
|
|
156
157
|
const jsonText = stripCodeFences(output);
|
|
157
158
|
const parsed = JSON.parse(jsonText);
|
|
158
|
-
return validateRoutingDecision(parsed, config);
|
|
159
|
+
return validateRoutingDecision(parsed, config, story);
|
|
159
160
|
}
|
|
160
161
|
|
|
161
162
|
/**
|
|
@@ -201,7 +202,7 @@ export function parseBatchResponse(
|
|
|
201
202
|
}
|
|
202
203
|
|
|
203
204
|
// Validate entry directly (no re-serialization needed)
|
|
204
|
-
const decision = validateRoutingDecision(entry, config);
|
|
205
|
+
const decision = validateRoutingDecision(entry, config, story);
|
|
205
206
|
decisions.set(entry.id, decision);
|
|
206
207
|
}
|
|
207
208
|
|
package/src/utils/git.ts
CHANGED
|
@@ -105,3 +105,24 @@ export async function hasCommitsForStory(workdir: string, storyId: string, maxCo
|
|
|
105
105
|
return false;
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Detect if git operation output contains merge conflict markers.
|
|
111
|
+
*
|
|
112
|
+
* Git outputs "CONFLICT" in uppercase for merge/rebase conflicts.
|
|
113
|
+
* Also checks lowercase "conflict" for edge cases.
|
|
114
|
+
*
|
|
115
|
+
* @param output - Combined stdout/stderr output from a git operation
|
|
116
|
+
* @returns true if output contains CONFLICT markers
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```typescript
|
|
120
|
+
* const hasConflict = detectMergeConflict(agentOutput);
|
|
121
|
+
* if (hasConflict) {
|
|
122
|
+
* // fire merge-conflict trigger
|
|
123
|
+
* }
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
export function detectMergeConflict(output: string): boolean {
|
|
127
|
+
return output.includes("CONFLICT") || output.includes("conflict");
|
|
128
|
+
}
|