popeye-cli 2.2.0 → 2.7.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 (323) hide show
  1. package/dist/adapters/gemini.d.ts +14 -0
  2. package/dist/adapters/gemini.d.ts.map +1 -1
  3. package/dist/adapters/gemini.js +41 -6
  4. package/dist/adapters/gemini.js.map +1 -1
  5. package/dist/adapters/grok.d.ts +14 -0
  6. package/dist/adapters/grok.d.ts.map +1 -1
  7. package/dist/adapters/grok.js +42 -6
  8. package/dist/adapters/grok.js.map +1 -1
  9. package/dist/adapters/openai.d.ts +10 -0
  10. package/dist/adapters/openai.d.ts.map +1 -1
  11. package/dist/adapters/openai.js +44 -5
  12. package/dist/adapters/openai.js.map +1 -1
  13. package/dist/cli/commands/create.js +1 -1
  14. package/dist/cli/commands/create.js.map +1 -1
  15. package/dist/cli/interactive.d.ts.map +1 -1
  16. package/dist/cli/interactive.js +324 -20
  17. package/dist/cli/interactive.js.map +1 -1
  18. package/dist/generators/all.d.ts.map +1 -1
  19. package/dist/generators/all.js +3 -2
  20. package/dist/generators/all.js.map +1 -1
  21. package/dist/generators/doc-parser.d.ts +21 -6
  22. package/dist/generators/doc-parser.d.ts.map +1 -1
  23. package/dist/generators/doc-parser.js +55 -4
  24. package/dist/generators/doc-parser.js.map +1 -1
  25. package/dist/generators/templates/fullstack.js +1 -1
  26. package/dist/generators/templates/website-components.js +1 -1
  27. package/dist/generators/templates/website-components.js.map +1 -1
  28. package/dist/generators/templates/website-config.d.ts +4 -1
  29. package/dist/generators/templates/website-config.d.ts.map +1 -1
  30. package/dist/generators/templates/website-config.js +17 -11
  31. package/dist/generators/templates/website-config.js.map +1 -1
  32. package/dist/generators/templates/website-conversion.js +1 -1
  33. package/dist/generators/templates/website-conversion.js.map +1 -1
  34. package/dist/generators/templates/website-landing.js +1 -1
  35. package/dist/generators/templates/website-landing.js.map +1 -1
  36. package/dist/generators/templates/website-layout.d.ts +36 -4
  37. package/dist/generators/templates/website-layout.d.ts.map +1 -1
  38. package/dist/generators/templates/website-layout.js +466 -23
  39. package/dist/generators/templates/website-layout.js.map +1 -1
  40. package/dist/generators/templates/website-pricing.js +1 -1
  41. package/dist/generators/templates/website-pricing.js.map +1 -1
  42. package/dist/generators/templates/website-sections.js +1 -1
  43. package/dist/generators/templates/website-sections.js.map +1 -1
  44. package/dist/generators/templates/website-seo.d.ts.map +1 -1
  45. package/dist/generators/templates/website-seo.js +4 -1
  46. package/dist/generators/templates/website-seo.js.map +1 -1
  47. package/dist/generators/templates/website.d.ts +1 -1
  48. package/dist/generators/templates/website.d.ts.map +1 -1
  49. package/dist/generators/templates/website.js +1 -1
  50. package/dist/generators/templates/website.js.map +1 -1
  51. package/dist/generators/website-content-ai.d.ts +52 -0
  52. package/dist/generators/website-content-ai.d.ts.map +1 -0
  53. package/dist/generators/website-content-ai.js +141 -0
  54. package/dist/generators/website-content-ai.js.map +1 -0
  55. package/dist/generators/website-content-scanner.d.ts +1 -1
  56. package/dist/generators/website-content-scanner.d.ts.map +1 -1
  57. package/dist/generators/website-content-scanner.js +98 -1
  58. package/dist/generators/website-content-scanner.js.map +1 -1
  59. package/dist/generators/website-context.d.ts +34 -1
  60. package/dist/generators/website-context.d.ts.map +1 -1
  61. package/dist/generators/website-context.js +131 -9
  62. package/dist/generators/website-context.js.map +1 -1
  63. package/dist/generators/website-debug.d.ts +12 -0
  64. package/dist/generators/website-debug.d.ts.map +1 -1
  65. package/dist/generators/website-debug.js +16 -0
  66. package/dist/generators/website-debug.js.map +1 -1
  67. package/dist/generators/website.d.ts.map +1 -1
  68. package/dist/generators/website.js +26 -4
  69. package/dist/generators/website.js.map +1 -1
  70. package/dist/pipeline/auto-recovery.d.ts +56 -0
  71. package/dist/pipeline/auto-recovery.d.ts.map +1 -0
  72. package/dist/pipeline/auto-recovery.js +185 -0
  73. package/dist/pipeline/auto-recovery.js.map +1 -0
  74. package/dist/pipeline/change-request.d.ts +39 -0
  75. package/dist/pipeline/change-request.d.ts.map +1 -1
  76. package/dist/pipeline/change-request.js +40 -1
  77. package/dist/pipeline/change-request.js.map +1 -1
  78. package/dist/pipeline/check-runner.d.ts +30 -1
  79. package/dist/pipeline/check-runner.d.ts.map +1 -1
  80. package/dist/pipeline/check-runner.js +122 -1
  81. package/dist/pipeline/check-runner.js.map +1 -1
  82. package/dist/pipeline/command-resolver.d.ts.map +1 -1
  83. package/dist/pipeline/command-resolver.js +33 -2
  84. package/dist/pipeline/command-resolver.js.map +1 -1
  85. package/dist/pipeline/consensus/arbitrator-query.d.ts +22 -0
  86. package/dist/pipeline/consensus/arbitrator-query.d.ts.map +1 -0
  87. package/dist/pipeline/consensus/arbitrator-query.js +70 -0
  88. package/dist/pipeline/consensus/arbitrator-query.js.map +1 -0
  89. package/dist/pipeline/consensus/consensus-runner.d.ts +131 -7
  90. package/dist/pipeline/consensus/consensus-runner.d.ts.map +1 -1
  91. package/dist/pipeline/consensus/consensus-runner.js +809 -35
  92. package/dist/pipeline/consensus/consensus-runner.js.map +1 -1
  93. package/dist/pipeline/cr-lifecycle.d.ts +42 -0
  94. package/dist/pipeline/cr-lifecycle.d.ts.map +1 -0
  95. package/dist/pipeline/cr-lifecycle.js +89 -0
  96. package/dist/pipeline/cr-lifecycle.js.map +1 -0
  97. package/dist/pipeline/gate-engine.d.ts +1 -0
  98. package/dist/pipeline/gate-engine.d.ts.map +1 -1
  99. package/dist/pipeline/gate-engine.js +26 -7
  100. package/dist/pipeline/gate-engine.js.map +1 -1
  101. package/dist/pipeline/orchestrator.d.ts +1 -1
  102. package/dist/pipeline/orchestrator.d.ts.map +1 -1
  103. package/dist/pipeline/orchestrator.js +306 -16
  104. package/dist/pipeline/orchestrator.js.map +1 -1
  105. package/dist/pipeline/packets/consensus-packet-builder.d.ts +15 -4
  106. package/dist/pipeline/packets/consensus-packet-builder.d.ts.map +1 -1
  107. package/dist/pipeline/packets/consensus-packet-builder.js +29 -17
  108. package/dist/pipeline/packets/consensus-packet-builder.js.map +1 -1
  109. package/dist/pipeline/phases/architecture.d.ts.map +1 -1
  110. package/dist/pipeline/phases/architecture.js +5 -3
  111. package/dist/pipeline/phases/architecture.js.map +1 -1
  112. package/dist/pipeline/phases/audit.d.ts.map +1 -1
  113. package/dist/pipeline/phases/audit.js +5 -3
  114. package/dist/pipeline/phases/audit.js.map +1 -1
  115. package/dist/pipeline/phases/consensus-architecture.d.ts.map +1 -1
  116. package/dist/pipeline/phases/consensus-architecture.js +10 -1
  117. package/dist/pipeline/phases/consensus-architecture.js.map +1 -1
  118. package/dist/pipeline/phases/consensus-master-plan.d.ts.map +1 -1
  119. package/dist/pipeline/phases/consensus-master-plan.js +10 -3
  120. package/dist/pipeline/phases/consensus-master-plan.js.map +1 -1
  121. package/dist/pipeline/phases/consensus-role-plans.d.ts.map +1 -1
  122. package/dist/pipeline/phases/consensus-role-plans.js +10 -1
  123. package/dist/pipeline/phases/consensus-role-plans.js.map +1 -1
  124. package/dist/pipeline/phases/done.d.ts.map +1 -1
  125. package/dist/pipeline/phases/done.js +9 -4
  126. package/dist/pipeline/phases/done.js.map +1 -1
  127. package/dist/pipeline/phases/intake.d.ts.map +1 -1
  128. package/dist/pipeline/phases/intake.js +7 -3
  129. package/dist/pipeline/phases/intake.js.map +1 -1
  130. package/dist/pipeline/phases/phase-context.d.ts +2 -0
  131. package/dist/pipeline/phases/phase-context.d.ts.map +1 -1
  132. package/dist/pipeline/phases/phase-context.js +3 -1
  133. package/dist/pipeline/phases/phase-context.js.map +1 -1
  134. package/dist/pipeline/phases/production-gate.d.ts.map +1 -1
  135. package/dist/pipeline/phases/production-gate.js +28 -3
  136. package/dist/pipeline/phases/production-gate.js.map +1 -1
  137. package/dist/pipeline/phases/qa-validation.d.ts.map +1 -1
  138. package/dist/pipeline/phases/qa-validation.js +38 -5
  139. package/dist/pipeline/phases/qa-validation.js.map +1 -1
  140. package/dist/pipeline/phases/recovery-loop.d.ts +2 -0
  141. package/dist/pipeline/phases/recovery-loop.d.ts.map +1 -1
  142. package/dist/pipeline/phases/recovery-loop.js +200 -6
  143. package/dist/pipeline/phases/recovery-loop.js.map +1 -1
  144. package/dist/pipeline/phases/review.d.ts.map +1 -1
  145. package/dist/pipeline/phases/review.js +58 -28
  146. package/dist/pipeline/phases/review.js.map +1 -1
  147. package/dist/pipeline/phases/role-planning.d.ts.map +1 -1
  148. package/dist/pipeline/phases/role-planning.js +18 -2
  149. package/dist/pipeline/phases/role-planning.js.map +1 -1
  150. package/dist/pipeline/phases/stuck.d.ts.map +1 -1
  151. package/dist/pipeline/phases/stuck.js +10 -0
  152. package/dist/pipeline/phases/stuck.js.map +1 -1
  153. package/dist/pipeline/repo-snapshot.d.ts.map +1 -1
  154. package/dist/pipeline/repo-snapshot.js +3 -0
  155. package/dist/pipeline/repo-snapshot.js.map +1 -1
  156. package/dist/pipeline/role-execution-adapter.d.ts +2 -1
  157. package/dist/pipeline/role-execution-adapter.d.ts.map +1 -1
  158. package/dist/pipeline/role-execution-adapter.js +22 -7
  159. package/dist/pipeline/role-execution-adapter.js.map +1 -1
  160. package/dist/pipeline/skill-loader.d.ts +19 -0
  161. package/dist/pipeline/skill-loader.d.ts.map +1 -1
  162. package/dist/pipeline/skill-loader.js +22 -0
  163. package/dist/pipeline/skill-loader.js.map +1 -1
  164. package/dist/pipeline/skills/coverage-gate.d.ts +44 -0
  165. package/dist/pipeline/skills/coverage-gate.d.ts.map +1 -0
  166. package/dist/pipeline/skills/coverage-gate.js +143 -0
  167. package/dist/pipeline/skills/coverage-gate.js.map +1 -0
  168. package/dist/pipeline/skills/usage-registry.d.ts +48 -0
  169. package/dist/pipeline/skills/usage-registry.d.ts.map +1 -0
  170. package/dist/pipeline/skills/usage-registry.js +55 -0
  171. package/dist/pipeline/skills/usage-registry.js.map +1 -0
  172. package/dist/pipeline/strategy-context.d.ts +20 -0
  173. package/dist/pipeline/strategy-context.d.ts.map +1 -0
  174. package/dist/pipeline/strategy-context.js +55 -0
  175. package/dist/pipeline/strategy-context.js.map +1 -0
  176. package/dist/pipeline/type-defs/artifacts.d.ts +25 -5
  177. package/dist/pipeline/type-defs/artifacts.d.ts.map +1 -1
  178. package/dist/pipeline/type-defs/artifacts.js +4 -0
  179. package/dist/pipeline/type-defs/artifacts.js.map +1 -1
  180. package/dist/pipeline/type-defs/audit.d.ts +25 -13
  181. package/dist/pipeline/type-defs/audit.d.ts.map +1 -1
  182. package/dist/pipeline/type-defs/checks.d.ts +18 -8
  183. package/dist/pipeline/type-defs/checks.d.ts.map +1 -1
  184. package/dist/pipeline/type-defs/checks.js +4 -0
  185. package/dist/pipeline/type-defs/checks.js.map +1 -1
  186. package/dist/pipeline/type-defs/packets.d.ts +104 -18
  187. package/dist/pipeline/type-defs/packets.d.ts.map +1 -1
  188. package/dist/pipeline/type-defs/packets.js +17 -1
  189. package/dist/pipeline/type-defs/packets.js.map +1 -1
  190. package/dist/pipeline/type-defs/state.d.ts +160 -16
  191. package/dist/pipeline/type-defs/state.d.ts.map +1 -1
  192. package/dist/pipeline/type-defs/state.js +26 -1
  193. package/dist/pipeline/type-defs/state.js.map +1 -1
  194. package/dist/shared/text-utils.d.ts +23 -0
  195. package/dist/shared/text-utils.d.ts.map +1 -0
  196. package/dist/shared/text-utils.js +66 -0
  197. package/dist/shared/text-utils.js.map +1 -0
  198. package/dist/shared/website-strategy-format.d.ts +18 -0
  199. package/dist/shared/website-strategy-format.d.ts.map +1 -0
  200. package/dist/shared/website-strategy-format.js +47 -0
  201. package/dist/shared/website-strategy-format.js.map +1 -0
  202. package/dist/state/index.d.ts +2 -0
  203. package/dist/state/index.d.ts.map +1 -1
  204. package/dist/state/index.js +57 -8
  205. package/dist/state/index.js.map +1 -1
  206. package/dist/types/consensus.d.ts +1 -0
  207. package/dist/types/consensus.d.ts.map +1 -1
  208. package/dist/types/consensus.js.map +1 -1
  209. package/dist/types/website-strategy.d.ts +1 -1
  210. package/dist/types/workflow.d.ts +447 -0
  211. package/dist/types/workflow.d.ts.map +1 -1
  212. package/dist/types/workflow.js +3 -0
  213. package/dist/types/workflow.js.map +1 -1
  214. package/dist/upgrade/handlers.d.ts.map +1 -1
  215. package/dist/upgrade/handlers.js +6 -3
  216. package/dist/upgrade/handlers.js.map +1 -1
  217. package/dist/workflow/consensus.d.ts.map +1 -1
  218. package/dist/workflow/consensus.js +1 -0
  219. package/dist/workflow/consensus.js.map +1 -1
  220. package/dist/workflow/website-strategy.d.ts.map +1 -1
  221. package/dist/workflow/website-strategy.js +2 -29
  222. package/dist/workflow/website-strategy.js.map +1 -1
  223. package/dist/workflow/website-updater.d.ts.map +1 -1
  224. package/dist/workflow/website-updater.js +3 -2
  225. package/dist/workflow/website-updater.js.map +1 -1
  226. package/package.json +1 -1
  227. package/src/adapters/gemini.ts +51 -6
  228. package/src/adapters/grok.ts +51 -6
  229. package/src/adapters/openai.ts +53 -5
  230. package/src/cli/commands/create.ts +1 -1
  231. package/src/cli/interactive.ts +333 -19
  232. package/src/generators/all.ts +3 -2
  233. package/src/generators/doc-parser.ts +75 -15
  234. package/src/generators/templates/fullstack.ts +1 -1
  235. package/src/generators/templates/website-components.ts +1 -1
  236. package/src/generators/templates/website-config.ts +23 -11
  237. package/src/generators/templates/website-conversion.ts +1 -1
  238. package/src/generators/templates/website-landing.ts +1 -1
  239. package/src/generators/templates/website-layout.ts +491 -23
  240. package/src/generators/templates/website-pricing.ts +1 -1
  241. package/src/generators/templates/website-sections.ts +1 -1
  242. package/src/generators/templates/website-seo.ts +4 -1
  243. package/src/generators/templates/website.ts +3 -0
  244. package/src/generators/website-content-ai.ts +186 -0
  245. package/src/generators/website-content-scanner.ts +113 -1
  246. package/src/generators/website-context.ts +151 -12
  247. package/src/generators/website-debug.ts +26 -0
  248. package/src/generators/website.ts +28 -3
  249. package/src/pipeline/auto-recovery.ts +283 -0
  250. package/src/pipeline/change-request.ts +63 -1
  251. package/src/pipeline/check-runner.ts +141 -2
  252. package/src/pipeline/command-resolver.ts +34 -2
  253. package/src/pipeline/consensus/arbitrator-query.ts +101 -0
  254. package/src/pipeline/consensus/consensus-runner.ts +1099 -42
  255. package/src/pipeline/cr-lifecycle.ts +103 -0
  256. package/src/pipeline/gate-engine.ts +35 -7
  257. package/src/pipeline/orchestrator.ts +361 -16
  258. package/src/pipeline/packets/consensus-packet-builder.ts +44 -18
  259. package/src/pipeline/phases/architecture.ts +6 -3
  260. package/src/pipeline/phases/audit.ts +6 -3
  261. package/src/pipeline/phases/consensus-architecture.ts +10 -1
  262. package/src/pipeline/phases/consensus-master-plan.ts +10 -3
  263. package/src/pipeline/phases/consensus-role-plans.ts +10 -1
  264. package/src/pipeline/phases/done.ts +15 -4
  265. package/src/pipeline/phases/intake.ts +7 -3
  266. package/src/pipeline/phases/phase-context.ts +6 -1
  267. package/src/pipeline/phases/production-gate.ts +41 -3
  268. package/src/pipeline/phases/qa-validation.ts +51 -5
  269. package/src/pipeline/phases/recovery-loop.ts +229 -7
  270. package/src/pipeline/phases/review.ts +73 -30
  271. package/src/pipeline/phases/role-planning.ts +21 -2
  272. package/src/pipeline/phases/stuck.ts +10 -0
  273. package/src/pipeline/repo-snapshot.ts +3 -0
  274. package/src/pipeline/role-execution-adapter.ts +30 -4
  275. package/src/pipeline/skill-loader.ts +33 -0
  276. package/src/pipeline/skills/coverage-gate.ts +199 -0
  277. package/src/pipeline/skills/usage-registry.ts +87 -0
  278. package/src/pipeline/strategy-context.ts +60 -0
  279. package/src/pipeline/type-defs/artifacts.ts +4 -0
  280. package/src/pipeline/type-defs/checks.ts +4 -0
  281. package/src/pipeline/type-defs/packets.ts +18 -1
  282. package/src/pipeline/type-defs/state.ts +26 -1
  283. package/src/shared/text-utils.ts +70 -0
  284. package/src/shared/website-strategy-format.ts +56 -0
  285. package/src/state/index.ts +60 -8
  286. package/src/types/consensus.ts +1 -0
  287. package/src/types/workflow.ts +6 -0
  288. package/src/upgrade/handlers.ts +9 -3
  289. package/src/workflow/consensus.ts +1 -0
  290. package/src/workflow/website-strategy.ts +2 -36
  291. package/src/workflow/website-updater.ts +4 -2
  292. package/tests/adapters/gemini.test.ts +165 -0
  293. package/tests/adapters/grok.test.ts +137 -0
  294. package/tests/adapters/openai.test.ts +128 -0
  295. package/tests/generators/doc-parser.test.ts +88 -9
  296. package/tests/generators/quality-gate.test.ts +19 -3
  297. package/tests/generators/website-components.test.ts +34 -0
  298. package/tests/generators/website-content-ai.test.ts +308 -0
  299. package/tests/generators/website-content-scanner.test.ts +86 -0
  300. package/tests/generators/website-context.test.ts +3 -2
  301. package/tests/integration/smokestack-scaffold.test.ts +385 -0
  302. package/tests/pipeline/auto-recovery.test.ts +337 -0
  303. package/tests/pipeline/change-request.test.ts +70 -0
  304. package/tests/pipeline/command-resolver.test.ts +42 -0
  305. package/tests/pipeline/consensus/arbitrator-query.test.ts +107 -0
  306. package/tests/pipeline/consensus-runner.test.ts +1333 -10
  307. package/tests/pipeline/consensus-scoring.test.ts +602 -18
  308. package/tests/pipeline/gate-engine.test.ts +34 -0
  309. package/tests/pipeline/install-check.test.ts +261 -0
  310. package/tests/pipeline/orchestrator.test.ts +1506 -15
  311. package/tests/pipeline/packets/builders.test.ts +29 -6
  312. package/tests/pipeline/phases/role-planning.strategy.test.ts +204 -0
  313. package/tests/pipeline/pipeline-persistence.test.ts +230 -0
  314. package/tests/pipeline/recovery-loop-guidance.test.ts +280 -0
  315. package/tests/pipeline/role-execution-adapter.test.ts +88 -0
  316. package/tests/pipeline/skills/coverage-gate.test.ts +370 -0
  317. package/tests/pipeline/skills/usage-registry.test.ts +114 -0
  318. package/tests/pipeline/strategy-context.test.ts +148 -0
  319. package/tests/shared/text-utils.test.ts +155 -0
  320. package/tests/state/progress-analysis.test.ts +375 -0
  321. package/tests/upgrade/handlers.test.ts +33 -2
  322. package/tests/workflow/consensus.test.ts +6 -0
  323. package/tsconfig.json +1 -1
