@nathapp/nax 0.20.0 → 0.22.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 (233) hide show
  1. package/.claude/settings.json +15 -0
  2. package/.mcp.json +8 -0
  3. package/docs/20260304-review-nax.md +492 -0
  4. package/docs/ROADMAP.md +65 -18
  5. package/docs/adr/ADR-005-implementation-plan.md +655 -0
  6. package/docs/adr/ADR-005-pipeline-re-architecture.md +464 -0
  7. package/docs/specs/bug-039-orphan-processes.md +131 -0
  8. package/docs/specs/bug-040-review-rectification.md +82 -0
  9. package/docs/specs/bug-041-cross-story-test-isolation.md +88 -0
  10. package/docs/specs/bug-042-verifier-failure-capture.md +117 -0
  11. package/docs/specs/feat-010-smart-runner-git-history.md +96 -0
  12. package/docs/specs/feat-011-file-context-strategy.md +73 -0
  13. package/docs/specs/feat-012-tdd-writer-tier.md +79 -0
  14. package/docs/specs/feat-013-test-after-review.md +89 -0
  15. package/docs/specs/feat-014-heartbeat-observability.md +127 -0
  16. package/memory/topic/feat-010-baseref.md +28 -0
  17. package/memory/topic/feat-013-test-after-deprecation.md +22 -0
  18. package/nax/config.json +7 -4
  19. package/nax/features/bug-039-medium/prd.json +45 -0
  20. package/package.json +2 -2
  21. package/src/agents/claude.ts +109 -15
  22. package/src/config/types.ts +11 -0
  23. package/src/context/builder.ts +9 -1
  24. package/src/execution/dry-run.ts +81 -0
  25. package/src/execution/escalation/tier-outcome.ts +29 -44
  26. package/src/execution/executor-types.ts +65 -0
  27. package/src/execution/index.ts +0 -17
  28. package/src/execution/iteration-runner.ts +132 -0
  29. package/src/execution/lifecycle/index.ts +0 -1
  30. package/src/execution/lifecycle/run-regression.ts +5 -5
  31. package/src/execution/pipeline-result-handler.ts +51 -254
  32. package/src/execution/sequential-executor.ts +72 -315
  33. package/src/execution/story-selector.ts +75 -0
  34. package/src/pipeline/event-bus.ts +276 -0
  35. package/src/pipeline/runner.ts +51 -77
  36. package/src/pipeline/stages/autofix.ts +133 -0
  37. package/src/pipeline/stages/completion.ts +22 -30
  38. package/src/pipeline/stages/index.ts +30 -13
  39. package/src/pipeline/stages/rectify.ts +93 -0
  40. package/src/pipeline/stages/regression.ts +88 -0
  41. package/src/pipeline/stages/review.ts +19 -153
  42. package/src/pipeline/stages/verify.ts +19 -3
  43. package/src/pipeline/subscribers/hooks.ts +133 -0
  44. package/src/pipeline/subscribers/interaction.ts +68 -0
  45. package/src/pipeline/subscribers/reporters.ts +174 -0
  46. package/src/pipeline/types.ts +12 -1
  47. package/src/review/orchestrator.ts +105 -0
  48. package/src/review/runner.ts +39 -4
  49. package/src/routing/router.ts +3 -3
  50. package/src/routing/strategies/keyword.ts +5 -2
  51. package/src/routing/strategies/llm.ts +27 -1
  52. package/src/tdd/prompts.ts +1 -1
  53. package/src/utils/git.ts +49 -25
  54. package/src/verification/executor.ts +8 -2
  55. package/src/verification/index.ts +1 -1
  56. package/src/verification/orchestrator-types.ts +145 -0
  57. package/src/verification/orchestrator.ts +76 -0
  58. package/src/{execution/post-verify-rectification.ts → verification/rectification-loop.ts} +13 -20
  59. package/src/verification/{gate.ts → runners.ts} +17 -105
  60. package/src/verification/smart-runner.ts +6 -10
  61. package/src/verification/strategies/acceptance.ts +133 -0
  62. package/src/verification/strategies/regression.ts +90 -0
  63. package/src/verification/strategies/scoped.ts +123 -0
  64. package/test/COVERAGE-GAPS.md +333 -0
  65. package/test/{acceptance → e2e}/cm-003-default-view.test.ts +1 -0
  66. package/test/{integration/e2e.test.ts → e2e/plan-analyze-run.test.ts} +1 -0
  67. package/test/integration/{agent-validation.test.ts → cli/agent-validation.test.ts} +3 -3
  68. package/test/integration/{cli-config-default-edge-cases.test.ts → cli/cli-config-default-edge-cases.test.ts} +6 -5
  69. package/test/integration/{cli-config-default-view.test.ts → cli/cli-config-default-view.test.ts} +8 -7
  70. package/test/integration/{cli-config-diff.test.ts → cli/cli-config-diff.test.ts} +3 -2
  71. package/test/integration/{cli-config.test.ts → cli/cli-config.test.ts} +3 -2
  72. package/test/integration/{cli-diagnose.test.ts → cli/cli-diagnose.test.ts} +5 -4
  73. package/test/integration/{cli-logs.test.ts → cli/cli-logs.test.ts} +12 -3
  74. package/test/integration/{cli-plugins.test.ts → cli/cli-plugins.test.ts} +4 -3
  75. package/test/integration/{cli-precheck.test.ts → cli/cli-precheck.test.ts} +4 -3
  76. package/test/integration/{cli-run-headless.test.ts → cli/cli-run-headless.test.ts} +3 -2
  77. package/test/integration/{cli.test.ts → cli/cli.test.ts} +2 -1
  78. package/test/integration/{precheck-integration.test.ts → cli/precheck-integration.test.ts} +10 -9
  79. package/test/integration/{precheck-orchestrator.test.ts → cli/precheck-orchestrator.test.ts} +4 -3
  80. package/test/integration/{precheck.test.ts → cli/precheck.test.ts} +5 -4
  81. package/test/integration/{config-loader.test.ts → config/config-loader.test.ts} +2 -1
  82. package/test/integration/{config.test.ts → config/config.test.ts} +2 -2
  83. package/test/integration/config/merger.test.ts +1 -0
  84. package/test/integration/config/paths.test.ts +1 -0
  85. package/test/integration/{security-loader.test.ts → config/security-loader.test.ts} +2 -2
  86. package/test/integration/{context-integration.test.ts → context/context-integration.test.ts} +7 -6
  87. package/test/integration/{path-security.test.ts → context/context-path-security.test.ts} +2 -2
  88. package/test/integration/{context-provider-injection.test.ts → context/context-provider-injection.test.ts} +7 -6
  89. package/test/integration/{context-verification-integration.test.ts → context/context-verification-integration.test.ts} +5 -4
  90. package/test/integration/{s5-greenfield-fallback.test.ts → context/s5-greenfield-fallback.test.ts} +4 -3
  91. package/test/integration/{isolation.test.ts → execution/execution-isolation.test.ts} +1 -1
  92. package/test/integration/{execution.test.ts → execution/execution.test.ts} +8 -8
  93. package/test/integration/{parallel.test.ts → execution/parallel.test.ts} +2 -1
  94. package/test/integration/{prd-pause.test.ts → execution/prd-pause.test.ts} +2 -2
  95. package/test/integration/{prd-resolvers.test.ts → execution/prd-resolvers.test.ts} +3 -2
  96. package/test/integration/{progress.test.ts → execution/progress.test.ts} +1 -1
  97. package/test/integration/execution/runner-batching.test.ts +682 -0
  98. package/test/integration/{runner-config-plugins.test.ts → execution/runner-config-plugins.test.ts} +3 -2
  99. package/test/integration/execution/runner-escalation.test.ts +561 -0
  100. package/test/integration/{runner-fixes.test.ts → execution/runner-fixes.test.ts} +4 -3
  101. package/test/integration/{runner-plugin-integration.test.ts → execution/runner-plugin-integration.test.ts} +6 -5
  102. package/test/integration/execution/runner-queue-and-attempts.test.ts +476 -0
  103. package/test/integration/{status-file-integration.test.ts → execution/status-file-integration.test.ts} +9 -8
  104. package/test/integration/{status-file.test.ts → execution/status-file.test.ts} +3 -2
  105. package/test/integration/{status-writer.test.ts → execution/status-writer.test.ts} +5 -4
  106. package/test/integration/{story-id-in-events.test.ts → execution/story-id-in-events.test.ts} +9 -8
  107. package/test/integration/{interaction-chain-pipeline.test.ts → interaction/interaction-chain-pipeline.test.ts} +26 -14
  108. package/test/integration/{hooks.test.ts → pipeline/hooks.test.ts} +4 -2
  109. package/test/integration/{pipeline-acceptance.test.ts → pipeline/pipeline-acceptance.test.ts} +7 -6
  110. package/test/integration/{pipeline-events.test.ts → pipeline/pipeline-events.test.ts} +7 -6
  111. package/test/integration/{pipeline.test.ts → pipeline/pipeline.test.ts} +9 -7
  112. package/test/integration/{reporter-lifecycle.test.ts → pipeline/reporter-lifecycle.test.ts} +9 -7
  113. package/test/integration/{verify-stage.test.ts → pipeline/verify-stage.test.ts} +7 -5
  114. package/test/integration/{analyze-integration.test.ts → plan/analyze-integration.test.ts} +3 -2
  115. package/test/integration/{analyze-scanner.test.ts → plan/analyze-scanner.test.ts} +8 -7
  116. package/test/integration/{logger.test.ts → plan/logger.test.ts} +1 -1
  117. package/test/integration/{plan.test.ts → plan/plan.test.ts} +3 -3
  118. package/test/integration/plugins/config-integration.test.ts +1 -0
  119. package/test/integration/plugins/config-resolution.test.ts +1 -0
  120. package/test/integration/plugins/loader.test.ts +1 -0
  121. package/test/integration/plugins/{registry.test.ts → plugins-registry.test.ts} +1 -0
  122. package/test/integration/plugins/validator.test.ts +1 -0
  123. package/test/integration/{review-config-commands.test.ts → review/review-config-commands.test.ts} +4 -3
  124. package/test/integration/{review-config-schema.test.ts → review/review-config-schema.test.ts} +3 -2
  125. package/test/integration/{review-plugin-integration.test.ts → review/review-plugin-integration.test.ts} +5 -4
  126. package/test/integration/{review.test.ts → review/review.test.ts} +3 -2
  127. package/test/integration/routing/plugin-routing-advanced.test.ts +461 -0
  128. package/test/integration/{plugin-routing.test.ts → routing/plugin-routing-core.test.ts} +10 -404
  129. package/test/integration/{routing-stage-bug-021.test.ts → routing/routing-stage-bug-021.test.ts} +8 -7
  130. package/test/integration/{routing-stage-greenfield.test.ts → routing/routing-stage-greenfield.test.ts} +7 -6
  131. package/test/integration/{tdd-cleanup.test.ts → tdd/tdd-cleanup.test.ts} +1 -1
  132. package/test/integration/tdd/tdd-orchestrator-core.test.ts +565 -0
  133. package/test/integration/tdd/tdd-orchestrator-failureCategory.test.ts +355 -0
  134. package/test/integration/tdd/tdd-orchestrator-fallback.test.ts +311 -0
  135. package/test/integration/tdd/tdd-orchestrator-lite.test.ts +289 -0
  136. package/test/integration/tdd/tdd-orchestrator-prompts.test.ts +260 -0
  137. package/test/integration/tdd/tdd-orchestrator-verdict.test.ts +536 -0
  138. package/test/integration/tmp/headless-test/test.jsonl +30 -0
  139. package/test/integration/{test-scanner.test.ts → verification/test-scanner.test.ts} +1 -1
  140. package/test/integration/{verification-asset-check.test.ts → verification/verification-asset-check.test.ts} +3 -2
  141. package/test/unit/acceptance.test.ts +1 -0
  142. package/test/unit/agent-stderr-capture.test.ts +1 -0
  143. package/test/unit/agents/claude.test.ts +107 -0
  144. package/test/unit/analyze-classifier.test.ts +1 -0
  145. package/test/unit/auto-detect.test.ts +1 -0
  146. package/test/unit/cli-status.test.ts +1 -0
  147. package/test/unit/commands/common.test.ts +1 -0
  148. package/test/unit/commands/logs.test.ts +1 -0
  149. package/test/unit/commands/unlock.test.ts +1 -0
  150. package/test/unit/config/defaults.test.ts +1 -0
  151. package/test/unit/config/regression-gate-schema.test.ts +1 -0
  152. package/test/unit/config/smart-runner-flag.test.ts +1 -0
  153. package/test/unit/constitution-generators.test.ts +1 -0
  154. package/test/unit/constitution.test.ts +1 -0
  155. package/test/unit/context/context-autodetect.test.ts +297 -0
  156. package/test/unit/context/context-build.test.ts +575 -0
  157. package/test/unit/context/context-coverage.test.ts +236 -0
  158. package/test/unit/context/context-error.test.ts +93 -0
  159. package/test/unit/context/context-estimate-tokens.test.ts +201 -0
  160. package/test/unit/context/context-format.test.ts +302 -0
  161. package/test/unit/context/context-isolation.test.ts +267 -0
  162. package/test/unit/context/context-sort.test.ts +93 -0
  163. package/test/unit/context/context-story.test.ts +108 -0
  164. package/test/{context → unit/context}/prior-failures.test.ts +5 -4
  165. package/test/unit/context.test.ts +7 -3
  166. package/test/unit/crash-recovery.test.ts +1 -0
  167. package/test/unit/escalation.test.ts +1 -0
  168. package/test/unit/execution/lifecycle/run-completion.test.ts +1 -0
  169. package/test/unit/execution/lifecycle/run-regression.test.ts +2 -0
  170. package/test/{execution → unit/execution}/pid-registry.test.ts +2 -1
  171. package/test/{execution → unit/execution}/structured-failure.test.ts +3 -2
  172. package/test/unit/execution-logging-stderr.test.ts +1 -0
  173. package/test/unit/execution-stage.test.ts +1 -0
  174. package/test/unit/fix-generator.test.ts +1 -0
  175. package/test/unit/greenfield.test.ts +1 -0
  176. package/test/unit/interaction/human-review-trigger.test.ts +1 -0
  177. package/test/unit/interaction-network-failures.test.ts +1 -0
  178. package/test/unit/interaction-plugins.test.ts +1 -0
  179. package/test/unit/logging/formatter.test.ts +1 -0
  180. package/test/unit/merge.test.ts +1 -0
  181. package/test/unit/pipeline/event-bus.test.ts +105 -0
  182. package/test/unit/pipeline/routing-partial-override.test.ts +1 -0
  183. package/test/unit/pipeline/runner-retry.test.ts +89 -0
  184. package/test/unit/pipeline/stages/autofix.test.ts +97 -0
  185. package/test/unit/pipeline/stages/rectify.test.ts +101 -0
  186. package/test/unit/pipeline/stages/regression-stage.test.ts +69 -0
  187. package/test/unit/pipeline/stages/verify.test.ts +1 -0
  188. package/test/unit/pipeline/subscribers/hooks.test.ts +45 -0
  189. package/test/unit/pipeline/subscribers/interaction.test.ts +31 -0
  190. package/test/unit/pipeline/subscribers/reporters.test.ts +90 -0
  191. package/test/unit/pipeline/verify-smart-runner.test.ts +2 -1
  192. package/test/unit/prd-auto-default.test.ts +3 -2
  193. package/test/unit/prd-failure-category.test.ts +1 -0
  194. package/test/unit/prd-get-next-story.test.ts +1 -0
  195. package/test/unit/precheck-checks.test.ts +1 -0
  196. package/test/unit/precheck-story-size-gate.test.ts +1 -0
  197. package/test/unit/precheck-types.test.ts +1 -0
  198. package/test/unit/prompts.test.ts +1 -0
  199. package/test/unit/rectification.test.ts +2 -1
  200. package/test/unit/registry.test.ts +1 -0
  201. package/test/unit/routing/routing-stability.test.ts +2 -1
  202. package/test/unit/routing/strategies/llm.test.ts +251 -0
  203. package/test/unit/routing-advanced.test.ts +313 -0
  204. package/test/unit/routing-core.test.ts +341 -0
  205. package/test/unit/routing-strategies.test.ts +442 -0
  206. package/test/unit/storyid-events.test.ts +1 -0
  207. package/test/{ui → unit/ui}/tui-controls.test.ts +8 -7
  208. package/test/{ui → unit/ui}/tui-cost-and-pty.test.ts +4 -3
  209. package/test/{ui → unit/ui}/tui-layout.test.ts +5 -4
  210. package/test/{ui → unit/ui}/tui-stories.test.ts +5 -4
  211. package/test/unit/{isolation.test.ts → unit-isolation.test.ts} +1 -0
  212. package/test/unit/{helpers.test.ts → utils-helpers.test.ts} +1 -0
  213. package/test/unit/verdict.test.ts +1 -0
  214. package/test/unit/verification/orchestrator-types.test.ts +54 -0
  215. package/test/unit/verification/orchestrator.test.ts +66 -0
  216. package/test/unit/verification/smart-runner-config.test.ts +1 -0
  217. package/test/unit/verification/smart-runner-discovery.test.ts +8 -7
  218. package/test/unit/verification/strategies/acceptance.test.ts +33 -0
  219. package/test/unit/verification/strategies/regression.test.ts +87 -0
  220. package/test/unit/verification/strategies/scoped.test.ts +100 -0
  221. package/test/unit/worktree-manager.test.ts +1 -0
  222. package/src/execution/lifecycle/story-hooks.ts +0 -38
  223. package/src/execution/post-verify.ts +0 -193
  224. package/src/execution/rectification.ts +0 -13
  225. package/src/execution/verification.ts +0 -72
  226. package/test/integration/rectification-flow.test.ts +0 -512
  227. package/test/integration/runner.test.ts +0 -1679
  228. package/test/integration/tdd-orchestrator.test.ts +0 -1762
  229. package/test/unit/execution/post-verify-regression.test.ts +0 -362
  230. package/test/unit/execution/post-verify.test.ts +0 -236
  231. package/test/unit/routing.test.ts +0 -1039
  232. /package/test/{integration → helpers}/helpers.test.ts +0 -0
  233. /package/test/integration/worktree/{merge.test.ts → worktree-merge.test.ts} +0 -0
