@nathapp/nax 0.24.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.
Files changed (37) hide show
  1. package/docs/ROADMAP.md +33 -15
  2. package/docs/specs/trigger-completion.md +145 -0
  3. package/nax/features/trigger-completion/prd.json +150 -0
  4. package/nax/features/trigger-completion/progress.txt +7 -0
  5. package/nax/status.json +14 -24
  6. package/package.json +1 -1
  7. package/src/config/types.ts +3 -1
  8. package/src/execution/crash-recovery.ts +11 -0
  9. package/src/execution/executor-types.ts +1 -1
  10. package/src/execution/lifecycle/run-setup.ts +4 -0
  11. package/src/execution/sequential-executor.ts +45 -7
  12. package/src/interaction/plugins/auto.ts +10 -1
  13. package/src/pipeline/event-bus.ts +14 -1
  14. package/src/pipeline/stages/completion.ts +20 -0
  15. package/src/pipeline/stages/execution.ts +62 -0
  16. package/src/pipeline/stages/review.ts +25 -1
  17. package/src/pipeline/subscribers/hooks.ts +32 -0
  18. package/src/pipeline/subscribers/interaction.ts +36 -1
  19. package/src/routing/router.ts +3 -2
  20. package/src/routing/strategies/keyword.ts +2 -1
  21. package/src/routing/strategies/llm-prompts.ts +29 -28
  22. package/src/utils/git.ts +21 -0
  23. package/test/integration/routing/plugin-routing-core.test.ts +1 -1
  24. package/test/unit/execution/sequential-executor.test.ts +235 -0
  25. package/test/unit/interaction/auto-plugin.test.ts +162 -0
  26. package/test/unit/interaction-plugins.test.ts +308 -1
  27. package/test/unit/pipeline/stages/completion-review-gate.test.ts +218 -0
  28. package/test/unit/pipeline/stages/execution-ambiguity.test.ts +311 -0
  29. package/test/unit/pipeline/stages/execution-merge-conflict.test.ts +218 -0
  30. package/test/unit/pipeline/stages/review.test.ts +201 -0
  31. package/test/unit/pipeline/subscribers/hooks.test.ts +43 -4
  32. package/test/unit/pipeline/subscribers/interaction.test.ts +284 -2
  33. package/test/unit/prd-auto-default.test.ts +2 -2
  34. package/test/unit/routing/routing-stability.test.ts +1 -1
  35. package/test/unit/routing-core.test.ts +5 -5
  36. package/test/unit/routing-strategies.test.ts +1 -3
  37. package/test/unit/utils/git.test.ts +50 -0
@@ -38,6 +38,14 @@ interface DecisionResponse {
38
38
  reasoning: string;
39
39
  }
40
40
 
41
+ /**
42
+ * Module-level deps for testability (_deps pattern).
43
+ * Override callLlm in tests to avoid spawning the claude CLI.
44
+ */
45
+ export const _deps = {
46
+ callLlm: null as ((request: InteractionRequest) => Promise<DecisionResponse>) | null,
47
+ };
48
+
41
49
  /**
42
50
  * Auto plugin for AI-powered interaction responses
43
51
  */
@@ -80,7 +88,8 @@ export class AutoInteractionPlugin implements InteractionPlugin {
80
88
  }
81
89
 