@@ -8,6 +8,7 @@ import { createGateEngine } from '../../src/pipeline/gate-engine.js';
8
8
  import { createDefaultPipelineState } from '../../src/pipeline/types.js';
9
9
  import type { PipelinePhase, PipelineState, ArtifactEntry } from '../../src/pipeline/types.js';
10
10
  import type { GateResult } from '../../src/pipeline/gate-engine.js';
11
+ import { resolveActiveCR, computeLoopSignature, checkStagnation, STAGNATION_THRESHOLD } from '../../src/pipeline/cr-lifecycle.js';
11
12
 
12
13
  // We test the orchestrator's core logic by simulating gate/phase behavior
13
14
  // rather than importing runPipeline (which pulls in LLM deps)
@@ -30,44 +31,139 @@ function makeArtifact(type: string, phase: string): ArtifactEntry {
30
31
  /**
31
32
  * Simulates the orchestrator's core transition logic without
32
33
  * actually running phase handlers or importing LLM dependencies.
34
+ *
35
+ * v2.5.4: Includes recovery budget reset on forward phase change and CR routing.
36
+ * v2.7.0: Includes failedPhase guard on budget reset, baseline capture, and regression detection.
33
37
  */
34
38
  function simulateOrchestratorLoop(
35
39
  startPhase: PipelinePhase,
36
40
  pipeline: PipelineState,
37
- phaseOutcomes: Map<PipelinePhase, boolean>, // phase -> gate pass?
38
- maxIterations = 50,
39
- ): { finalPhase: PipelinePhase; recoveryCount: number; phaseLog: PipelinePhase[] } {
41
+ phaseOutcomes: Map<PipelinePhase, boolean> | ((phase: PipelinePhase, attempt: number) => boolean),
42
+ options?: {
43
+ maxIterations?: number;
44
+ /** Pending CRs to route after REVIEW/AUDIT */
45
+ pendingCRs?: Array<{ cr_id: string; target_phase: PipelinePhase; status: string }>;
46
+ /** v2.7.0: Custom gate results per phase (for regression detection tests) */
47
+ gateResultOverrides?: Map<PipelinePhase, (attempt: number) => Partial<GateResult>>;
48
+ },
49
+ ): { finalPhase: PipelinePhase; recoveryCount: number; phaseLog: PipelinePhase[]; progressLog: string[] } {
50
+ const maxIterations = options?.maxIterations ?? 50;
40
51
  const engine = createGateEngine();
41
52
  let phase = startPhase;
42
53
  let failedPhase: PipelinePhase | null = null;
43
54
  const phaseLog: PipelinePhase[] = [phase];
55
+ const progressLog: string[] = [];
56
+ const attemptCounts = new Map<PipelinePhase, number>();
57
+
58
+ // Install pending CRs if provided
59
+ if (options?.pendingCRs) {
60
+ pipeline.pendingChangeRequests = options.pendingCRs as PipelineState['pendingChangeRequests'];
61
+ }
62
+
63
+ const crCheckPhases = new Set<PipelinePhase>(['REVIEW', 'AUDIT']);
64
+
65
+ const getOutcome = (p: PipelinePhase): boolean => {
66
+ const attempt = (attemptCounts.get(p) ?? 0) + 1;
67
+ attemptCounts.set(p, attempt);
68
+ if (typeof phaseOutcomes === 'function') {
69
+ return phaseOutcomes(p, attempt);
70
+ }
71
+ return phaseOutcomes.get(p) ?? false;
72
+ };
73
+
74
+ /** v2.7.0: Build a GateResult for the current phase (used for regression detection) */
75
+ const getGateResult = (p: PipelinePhase, pass: boolean): GateResult => {
76
+ const attempt = attemptCounts.get(p) ?? 1;
77
+ const overrideFn = options?.gateResultOverrides?.get(p);
78
+ const overrides = overrideFn ? overrideFn(attempt) : {};
79
+ return {
80
+ phase: p,
81
+ pass,
82
+ blockers: [],
83
+ missingArtifacts: [],
84
+ failedChecks: [],
85
+ timestamp: new Date().toISOString(),
86
+ ...overrides,
87
+ };
88
+ };
44
89
 
45
90
  for (let i = 0; i < maxIterations; i++) {
46
91
  if (phase === 'DONE' || phase === 'STUCK') break;
47
92
 
48
- const gatePass = phaseOutcomes.get(phase) ?? false;
93
+ const gatePass = getOutcome(phase);
94
+ const gateResult = getGateResult(phase, gatePass);
49
95
 
50
96
  if (gatePass) {
97
+ // v2.7.0: Clear failedPhase when the originally-failed phase now passes
98
+ if (pipeline.failedPhase === phase) {
99
+ pipeline.failedPhase = undefined;
100
+ pipeline.recoveryBaselineFailedCheckCount = undefined;
101
+ }
102
+
103
+ // v2.5.4: Check for pending CR routing after REVIEW/AUDIT
104
+ if (crCheckPhases.has(phase) && pipeline.pendingChangeRequests) {
105
+ const nextCR = pipeline.pendingChangeRequests.find((cr) => cr.status === 'proposed');
106
+ if (nextCR) {
107
+ nextCR.status = 'approved';
108
+ // v2.5.4: CR routing to a new phase — reset recovery budget
109
+ if (nextCR.target_phase !== phase && pipeline.recoveryCount > 0) {
110
+ progressLog.push(`Recovery budget reset: ${pipeline.recoveryCount} -> 0 (CR routing ${phase} -> ${nextCR.target_phase})`);
111
+ pipeline.recoveryCount = 0;
112
+ pipeline.lastRewindTarget = undefined;
113
+ }
114
+ pipeline.activeChangeRequestId = nextCR.cr_id;
115
+ phase = nextCR.target_phase;
116
+ pipeline.pipelinePhase = phase;
117
+ phaseLog.push(phase);
118
+ continue;
119
+ }
120
+ }
121
+
51
122
  if (phase === 'RECOVERY_LOOP') {
52
123
  // Recovery succeeded — go back to failed phase
53
124
  phase = failedPhase ?? 'QA_VALIDATION';
54
125
  failedPhase = null;
55
126
  } else {
56
- const gateResult: GateResult = {
57
- phase,
58
- pass: true,
59
- blockers: [],
60
- missingArtifacts: [],
61
- failedChecks: [],
62
- timestamp: new Date().toISOString(),
63
- };
64
- phase = engine.getNextPhase(phase, gateResult);
127
+ const nextPhase = engine.getNextPhase(phase, gateResult);
128
+
129
+ // v2.5.4 + v2.7.0: Reset recovery budget on forward phase change,
130
+ // but NOT during recovery traversal (failedPhase still set)
131
+ if (nextPhase !== phase && pipeline.recoveryCount > 0 && !pipeline.failedPhase) {
132
+ progressLog.push(`Recovery budget reset: ${pipeline.recoveryCount} -> 0 (advancing ${phase} -> ${nextPhase})`);
133
+ pipeline.recoveryCount = 0;
134
+ pipeline.lastRewindTarget = undefined;
135
+ }
136
+
137
+ phase = nextPhase;
65
138
  }
66
139
  } else {
140
+ // v2.7.0: Regression detection — recovery made things worse
141
+ if (
142
+ pipeline.failedPhase === phase &&
143
+ pipeline.recoveryBaselineFailedCheckCount !== undefined &&
144
+ (gateResult.failedChecks.length + gateResult.missingArtifacts.length) > pipeline.recoveryBaselineFailedCheckCount
145
+ ) {
146
+ progressLog.push(
147
+ `[regression] Recovery worsened ${phase}: ` +
148
+ `${pipeline.recoveryBaselineFailedCheckCount} -> ` +
149
+ `${gateResult.failedChecks.length + gateResult.missingArtifacts.length} failing checks. ` +
150
+ `Treating budget as exhausted.`
151
+ );
152
+ pipeline.recoveryCount = pipeline.maxRecoveryIterations;
153
+ }
154
+
67
155
  if (pipeline.recoveryCount >= pipeline.maxRecoveryIterations) {
68
156
  phase = 'STUCK';
69
157
  } else {
158
+ // v2.7.0: Capture baseline for fresh failure or when failure origin changes
159
+ if (
160
+ pipeline.recoveryBaselineFailedCheckCount === undefined ||
161
+ pipeline.failedPhase !== phase
162
+ ) {
163
+ pipeline.recoveryBaselineFailedCheckCount = gateResult.failedChecks.length + gateResult.missingArtifacts.length;
164
+ }
70
165
  failedPhase = phase;
166
+ pipeline.failedPhase = phase;
71
167
  phase = 'RECOVERY_LOOP';
72
168
  pipeline.recoveryCount++;
73
169
  }
@@ -77,7 +173,7 @@ function simulateOrchestratorLoop(
77
173
  phaseLog.push(phase);
78
174
  }
79
175
 
80
- return { finalPhase: phase, recoveryCount: pipeline.recoveryCount, phaseLog };
176
+ return { finalPhase: phase, recoveryCount: pipeline.recoveryCount, phaseLog, progressLog };
81
177
  }
82
178
 
83
179
  describe('Orchestrator Transition Logic', () => {
@@ -284,12 +380,13 @@ describe('Orchestrator Transition Logic', () => {
284
380
  const result1 = engine.evaluateGate('PRODUCTION_GATE', pipeline);
285
381
  expect(result1.pass).toBe(false);
286
382
 
287
- // Add check results
383
+ // Add check results (including skill_coverage added in v2.2.1)
288
384
  pipeline.gateChecks['PRODUCTION_GATE'] = [
289
385
  { check_type: 'build', status: 'pass', command: 'npm run build', exit_code: 0, duration_ms: 1000, timestamp: '' },
290
386
  { check_type: 'test', status: 'pass', command: 'npm test', exit_code: 0, duration_ms: 2000, timestamp: '' },
291
387
  { check_type: 'lint', status: 'pass', command: 'npm run lint', exit_code: 0, duration_ms: 500, timestamp: '' },
292
388
  { check_type: 'typecheck', status: 'pass', command: 'tsc', exit_code: 0, duration_ms: 300, timestamp: '' },
389
+ { check_type: 'skill_coverage', status: 'pass', command: 'skill-coverage-check', exit_code: 0, duration_ms: 0, timestamp: '' },
293
390
  ];
294
391
 
295
392
  const result2 = engine.evaluateGate('PRODUCTION_GATE', pipeline);
@@ -611,4 +708,1398 @@ describe('Orchestrator Transition Logic', () => {
611
708
  expect(phase).toBe('QA_VALIDATION');
612
709
  });
613
710
  });
711
+
712
+ // ─── v2.4.5: Resume from STUCK ──────────────────────────
713
+
714
+ describe('v2.4.5: resume from STUCK', () => {
715
+ it('should reset to failedPhase when resuming from STUCK', () => {
716
+ pipeline.pipelinePhase = 'STUCK';
717
+ pipeline.failedPhase = 'CONSENSUS_ROLE_PLANS';
718
+ pipeline.recoveryCount = 5;
719
+
720
+ // Simulate resumePipeline logic (without calling runPipeline)
721
+ if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
722
+ pipeline.pipelinePhase = pipeline.failedPhase;
723
+ pipeline.recoveryCount = 0;
724
+ }
725
+
726
+ expect(pipeline.pipelinePhase).toBe('CONSENSUS_ROLE_PLANS');
727
+ expect(pipeline.recoveryCount).toBe(0);
728
+ });
729
+
730
+ it('should clear stale gate data for the failed phase', () => {
731
+ pipeline.pipelinePhase = 'STUCK';
732
+ pipeline.failedPhase = 'CONSENSUS_ROLE_PLANS';
733
+ pipeline.recoveryCount = 5;
734
+
735
+ // Populate gate data that should be cleared
736
+ pipeline.gateResults['CONSENSUS_ROLE_PLANS'] = {
737
+ phase: 'CONSENSUS_ROLE_PLANS',
738
+ pass: false,
739
+ blockers: ['skill_coverage failed'],
740
+ missingArtifacts: [],
741
+ failedChecks: [],
742
+ timestamp: new Date().toISOString(),
743
+ };
744
+ pipeline.gateChecks['CONSENSUS_ROLE_PLANS'] = [
745
+ { check_type: 'skill_coverage', status: 'fail', command: 'skill-coverage-check', exit_code: 1, duration_ms: 0, timestamp: '' },
746
+ ];
747
+
748
+ // Preserve other phases' gate data
749
+ pipeline.gateResults['CONSENSUS_ARCHITECTURE'] = {
750
+ phase: 'CONSENSUS_ARCHITECTURE',
751
+ pass: true,
752
+ blockers: [],
753
+ missingArtifacts: [],
754
+ failedChecks: [],
755
+ timestamp: new Date().toISOString(),
756
+ };
757
+
758
+ // Simulate resume logic
759
+ if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
760
+ pipeline.pipelinePhase = pipeline.failedPhase;
761
+ pipeline.recoveryCount = 0;
762
+ delete pipeline.gateResults[pipeline.failedPhase];
763
+ delete pipeline.gateChecks[pipeline.failedPhase];
764
+ }
765
+
766
+ expect(pipeline.gateResults['CONSENSUS_ROLE_PLANS']).toBeUndefined();
767
+ expect(pipeline.gateChecks['CONSENSUS_ROLE_PLANS']).toBeUndefined();
768
+ // Other phases preserved
769
+ expect(pipeline.gateResults['CONSENSUS_ARCHITECTURE']).toBeDefined();
770
+ });
771
+
772
+ it('should not modify pipeline when not at STUCK', () => {
773
+ pipeline.pipelinePhase = 'ROLE_PLANNING';
774
+ pipeline.failedPhase = undefined;
775
+ pipeline.recoveryCount = 2;
776
+
777
+ // Simulate resume logic — condition not met
778
+ if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
779
+ pipeline.pipelinePhase = pipeline.failedPhase;
780
+ pipeline.recoveryCount = 0;
781
+ }
782
+
783
+ expect(pipeline.pipelinePhase).toBe('ROLE_PLANNING');
784
+ expect(pipeline.recoveryCount).toBe(2);
785
+ });
786
+
787
+ it('should not auto-recover when STUCK without failedPhase', () => {
788
+ pipeline.pipelinePhase = 'STUCK';
789
+ pipeline.failedPhase = undefined;
790
+ pipeline.recoveryCount = 5;
791
+ const progressMessages: string[] = [];
792
+
793
+ // Simulate resume logic with progress tracking
794
+ if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
795
+ pipeline.pipelinePhase = pipeline.failedPhase;
796
+ pipeline.recoveryCount = 0;
797
+ } else if (pipeline.pipelinePhase === 'STUCK' && !pipeline.failedPhase) {
798
+ progressMessages.push('Pipeline is STUCK but failedPhase is missing');
799
+ }
800
+
801
+ expect(pipeline.pipelinePhase).toBe('STUCK');
802
+ expect(pipeline.recoveryCount).toBe(5);
803
+ expect(progressMessages).toHaveLength(1);
804
+ expect(progressMessages[0]).toContain('failedPhase is missing');
805
+ });
806
+
807
+ it('should reset recoveryCount from previous value to 0', () => {
808
+ pipeline.pipelinePhase = 'STUCK';
809
+ pipeline.failedPhase = 'PRODUCTION_GATE';
810
+ pipeline.recoveryCount = 3;
811
+
812
+ const prevRecovery = pipeline.recoveryCount;
813
+
814
+ if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
815
+ pipeline.pipelinePhase = pipeline.failedPhase;
816
+ pipeline.recoveryCount = 0;
817
+ }
818
+
819
+ expect(prevRecovery).toBe(3);
820
+ expect(pipeline.recoveryCount).toBe(0);
821
+ expect(pipeline.pipelinePhase).toBe('PRODUCTION_GATE');
822
+ });
823
+
824
+ it('should purge legacy CRs without drift_key on resume from STUCK with guidance (v2.5.2)', () => {
825
+ pipeline.pipelinePhase = 'STUCK';
826
+ pipeline.failedPhase = 'QA_VALIDATION';
827
+ pipeline.recoveryCount = 5;
828
+ pipeline.activeChangeRequestId = 'CR-STALE';
829
+ pipeline.pendingChangeRequests = [
830
+ // Legacy CRs (no drift_key, approved) — should be purged
831
+ { cr_id: 'CR-OLD1', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'approved' },
832
+ { cr_id: 'CR-OLD2', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'approved' },
833
+ { cr_id: 'CR-STALE', change_type: 'scope', target_phase: 'CONSENSUS_MASTER_PLAN', status: 'approved' },
834
+ // New CR with drift_key — should be kept
835
+ { cr_id: 'CR-NEW', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'resolved', drift_key: 'dk-1' },
836
+ // Proposed CR without drift_key (possibly manual) — should be kept
837
+ { cr_id: 'CR-MANUAL', change_type: 'scope', target_phase: 'CONSENSUS_MASTER_PLAN', status: 'proposed' },
838
+ ];
839
+
840
+ // Simulate resume logic: purge runs BEFORE guidance check (v2.5.2)
841
+ if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
842
+ // v2.5.2: Purge legacy CRs (unconditional)
843
+ pipeline.pendingChangeRequests = pipeline.pendingChangeRequests!.filter(
844
+ (cr) => cr.drift_key != null || cr.status === 'proposed',
845
+ );
846
+ if (!pipeline.pendingChangeRequests.some((cr) => cr.cr_id === pipeline.activeChangeRequestId)) {
847
+ pipeline.activeChangeRequestId = undefined;
848
+ }
849
+
850
+ // Guidance provided — reset phase
851
+ pipeline.pipelinePhase = pipeline.failedPhase;
852
+ pipeline.recoveryCount = 0;
853
+ pipeline.lastRewindTarget = undefined;
854
+ delete pipeline.gateResults[pipeline.failedPhase];
855
+ delete pipeline.gateChecks[pipeline.failedPhase];
856
+ }
857
+
858
+ expect(pipeline.pendingChangeRequests).toHaveLength(2); // CR-NEW + CR-MANUAL
859
+ expect(pipeline.pendingChangeRequests!.map(cr => cr.cr_id)).toEqual(['CR-NEW', 'CR-MANUAL']);
860
+ expect(pipeline.activeChangeRequestId).toBeUndefined(); // CR-STALE was purged
861
+ expect(pipeline.pipelinePhase).toBe('QA_VALIDATION'); // Reset to failed phase
862
+ });
863
+
864
+ it('should purge legacy CRs even without guidance on resume from STUCK (v2.5.2)', () => {
865
+ pipeline.pipelinePhase = 'STUCK';
866
+ pipeline.failedPhase = 'QA_VALIDATION';
867
+ pipeline.recoveryCount = 5;
868
+ pipeline.activeChangeRequestId = 'CR-STALE';
869
+ pipeline.pendingChangeRequests = [
870
+ { cr_id: 'CR-OLD1', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'approved' },
871
+ { cr_id: 'CR-STALE', change_type: 'scope', target_phase: 'CONSENSUS_MASTER_PLAN', status: 'approved' },
872
+ { cr_id: 'CR-NEW', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'resolved', drift_key: 'dk-1' },
873
+ ];
874
+
875
+ // Simulate resume logic: purge runs even without guidance
876
+ const guidance = ''; // No guidance
877
+ if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
878
+ // v2.5.2: Purge legacy CRs (unconditional — before guidance check)
879
+ pipeline.pendingChangeRequests = pipeline.pendingChangeRequests!.filter(
880
+ (cr) => cr.drift_key != null || cr.status === 'proposed',
881
+ );
882
+ if (!pipeline.pendingChangeRequests.some((cr) => cr.cr_id === pipeline.activeChangeRequestId)) {
883
+ pipeline.activeChangeRequestId = undefined;
884
+ }
885
+
886
+ if (guidance.length === 0) {
887
+ // No guidance — pipeline stays STUCK, but CRs are cleaned
888
+ }
889
+ }
890
+
891
+ expect(pipeline.pendingChangeRequests).toHaveLength(1); // Only CR-NEW
892
+ expect(pipeline.pendingChangeRequests![0].cr_id).toBe('CR-NEW');
893
+ expect(pipeline.activeChangeRequestId).toBeUndefined(); // CR-STALE was purged
894
+ expect(pipeline.pipelinePhase).toBe('STUCK'); // Still stuck (no guidance)
895
+ expect(pipeline.recoveryCount).toBe(5); // Not reset (no guidance)
896
+ });
897
+ });
898
+
899
+ // ─── v2.4.6: Recovery rewind loop detection ──────────────
900
+
901
+ describe('v2.4.6: Recovery rewind loop detection', () => {
902
+ it('should rewind normally on first QA failure (lastRewindTarget undefined)', () => {
903
+ // Simulate: RECOVERY_LOOP passes, RCA says IMPLEMENTATION, lastRewindTarget is undefined
904
+ let phase: PipelinePhase = 'RECOVERY_LOOP';
905
+ let failedPhase: PipelinePhase | null = 'QA_VALIDATION';
906
+ pipeline.lastRewindTarget = undefined;
907
+ pipeline.recoveryCount = 1;
908
+
909
+ // Simulate RCA rewind target
910
+ let rewindTarget: PipelinePhase | undefined = 'IMPLEMENTATION';
911
+
912
+ // v2.4.6 repeated-rewind check
913
+ if (rewindTarget && rewindTarget === pipeline.lastRewindTarget) {
914
+ rewindTarget = undefined;
915
+ }
916
+
917
+ const effectiveTarget = rewindTarget ?? failedPhase ?? 'QA_VALIDATION';
918
+ phase = effectiveTarget;
919
+ pipeline.lastRewindTarget = rewindTarget;
920
+
921
+ if (failedPhase) {
922
+ delete pipeline.gateResults[failedPhase];
923
+ delete pipeline.gateChecks[failedPhase];
924
+ }
925
+ failedPhase = null;
926
+
927
+ expect(phase).toBe('IMPLEMENTATION');
928
+ expect(pipeline.lastRewindTarget).toBe('IMPLEMENTATION');
929
+ });
930
+
931
+ it('should skip repeated same-target rewind and re-test failed phase', () => {
932
+ // Simulate: lastRewindTarget === 'IMPLEMENTATION', RCA says IMPLEMENTATION again
933
+ let phase: PipelinePhase = 'RECOVERY_LOOP';
934
+ let failedPhase: PipelinePhase | null = 'QA_VALIDATION';
935
+ pipeline.lastRewindTarget = 'IMPLEMENTATION';
936
+ pipeline.recoveryCount = 2;
937
+ const progressMessages: string[] = [];
938
+
939
+ let rewindTarget: PipelinePhase | undefined = 'IMPLEMENTATION';
940
+
941
+ // v2.4.6 repeated-rewind check
942
+ if (rewindTarget && rewindTarget === pipeline.lastRewindTarget) {
943
+ progressMessages.push(
944
+ `Repeated rewind to ${rewindTarget} detected ` +
945
+ `(recovery #${pipeline.recoveryCount}) — re-testing ` +
946
+ `${failedPhase ?? 'QA_VALIDATION'} directly`,
947
+ );
948
+ rewindTarget = undefined;
949
+ }
950
+
951
+ const effectiveTarget = rewindTarget ?? failedPhase ?? 'QA_VALIDATION';
952
+ phase = effectiveTarget;
953
+ pipeline.lastRewindTarget = rewindTarget;
954
+ failedPhase = null;
955
+
956
+ expect(phase).toBe('QA_VALIDATION');
957
+ expect(pipeline.lastRewindTarget).toBeUndefined();
958
+ expect(progressMessages).toHaveLength(1);
959
+ expect(progressMessages[0]).toContain('Repeated rewind');
960
+ });
961
+
962
+ it('should allow different rewind targets to proceed normally', () => {
963
+ // First rewind: IMPLEMENTATION
964
+ pipeline.lastRewindTarget = 'IMPLEMENTATION';
965
+
966
+ // Second RCA says ARCHITECTURE (different target)
967
+ let rewindTarget: PipelinePhase | undefined = 'ARCHITECTURE';
968
+
969
+ if (rewindTarget && rewindTarget === pipeline.lastRewindTarget) {
970
+ rewindTarget = undefined;
971
+ }
972
+
973
+ const effectiveTarget = rewindTarget ?? 'QA_VALIDATION';
974
+ pipeline.lastRewindTarget = rewindTarget;
975
+
976
+ expect(effectiveTarget).toBe('ARCHITECTURE');
977
+ expect(pipeline.lastRewindTarget).toBe('ARCHITECTURE');
978
+ });
979
+
980
+ it('should clear stale gate data after recovery rewind', () => {
981
+ const failedPhase: PipelinePhase = 'QA_VALIDATION';
982
+
983
+ // Populate stale gate data
984
+ pipeline.gateResults['QA_VALIDATION'] = {
985
+ phase: 'QA_VALIDATION',
986
+ pass: false,
987
+ blockers: ['test failed'],
988
+ missingArtifacts: [],
989
+ failedChecks: [],
990
+ timestamp: new Date().toISOString(),
991
+ };
992
+ pipeline.gateChecks['QA_VALIDATION'] = [
993
+ { check_type: 'test', status: 'fail', command: 'npm test', exit_code: 1, duration_ms: 500, timestamp: '' },
994
+ ];
995
+
996
+ // Preserve other phases' data
997
+ pipeline.gateResults['IMPLEMENTATION'] = {
998
+ phase: 'IMPLEMENTATION',
999
+ pass: true,
1000
+ blockers: [],
1001
+ missingArtifacts: [],
1002
+ failedChecks: [],
1003
+ timestamp: new Date().toISOString(),
1004
+ };
1005
+
1006
+ // Simulate clearing (as orchestrator does after recovery)
1007
+ delete pipeline.gateResults[failedPhase];
1008
+ delete pipeline.gateChecks[failedPhase];
1009
+
1010
+ expect(pipeline.gateResults['QA_VALIDATION']).toBeUndefined();
1011
+ expect(pipeline.gateChecks['QA_VALIDATION']).toBeUndefined();
1012
+ expect(pipeline.gateResults['IMPLEMENTATION']).toBeDefined();
1013
+ });
1014
+
1015
+ it('should clear lastRewindTarget when resuming from STUCK', () => {
1016
+ pipeline.pipelinePhase = 'STUCK';
1017
+ pipeline.failedPhase = 'QA_VALIDATION';
1018
+ pipeline.recoveryCount = 5;
1019
+ pipeline.lastRewindTarget = 'IMPLEMENTATION';
1020
+
1021
+ // Simulate resumePipeline logic
1022
+ if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
1023
+ pipeline.pipelinePhase = pipeline.failedPhase;
1024
+ pipeline.recoveryCount = 0;
1025
+ pipeline.lastRewindTarget = undefined;
1026
+ delete pipeline.gateResults[pipeline.failedPhase];
1027
+ delete pipeline.gateChecks[pipeline.failedPhase];
1028
+ }
1029
+
1030
+ expect(pipeline.pipelinePhase).toBe('QA_VALIDATION');
1031
+ expect(pipeline.recoveryCount).toBe(0);
1032
+ expect(pipeline.lastRewindTarget).toBeUndefined();
1033
+ });
1034
+ });
1035
+
1036
+ // ─── v2.4.8: RECOVERY_LOOP auto-resume ──────────────
1037
+
1038
+ describe('v2.4.8: RECOVERY_LOOP auto-resume', () => {
1039
+ it('should auto-resume from RECOVERY_LOOP without guidance', () => {
1040
+ pipeline.pipelinePhase = 'RECOVERY_LOOP';
1041
+ pipeline.failedPhase = 'QA_VALIDATION';
1042
+ pipeline.recoveryCount = 1;
1043
+ pipeline.maxRecoveryIterations = 5;
1044
+ pipeline.lastRewindTarget = 'IMPLEMENTATION';
1045
+
1046
+ // Simulate v2.4.8 resumePipeline logic: RECOVERY_LOOP with remaining attempts
1047
+ if (
1048
+ pipeline.pipelinePhase === 'RECOVERY_LOOP' &&
1049
+ pipeline.failedPhase &&
1050
+ pipeline.recoveryCount < (pipeline.maxRecoveryIterations ?? 5)
1051
+ ) {
1052
+ pipeline.pipelinePhase = pipeline.failedPhase;
1053
+ delete pipeline.gateResults[pipeline.failedPhase];
1054
+ delete pipeline.gateChecks[pipeline.failedPhase];
1055
+ // Do NOT reset recoveryCount or lastRewindTarget
1056
+ }
1057
+
1058
+ expect(pipeline.pipelinePhase).toBe('QA_VALIDATION');
1059
+ expect(pipeline.recoveryCount).toBe(1); // preserved, not reset
1060
+ expect(pipeline.lastRewindTarget).toBe('IMPLEMENTATION'); // preserved
1061
+ });
1062
+
1063
+ it('should preserve recoveryCount (not reset to 0) on auto-resume', () => {
1064
+ pipeline.pipelinePhase = 'RECOVERY_LOOP';
1065
+ pipeline.failedPhase = 'QA_VALIDATION';
1066
+ pipeline.recoveryCount = 3;
1067
+ pipeline.maxRecoveryIterations = 5;
1068
+
1069
+ if (
1070
+ pipeline.pipelinePhase === 'RECOVERY_LOOP' &&
1071
+ pipeline.failedPhase &&
1072
+ pipeline.recoveryCount < (pipeline.maxRecoveryIterations ?? 5)
1073
+ ) {
1074
+ pipeline.pipelinePhase = pipeline.failedPhase;
1075
+ delete pipeline.gateResults[pipeline.failedPhase];
1076
+ delete pipeline.gateChecks[pipeline.failedPhase];
1077
+ }
1078
+
1079
+ expect(pipeline.recoveryCount).toBe(3);
1080
+ expect(pipeline.pipelinePhase).toBe('QA_VALIDATION');
1081
+ });
1082
+
1083
+ it('should clear stale gate data on auto-resume', () => {
1084
+ pipeline.pipelinePhase = 'RECOVERY_LOOP';
1085
+ pipeline.failedPhase = 'QA_VALIDATION';
1086
+ pipeline.recoveryCount = 1;
1087
+ pipeline.maxRecoveryIterations = 5;
1088
+
1089
+ // Pre-fill gate data
1090
+ pipeline.gateResults['QA_VALIDATION'] = {
1091
+ phase: 'QA_VALIDATION',
1092
+ pass: false,
1093
+ blockers: ['test failed'],
1094
+ missingArtifacts: [],
1095
+ failedChecks: [],
1096
+ timestamp: new Date().toISOString(),
1097
+ };
1098
+ pipeline.gateChecks['QA_VALIDATION'] = [
1099
+ { check_type: 'test', status: 'fail', command: 'npm test', exit_code: 1, duration_ms: 500, timestamp: '' },
1100
+ ];
1101
+ pipeline.gateResults['IMPLEMENTATION'] = {
1102
+ phase: 'IMPLEMENTATION',
1103
+ pass: true,
1104
+ blockers: [],
1105
+ missingArtifacts: [],
1106
+ failedChecks: [],
1107
+ timestamp: new Date().toISOString(),
1108
+ };
1109
+
1110
+ if (
1111
+ pipeline.pipelinePhase === 'RECOVERY_LOOP' &&
1112
+ pipeline.failedPhase &&
1113
+ pipeline.recoveryCount < (pipeline.maxRecoveryIterations ?? 5)
1114
+ ) {
1115
+ pipeline.pipelinePhase = pipeline.failedPhase;
1116
+ delete pipeline.gateResults[pipeline.failedPhase];
1117
+ delete pipeline.gateChecks[pipeline.failedPhase];
1118
+ }
1119
+
1120
+ expect(pipeline.gateResults['QA_VALIDATION']).toBeUndefined();
1121
+ expect(pipeline.gateChecks['QA_VALIDATION']).toBeUndefined();
1122
+ expect(pipeline.gateResults['IMPLEMENTATION']).toBeDefined();
1123
+ });
1124
+
1125
+ it('should NOT auto-resume RECOVERY_LOOP at max iterations (block like STUCK)', () => {
1126
+ pipeline.pipelinePhase = 'RECOVERY_LOOP';
1127
+ pipeline.failedPhase = 'QA_VALIDATION';
1128
+ pipeline.recoveryCount = 5;
1129
+ pipeline.maxRecoveryIterations = 5;
1130
+
1131
+ let autoResumed = false;
1132
+ let blockedLikeStuck = false;
1133
+
1134
+ // Simulate v2.4.8 resume logic
1135
+ if (
1136
+ pipeline.pipelinePhase === 'RECOVERY_LOOP' &&
1137
+ pipeline.failedPhase &&
1138
+ pipeline.recoveryCount < (pipeline.maxRecoveryIterations ?? 5)
1139
+ ) {
1140
+ autoResumed = true;
1141
+ } else if (
1142
+ (pipeline.pipelinePhase === 'STUCK' ||
1143
+ (pipeline.pipelinePhase === 'RECOVERY_LOOP' &&
1144
+ pipeline.recoveryCount >= (pipeline.maxRecoveryIterations ?? 5))) &&
1145
+ pipeline.failedPhase
1146
+ ) {
1147
+ const guidance = ''; // no guidance provided
1148
+ if (guidance.length > 0) {
1149
+ autoResumed = true;
1150
+ } else {
1151
+ blockedLikeStuck = true;
1152
+ }
1153
+ }
1154
+
1155
+ expect(autoResumed).toBe(false);
1156
+ expect(blockedLikeStuck).toBe(true);
1157
+ expect(pipeline.pipelinePhase).toBe('RECOVERY_LOOP'); // unchanged, would return error
1158
+ });
1159
+
1160
+ it('should still require guidance for STUCK (regression guard)', () => {
1161
+ pipeline.pipelinePhase = 'STUCK';
1162
+ pipeline.failedPhase = 'QA_VALIDATION';
1163
+ pipeline.recoveryCount = 5;
1164
+ pipeline.maxRecoveryIterations = 5;
1165
+
1166
+ let autoResumed = false;
1167
+ let blockedLikeStuck = false;
1168
+
1169
+ // Simulate v2.4.8 resume logic
1170
+ if (
1171
+ pipeline.pipelinePhase === 'RECOVERY_LOOP' &&
1172
+ pipeline.failedPhase &&
1173
+ pipeline.recoveryCount < (pipeline.maxRecoveryIterations ?? 5)
1174
+ ) {
1175
+ autoResumed = true;
1176
+ } else if (
1177
+ (pipeline.pipelinePhase === 'STUCK' ||
1178
+ (pipeline.pipelinePhase === 'RECOVERY_LOOP' &&
1179
+ pipeline.recoveryCount >= (pipeline.maxRecoveryIterations ?? 5))) &&
1180
+ pipeline.failedPhase
1181
+ ) {
1182
+ const guidance = '';
1183
+ if (guidance.length > 0) {
1184
+ autoResumed = true;
1185
+ } else {
1186
+ blockedLikeStuck = true;
1187
+ }
1188
+ }
1189
+
1190
+ expect(autoResumed).toBe(false);
1191
+ expect(blockedLikeStuck).toBe(true);
1192
+ expect(pipeline.pipelinePhase).toBe('STUCK');
1193
+ });
1194
+ });
1195
+
1196
+ // ─── v2.4.9: CR resolution after QA passes ──────────────
1197
+
1198
+ describe('v2.4.9: CR resolution after QA passes', () => {
1199
+ it('should resolve active config CR and set baselineSnapshotOverride', () => {
1200
+ pipeline.pendingChangeRequests = [
1201
+ { cr_id: 'CR-100', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'approved', drift_key: 'dk-1' },
1202
+ ];
1203
+ pipeline.activeChangeRequestId = 'CR-100';
1204
+ pipeline.latestRepoSnapshot = {
1205
+ artifact_id: 'snap-latest',
1206
+ path: 'docs/snapshot.json',
1207
+ sha256: 'abc',
1208
+ version: 1,
1209
+ type: 'repo_snapshot',
1210
+ };
1211
+
1212
+ resolveActiveCR(pipeline);
1213
+
1214
+ expect(pipeline.pendingChangeRequests![0].status).toBe('resolved');
1215
+ expect(pipeline.activeChangeRequestId).toBeUndefined();
1216
+ expect(pipeline.baselineSnapshotOverride).toBeDefined();
1217
+ expect(pipeline.baselineSnapshotOverride!.artifact_id).toBe('snap-latest');
1218
+ });
1219
+
1220
+ it('should resolve non-config CR and advance baseline (v2.5.1: all types advance)', () => {
1221
+ // v2.5.1: ALL CR types advance baseline, not just config
1222
+ const changeTypes = ['scope', 'architecture', 'dependency', 'requirement'] as const;
1223
+ for (const changeType of changeTypes) {
1224
+ const p = createDefaultPipelineState();
1225
+ p.pendingChangeRequests = [
1226
+ { cr_id: `CR-${changeType}`, change_type: changeType, target_phase: 'CONSENSUS_MASTER_PLAN', status: 'approved', drift_key: `dk-${changeType}` },
1227
+ ];
1228
+ p.activeChangeRequestId = `CR-${changeType}`;
1229
+ p.latestRepoSnapshot = {
1230
+ artifact_id: `snap-${changeType}`,
1231
+ path: 'docs/snapshot.json',
1232
+ sha256: 'abc',
1233
+ version: 1,
1234
+ type: 'repo_snapshot',
1235
+ };
1236
+
1237
+ resolveActiveCR(p);
1238
+
1239
+ expect(p.pendingChangeRequests![0].status).toBe('resolved');
1240
+ expect(p.activeChangeRequestId).toBeUndefined();
1241
+ expect(p.baselineSnapshotOverride).toBeDefined();
1242
+ expect(p.baselineSnapshotOverride!.artifact_id).toBe(`snap-${changeType}`);
1243
+ }
1244
+ });
1245
+
1246
+ it('should gracefully do nothing when no activeChangeRequestId', () => {
1247
+ pipeline.activeChangeRequestId = undefined;
1248
+ pipeline.pendingChangeRequests = [
1249
+ { cr_id: 'CR-300', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'approved' },
1250
+ ];
1251
+
1252
+ resolveActiveCR(pipeline);
1253
+
1254
+ // Nothing changed
1255
+ expect(pipeline.pendingChangeRequests![0].status).toBe('approved');
1256
+ });
1257
+ });
1258
+
1259
+ // ─── v2.4.9: Stagnation detection ──────────────────────
1260
+
1261
+ describe('v2.4.9: Stagnation detection', () => {
1262
+ it('should detect stagnation when same signature repeats STAGNATION_THRESHOLD times', () => {
1263
+ // Set up identical state to produce identical signatures
1264
+ pipeline.pipelinePhase = 'REVIEW';
1265
+ pipeline.lastSignatures = [];
1266
+
1267
+ // Call checkStagnation repeatedly with identical state
1268
+ let stagnant = false;
1269
+ for (let i = 0; i < STAGNATION_THRESHOLD; i++) {
1270
+ stagnant = checkStagnation(pipeline);
1271
+ }
1272
+
1273
+ expect(stagnant).toBe(true);
1274
+ expect(pipeline.lastSignatures!.length).toBe(STAGNATION_THRESHOLD);
1275
+ });
1276
+
1277
+ it('should not detect stagnation with fewer than threshold calls', () => {
1278
+ pipeline.pipelinePhase = 'REVIEW';
1279
+ pipeline.lastSignatures = [];
1280
+
1281
+ let stagnant = false;
1282
+ for (let i = 0; i < STAGNATION_THRESHOLD - 1; i++) {
1283
+ stagnant = checkStagnation(pipeline);
1284
+ }
1285
+
1286
+ expect(stagnant).toBe(false);
1287
+ });
1288
+
1289
+ it('should not detect stagnation with different signatures', () => {
1290
+ pipeline.lastSignatures = [];
1291
+
1292
+ let stagnant = false;
1293
+ for (let i = 0; i < STAGNATION_THRESHOLD; i++) {
1294
+ pipeline.pipelinePhase = (['REVIEW', 'QA_VALIDATION', 'AUDIT'] as const)[i % 3];
1295
+ stagnant = checkStagnation(pipeline);
1296
+ }
1297
+
1298
+ expect(stagnant).toBe(false);
1299
+ });
1300
+
1301
+ it('should transition to STUCK on stagnation', () => {
1302
+ pipeline.pipelinePhase = 'REVIEW';
1303
+ pipeline.lastSignatures = [];
1304
+
1305
+ // Simulate the orchestrator's stagnation → STUCK transition
1306
+ let phase = pipeline.pipelinePhase;
1307
+ for (let i = 0; i < STAGNATION_THRESHOLD; i++) {
1308
+ if (checkStagnation(pipeline)) {
1309
+ phase = 'STUCK';
1310
+ }
1311
+ }
1312
+
1313
+ expect(phase).toBe('STUCK');
1314
+ });
1315
+
1316
+ it('should produce a 16-char hex signature', () => {
1317
+ pipeline.pipelinePhase = 'REVIEW';
1318
+ const sig = computeLoopSignature(pipeline);
1319
+ expect(sig).toMatch(/^[0-9a-f]{16}$/);
1320
+ });
1321
+
1322
+ it('should produce same signature regardless of pending CR count (uses boolean)', () => {
1323
+ pipeline.pipelinePhase = 'REVIEW';
1324
+
1325
+ // 1 pending CR
1326
+ pipeline.pendingChangeRequests = [
1327
+ { cr_id: 'CR-1', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'proposed' },
1328
+ ];
1329
+ const sig1 = computeLoopSignature(pipeline);
1330
+
1331
+ // 5 pending CRs — signature should be the same (boolean, not count)
1332
+ pipeline.pendingChangeRequests = [
1333
+ { cr_id: 'CR-1', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'proposed' },
1334
+ { cr_id: 'CR-2', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'approved' },
1335
+ { cr_id: 'CR-3', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'proposed' },
1336
+ { cr_id: 'CR-4', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'approved' },
1337
+ { cr_id: 'CR-5', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'proposed' },
1338
+ ];
1339
+ const sig5 = computeLoopSignature(pipeline);
1340
+
1341
+ expect(sig1).toBe(sig5);
1342
+ });
1343
+ });
1344
+
1345
+ // ─── v2.4.9: activeChangeRequestId tracking ──────────────
1346
+
1347
+ describe('v2.4.9: activeChangeRequestId tracking', () => {
1348
+ it('should set activeChangeRequestId when routing CR', () => {
1349
+ pipeline.pendingChangeRequests = [
1350
+ { cr_id: 'CR-TRACK', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'proposed' },
1351
+ ];
1352
+
1353
+ // Simulate getNextCRRoute + activeChangeRequestId assignment
1354
+ const nextCR = pipeline.pendingChangeRequests.find((cr) => cr.status === 'proposed');
1355
+ expect(nextCR).toBeDefined();
1356
+ nextCR!.status = 'approved';
1357
+ pipeline.activeChangeRequestId = nextCR!.cr_id;
1358
+
1359
+ expect(pipeline.activeChangeRequestId).toBe('CR-TRACK');
1360
+ expect(nextCR!.status).toBe('approved');
1361
+ });
1362
+
1363
+ it('should clear activeChangeRequestId on resolution', () => {
1364
+ pipeline.pendingChangeRequests = [
1365
+ { cr_id: 'CR-CLEAR', change_type: 'scope', target_phase: 'CONSENSUS_MASTER_PLAN', status: 'approved', drift_key: 'dk-x' },
1366
+ ];
1367
+ pipeline.activeChangeRequestId = 'CR-CLEAR';
1368
+
1369
+ resolveActiveCR(pipeline);
1370
+
1371
+ expect(pipeline.activeChangeRequestId).toBeUndefined();
1372
+ expect(pipeline.pendingChangeRequests![0].status).toBe('resolved');
1373
+ });
1374
+ });
1375
+
1376
+ // ─── v2.5.1: Scope drift guard (first pass vs revision) ──────
1377
+
1378
+ describe('v2.5.1: Scope drift guard', () => {
1379
+ it('should NOT create scope CR on first REVIEW (no baselineSnapshotOverride)', () => {
1380
+ // Simulate: first REVIEW pass, baseline is CONSENSUS_ROLE_PLANS, large line delta
1381
+ pipeline.baselineSnapshotOverride = undefined;
1382
+ const isRevisionComparison = !!pipeline.baselineSnapshotOverride;
1383
+ const linesDelta = 5000; // huge, but expected from implementation
1384
+
1385
+ // The guard in review.ts: `if (isRevisionComparison && Math.abs(diff.lines_delta) > 1000)`
1386
+ const shouldCreateScopeCR = isRevisionComparison && Math.abs(linesDelta) > 1000;
1387
+
1388
+ expect(isRevisionComparison).toBe(false);
1389
+ expect(shouldCreateScopeCR).toBe(false);
1390
+ });
1391
+
1392
+ it('should create scope CR on revision pass (baselineSnapshotOverride set)', () => {
1393
+ // Simulate: subsequent REVIEW, baseline override set, large line delta
1394
+ pipeline.baselineSnapshotOverride = {
1395
+ artifact_id: 'snap-override',
1396
+ path: 'docs/snapshot.json',
1397
+ sha256: 'abc',
1398
+ version: 1,
1399
+ type: 'repo_snapshot',
1400
+ };
1401
+ const isRevisionComparison = !!pipeline.baselineSnapshotOverride;
1402
+ const linesDelta = 2000;
1403
+
1404
+ const shouldCreateScopeCR = isRevisionComparison && Math.abs(linesDelta) > 1000;
1405
+
1406
+ expect(isRevisionComparison).toBe(true);
1407
+ expect(shouldCreateScopeCR).toBe(true);
1408
+ });
1409
+
1410
+ it('should NOT create scope CR on revision pass if lines_delta <= 1000', () => {
1411
+ pipeline.baselineSnapshotOverride = {
1412
+ artifact_id: 'snap-override',
1413
+ path: 'docs/snapshot.json',
1414
+ sha256: 'abc',
1415
+ version: 1,
1416
+ type: 'repo_snapshot',
1417
+ };
1418
+ const isRevisionComparison = !!pipeline.baselineSnapshotOverride;
1419
+ const linesDelta = 500;
1420
+
1421
+ const shouldCreateScopeCR = isRevisionComparison && Math.abs(linesDelta) > 1000;
1422
+
1423
+ expect(isRevisionComparison).toBe(true);
1424
+ expect(shouldCreateScopeCR).toBe(false);
1425
+ });
1426
+ });
1427
+
1428
+ // ─── v2.5.4: Recovery budget reset on phase change ──────
1429
+
1430
+ describe('v2.5.4: Recovery budget reset on phase change', () => {
1431
+ it('should reset recoveryCount when advancing to next phase', () => {
1432
+ // CONSENSUS_ROLE_PLANS fails 4 times (consuming budget), then passes.
1433
+ // When it advances to IMPLEMENTATION, budget should reset.
1434
+ let consensusAttempts = 0;
1435
+ const result = simulateOrchestratorLoop(
1436
+ 'CONSENSUS_ROLE_PLANS',
1437
+ pipeline,
1438
+ (phase, attempt) => {
1439
+ if (phase === 'CONSENSUS_ROLE_PLANS') {
1440
+ consensusAttempts++;
1441
+ return consensusAttempts >= 5; // Pass on 5th attempt
1442
+ }
1443
+ if (phase === 'RECOVERY_LOOP') return true;
1444
+ // After advancing, fail IMPLEMENTATION once to verify budget was reset
1445
+ if (phase === 'IMPLEMENTATION') return attempt > 1;
1446
+ return true;
1447
+ },
1448
+ );
1449
+
1450
+ // Should reach DONE, not STUCK
1451
+ expect(result.finalPhase).toBe('DONE');
1452
+ // recoveryCount should be 1 (from IMPLEMENTATION's single failure), not 5
1453
+ expect(result.recoveryCount).toBeLessThanOrEqual(1);
1454
+ // Verify progress log shows budget resets
1455
+ expect(result.progressLog.some(m => m.includes('Recovery budget reset'))).toBe(true);
1456
+ });
1457
+
1458
+ it('should reset recoveryCount when CR routes to a new phase', () => {
1459
+ // REVIEW passes with pending CR routing to CONSENSUS_ARCHITECTURE
1460
+ // Pipeline had previous recovery attempts — budget should reset on CR route
1461
+ pipeline.recoveryCount = 3;
1462
+ pipeline.lastRewindTarget = 'IMPLEMENTATION';
1463
+
1464
+ const result = simulateOrchestratorLoop(
1465
+ 'REVIEW',
1466
+ pipeline,
1467
+ new Map<PipelinePhase, boolean>([
1468
+ ['REVIEW', true],
1469
+ ['CONSENSUS_ARCHITECTURE', true],
1470
+ ['ROLE_PLANNING', true],
1471
+ ['CONSENSUS_ROLE_PLANS', true],
1472
+ ['IMPLEMENTATION', true],
1473
+ ['QA_VALIDATION', true],
1474
+ ['AUDIT', true],
1475
+ ['PRODUCTION_GATE', true],
1476
+ ]),
1477
+ {
1478
+ pendingCRs: [
1479
+ { cr_id: 'CR-ARCH', target_phase: 'CONSENSUS_ARCHITECTURE' as PipelinePhase, status: 'proposed' },
1480
+ ],
1481
+ },
1482
+ );
1483
+
1484
+ expect(result.finalPhase).toBe('DONE');
1485
+ expect(result.recoveryCount).toBe(0);
1486
+ // Verify CR routing triggered budget reset
1487
+ expect(result.progressLog.some(m => m.includes('CR routing'))).toBe(true);
1488
+ });
1489
+
1490
+ it('should NOT reset recoveryCount if phase does not change (RECOVERY_LOOP rewind)', () => {
1491
+ // Simulate: QA fails, RECOVERY_LOOP succeeds, rewinds back to QA
1492
+ // Budget should NOT reset because QA→RECOVERY→QA is not forward progress
1493
+ let qaAttempts = 0;
1494
+ const result = simulateOrchestratorLoop(
1495
+ 'QA_VALIDATION',
1496
+ pipeline,
1497
+ (phase, _attempt) => {
1498
+ if (phase === 'QA_VALIDATION') {
1499
+ qaAttempts++;
1500
+ return qaAttempts >= 2; // Fail first, pass second
1501
+ }
1502
+ if (phase === 'RECOVERY_LOOP') return true;
1503
+ return true;
1504
+ },
1505
+ );
1506
+
1507
+ expect(result.finalPhase).toBe('DONE');
1508
+ // recoveryCount should be 1 (from the QA failure), then reset to 0 when
1509
+ // QA passes and advances to REVIEW
1510
+ expect(result.progressLog.some(m => m.includes('advancing QA_VALIDATION -> REVIEW'))).toBe(true);
1511
+ });
1512
+
1513
+ it('should reproduce Gateco scenario: CONSENSUS_ROLE_PLANS exhausts budget, CONSENSUS_ARCHITECTURE gets fresh budget', () => {
1514
+ // Gateco regression guard: CONSENSUS_ROLE_PLANS consumes max iterations,
1515
+ // passes (via arbitration), pipeline advances through IMPL/QA/REVIEW/AUDIT,
1516
+ // CR routes to CONSENSUS_ARCHITECTURE, gate fails
1517
+ // → should enter RECOVERY_LOOP (not STUCK)
1518
+ pipeline.maxRecoveryIterations = 5;
1519
+
1520
+ let consensusRolePlansAttempts = 0;
1521
+ let consensusArchAttempts = 0;
1522
+
1523
+ const result = simulateOrchestratorLoop(
1524
+ 'CONSENSUS_ROLE_PLANS',
1525
+ pipeline,
1526
+ (phase, _attempt) => {
1527
+ if (phase === 'CONSENSUS_ROLE_PLANS') {
1528
+ consensusRolePlansAttempts++;
1529
+ // Fail 4 times, pass on 5th (via arbitration)
1530
+ return consensusRolePlansAttempts >= 5;
1531
+ }
1532
+ if (phase === 'CONSENSUS_ARCHITECTURE') {
1533
+ consensusArchAttempts++;
1534
+ // Fail first time, pass on 2nd
1535
+ return consensusArchAttempts >= 2;
1536
+ }
1537
+ if (phase === 'RECOVERY_LOOP') return true;
1538
+ return true; // IMPL, QA, REVIEW, AUDIT all pass
1539
+ },
1540
+ {
1541
+ // After AUDIT, CR routes to CONSENSUS_ARCHITECTURE
1542
+ pendingCRs: [
1543
+ { cr_id: 'CR-GATECO', target_phase: 'CONSENSUS_ARCHITECTURE' as PipelinePhase, status: 'proposed' },
1544
+ ],
1545
+ },
1546
+ );
1547
+
1548
+ // Key assertion: pipeline should reach DONE, NOT STUCK
1549
+ // Without v2.5.4 fix, CONSENSUS_ARCHITECTURE would immediately STUCK
1550
+ // because recoveryCount(5) >= maxRecoveryIterations(5)
1551
+ expect(result.finalPhase).toBe('DONE');
1552
+ // Budget should have been reset when advancing past CONSENSUS_ROLE_PLANS
1553
+ expect(result.phaseLog).toContain('RECOVERY_LOOP');
1554
+ expect(result.phaseLog).not.toContain('STUCK');
1555
+ });
1556
+
1557
+ it('should NOT reset budget when same phase loops back to itself', () => {
1558
+ // Edge case: phase passes but getNextPhase returns same phase (shouldn't happen
1559
+ // in practice, but verifies the !== guard)
1560
+ pipeline.recoveryCount = 3;
1561
+
1562
+ // Manually test the condition
1563
+ const prevPhase: PipelinePhase = 'REVIEW';
1564
+ const nextPhase: PipelinePhase = 'REVIEW'; // hypothetical same-phase transition
1565
+ let budgetReset = false;
1566
+
1567
+ if (nextPhase !== prevPhase && pipeline.recoveryCount > 0) {
1568
+ pipeline.recoveryCount = 0;
1569
+ pipeline.lastRewindTarget = undefined;
1570
+ budgetReset = true;
1571
+ }
1572
+
1573
+ expect(budgetReset).toBe(false);
1574
+ expect(pipeline.recoveryCount).toBe(3); // unchanged
1575
+ });
1576
+
1577
+ it('should clear lastRewindTarget when budget resets on forward progress', () => {
1578
+ pipeline.recoveryCount = 2;
1579
+ pipeline.lastRewindTarget = 'IMPLEMENTATION';
1580
+
1581
+ // Simulate forward phase change
1582
+ const prevPhase: PipelinePhase = 'QA_VALIDATION';
1583
+ const nextPhase: PipelinePhase = 'REVIEW';
1584
+
1585
+ if (nextPhase !== prevPhase && pipeline.recoveryCount > 0) {
1586
+ pipeline.recoveryCount = 0;
1587
+ pipeline.lastRewindTarget = undefined;
1588
+ }
1589
+
1590
+ expect(pipeline.recoveryCount).toBe(0);
1591
+ expect(pipeline.lastRewindTarget).toBeUndefined();
1592
+ });
1593
+ });
1594
+
1595
+ // ─── v2.6.0: Auto-recovery from STUCK via arbitrator ──────
1596
+
1597
+ describe('v2.6.0: Auto-recovery from STUCK via arbitrator', () => {
1598
+ it('should attempt auto-recovery when budget exhausted and no prior attempt', () => {
1599
+ pipeline.maxRecoveryIterations = 5;
1600
+ pipeline.recoveryCount = 5;
1601
+ pipeline.failedPhase = 'QA_VALIDATION';
1602
+ pipeline.autoRecoveryResult = undefined;
1603
+
1604
+ const exhaustedPhase: PipelinePhase = 'QA_VALIDATION';
1605
+ const arbitratorConfigured = true;
1606
+
1607
+ // Simulate: budget exhausted + no prior attempt + arbitrator configured
1608
+ let phase: PipelinePhase = exhaustedPhase;
1609
+ let autoRecoveryAttempted = false;
1610
+
1611
+ if (pipeline.recoveryCount >= pipeline.maxRecoveryIterations) {
1612
+ if (!pipeline.autoRecoveryResult && arbitratorConfigured) {
1613
+ // Auto-recovery would be attempted here
1614
+ autoRecoveryAttempted = true;
1615
+
1616
+ // Simulate successful auto-recovery
1617
+ pipeline.autoRecoveryResult = 'success';
1618
+ pipeline.recoveryCount = 0;
1619
+ pipeline.lastRewindTarget = undefined;
1620
+ phase = exhaustedPhase;
1621
+ pipeline.pipelinePhase = phase;
1622
+ pipeline.failedPhase = exhaustedPhase;
1623
+ delete pipeline.gateResults[exhaustedPhase];
1624
+ delete pipeline.gateChecks[exhaustedPhase];
1625
+ }
1626
+ }
1627
+
1628
+ expect(autoRecoveryAttempted).toBe(true);
1629
+ expect(pipeline.recoveryCount).toBe(0);
1630
+ expect(pipeline.autoRecoveryResult).toBe('success');
1631
+ expect(phase).toBe('QA_VALIDATION');
1632
+ });
1633
+
1634
+ it('should only attempt auto-recovery once (autoRecoveryResult already set)', () => {
1635
+ pipeline.maxRecoveryIterations = 5;
1636
+ pipeline.recoveryCount = 5;
1637
+ pipeline.failedPhase = 'QA_VALIDATION';
1638
+ pipeline.autoRecoveryResult = 'invalid'; // already attempted
1639
+
1640
+ const exhaustedPhase: PipelinePhase = 'QA_VALIDATION';
1641
+ const arbitratorConfigured = true;
1642
+
1643
+ let phase: PipelinePhase = exhaustedPhase;
1644
+ let autoRecoveryAttempted = false;
1645
+
1646
+ if (pipeline.recoveryCount >= pipeline.maxRecoveryIterations) {
1647
+ if (!pipeline.autoRecoveryResult && arbitratorConfigured) {
1648
+ autoRecoveryAttempted = true;
1649
+ } else {
1650
+ phase = 'STUCK';
1651
+ }
1652
+ }
1653
+
1654
+ expect(autoRecoveryAttempted).toBe(false);
1655
+ expect(phase).toBe('STUCK');
1656
+ });
1657
+
1658
+ it('should not attempt auto-recovery when no arbitrator configured', () => {
1659
+ pipeline.maxRecoveryIterations = 5;
1660
+ pipeline.recoveryCount = 5;
1661
+ pipeline.failedPhase = 'QA_VALIDATION';
1662
+ pipeline.autoRecoveryResult = undefined;
1663
+
1664
+ const exhaustedPhase: PipelinePhase = 'QA_VALIDATION';
1665
+ const arbitratorConfigured = false; // no arbitrator
1666
+
1667
+ let phase: PipelinePhase = exhaustedPhase;
1668
+
1669
+ if (pipeline.recoveryCount >= pipeline.maxRecoveryIterations) {
1670
+ if (!pipeline.autoRecoveryResult && arbitratorConfigured) {
1671
+ // Would attempt auto-recovery
1672
+ } else {
1673
+ phase = 'STUCK';
1674
+ }
1675
+ }
1676
+
1677
+ expect(phase).toBe('STUCK');
1678
+ });
1679
+
1680
+ it('should not increment recoveryCount after successful auto-recovery', () => {
1681
+ pipeline.maxRecoveryIterations = 5;
1682
+ pipeline.recoveryCount = 5;
1683
+ pipeline.failedPhase = 'QA_VALIDATION';
1684
+ pipeline.autoRecoveryResult = undefined;
1685
+
1686
+ // Simulate successful auto-recovery
1687
+ pipeline.autoRecoveryResult = 'success';
1688
+ pipeline.recoveryCount = 0;
1689
+
1690
+ // After auto-recovery, recoveryCount should be 0 (not 1)
1691
+ expect(pipeline.recoveryCount).toBe(0);
1692
+ });
1693
+
1694
+ it('should reset autoRecoveryResult on user-guided resume from STUCK', () => {
1695
+ pipeline.pipelinePhase = 'STUCK';
1696
+ pipeline.failedPhase = 'QA_VALIDATION';
1697
+ pipeline.recoveryCount = 5;
1698
+ pipeline.autoRecoveryResult = 'success'; // from prior auto-recovery
1699
+
1700
+ const guidance = 'User provided new guidance';
1701
+
1702
+ // Simulate resumePipeline STUCK-with-guidance logic (v2.6.0)
1703
+ if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase && guidance.length > 0) {
1704
+ pipeline.pipelinePhase = pipeline.failedPhase;
1705
+ pipeline.recoveryCount = 0;
1706
+ pipeline.lastRewindTarget = undefined;
1707
+ pipeline.autoRecoveryResult = undefined; // v2.6.0
1708
+ delete pipeline.gateResults[pipeline.failedPhase];
1709
+ delete pipeline.gateChecks[pipeline.failedPhase];
1710
+ }
1711
+
1712
+ expect(pipeline.autoRecoveryResult).toBeUndefined();
1713
+ expect(pipeline.pipelinePhase).toBe('QA_VALIDATION');
1714
+ expect(pipeline.recoveryCount).toBe(0);
1715
+ });
1716
+
1717
+ it('should preserve exhaustedPhase correctly on auto-recovery', () => {
1718
+ pipeline.maxRecoveryIterations = 5;
1719
+ pipeline.recoveryCount = 5;
1720
+ pipeline.failedPhase = 'CONSENSUS_ROLE_PLANS';
1721
+
1722
+ const exhaustedPhase: PipelinePhase = 'CONSENSUS_ROLE_PLANS';
1723
+
1724
+ // Simulate auto-recovery: capture exhaustedPhase before reassignment
1725
+ let phase: PipelinePhase = exhaustedPhase;
1726
+ pipeline.autoRecoveryResult = 'success';
1727
+ pipeline.recoveryCount = 0;
1728
+ phase = exhaustedPhase;
1729
+ pipeline.pipelinePhase = phase;
1730
+ pipeline.failedPhase = exhaustedPhase;
1731
+
1732
+ expect(phase).toBe('CONSENSUS_ROLE_PLANS');
1733
+ expect(pipeline.failedPhase).toBe('CONSENSUS_ROLE_PLANS');
1734
+ expect(pipeline.pipelinePhase).toBe('CONSENSUS_ROLE_PLANS');
1735
+ });
1736
+
1737
+ it('should clear stale gate data on auto-recovery success', () => {
1738
+ pipeline.maxRecoveryIterations = 5;
1739
+ pipeline.recoveryCount = 5;
1740
+ pipeline.failedPhase = 'QA_VALIDATION';
1741
+
1742
+ // Pre-fill gate data
1743
+ pipeline.gateResults['QA_VALIDATION'] = {
1744
+ phase: 'QA_VALIDATION',
1745
+ pass: false,
1746
+ blockers: ['test failed'],
1747
+ missingArtifacts: [],
1748
+ failedChecks: [],
1749
+ timestamp: new Date().toISOString(),
1750
+ };
1751
+ pipeline.gateChecks['QA_VALIDATION'] = [
1752
+ { check_type: 'test', status: 'fail', command: 'npm test', exit_code: 1, duration_ms: 500, timestamp: '' },
1753
+ ];
1754
+
1755
+ // Keep other phases' data
1756
+ pipeline.gateResults['IMPLEMENTATION'] = {
1757
+ phase: 'IMPLEMENTATION',
1758
+ pass: true,
1759
+ blockers: [],
1760
+ missingArtifacts: [],
1761
+ failedChecks: [],
1762
+ timestamp: new Date().toISOString(),
1763
+ };
1764
+
1765
+ // Simulate auto-recovery success: clear stale gate data
1766
+ const exhaustedPhase = pipeline.failedPhase!;
1767
+ delete pipeline.gateResults[exhaustedPhase];
1768
+ delete pipeline.gateChecks[exhaustedPhase];
1769
+
1770
+ expect(pipeline.gateResults['QA_VALIDATION']).toBeUndefined();
1771
+ expect(pipeline.gateChecks['QA_VALIDATION']).toBeUndefined();
1772
+ expect(pipeline.gateResults['IMPLEMENTATION']).toBeDefined();
1773
+ });
1774
+
1775
+ it('should attempt auto-recovery on resume from STUCK without guidance', () => {
1776
+ // v2.6.0: resumePipeline should try auto-recovery when no guidance is provided
1777
+ pipeline.pipelinePhase = 'STUCK';
1778
+ pipeline.failedPhase = 'CONSENSUS_ARCHITECTURE';
1779
+ pipeline.recoveryCount = 5;
1780
+ pipeline.autoRecoveryResult = undefined;
1781
+
1782
+ const arbitratorConfigured = true;
1783
+ const guidance = ''; // no guidance from user
1784
+
1785
+ let autoRecoveryAttempted = false;
1786
+ let finalState: 'running' | 'stuck' = 'stuck';
1787
+
1788
+ // Simulate the resume logic for no-guidance path
1789
+ if (guidance.length === 0 && !pipeline.autoRecoveryResult && arbitratorConfigured) {
1790
+ autoRecoveryAttempted = true;
1791
+
1792
+ // Simulate successful auto-recovery
1793
+ pipeline.autoRecoveryResult = 'success';
1794
+ pipeline.pipelinePhase = pipeline.failedPhase;
1795
+ pipeline.recoveryCount = 0;
1796
+ pipeline.lastRewindTarget = undefined;
1797
+ delete pipeline.gateResults[pipeline.failedPhase];
1798
+ delete pipeline.gateChecks[pipeline.failedPhase];
1799
+ finalState = 'running';
1800
+ }
1801
+
1802
+ expect(autoRecoveryAttempted).toBe(true);
1803
+ expect(finalState).toBe('running');
1804
+ expect(pipeline.pipelinePhase).toBe('CONSENSUS_ARCHITECTURE');
1805
+ expect(pipeline.recoveryCount).toBe(0);
1806
+ expect(pipeline.autoRecoveryResult).toBe('success');
1807
+ });
1808
+
1809
+ it('should skip auto-recovery on resume if already attempted', () => {
1810
+ pipeline.pipelinePhase = 'STUCK';
1811
+ pipeline.failedPhase = 'CONSENSUS_ARCHITECTURE';
1812
+ pipeline.recoveryCount = 5;
1813
+ pipeline.autoRecoveryResult = 'invalid'; // already tried
1814
+
1815
+ const arbitratorConfigured = true;
1816
+ const guidance = '';
1817
+
1818
+ let autoRecoveryAttempted = false;
1819
+
1820
+ if (guidance.length === 0 && !pipeline.autoRecoveryResult && arbitratorConfigured) {
1821
+ autoRecoveryAttempted = true;
1822
+ }
1823
+
1824
+ expect(autoRecoveryAttempted).toBe(false);
1825
+ expect(pipeline.pipelinePhase).toBe('STUCK'); // unchanged
1826
+ });
1827
+ });
1828
+
1829
+ // ─── v2.7.0: Recovery budget guard (infinite loop fix) ──────
1830
+
1831
+ describe('v2.7.0: Recovery budget guard (infinite loop fix)', () => {
1832
+ it('should preserve recoveryCount during recovery traversal (no budget reset)', () => {
1833
+ // PRODUCTION_GATE fails → recovery rewinds to IMPLEMENTATION → traverses forward
1834
+ // Budget must NOT reset during IMPL→QA→REVIEW→AUDIT traversal
1835
+ pipeline.maxRecoveryIterations = 5;
1836
+
1837
+ let prodGateAttempts = 0;
1838
+ const result = simulateOrchestratorLoop(
1839
+ 'PRODUCTION_GATE',
1840
+ pipeline,
1841
+ (phase, _attempt) => {
1842
+ if (phase === 'PRODUCTION_GATE') {
1843
+ prodGateAttempts++;
1844
+ return prodGateAttempts >= 2; // Fail first, pass second
1845
+ }
1846
+ if (phase === 'RECOVERY_LOOP') return true;
1847
+ // All traversal phases pass (IMPLEMENTATION→QA→REVIEW→AUDIT)
1848
+ return true;
1849
+ },
1850
+ );
1851
+
1852
+ expect(result.finalPhase).toBe('DONE');
1853
+ // Budget should have been 1 when PRODUCTION_GATE was re-entered (not reset to 0)
1854
+ // After PRODUCTION_GATE passes, failedPhase cleared, then budget resets on advance to DONE
1855
+ expect(result.progressLog.every(m => !m.includes('advancing IMPLEMENTATION'))).toBe(true);
1856
+ });
1857
+
1858
+ it('should reach STUCK when PRODUCTION_GATE keeps failing (no infinite loop)', () => {
1859
+ // The core infinite loop scenario: PRODUCTION_GATE always fails,
1860
+ // recovery always succeeds and rewinds to IMPLEMENTATION
1861
+ pipeline.maxRecoveryIterations = 3;
1862
+
1863
+ const result = simulateOrchestratorLoop(
1864
+ 'PRODUCTION_GATE',
1865
+ pipeline,
1866
+ (phase, _attempt) => {
1867
+ if (phase === 'PRODUCTION_GATE') return false; // Always fails
1868
+ if (phase === 'RECOVERY_LOOP') return true;
1869
+ return true; // Traversal phases pass
1870
+ },
1871
+ { maxIterations: 100 },
1872
+ );
1873
+
1874
+ expect(result.finalPhase).toBe('STUCK');
1875
+ expect(result.recoveryCount).toBe(3);
1876
+ // Should NOT have looped more than ~20 iterations (3 recovery cycles * ~6 phases)
1877
+ expect(result.phaseLog.length).toBeLessThan(30);
1878
+ });
1879
+
1880
+ it('should reset budget on genuine progress (failed phase passes then advances)', () => {
1881
+ // QA_VALIDATION fails once, recovery succeeds, QA passes, advances to REVIEW
1882
+ // REVIEW fails once — budget should be fresh (reset after QA→REVIEW advance)
1883
+ pipeline.maxRecoveryIterations = 3;
1884
+
1885
+ let qaAttempts = 0;
1886
+ let reviewAttempts = 0;
1887
+ const result = simulateOrchestratorLoop(
1888
+ 'QA_VALIDATION',
1889
+ pipeline,
1890
+ (phase, _attempt) => {
1891
+ if (phase === 'QA_VALIDATION') {
1892
+ qaAttempts++;
1893
+ return qaAttempts >= 2; // Fail first, pass second
1894
+ }
1895
+ if (phase === 'REVIEW') {
1896
+ reviewAttempts++;
1897
+ return reviewAttempts >= 2; // Fail first, pass second
1898
+ }
1899
+ if (phase === 'RECOVERY_LOOP') return true;
1900
+ return true;
1901
+ },
1902
+ );
1903
+
1904
+ expect(result.finalPhase).toBe('DONE');
1905
+ // Budget should have reset when QA passed and advanced to REVIEW
1906
+ expect(result.progressLog.some(m => m.includes('advancing QA_VALIDATION -> REVIEW'))).toBe(true);
1907
+ });
1908
+
1909
+ it('should replace failedPhase and baseline when different phase fails during recovery', () => {
1910
+ // PRODUCTION_GATE fails (baseline=1) → recovery → rewind to IMPLEMENTATION
1911
+ // → QA_VALIDATION fails (new failure origin) → failedPhase should become QA
1912
+ pipeline.maxRecoveryIterations = 5;
1913
+
1914
+ let prodGateAttempts = 0;
1915
+ let qaAttempts = 0;
1916
+ const result = simulateOrchestratorLoop(
1917
+ 'PRODUCTION_GATE',
1918
+ pipeline,
1919
+ (phase, _attempt) => {
1920
+ if (phase === 'PRODUCTION_GATE') {
1921
+ prodGateAttempts++;
1922
+ return prodGateAttempts >= 2; // Fail first time only
1923
+ }
1924
+ if (phase === 'QA_VALIDATION') {
1925
+ qaAttempts++;
1926
+ return qaAttempts >= 2; // Fail first, pass second
1927
+ }
1928
+ if (phase === 'RECOVERY_LOOP') return true;
1929
+ return true;
1930
+ },
1931
+ {
1932
+ gateResultOverrides: new Map([
1933
+ ['PRODUCTION_GATE', (_attempt: number) => ({ failedChecks: ['lint' as any] })],
1934
+ ['QA_VALIDATION', (_attempt: number) => ({ failedChecks: ['test' as any, 'build' as any] })],
1935
+ ]),
1936
+ },
1937
+ );
1938
+
1939
+ // Pipeline should eventually reach DONE (both phases pass on retry)
1940
+ expect(result.finalPhase).toBe('DONE');
1941
+ // QA failure should have set a new baseline (2, not 1)
1942
+ expect(pipeline.recoveryBaselineFailedCheckCount).toBeUndefined(); // cleared on success
1943
+ });
1944
+ });
1945
+
1946
+ // ─── v2.7.0: Recovery regression detection ──────
1947
+
1948
+ describe('v2.7.0: Recovery regression detection', () => {
1949
+ it('should detect regression when failing checks increase', () => {
1950
+ // First PRODUCTION_GATE failure: 1 check. After recovery: 3 checks → regression
1951
+ pipeline.maxRecoveryIterations = 5;
1952
+
1953
+ let prodGateAttempts = 0;
1954
+ const result = simulateOrchestratorLoop(
1955
+ 'PRODUCTION_GATE',
1956
+ pipeline,
1957
+ (phase, _attempt) => {
1958
+ if (phase === 'PRODUCTION_GATE') {
1959
+ prodGateAttempts++;
1960
+ return false; // Always fails
1961
+ }
1962
+ if (phase === 'RECOVERY_LOOP') return true;
1963
+ return true;
1964
+ },
1965
+ {
1966
+ gateResultOverrides: new Map([
1967
+ ['PRODUCTION_GATE', (attempt: number) => {
1968
+ if (attempt === 1) return { failedChecks: ['lint' as any] };
1969
+ // After recovery: 3 failing checks (regression)
1970
+ return { failedChecks: ['build' as any, 'test' as any, 'lint' as any] };
1971
+ }],
1972
+ ]),
1973
+ },
1974
+ );
1975
+
1976
+ expect(result.finalPhase).toBe('STUCK');
1977
+ // Should have detected regression and exhausted budget immediately
1978
+ expect(result.progressLog.some(m => m.includes('[regression]'))).toBe(true);
1979
+ expect(result.progressLog.some(m => m.includes('1 -> 3'))).toBe(true);
1980
+ });
1981
+
1982
+ it('should NOT detect regression when failing checks decrease (improvement)', () => {
1983
+ // First failure: 3 checks. After recovery: 1 check → improvement, continue normally
1984
+ pipeline.maxRecoveryIterations = 5;
1985
+
1986
+ let prodGateAttempts = 0;
1987
+ const result = simulateOrchestratorLoop(
1988
+ 'PRODUCTION_GATE',
1989
+ pipeline,
1990
+ (phase, _attempt) => {
1991
+ if (phase === 'PRODUCTION_GATE') {
1992
+ prodGateAttempts++;
1993
+ return prodGateAttempts >= 3; // Fail twice, pass on third
1994
+ }
1995
+ if (phase === 'RECOVERY_LOOP') return true;
1996
+ return true;
1997
+ },
1998
+ {
1999
+ gateResultOverrides: new Map([
2000
+ ['PRODUCTION_GATE', (attempt: number) => {
2001
+ if (attempt === 1) return { failedChecks: ['build' as any, 'test' as any, 'lint' as any] };
2002
+ if (attempt === 2) return { failedChecks: ['lint' as any] }; // Improvement
2003
+ return {}; // Pass
2004
+ }],
2005
+ ]),
2006
+ },
2007
+ );
2008
+
2009
+ expect(result.finalPhase).toBe('DONE');
2010
+ expect(result.progressLog.every(m => !m.includes('[regression]'))).toBe(true);
2011
+ });
2012
+
2013
+ it('should NOT detect regression when check count stays the same', () => {
2014
+ // First failure: 2 checks. After recovery: 2 checks → same count, not regression
2015
+ pipeline.maxRecoveryIterations = 5;
2016
+
2017
+ let prodGateAttempts = 0;
2018
+ const result = simulateOrchestratorLoop(
2019
+ 'PRODUCTION_GATE',
2020
+ pipeline,
2021
+ (phase, _attempt) => {
2022
+ if (phase === 'PRODUCTION_GATE') {
2023
+ prodGateAttempts++;
2024
+ return prodGateAttempts >= 3; // Fail twice, pass on third
2025
+ }
2026
+ if (phase === 'RECOVERY_LOOP') return true;
2027
+ return true;
2028
+ },
2029
+ {
2030
+ gateResultOverrides: new Map([
2031
+ ['PRODUCTION_GATE', (attempt: number) => {
2032
+ if (attempt <= 2) return { failedChecks: ['build' as any, 'lint' as any] };
2033
+ return {}; // Pass
2034
+ }],
2035
+ ]),
2036
+ },
2037
+ );
2038
+
2039
+ expect(result.finalPhase).toBe('DONE');
2040
+ expect(result.progressLog.every(m => !m.includes('[regression]'))).toBe(true);
2041
+ });
2042
+
2043
+ it('should clear baseline on success (failedPhase passes)', () => {
2044
+ // Phase fails, recovers, passes → baseline should be cleared
2045
+ pipeline.maxRecoveryIterations = 5;
2046
+
2047
+ let qaAttempts = 0;
2048
+ simulateOrchestratorLoop(
2049
+ 'QA_VALIDATION',
2050
+ pipeline,
2051
+ (phase, _attempt) => {
2052
+ if (phase === 'QA_VALIDATION') {
2053
+ qaAttempts++;
2054
+ return qaAttempts >= 2;
2055
+ }
2056
+ if (phase === 'RECOVERY_LOOP') return true;
2057
+ return true;
2058
+ },
2059
+ {
2060
+ gateResultOverrides: new Map([
2061
+ ['QA_VALIDATION', (attempt: number) => {
2062
+ if (attempt === 1) return { failedChecks: ['test' as any] };
2063
+ return {};
2064
+ }],
2065
+ ]),
2066
+ },
2067
+ );
2068
+
2069
+ expect(pipeline.recoveryBaselineFailedCheckCount).toBeUndefined();
2070
+ expect(pipeline.failedPhase).toBeUndefined();
2071
+ });
2072
+
2073
+ it('should preserve baseline across retries and detect regression against original', () => {
2074
+ // First failure: 1 check (baseline). Second failure: 1 check (same, no regression).
2075
+ // Third failure: 2 checks → regression detected against original baseline of 1
2076
+ pipeline.maxRecoveryIterations = 5;
2077
+
2078
+ let prodGateAttempts = 0;
2079
+ const result = simulateOrchestratorLoop(
2080
+ 'PRODUCTION_GATE',
2081
+ pipeline,
2082
+ (phase, _attempt) => {
2083
+ if (phase === 'PRODUCTION_GATE') {
2084
+ prodGateAttempts++;
2085
+ return false; // Always fails
2086
+ }
2087
+ if (phase === 'RECOVERY_LOOP') return true;
2088
+ return true;
2089
+ },
2090
+ {
2091
+ gateResultOverrides: new Map([
2092
+ ['PRODUCTION_GATE', (attempt: number) => {
2093
+ if (attempt <= 2) return { failedChecks: ['lint' as any] }; // 1 check
2094
+ return { failedChecks: ['lint' as any, 'build' as any] }; // 2 checks → regression
2095
+ }],
2096
+ ]),
2097
+ },
2098
+ );
2099
+
2100
+ expect(result.finalPhase).toBe('STUCK');
2101
+ expect(result.progressLog.some(m => m.includes('[regression]'))).toBe(true);
2102
+ expect(result.progressLog.some(m => m.includes('1 -> 2'))).toBe(true);
2103
+ });
2104
+ });
614
2105
  });