@@ -0,0 +1,68 @@
1
+ // RE-ARCH: keep
2
+ /**
3
+ * Interaction Subscriber (ADR-005, Phase 3 US-P3-003)
4
+ *
5
+ * Maps pipeline events to interaction trigger calls.
6
+ * Currently handles:
7
+ * - human-review:requested → executeTrigger("human-review")
8
+ *
9
+ * Future triggers (story-ambiguity, merge-conflict, etc.) can be added
10
+ * by subscribing to the appropriate events.
11
+ *
12
+ * Design:
13
+ * - Interaction triggers MAY block (await user response) — uses emitAsync where needed
14
+ * - Errors are caught and logged; never rethrown to avoid blocking the pipeline
15
+ * - Returns unsubscribe function for cleanup
16
+ */
17
+
18
+ import type { NaxConfig } from "../../config";
19
+ import type { InteractionChain } from "../../interaction/chain";
20
+ import { executeTrigger, isTriggerEnabled } from "../../interaction/triggers";
21
+ import { getSafeLogger } from "../../logger";
22
+ import type { PipelineEventBus } from "../event-bus";
23
+ import type { UnsubscribeFn } from "./hooks";
24
+
25
+ /**
26
+ * Wire interaction triggers to the event bus.
27
+ *
28
+ * @param bus - The pipeline event bus
29
+ * @param interactionChain - The active interaction chain (may be null)
30
+ * @param config - Nax config (for isTriggerEnabled checks)
31
+ * @returns Unsubscribe function
32
+ */
33
+ export function wireInteraction(
34
+ bus: PipelineEventBus,
35
+ interactionChain: InteractionChain | null | undefined,
36
+ config: NaxConfig,
37
+ ): UnsubscribeFn {
38
+ const logger = getSafeLogger();
39
+ const unsubs: UnsubscribeFn[] = [];
40
+
41
+ // human-review:requested → executeTrigger("human-review")
42
+ if (interactionChain && isTriggerEnabled("human-review", config)) {
43
+ unsubs.push(
44
+ bus.on("human-review:requested", (ev) => {
45
+ executeTrigger(
46
+ "human-review",
47
+ {
48
+ featureName: ev.feature ?? "",
49
+ storyId: ev.storyId,
50
+ iteration: ev.attempts ?? 0,
51
+ reason: ev.reason,
52
+ },
53
+ config,
54
+ interactionChain,
55
+ ).catch((err) => {
56
+ logger?.warn("interaction-subscriber", "human-review trigger failed", {
57
+ storyId: ev.storyId,
58
+ error: String(err),
59
+ });
60
+ });
61
+ }),
62
+ );
63
+ }
64
+
65
+ return () => {
66
+ for (const u of unsubs) u();
67
+ };
68
+ }
@@ -0,0 +1,174 @@
1
+ // RE-ARCH: keep
2
+ /**
3
+ * Reporters Subscriber (ADR-005, Phase 3 US-P3-002)
4
+ *
5
+ * Maps pipeline events to IReporter plugin methods
6
+ * (onRunStart, onStoryComplete, onRunEnd).
7
+ *
8
+ * Design:
9
+ * - Each reporter call is fire-and-forget
10
+ * - Errors in individual reporters are caught and logged
11
+ * - Returns unsubscribe function for cleanup
12
+ */
13
+
14
+ import { getSafeLogger } from "../../logger";
15
+ import type { PluginRegistry } from "../../plugins";
16
+ import type { PipelineEventBus } from "../event-bus";
17
+ import type { UnsubscribeFn } from "./hooks";
18
+
19
+ /**
20
+ * Wire reporter plugin lifecycle events to the event bus.
21
+ *
22
+ * @param bus - The pipeline event bus
23
+ * @param pluginRegistry - Plugin registry exposing getReporters()
24
+ * @param runId - Current run ID (for reporter events)
25
+ * @param startTime - Run start timestamp in ms (for duration calculation)
26
+ * @returns Unsubscribe function
27
+ */
28
+ export function wireReporters(
29
+ bus: PipelineEventBus,
30
+ pluginRegistry: PluginRegistry,
31
+ runId: string,
32
+ startTime: number,
33
+ ): UnsubscribeFn {
34
+ const logger = getSafeLogger();
35
+
36
+ const safe = (name: string, fn: () => Promise<void>) => {
37
+ fn().catch((err) => logger?.warn("reporters-subscriber", `Reporter "${name}" error`, { error: String(err) }));
38
+ };
39
+
40
+ const unsubs: UnsubscribeFn[] = [];
41
+
42
+ // run:started → reporter.onRunStart
43
+ unsubs.push(
44
+ bus.on("run:started", (ev) => {
45
+ safe("onRunStart", async () => {
46
+ const reporters = pluginRegistry.getReporters();
47
+ for (const r of reporters) {
48
+ if (r.onRunStart) {
49
+ try {
50
+ await r.onRunStart({
51
+ runId,
52
+ feature: ev.feature,
53
+ totalStories: ev.totalStories,
54
+ startTime: new Date(startTime).toISOString(),
55
+ });
56
+ } catch (err) {
57
+ logger?.warn("plugins", `Reporter '${r.name}' onRunStart failed`, { error: err });
58
+ }
59
+ }
60
+ }
61
+ });
62
+ }),
63
+ );
64
+
65
+ // story:completed → reporter.onStoryComplete(status: "completed")
66
+ unsubs.push(
67
+ bus.on("story:completed", (ev) => {
68
+ safe("onStoryComplete(completed)", async () => {
69
+ const reporters = pluginRegistry.getReporters();
70
+ for (const r of reporters) {
71
+ if (r.onStoryComplete) {
72
+ try {
73
+ await r.onStoryComplete({
74
+ runId,
75
+ storyId: ev.storyId,
76
+ status: "completed",
77
+ durationMs: ev.durationMs,
78
+ cost: ev.cost ?? 0,
79
+ tier: ev.modelTier ?? "balanced",
80
+ testStrategy: ev.testStrategy ?? "test-after",
81
+ });
82
+ } catch (err) {
83
+ logger?.warn("plugins", `Reporter '${r.name}' onStoryComplete failed`, { error: err });
84
+ }
85
+ }
86
+ }
87
+ });
88
+ }),
89
+ );
90
+
91
+ // story:failed → reporter.onStoryComplete(status: "failed")
92
+ unsubs.push(
93
+ bus.on("story:failed", (ev) => {
94
+ safe("onStoryComplete(failed)", async () => {
95
+ const reporters = pluginRegistry.getReporters();
96
+ for (const r of reporters) {
97
+ if (r.onStoryComplete) {
98
+ try {
99
+ await r.onStoryComplete({
100
+ runId,
101
+ storyId: ev.storyId,
102
+ status: "failed",
103
+ durationMs: Date.now() - startTime,
104
+ cost: 0,
105
+ tier: "balanced",
106
+ testStrategy: "test-after",
107
+ });
108
+ } catch (err) {
109
+ logger?.warn("plugins", `Reporter '${r.name}' onStoryComplete failed`, { error: err });
110
+ }
111
+ }
112
+ }
113
+ });
114
+ }),
115
+ );
116
+
117
+ // story:paused → reporter.onStoryComplete(status: "paused")
118
+ unsubs.push(
119
+ bus.on("story:paused", (ev) => {
120
+ safe("onStoryComplete(paused)", async () => {
121
+ const reporters = pluginRegistry.getReporters();
122
+ for (const r of reporters) {
123
+ if (r.onStoryComplete) {
124
+ try {
125
+ await r.onStoryComplete({
126
+ runId,
127
+ storyId: ev.storyId,
128
+ status: "paused",
129
+ durationMs: Date.now() - startTime,
130
+ cost: 0,
131
+ tier: "balanced",
132
+ testStrategy: "test-after",
133
+ });
134
+ } catch (err) {
135
+ logger?.warn("plugins", `Reporter '${r.name}' onStoryComplete failed`, { error: err });
136
+ }
137
+ }
138
+ }
139
+ });
140
+ }),
141
+ );
142
+
143
+ // run:completed → reporter.onRunEnd
144
+ unsubs.push(
145
+ bus.on("run:completed", (ev) => {
146
+ safe("onRunEnd", async () => {
147
+ const reporters = pluginRegistry.getReporters();
148
+ for (const r of reporters) {
149
+ if (r.onRunEnd) {
150
+ try {
151
+ await r.onRunEnd({
152
+ runId,
153
+ totalDurationMs: Date.now() - startTime,
154
+ totalCost: ev.totalCost ?? 0,
155
+ storySummary: {
156
+ completed: ev.passedStories,
157
+ failed: ev.failedStories,
158
+ skipped: 0,
159
+ paused: 0,
160
+ },
161
+ });
162
+ } catch (err) {
163
+ logger?.warn("plugins", `Reporter '${r.name}' onRunEnd failed`, { error: err });
164
+ }
165
+ }
166
+ }
167
+ });
168
+ }),
169
+ );
170
+
171
+ return () => {
172
+ for (const u of unsubs) u();
173
+ };
174
+ }
@@ -15,6 +15,7 @@ import type { PluginRegistry } from "../plugins/registry";
15
15
  import type { PRD, UserStory } from "../prd/types";