82
90
  try {
83
- const decision = await this.callLlm(request);
91
+ const callFn = _deps.callLlm ?? this.callLlm.bind(this);
92
+ const decision = await callFn(request);
84
93
 
85
94
  // Check confidence threshold
86
95
  if (decision.confidence < (this.config.confidenceThreshold ?? 0.7)) {
@@ -135,6 +135,17 @@ export interface StoryPausedEvent {
135
135
  cost: number;
136
136
  }
137
137
 
138
+ export interface RunResumedEvent {
139
+ type: "run:resumed";
140
+ feature: string;
141
+ }
142
+
143
+ export interface RunErroredEvent {
144
+ type: "run:errored";
145
+ reason: string;
146
+ feature?: string;
147
+ }
148
+
138
149
  /** Discriminated union of all pipeline events. */
139
150
  export type PipelineEvent =
140
151
  | StoryStartedEvent
@@ -150,7 +161,9 @@ export type PipelineEvent =
150
161
  | HumanReviewRequestedEvent
151
162
  | RunStartedEvent
152
163
  | RunPausedEvent
153
- | StoryPausedEvent;
164
+ | StoryPausedEvent
165
+ | RunResumedEvent
166
+ | RunErroredEvent;
154
167
 
155
168
  export type PipelineEventType = PipelineEvent["type"];
156
169
 
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { appendProgress } from "../../execution/progress";
16
+ import { checkReviewGate, isTriggerEnabled } from "../../interaction/triggers";
16
17
  import { getLogger } from "../../logger";
17
18
  import { collectBatchMetrics, collectStoryMetrics } from "../../metrics";
18
19
  import { countStories, markStoryPassed, savePRD } from "../../prd";
@@ -72,6 +73,18 @@ export const completionStage: PipelineStage = {
72
73
  modelTier: ctx.routing?.modelTier,
73
74
  testStrategy: ctx.routing?.testStrategy,
74
75
  });
76
+
77
+ // review-gate trigger: check if story needs re-review after passing
78
+ if (ctx.interaction && isTriggerEnabled("review-gate", ctx.config)) {
79
+ const shouldContinue = await _completionDeps.checkReviewGate(
80
+ { featureName: ctx.prd.feature, storyId: completedStory.id },
81
+ ctx.config,
82
+ ctx.interaction,
83
+ );
84
+ if (!shouldContinue) {
85
+ logger.warn("completion", "Story marked for re-review", { storyId: completedStory.id });
86
+ }
87
+ }
75
88
  }
76
89
 
77
90
  // Save PRD
@@ -89,3 +102,10 @@ export const completionStage: PipelineStage = {
89
102
  return { action: "continue" };
90
103
  },
91
104
  };
105
+
106
+ /**
107
+ * Swappable dependencies for testing (avoids mock.module() which leaks in Bun 1.x).
108
+ */
109
+ export const _completionDeps = {
110
+ checkReviewGate,
111
+ };
@@ -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
- * - `fail`: Plugin reviewer hard-failed
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
+ };
@@ -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
  };
@@ -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
- * otherwise → three-session-tdd-lite (test-after deprecated from auto mode)
155
+ * simpletest-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
- // Simple/mediumthree-session-tdd-lite (FEAT-013: test-after deprecated from auto mode)
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
- // FEAT-013: test-after deprecated from auto mode — use three-session-tdd-lite as default
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 option that will succeed.
43
- - three-session-tdd ONLY when: (a) security/auth logic, (b) complex algorithms, (c) public API contracts that consumers depend on.
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","testStrategy":"test-after|three-session-tdd","reasoning":"<one line>"}`;
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 option that will succeed.
86
- - three-session-tdd ONLY when: (a) security/auth logic, (b) complex algorithms, (c) public API contracts that consumers depend on.
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","testStrategy":"test-after|three-session-tdd","reasoning":"<one line>"}]`;
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(parsed: Record<string, unknown>, config: NaxConfig): RoutingDecision {
103
- // Validate required fields
104
- if (!parsed.complexity || !parsed.modelTier || !parsed.testStrategy || !parsed.reasoning) {
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: parsed.testStrategy as 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
+ }
@@ -318,7 +318,7 @@ describe("Plugin router fallback to built-in strategy", () => {
318
318
  // Keyword strategy decision (not from plugin)
319
319
  expect(decision.complexity).toBe("simple");
320
320
  expect(decision.modelTier).toBe("fast");
321
- expect(decision.testStrategy).toBe("three-session-tdd-lite");
321
+ expect(decision.testStrategy).toBe("test-after");
322
322
  });
323
323
 
324
324
  test("keyword strategy handles complex story when plugins return null", async () => {