16
16
  import type { ReviewResult } from "../review/types";
17
17
  import type { FailureCategory } from "../tdd/types";
18
+ import type { VerifyResult } from "../verification/orchestrator-types";
18
19
 
19
20
  /**
20
21
  * Routing result from complexity classification
@@ -82,6 +83,8 @@ export interface PipelineContext {
82
83
  prompt?: string;
83
84
  /** Agent execution result (set by executionStage) */
84
85
  agentResult?: AgentResult;
86
+ /** Verify result (set by verifyStage) */
87
+ verifyResult?: VerifyResult;
85
88
  /** Review result (set by reviewStage) */
86
89
  reviewResult?: ReviewResult;
87
90
  /** Acceptance test failures (set by acceptanceStage) */
@@ -91,6 +94,12 @@ export interface PipelineContext {
91
94
  };
92
95
  /** Story start timestamp (ISO string, set by runner before pipeline) */
93
96
  storyStartTime?: string;
97
+ /** Tracks how many times the rectify stage has run this pipeline (for event attempt numbers). */
98
+ rectifyAttempt?: number;
99
+ /** Tracks how many times the autofix stage has run this pipeline (for event attempt numbers). */
100
+ autofixAttempt?: number;
101
+ /** Git HEAD ref captured before agent ran this attempt (FEAT-010: precise smart-runner diff) */
102
+ storyGitRef?: string;
94
103
  /** Collected story metrics (set by completionStage) */
95
104
  storyMetrics?: StoryMetrics[];
96
105
  /** Whether to retry the story in lite mode after a failure */
@@ -112,7 +121,9 @@ export type StageAction =
112
121
  /** Escalate to a higher tier and retry the pipeline */
113
122
  | { action: "escalate"; reason?: string; cost?: number }
114
123
  /** Pause execution (user intervention required via queue command) */
115
- | { action: "pause"; reason: string; cost?: number };
124
+ | { action: "pause"; reason: string; cost?: number }
125
+ /** Retry from a specific stage (used by rectify/autofix stages) */
126
+ | { action: "retry"; fromStage: string; cost?: number };
116
127
 
117
128
  /**
118
129
  * Result returned by a pipeline stage after execution.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Review Orchestrator (ADR-005, Phase 2)
3
+ *
4
+ * Single entry point for all post-implementation review. Delegates to the
5
+ * review runner and plugin reviewers. Provides a unified result.
6
+ *
7
+ * Usage:
8
+ * const result = await reviewOrchestrator.review(config, workdir, executionConfig, plugins);
9
+ */
10
+
11
+ import { spawn } from "bun";
12
+ import type { NaxConfig } from "../config";
13
+ import { getSafeLogger } from "../logger";
14
+ import type { PluginRegistry } from "../plugins";
15
+ import { runReview } from "./runner";
16
+ import type { ReviewConfig, ReviewResult } from "./types";
17
+
18
+ async function getChangedFiles(workdir: string): Promise<string[]> {
19
+ try {
20
+ const [stagedProc, unstagedProc] = [
21
+ spawn({ cmd: ["git", "diff", "--name-only", "--cached"], cwd: workdir, stdout: "pipe", stderr: "pipe" }),
22
+ spawn({ cmd: ["git", "diff", "--name-only"], cwd: workdir, stdout: "pipe", stderr: "pipe" }),
23
+ ];
24
+ await Promise.all([stagedProc.exited, unstagedProc.exited]);
25
+ const staged = (await new Response(stagedProc.stdout).text()).trim().split("\n").filter(Boolean);
26
+ const unstaged = (await new Response(unstagedProc.stdout).text()).trim().split("\n").filter(Boolean);
27
+ return Array.from(new Set([...staged, ...unstaged]));
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ export interface OrchestratorReviewResult {
34
+ /** Built-in review result (typecheck, lint, format) */
35
+ builtIn: ReviewResult;
36
+ /** Whether ALL checks passed (built-in + plugin reviewers) */
37
+ success: boolean;
38
+ /** Failure reason if success === false */
39
+ failureReason?: string;
40
+ /** Plugin reviewer hard-failure flag (determines escalate vs fail) */
41
+ pluginFailed: boolean;
42
+ }
43
+
44
+ export class ReviewOrchestrator {
45
+ /** Run built-in checks + plugin reviewers. Returns unified result. */
46
+ async review(
47
+ reviewConfig: ReviewConfig,
48
+ workdir: string,
49
+ executionConfig: NaxConfig["execution"],
50
+ plugins?: PluginRegistry,
51
+ ): Promise<OrchestratorReviewResult> {
52
+ const logger = getSafeLogger();
53
+
54
+ const builtIn = await runReview(reviewConfig, workdir, executionConfig);
55
+
56
+ if (!builtIn.success) {
57
+ return { builtIn, success: false, failureReason: builtIn.failureReason, pluginFailed: false };
58
+ }
59
+
60
+ if (plugins) {
61
+ const reviewers = plugins.getReviewers();
62
+ if (reviewers.length > 0) {
63
+ const changedFiles = await getChangedFiles(workdir);
64
+ const pluginResults: ReviewResult["pluginReviewers"] = [];
65
+
66
+ for (const reviewer of reviewers) {
67
+ logger?.info("review", `Running plugin reviewer: ${reviewer.name}`);
68
+ try {
69
+ const result = await reviewer.check(workdir, changedFiles);
70
+ pluginResults.push({
71
+ name: reviewer.name,
72
+ passed: result.passed,
73
+ output: result.output,
74
+ exitCode: result.exitCode,
75
+ });
76
+ if (!result.passed) {
77
+ builtIn.pluginReviewers = pluginResults;
78
+ return {
79
+ builtIn,
80
+ success: false,
81
+ failureReason: `plugin reviewer '${reviewer.name}' failed`,
82
+ pluginFailed: true,
83
+ };
84
+ }
85
+ } catch (error) {
86
+ const errorMsg = error instanceof Error ? error.message : String(error);
87
+ pluginResults.push({ name: reviewer.name, passed: false, output: "", error: errorMsg });
88
+ builtIn.pluginReviewers = pluginResults;
89
+ return {
90
+ builtIn,
91
+ success: false,
92
+ failureReason: `plugin reviewer '${reviewer.name}' threw error`,
93
+ pluginFailed: true,
94
+ };
95
+ }
96
+ }
97
+ builtIn.pluginReviewers = pluginResults;
98
+ }
99
+ }
100
+
101
+ return { builtIn, success: true, pluginFailed: false };
102
+ }
103
+ }
104
+
105
+ export const reviewOrchestrator = new ReviewOrchestrator();
@@ -76,8 +76,13 @@ async function resolveCommand(
76
76
  return null;
77
77
  }
78
78
 
79
+ /** Default timeout for review checks (lint, typecheck). BUG-039. */
80
+ const REVIEW_CHECK_TIMEOUT_MS = 120_000;
81
+
79
82
  /**
80
- * Run a single review check
83
+ * Run a single review check with a hard timeout.
84
+ *
85
+ * BUG-039: Added SIGTERM + SIGKILL cleanup to prevent orphan lint/typecheck processes.
81
86
  */
82
87
  async function runCheck(check: ReviewCheckName, command: string, workdir: string): Promise<ReviewCheckResult> {
83
88
  const startTime = Date.now();
@@ -96,8 +101,38 @@ async function runCheck(check: ReviewCheckName, command: string, workdir: string
96
101
  stderr: "pipe",
97
102
  });
98
103
 
104
+ // BUG-039: Hard timeout — kill the process if it hangs
105
+ let timedOut = false;
106
+ const timerId = setTimeout(() => {
107
+ timedOut = true;
108
+ try {
109
+ proc.kill("SIGTERM");
110
+ } catch {
111
+ /* already exited */
112
+ }
113
+ setTimeout(() => {
114
+ try {
115
+ proc.kill("SIGKILL");
116
+ } catch {
117
+ /* already exited */
118
+ }
119
+ }, 5000);
120
+ }, REVIEW_CHECK_TIMEOUT_MS);
121
+
99
122
  // Wait for completion
100
- const result = await proc.exited;
123
+ const exitCode = await proc.exited;
124
+ clearTimeout(timerId);
125
+
126
+ if (timedOut) {
127
+ return {
128
+ check,
129
+ command,
130
+ success: false,
131
+ exitCode: -1,
132
+ output: `[nax] ${check} timed out after ${REVIEW_CHECK_TIMEOUT_MS / 1000}s`,
133
+ durationMs: Date.now() - startTime,
134
+ };
135
+ }
101
136
 
102
137
  // Collect output
103
138
  const stdout = await new Response(proc.stdout).text();
@@ -107,8 +142,8 @@ async function runCheck(check: ReviewCheckName, command: string, workdir: string
107
142
  return {
108
143
  check,
109
144
  command,
110
- success: result === 0,
111
- exitCode: result,
145
+ success: exitCode === 0,
146
+ exitCode,
112
147
  output,
113
148
  durationMs: Date.now() - startTime,
114
149
  };
@@ -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 → test-after
155
+ * otherwise → three-session-tdd-lite (test-after deprecated from auto mode)
156
156
  *
157
157
  * @param complexity - Pre-classified complexity level
158
158
  * @param title - Story title
@@ -201,8 +201,8 @@ export function determineTestStrategy(
201
201
  return hasLiteTag ? "three-session-tdd-lite" : "three-session-tdd";
202
202
  }
203
203
 
204
- // Simple/medium → test-after (default)
205
- return "test-after";
204
+ // Simple/medium → three-session-tdd-lite (FEAT-013: test-after deprecated from auto mode)
205
+ return "three-session-tdd-lite";
206
206
  }
207
207
 
208
208
  /** Map complexity to model tier */
@@ -117,7 +117,8 @@ function determineTestStrategy(
117
117
  return "three-session-tdd";
118
118
  }
119
119
 
120
- return "test-after";
120
+ // FEAT-013: test-after deprecated from auto mode — use three-session-tdd-lite as default
121
+ return "three-session-tdd-lite";
121
122
  }
122
123
 
123
124
  /** Map complexity to model tier */
@@ -160,7 +161,9 @@ export const keywordStrategy: RoutingStrategy = {
160
161
  modelTier,
161
162
  testStrategy,
162
163
  reasoning:
163
- reasons.length > 0 ? `three-session-tdd: ${reasons.join(", ")}` : `test-after: simple task (${complexity})`,
164
+ reasons.length > 0
165
+ ? `three-session-tdd: ${reasons.join(", ")}`
166
+ : `three-session-tdd-lite: simple task (${complexity})`,
164
167
  };
165
168
  },
166
169
  };
@@ -49,6 +49,22 @@ function evictOldest(): void {
49
49
  }
50
50
  }
51
51
 
52
+ /** Minimal proc shape returned by spawn for piped stdio. */
53
+ export interface PipedProc {
54
+ stdout: ReadableStream<Uint8Array>;
55
+ stderr: ReadableStream<Uint8Array>;
56
+ exited: Promise<number>;
57
+ kill(signal?: number | NodeJS.Signals): void;
58
+ }
59
+
60
+ /**
61
+ * Swappable dependencies for testing (avoids mock.module() which leaks in Bun 1.x).
62
+ */
63
+ export const _deps = {
64
+ spawn: (cmd: string[], opts: { stdout: "pipe"; stderr: "pipe" }): PipedProc =>
65
+ Bun.spawn(cmd, opts) as unknown as PipedProc,
66
+ };
67
+
52
68
  /**
53
69
  * Call LLM via claude CLI with timeout.
54
70
  *
@@ -69,7 +85,7 @@ async function callLlmOnce(modelTier: string, prompt: string, config: NaxConfig,
69
85
  const modelArg = modelDef.model;
70
86
 
71
87
  // Spawn claude CLI with timeout
72
- const proc = Bun.spawn(["claude", "-p", prompt, "--model", modelArg], {
88
+ const proc = _deps.spawn(["claude", "-p", prompt, "--model", modelArg], {
73
89
  stdout: "pipe",
74
90
  stderr: "pipe",
75
91
  });
@@ -100,6 +116,16 @@ async function callLlmOnce(modelTier: string, prompt: string, config: NaxConfig,
100
116
  return result;
101
117
  } catch (err) {
102
118
  clearTimeout(timeoutId);
119
+ try {
120
+ proc.stdout.cancel();
121
+ } catch {
122
+ // ignore cancel errors
123
+ }
124
+ try {
125
+ proc.stderr.cancel();
126
+ } catch {
127
+ // ignore cancel errors
128
+ }
103
129
  proc.kill();
104
130
  throw err;
105
131
  }
@@ -1,7 +1,7 @@
1
1
  import type { RectificationConfig } from "../config";
2
- import { createRectificationPrompt } from "../execution/rectification";
3
2
  import type { TestFailure } from "../execution/test-output-parser";
4
3
  import type { UserStory } from "../prd";
4
+ import { createRectificationPrompt } from "../verification/rectification";
5
5
  import type { TddSessionRole } from "./types";
6
6
 
7
7
  /**
package/src/utils/git.ts CHANGED
@@ -4,6 +4,48 @@
4
4
 
5
5
  import { spawn } from "bun";
6
6
 
7
+ /**
8
+ * Default timeout for git subprocess calls.
9
+ * Prevents git from hanging indefinitely on locked repos or network mounts.
10
+ */
11
+ const GIT_TIMEOUT_MS = 10_000;
12
+
13
+ /**
14
+ * Spawn a git command with a hard timeout.
15
+ *
16
+ * Kills the process with SIGKILL after GIT_TIMEOUT_MS if it hasn't exited.
17
+ * Returns empty stdout and exit code 1 on timeout.
18
+ *
19
+ * @internal
20
+ */
21
+ export async function gitWithTimeout(args: string[], workdir: string): Promise<{ stdout: string; exitCode: number }> {
22
+ const proc = Bun.spawn(["git", ...args], {
23
+ cwd: workdir,
24
+ stdout: "pipe",
25
+ stderr: "pipe",
26
+ });
27
+
28
+ let timedOut = false;
29
+ const timerId = setTimeout(() => {
30
+ timedOut = true;
31
+ try {
32
+ proc.kill("SIGKILL");
33
+ } catch {
34
+ // Process may have already exited
35
+ }
36
+ }, GIT_TIMEOUT_MS);
37
+
38
+ const exitCode = await proc.exited;
39
+ clearTimeout(timerId);
40
+
41
+ if (timedOut) {
42
+ return { stdout: "", exitCode: 1 };
43
+ }
44
+
45
+ const stdout = await new Response(proc.stdout).text();
46
+ return { stdout, exitCode };
47
+ }
48
+
7
49
  /**
8
50
  * Capture current git HEAD ref.
9
51
  *
@@ -23,17 +65,8 @@ import { spawn } from "bun";
23
65
  */
24
66
  export async function captureGitRef(workdir: string): Promise<string | undefined> {
25
67
  try {
26
- const proc = spawn({
27
- cmd: ["git", "rev-parse", "HEAD"],
28
- cwd: workdir,
29
- stdout: "pipe",
30
- stderr: "pipe",
31
- });
32
-
33
- const exitCode = await proc.exited;
68
+ const { stdout, exitCode } = await gitWithTimeout(["rev-parse", "HEAD"], workdir);
34
69
  if (exitCode !== 0) return undefined;
35
-
36
- const stdout = await new Response(proc.stdout).text();
37
70
  return stdout.trim() || undefined;
38
71
  } catch {
39
72
  return undefined;
@@ -43,7 +76,7 @@ export async function captureGitRef(workdir: string): Promise<string | undefined
43
76
  /**
44
77
  * Check if a story ID appears in recent git commit messages.
45
78
  *
46
- * Searches the last 20 commits for commit messages containing the story ID.
79
+ * Searches the last N commits for commit messages containing the story ID.
47
80
  * Used for state reconciliation: if a failed story has commits in git history,
48
81
  * it means the story was partially completed and should be marked as passed.
49
82
  *
@@ -62,21 +95,12 @@ export async function captureGitRef(workdir: string): Promise<string | undefined
62
95
  */
63
96
  export async function hasCommitsForStory(workdir: string, storyId: string, maxCommits = 20): Promise<boolean> {
64
97
  try {
65
- const proc = spawn({
66
- cmd: ["git", "log", `-${maxCommits}`, "--oneline", "--grep", storyId],
67
- cwd: workdir,
68
- stdout: "pipe",
69
- stderr: "pipe",
70
- });
71
-
72
- const exitCode = await proc.exited;
98
+ const { stdout, exitCode } = await gitWithTimeout(
99
+ ["log", `-${maxCommits}`, "--oneline", "--grep", storyId],
100
+ workdir,
101
+ );
73
102
  if (exitCode !== 0) return false;
74
-
75
- const stdout = await new Response(proc.stdout).text();
76
- const commits = stdout.trim();
77
-
78
- // If any commits found, return true
79
- return commits.length > 0;
103
+ return stdout.trim().length > 0;
80
104
  } catch {
81
105
  return false;
82
106
  }
@@ -17,8 +17,14 @@ import type { TestExecutionResult } from "./types";
17
17
  */
18
18
  async function drainWithDeadline(proc: Subprocess, deadlineMs: number): Promise<string> {
19
19
  const EMPTY = Symbol("timeout");
20
- const race = <T>(p: Promise<T>) =>
21
- Promise.race([p, new Promise<typeof EMPTY>((r) => setTimeout(() => r(EMPTY), deadlineMs))]);
20
+ const race = <T>(p: Promise<T>) => {
21
+ // BUG-039: Store timer handle so it can be cleared after race resolves (prevent leak)
22
+ let timerId: ReturnType<typeof setTimeout>;
23
+ const timeoutPromise = new Promise<typeof EMPTY>((r) => {
24
+ timerId = setTimeout(() => r(EMPTY), deadlineMs);
25
+ });
26
+ return Promise.race([p, timeoutPromise]).finally(() => clearTimeout(timerId));
27
+ };
22
28
 
23
29
  let out = "";
24
30
  try {
@@ -8,5 +8,5 @@
8
8
  export * from "./types";
9
9
  export * from "./executor";
10
10
  export * from "./parser";
11
- export * from "./gate";
11
+ export * from "./runners";
12
12
  export * from "./rectification";