all-hands-cli 0.1.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 (305) hide show
  1. package/.allhands/README.md +75 -0
  2. package/.allhands/agents/compounder.yaml +15 -0
  3. package/.allhands/agents/coordinator.yaml +17 -0
  4. package/.allhands/agents/documentor.yaml +15 -0
  5. package/.allhands/agents/e2e-test-planner.yaml +17 -0
  6. package/.allhands/agents/emergent.yaml +22 -0
  7. package/.allhands/agents/executor.yaml +14 -0
  8. package/.allhands/agents/ideation.yaml +11 -0
  9. package/.allhands/agents/initiative-steering.yaml +19 -0
  10. package/.allhands/agents/judge.yaml +13 -0
  11. package/.allhands/agents/planner.yaml +19 -0
  12. package/.allhands/agents/pr-reviewer.yaml +15 -0
  13. package/.allhands/docs.json +5 -0
  14. package/.allhands/docs.local.json +26 -0
  15. package/.allhands/flows/COMPOUNDING.md +203 -0
  16. package/.allhands/flows/COORDINATION.md +89 -0
  17. package/.allhands/flows/CORE.md +87 -0
  18. package/.allhands/flows/DOCUMENTATION.md +218 -0
  19. package/.allhands/flows/E2E_TEST_PLAN_BUILDING.md +140 -0
  20. package/.allhands/flows/EMERGENT_PLANNING.md +57 -0
  21. package/.allhands/flows/IDEATION_SCOPING.md +154 -0
  22. package/.allhands/flows/INITIATIVE_STEERING.md +110 -0
  23. package/.allhands/flows/JUDGE_REVIEWING.md +79 -0
  24. package/.allhands/flows/PROMPT_TASK_EXECUTION.md +68 -0
  25. package/.allhands/flows/PR_REVIEWING.md +43 -0
  26. package/.allhands/flows/SPEC_PLANNING.md +216 -0
  27. package/.allhands/flows/harness/WRITING_HARNESS_FLOWS.md +27 -0
  28. package/.allhands/flows/harness/WRITING_HARNESS_KNOWLEDGE.md +27 -0
  29. package/.allhands/flows/harness/WRITING_HARNESS_ORCHESTRATION.md +27 -0
  30. package/.allhands/flows/harness/WRITING_HARNESS_SKILLS.md +27 -0
  31. package/.allhands/flows/harness/WRITING_HARNESS_TOOLS.md +27 -0
  32. package/.allhands/flows/harness/WRITING_HARNESS_VALIDATION_TOOLING.md +27 -0
  33. package/.allhands/flows/shared/CODEBASE_UNDERSTANDING.md +72 -0
  34. package/.allhands/flows/shared/CREATE_HARNESS_SPEC.md +48 -0
  35. package/.allhands/flows/shared/CREATE_SPEC.md +41 -0
  36. package/.allhands/flows/shared/CREATE_VALIDATION_TOOLING_SPEC.md +70 -0
  37. package/.allhands/flows/shared/DOCUMENTATION_DISCOVERY.md +123 -0
  38. package/.allhands/flows/shared/DOCUMENTATION_WRITER.md +101 -0
  39. package/.allhands/flows/shared/EMERGENT_REFINEMENT_ANALYSIS.md +76 -0
  40. package/.allhands/flows/shared/EXTERNAL_TECH_GUIDANCE.md +97 -0
  41. package/.allhands/flows/shared/IDEATION_CODEBASE_GROUNDING.md +49 -0
  42. package/.allhands/flows/shared/PLAN_DEEPENING.md +152 -0
  43. package/.allhands/flows/shared/PROMPT_TASKS_CURATION.md +113 -0
  44. package/.allhands/flows/shared/PROMPT_VALIDATION_REVIEW.MD +99 -0
  45. package/.allhands/flows/shared/QUICK_PREMORTEM.md +70 -0
  46. package/.allhands/flows/shared/RESEARCH_GUIDANCE.md +38 -0
  47. package/.allhands/flows/shared/REVIEW_OPTIONS_BREAKDOWN.md +68 -0
  48. package/.allhands/flows/shared/SKILL_EXTRACTION.md +84 -0
  49. package/.allhands/flows/shared/SPEC_FLOW_ANALYSIS.md +119 -0
  50. package/.allhands/flows/shared/TDD_WORKFLOW.md +109 -0
  51. package/.allhands/flows/shared/UTILIZE_VALIDATION_TOOLING.md +84 -0
  52. package/.allhands/flows/shared/WRITING_HARNESS_FLOWS.md +11 -0
  53. package/.allhands/flows/shared/WRITING_HARNESS_MCP_TOOLS.md +84 -0
  54. package/.allhands/flows/shared/jury/ARCHITECTURE_REVIEW.md +91 -0
  55. package/.allhands/flows/shared/jury/BEST_PRACTICES_REVIEW.md +80 -0
  56. package/.allhands/flows/shared/jury/CLAIM_VERIFICATION_REVIEW.md +101 -0
  57. package/.allhands/flows/shared/jury/EXPECTATIONS_FIT_REVIEW.md +78 -0
  58. package/.allhands/flows/shared/jury/MAINTAINABILITY_REVIEW.md +110 -0
  59. package/.allhands/flows/shared/jury/PROMPTS_EXPECTATIONS_FIT.md +74 -0
  60. package/.allhands/flows/shared/jury/PROMPTS_FLOW_ANALYSIS.md +92 -0
  61. package/.allhands/flows/shared/jury/PROMPTS_YAGNI.md +78 -0
  62. package/.allhands/flows/shared/jury/PROMPT_PREMORTEM.md +125 -0
  63. package/.allhands/flows/shared/jury/SECURITY_REVIEW.md +86 -0
  64. package/.allhands/flows/shared/jury/YAGNI_REVIEW.md +82 -0
  65. package/.allhands/flows/wip/DEBUG_INVESTIGATION.md +162 -0
  66. package/.allhands/flows/wip/MEMORY_RECALL.md +62 -0
  67. package/.allhands/harness/ah +131 -0
  68. package/.allhands/harness/package-lock.json +5292 -0
  69. package/.allhands/harness/package.json +52 -0
  70. package/.allhands/harness/src/__tests__/e2e/commands.test.ts +307 -0
  71. package/.allhands/harness/src/__tests__/e2e/event-loop.test.ts +539 -0
  72. package/.allhands/harness/src/__tests__/e2e/hooks.test.ts +427 -0
  73. package/.allhands/harness/src/__tests__/e2e/new-initiative-routing.test.ts +137 -0
  74. package/.allhands/harness/src/__tests__/e2e/run-e2e.ts +109 -0
  75. package/.allhands/harness/src/__tests__/e2e/specs-type.test.ts +210 -0
  76. package/.allhands/harness/src/__tests__/e2e/validation-hooks.test.ts +669 -0
  77. package/.allhands/harness/src/__tests__/e2e/validation-path-consistency.test.ts +354 -0
  78. package/.allhands/harness/src/__tests__/e2e/validation.test.ts +528 -0
  79. package/.allhands/harness/src/__tests__/harness/assertions.ts +318 -0
  80. package/.allhands/harness/src/__tests__/harness/cli-runner.ts +359 -0
  81. package/.allhands/harness/src/__tests__/harness/fixture.ts +384 -0
  82. package/.allhands/harness/src/__tests__/harness/hook-runner.ts +411 -0
  83. package/.allhands/harness/src/__tests__/harness/index.ts +122 -0
  84. package/.allhands/harness/src/cli.ts +36 -0
  85. package/.allhands/harness/src/commands/complexity.ts +177 -0
  86. package/.allhands/harness/src/commands/context7.ts +202 -0
  87. package/.allhands/harness/src/commands/docs.ts +557 -0
  88. package/.allhands/harness/src/commands/hooks.ts +24 -0
  89. package/.allhands/harness/src/commands/index.ts +51 -0
  90. package/.allhands/harness/src/commands/knowledge.ts +382 -0
  91. package/.allhands/harness/src/commands/memories.ts +302 -0
  92. package/.allhands/harness/src/commands/notify.ts +61 -0
  93. package/.allhands/harness/src/commands/oracle.ts +158 -0
  94. package/.allhands/harness/src/commands/perplexity.ts +220 -0
  95. package/.allhands/harness/src/commands/planning.ts +245 -0
  96. package/.allhands/harness/src/commands/schema.ts +73 -0
  97. package/.allhands/harness/src/commands/skills.ts +128 -0
  98. package/.allhands/harness/src/commands/solutions.ts +353 -0
  99. package/.allhands/harness/src/commands/spawn.ts +158 -0
  100. package/.allhands/harness/src/commands/specs.ts +532 -0
  101. package/.allhands/harness/src/commands/tavily.ts +226 -0
  102. package/.allhands/harness/src/commands/tools.ts +579 -0
  103. package/.allhands/harness/src/commands/trace.ts +327 -0
  104. package/.allhands/harness/src/commands/tui.ts +960 -0
  105. package/.allhands/harness/src/commands/validate.ts +143 -0
  106. package/.allhands/harness/src/commands/validation-tools.ts +108 -0
  107. package/.allhands/harness/src/hooks/context.ts +1442 -0
  108. package/.allhands/harness/src/hooks/enforcement.ts +170 -0
  109. package/.allhands/harness/src/hooks/index.ts +54 -0
  110. package/.allhands/harness/src/hooks/lifecycle.ts +229 -0
  111. package/.allhands/harness/src/hooks/notification.ts +104 -0
  112. package/.allhands/harness/src/hooks/observability.ts +551 -0
  113. package/.allhands/harness/src/hooks/session.ts +88 -0
  114. package/.allhands/harness/src/hooks/shared.ts +815 -0
  115. package/.allhands/harness/src/hooks/transcript-parser.ts +208 -0
  116. package/.allhands/harness/src/hooks/validation.ts +617 -0
  117. package/.allhands/harness/src/lib/__tests__/ctags.test.ts +244 -0
  118. package/.allhands/harness/src/lib/__tests__/docs-validation.test.ts +344 -0
  119. package/.allhands/harness/src/lib/__tests__/mcp-runtime.test.ts +190 -0
  120. package/.allhands/harness/src/lib/__tests__/schema.test.ts +861 -0
  121. package/.allhands/harness/src/lib/base-command.ts +198 -0
  122. package/.allhands/harness/src/lib/cli-daemon.ts +343 -0
  123. package/.allhands/harness/src/lib/compaction.ts +313 -0
  124. package/.allhands/harness/src/lib/ctags.ts +497 -0
  125. package/.allhands/harness/src/lib/docs-validation.ts +907 -0
  126. package/.allhands/harness/src/lib/event-loop.ts +662 -0
  127. package/.allhands/harness/src/lib/flows.ts +155 -0
  128. package/.allhands/harness/src/lib/git.ts +276 -0
  129. package/.allhands/harness/src/lib/knowledge-worker.ts +72 -0
  130. package/.allhands/harness/src/lib/knowledge.ts +810 -0
  131. package/.allhands/harness/src/lib/llm.ts +255 -0
  132. package/.allhands/harness/src/lib/mcp-client.ts +432 -0
  133. package/.allhands/harness/src/lib/mcp-daemon.ts +486 -0
  134. package/.allhands/harness/src/lib/mcp-runtime.ts +418 -0
  135. package/.allhands/harness/src/lib/notification.ts +115 -0
  136. package/.allhands/harness/src/lib/opencode/index.ts +70 -0
  137. package/.allhands/harness/src/lib/opencode/profiles.ts +300 -0
  138. package/.allhands/harness/src/lib/opencode/prompts/codesearch.md +98 -0
  139. package/.allhands/harness/src/lib/opencode/prompts/knowledge-aggregator.md +67 -0
  140. package/.allhands/harness/src/lib/opencode/runner.ts +281 -0
  141. package/.allhands/harness/src/lib/oracle.ts +926 -0
  142. package/.allhands/harness/src/lib/planning-utils.ts +150 -0
  143. package/.allhands/harness/src/lib/planning.ts +605 -0
  144. package/.allhands/harness/src/lib/pr-review.ts +225 -0
  145. package/.allhands/harness/src/lib/prompts.ts +522 -0
  146. package/.allhands/harness/src/lib/schema.ts +418 -0
  147. package/.allhands/harness/src/lib/schemas/agent-profile.ts +141 -0
  148. package/.allhands/harness/src/lib/schemas/template-vars.ts +138 -0
  149. package/.allhands/harness/src/lib/session.ts +164 -0
  150. package/.allhands/harness/src/lib/specs.ts +348 -0
  151. package/.allhands/harness/src/lib/tldr.ts +829 -0
  152. package/.allhands/harness/src/lib/tmux.ts +1051 -0
  153. package/.allhands/harness/src/lib/trace-store.ts +714 -0
  154. package/.allhands/harness/src/mcp/__tests__/index.test.ts +46 -0
  155. package/.allhands/harness/src/mcp/_template.ts +47 -0
  156. package/.allhands/harness/src/mcp/filesystem.ts +33 -0
  157. package/.allhands/harness/src/mcp/index.ts +69 -0
  158. package/.allhands/harness/src/mcp/playwright.ts +34 -0
  159. package/.allhands/harness/src/mcp/xcodebuild.ts +29 -0
  160. package/.allhands/harness/src/schemas/docs.schema.json +44 -0
  161. package/.allhands/harness/src/schemas/settings.schema.json +214 -0
  162. package/.allhands/harness/src/tui/actions.ts +227 -0
  163. package/.allhands/harness/src/tui/file-viewer-modal.ts +270 -0
  164. package/.allhands/harness/src/tui/index.ts +1574 -0
  165. package/.allhands/harness/src/tui/modal.ts +232 -0
  166. package/.allhands/harness/src/tui/prompts-pane.ts +186 -0
  167. package/.allhands/harness/src/tui/status-pane.ts +434 -0
  168. package/.allhands/harness/tsconfig.json +22 -0
  169. package/.allhands/harness/vitest.config.ts +13 -0
  170. package/.allhands/pillars.md +33 -0
  171. package/.allhands/principles.md +88 -0
  172. package/.allhands/schemas/alignment.yaml +51 -0
  173. package/.allhands/schemas/documentation.yaml +10 -0
  174. package/.allhands/schemas/prompt.yaml +92 -0
  175. package/.allhands/schemas/skill.yaml +34 -0
  176. package/.allhands/schemas/solution.yaml +131 -0
  177. package/.allhands/schemas/spec.yaml +67 -0
  178. package/.allhands/schemas/validation-suite.yaml +49 -0
  179. package/.allhands/schemas/workflow.yaml +51 -0
  180. package/.allhands/settings.json +57 -0
  181. package/.allhands/skills/claude-code-patterns/SKILL.md +60 -0
  182. package/.allhands/skills/claude-code-patterns/docs/context-hygiene.md +19 -0
  183. package/.allhands/skills/harness-maintenance/SKILL.md +449 -0
  184. package/.allhands/skills/harness-maintenance/references/core-architecture.md +187 -0
  185. package/.allhands/skills/harness-maintenance/references/harness-skills.md +87 -0
  186. package/.allhands/skills/harness-maintenance/references/knowledge-compounding.md +78 -0
  187. package/.allhands/skills/harness-maintenance/references/tools-commands-mcp-hooks.md +115 -0
  188. package/.allhands/skills/harness-maintenance/references/validation-tooling.md +77 -0
  189. package/.allhands/skills/harness-maintenance/references/writing-flows.md +84 -0
  190. package/.allhands/validation/browser-automation.md +109 -0
  191. package/.allhands/validation/xcode-automation.md +195 -0
  192. package/.allhands/workflows/documentation.md +86 -0
  193. package/.allhands/workflows/investigation.md +81 -0
  194. package/.allhands/workflows/milestone.md +91 -0
  195. package/.allhands/workflows/optimization.md +85 -0
  196. package/.allhands/workflows/refactor.md +99 -0
  197. package/.allhands/workflows/triage.md +81 -0
  198. package/.claude/README.md +1 -0
  199. package/.claude/agents/explorer.md +10 -0
  200. package/.claude/agents/researcher.md +11 -0
  201. package/.claude/agents/task-runner.md +8 -0
  202. package/.claude/settings.json +231 -0
  203. package/.env.ai.example +7 -0
  204. package/.github/workflows/npm-publish.yml +69 -0
  205. package/.internal.json +45 -0
  206. package/.tldr/config.json +11 -0
  207. package/.tldrignore +90 -0
  208. package/CLAUDE.md +6 -0
  209. package/README.md +98 -0
  210. package/bin/sync-cli.js +7552 -0
  211. package/concerns.md +7 -0
  212. package/docs/README.md +41 -0
  213. package/docs/agents/README.md +24 -0
  214. package/docs/agents/agent-configuration-system.md +86 -0
  215. package/docs/agents/execution-agents.md +50 -0
  216. package/docs/agents/knowledge-agents.md +61 -0
  217. package/docs/agents/orchestration-agent.md +57 -0
  218. package/docs/agents/planning-agents.md +84 -0
  219. package/docs/agents/quality-review-agents.md +67 -0
  220. package/docs/agents/workflow-agent-orchestration.md +69 -0
  221. package/docs/flows/README.md +44 -0
  222. package/docs/flows/compounding.md +126 -0
  223. package/docs/flows/coordination.md +72 -0
  224. package/docs/flows/core-harness-integration.md +63 -0
  225. package/docs/flows/documentation-orchestration.md +98 -0
  226. package/docs/flows/e2e-test-plan-building.md +83 -0
  227. package/docs/flows/emergent-refinement.md +104 -0
  228. package/docs/flows/flow-authoring-and-mcp-tools.md +89 -0
  229. package/docs/flows/judge-reviewing.md +112 -0
  230. package/docs/flows/plan-deepening-and-research.md +107 -0
  231. package/docs/flows/plan-review-jury.md +114 -0
  232. package/docs/flows/pr-reviewing.md +54 -0
  233. package/docs/flows/prompt-task-execution.md +119 -0
  234. package/docs/flows/spec-planning.md +162 -0
  235. package/docs/flows/type-specific-scoping-flows.md +49 -0
  236. package/docs/flows/validation-and-skills-integration.md +145 -0
  237. package/docs/flows/wip/wip-flows.md +102 -0
  238. package/docs/harness/README.md +23 -0
  239. package/docs/harness/agent-profiles.md +84 -0
  240. package/docs/harness/cli/README.md +24 -0
  241. package/docs/harness/cli/cli-entry-and-command-discovery.md +91 -0
  242. package/docs/harness/cli/docs-command.md +87 -0
  243. package/docs/harness/cli/knowledge-command.md +91 -0
  244. package/docs/harness/cli/minor-cli-commands.md +65 -0
  245. package/docs/harness/cli/oracle-command.md +113 -0
  246. package/docs/harness/cli/planning-command.md +95 -0
  247. package/docs/harness/cli/schema-and-validation-commands.md +154 -0
  248. package/docs/harness/cli/search-commands.md +97 -0
  249. package/docs/harness/cli/spawn-command.md +136 -0
  250. package/docs/harness/cli/specs-command.md +102 -0
  251. package/docs/harness/cli/tools-command.md +122 -0
  252. package/docs/harness/cli/trace-command.md +122 -0
  253. package/docs/harness/cli-daemon.md +92 -0
  254. package/docs/harness/event-loop.md +184 -0
  255. package/docs/harness/hooks/README.md +15 -0
  256. package/docs/harness/hooks/context-hooks.md +96 -0
  257. package/docs/harness/hooks/lifecycle-and-observability-hooks.md +135 -0
  258. package/docs/harness/hooks/validation-hooks.md +97 -0
  259. package/docs/harness/test-harness.md +149 -0
  260. package/docs/harness/tui.md +176 -0
  261. package/docs/memories.md +20 -0
  262. package/docs/solutions/agentic-issues/premature-agent-deletion-tui-action-dependency-20260130.md +49 -0
  263. package/docs/solutions/agentic-issues/ref-anchor-scope-mismatch-skill-references-20260131.md +55 -0
  264. package/docs/solutions/agentic-issues/tautological-tests-routing-20260131.md +52 -0
  265. package/docs/solutions/integration_issue/blocktool-output-format-mismatch-hook-runner-20260130.md +52 -0
  266. package/docs/solutions/integration_issue/dual-validation-path-divergence-schema-20260130.md +66 -0
  267. package/docs/solutions/security-issues/unsanitized-domain-path-join-20260131.md +52 -0
  268. package/docs/solutions/test-failures/event-loop-mock-ordering-checkAgentWindows-20260130.md +63 -0
  269. package/docs/sync-cli/README.md +19 -0
  270. package/docs/sync-cli/cli-entrypoint-and-commands.md +39 -0
  271. package/docs/sync-cli/commands/README.md +11 -0
  272. package/docs/sync-cli/commands/pull-manifest-command.md +36 -0
  273. package/docs/sync-cli/commands/push-command.md +84 -0
  274. package/docs/sync-cli/commands/sync-command.md +71 -0
  275. package/docs/sync-cli/systems/README.md +14 -0
  276. package/docs/sync-cli/systems/git-and-github-integration.md +49 -0
  277. package/docs/sync-cli/systems/interactive-ui.md +43 -0
  278. package/docs/sync-cli/systems/manifest-and-distribution.md +51 -0
  279. package/docs/sync-cli/systems/path-resolution.md +42 -0
  280. package/package.json +46 -0
  281. package/scripts/install-shim.sh +40 -0
  282. package/scripts/pre-pack.sh +25 -0
  283. package/specs/harness-maintenance-skill.spec.md +138 -0
  284. package/specs/roadmap/git-spec-lifecycle-management.spec.md +113 -0
  285. package/specs/sync-init-flag.spec.md +117 -0
  286. package/specs/unified-workflow-orchestration.spec.md +250 -0
  287. package/specs/validation-tooling-practice.spec.md +98 -0
  288. package/specs/workflow-domain-configuration.spec.md +265 -0
  289. package/src/commands/pull-manifest.ts +31 -0
  290. package/src/commands/push.ts +344 -0
  291. package/src/commands/sync.ts +289 -0
  292. package/src/lib/constants.ts +10 -0
  293. package/src/lib/dotfiles.ts +36 -0
  294. package/src/lib/fs-utils.ts +18 -0
  295. package/src/lib/gh.ts +40 -0
  296. package/src/lib/git.ts +63 -0
  297. package/src/lib/gitignore.ts +167 -0
  298. package/src/lib/manifest.ts +121 -0
  299. package/src/lib/marker-sync.ts +39 -0
  300. package/src/lib/paths.ts +38 -0
  301. package/src/lib/target-lines.ts +66 -0
  302. package/src/lib/ui.ts +78 -0
  303. package/src/sync-cli.ts +120 -0
  304. package/target-lines.json +23 -0
  305. package/tsconfig.json +20 -0
@@ -0,0 +1,539 @@
1
+ /**
2
+ * Event Loop Decision Logic Tests
3
+ *
4
+ * Exercises the unified checkPromptLoop() decision branches via the public
5
+ * forceTick() method, testing:
6
+ * - 4 unified decision branches (spawn executor, spawn emergent planner, wait, disabled)
7
+ * - Parallel execution capacity enforcement
8
+ * - Spawn cooldown timer (10s SPAWN_COOLDOWN_MS)
9
+ * - Emergent planner singleton blocking
10
+ * - activeExecutorPrompts reconciliation on agent exit
11
+ */
12
+
13
+ import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
14
+ import type { PromptFile, PickerResult } from '../../lib/prompts.js';
15
+ import type { EventLoopCallbacks } from '../../lib/event-loop.js';
16
+ import { createFixture, type TestFixture } from '../harness/index.js';
17
+
18
+ // ─── Mocks (hoisted before imports) ─────────────────────────────────────────
19
+
20
+ vi.mock('../../lib/tmux.js', () => ({
21
+ listWindows: vi.fn(() => [{ index: 0, name: 'hub', id: '@0' }]),
22
+ sessionExists: vi.fn(() => true),
23
+ getSpawnedAgentRegistry: vi.fn(() => new Set<string>()),
24
+ getCurrentSession: vi.fn(() => 'test-session'),
25
+ SESSION_NAME: 'all-hands',
26
+ unregisterSpawnedAgent: vi.fn(),
27
+ }));
28
+
29
+ vi.mock('../../lib/prompts.js', () => ({
30
+ pickNextPrompt: vi.fn(() => ({
31
+ prompt: null,
32
+ reason: 'No prompt files found',
33
+ stats: { total: 0, pending: 0, inProgress: 0, done: 0, blocked: 0 },
34
+ })),
35
+ loadAllPrompts: vi.fn(() => []),
36
+ markPromptInProgress: vi.fn(),
37
+ }));
38
+
39
+ vi.mock('../../hooks/shared.js', () => ({
40
+ loadProjectSettings: vi.fn(() => ({
41
+ spawn: { maxParallelPrompts: 3 },
42
+ eventLoop: { tickIntervalMs: 1000 },
43
+ })),
44
+ }));
45
+
46
+ vi.mock('../../lib/planning.js', () => ({
47
+ getCurrentBranch: vi.fn(() => 'feature/test-branch'),
48
+ sanitizeBranchForDir: vi.fn(() => 'feature-test-branch'),
49
+ readStatus: vi.fn(() => ({ stage: 'executing' })),
50
+ updatePRReviewStatus: vi.fn(),
51
+ }));
52
+
53
+ vi.mock('../../lib/specs.js', () => ({
54
+ getSpecForBranch: vi.fn(() => null),
55
+ }));
56
+
57
+ vi.mock('../../lib/mcp-client.js', () => ({
58
+ shutdownDaemon: vi.fn(() => Promise.resolve()),
59
+ }));
60
+
61
+ vi.mock('../../lib/pr-review.js', () => ({
62
+ checkPRReviewStatus: vi.fn(() =>
63
+ Promise.resolve({ status: 'none', lastCommentId: null, lastCommentTime: null, reviewCycle: 0 })
64
+ ),
65
+ hasNewReview: vi.fn(() => false),
66
+ parsePRUrl: vi.fn(() => null),
67
+ }));
68
+
69
+ // Imports after mocks (vi.mock calls are hoisted)
70
+ import { EventLoop } from '../../lib/event-loop.js';
71
+ import { pickNextPrompt, markPromptInProgress, loadAllPrompts } from '../../lib/prompts.js';
72
+ import { listWindows, getSpawnedAgentRegistry } from '../../lib/tmux.js';
73
+
74
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
75
+
76
+ function makePrompt(
77
+ number: number,
78
+ status: 'pending' | 'in_progress' | 'done' = 'pending',
79
+ ): PromptFile {
80
+ return {
81
+ path: `/tmp/prompts/${number.toString().padStart(2, '0')}-test.prompt.md`,
82
+ filename: `${number.toString().padStart(2, '0')}-test.prompt.md`,
83
+ frontmatter: {
84
+ number,
85
+ title: `Test Prompt ${number}`,
86
+ status,
87
+ dependencies: [],
88
+ priority: 'medium',
89
+ attempts: 0,
90
+ commits: [],
91
+ created: '2026-01-30T00:00:00.000Z',
92
+ updated: '2026-01-30T00:00:00.000Z',
93
+ },
94
+ body: '## Tasks\n\n- Test task',
95
+ rawContent: '',
96
+ };
97
+ }
98
+
99
+ function pickerResult(
100
+ prompt: PromptFile | null,
101
+ stats: PickerResult['stats'],
102
+ reason = '',
103
+ ): PickerResult {
104
+ return { prompt, reason, stats };
105
+ }
106
+
107
+ // ─── Tests ───────────────────────────────────────────────────────────────────
108
+
109
+ describe('EventLoop Decision Logic', () => {
110
+ let fixture: TestFixture;
111
+ let loop: EventLoop;
112
+ let callbacks: Required<EventLoopCallbacks>;
113
+
114
+ beforeAll(() => {
115
+ fixture = createFixture({ name: 'event-loop-test' });
116
+ });
117
+
118
+ afterAll(() => {
119
+ fixture.cleanup();
120
+ });
121
+
122
+ beforeEach(() => {
123
+ vi.clearAllMocks();
124
+
125
+ // Reset mocks that individual tests may override
126
+ vi.mocked(listWindows).mockReturnValue([{ index: 0, name: 'hub', id: '@0' }]);
127
+ vi.mocked(getSpawnedAgentRegistry).mockReturnValue(new Set());
128
+ vi.mocked(pickNextPrompt).mockReturnValue({
129
+ prompt: null,
130
+ reason: 'No prompt files found',
131
+ stats: { total: 0, pending: 0, inProgress: 0, done: 0, blocked: 0 },
132
+ });
133
+
134
+ callbacks = {
135
+ onPRReviewFeedback: vi.fn(),
136
+ onBranchChange: vi.fn(),
137
+ onAgentsChange: vi.fn(),
138
+ onSpawnExecutor: vi.fn(),
139
+ onSpawnEmergentPlanning: vi.fn(),
140
+ onLoopStatus: vi.fn(),
141
+ onPromptsChange: vi.fn(),
142
+ };
143
+
144
+ loop = new EventLoop(fixture.root, callbacks);
145
+ loop.setLoopEnabled(true);
146
+ });
147
+
148
+ // ─── 4 Unified Decision Branches ─────────────────────────────────────
149
+
150
+ describe('unified decision branches', () => {
151
+ it('spawns executor when pending prompt available', async () => {
152
+ const prompt = makePrompt(1);
153
+ vi.mocked(pickNextPrompt).mockReturnValue(
154
+ pickerResult(prompt, { total: 3, pending: 1, inProgress: 1, done: 1, blocked: 0 }),
155
+ );
156
+
157
+ await loop.forceTick();
158
+
159
+ expect(callbacks.onSpawnExecutor).toHaveBeenCalledWith(prompt);
160
+ expect(callbacks.onSpawnEmergentPlanning).not.toHaveBeenCalled();
161
+ expect(markPromptInProgress).toHaveBeenCalledWith(prompt.path);
162
+ expect(loop.getState().activeExecutorPrompts).toContain(1);
163
+ expect(loop.getState().lastExecutorSpawnTime).not.toBeNull();
164
+ });
165
+
166
+ it('spawns emergent planner when no pending and no in_progress', async () => {
167
+ vi.mocked(pickNextPrompt).mockReturnValue(
168
+ pickerResult(null, { total: 5, pending: 0, inProgress: 0, done: 5, blocked: 0 }),
169
+ );
170
+
171
+ await loop.forceTick();
172
+
173
+ expect(callbacks.onSpawnEmergentPlanning).toHaveBeenCalledOnce();
174
+ expect(callbacks.onSpawnExecutor).not.toHaveBeenCalled();
175
+ expect(loop.getState().lastExecutorSpawnTime).not.toBeNull();
176
+ });
177
+
178
+ it('waits when no pending but in_progress exist', async () => {
179
+ vi.mocked(pickNextPrompt).mockReturnValue(
180
+ pickerResult(null, { total: 5, pending: 0, inProgress: 2, done: 3, blocked: 0 }, 'Executors still working'),
181
+ );
182
+
183
+ await loop.forceTick();
184
+
185
+ expect(callbacks.onSpawnExecutor).not.toHaveBeenCalled();
186
+ expect(callbacks.onSpawnEmergentPlanning).not.toHaveBeenCalled();
187
+ expect(callbacks.onLoopStatus).toHaveBeenCalledWith('Executors still working');
188
+ });
189
+
190
+ it('does nothing when loop disabled', async () => {
191
+ loop.setLoopEnabled(false);
192
+ vi.mocked(pickNextPrompt).mockReturnValue(
193
+ pickerResult(makePrompt(1), { total: 3, pending: 1, inProgress: 0, done: 2, blocked: 0 }),
194
+ );
195
+
196
+ await loop.forceTick();
197
+
198
+ expect(callbacks.onSpawnExecutor).not.toHaveBeenCalled();
199
+ expect(callbacks.onSpawnEmergentPlanning).not.toHaveBeenCalled();
200
+ expect(pickNextPrompt).not.toHaveBeenCalled();
201
+ });
202
+ });
203
+
204
+ // ─── Parallel Execution Capacity ──────────────────────────────────────
205
+
206
+ describe('parallel execution capacity', () => {
207
+ it('blocks spawn at max parallel capacity', async () => {
208
+ loop.setParallelEnabled(true);
209
+
210
+ // 3 active executors = maxParallelPrompts (3)
211
+ vi.mocked(listWindows).mockReturnValue([
212
+ { index: 0, name: 'hub', id: '@0' },
213
+ { index: 1, name: 'executor-01', id: '@1' },
214
+ { index: 2, name: 'executor-02', id: '@2' },
215
+ { index: 3, name: 'executor-03', id: '@3' },
216
+ ]);
217
+ vi.mocked(getSpawnedAgentRegistry).mockReturnValue(
218
+ new Set(['executor-01', 'executor-02', 'executor-03']),
219
+ );
220
+ vi.mocked(pickNextPrompt).mockReturnValue(
221
+ pickerResult(makePrompt(4), { total: 6, pending: 1, inProgress: 3, done: 2, blocked: 0 }),
222
+ );
223
+
224
+ await loop.forceTick();
225
+
226
+ expect(callbacks.onSpawnExecutor).not.toHaveBeenCalled();
227
+ });
228
+
229
+ it('allows spawn below max parallel capacity', async () => {
230
+ loop.setParallelEnabled(true);
231
+
232
+ // 2 active executors < maxParallelPrompts (3)
233
+ vi.mocked(listWindows).mockReturnValue([
234
+ { index: 0, name: 'hub', id: '@0' },
235
+ { index: 1, name: 'executor-01', id: '@1' },
236
+ { index: 2, name: 'executor-02', id: '@2' },
237
+ ]);
238
+ vi.mocked(getSpawnedAgentRegistry).mockReturnValue(
239
+ new Set(['executor-01', 'executor-02']),
240
+ );
241
+ const prompt = makePrompt(3);
242
+ vi.mocked(pickNextPrompt).mockReturnValue(
243
+ pickerResult(prompt, { total: 5, pending: 1, inProgress: 2, done: 2, blocked: 0 }),
244
+ );
245
+
246
+ await loop.forceTick();
247
+
248
+ expect(callbacks.onSpawnExecutor).toHaveBeenCalledWith(prompt);
249
+ });
250
+
251
+ it('limits to 1 executor when parallel disabled', async () => {
252
+ loop.setParallelEnabled(false);
253
+
254
+ // 1 executor already running — parallel disabled means max=1
255
+ vi.mocked(listWindows).mockReturnValue([
256
+ { index: 0, name: 'hub', id: '@0' },
257
+ { index: 1, name: 'executor-01', id: '@1' },
258
+ ]);
259
+ vi.mocked(getSpawnedAgentRegistry).mockReturnValue(new Set(['executor-01']));
260
+ vi.mocked(pickNextPrompt).mockReturnValue(
261
+ pickerResult(makePrompt(2), { total: 4, pending: 1, inProgress: 1, done: 2, blocked: 0 }),
262
+ );
263
+
264
+ await loop.forceTick();
265
+
266
+ expect(callbacks.onSpawnExecutor).not.toHaveBeenCalled();
267
+ });
268
+ });
269
+
270
+ // ─── Spawn Cooldown Timer ─────────────────────────────────────────────
271
+
272
+ describe('spawn cooldown timer', () => {
273
+ it('suppresses spawn within 10s SPAWN_COOLDOWN_MS window', async () => {
274
+ // Use parallel so capacity doesn't block before cooldown check
275
+ loop.setParallelEnabled(true);
276
+
277
+ // Tick 1: spawn executor (no executor windows yet)
278
+ vi.mocked(pickNextPrompt).mockReturnValue(
279
+ pickerResult(makePrompt(1), { total: 3, pending: 2, inProgress: 0, done: 1, blocked: 0 }),
280
+ );
281
+ await loop.forceTick();
282
+ expect(callbacks.onSpawnExecutor).toHaveBeenCalledTimes(1);
283
+
284
+ // Tick 2: executor-01 window now visible (prevents reconciliation from
285
+ // clearing the timestamp), but cooldown still active → blocks spawn
286
+ vi.mocked(listWindows).mockReturnValue([
287
+ { index: 0, name: 'hub', id: '@0' },
288
+ { index: 1, name: 'executor-01', id: '@1' },
289
+ ]);
290
+ vi.mocked(getSpawnedAgentRegistry).mockReturnValue(new Set(['executor-01']));
291
+ vi.mocked(pickNextPrompt).mockReturnValue(
292
+ pickerResult(makePrompt(2), { total: 3, pending: 1, inProgress: 1, done: 1, blocked: 0 }),
293
+ );
294
+ await loop.forceTick();
295
+ expect(callbacks.onSpawnExecutor).toHaveBeenCalledTimes(1); // unchanged
296
+ });
297
+
298
+ it('allows spawn after cooldown expires', async () => {
299
+ loop.setParallelEnabled(true);
300
+
301
+ // Tick 1: spawn executor
302
+ vi.mocked(pickNextPrompt).mockReturnValue(
303
+ pickerResult(makePrompt(1), { total: 3, pending: 2, inProgress: 0, done: 1, blocked: 0 }),
304
+ );
305
+ await loop.forceTick();
306
+ expect(callbacks.onSpawnExecutor).toHaveBeenCalledTimes(1);
307
+
308
+ // Tick 2: executor-01 visible, advance past cooldown
309
+ vi.mocked(listWindows).mockReturnValue([
310
+ { index: 0, name: 'hub', id: '@0' },
311
+ { index: 1, name: 'executor-01', id: '@1' },
312
+ ]);
313
+ vi.mocked(getSpawnedAgentRegistry).mockReturnValue(new Set(['executor-01']));
314
+ const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 11000);
315
+
316
+ vi.mocked(pickNextPrompt).mockReturnValue(
317
+ pickerResult(makePrompt(2), { total: 3, pending: 1, inProgress: 1, done: 1, blocked: 0 }),
318
+ );
319
+ await loop.forceTick();
320
+ expect(callbacks.onSpawnExecutor).toHaveBeenCalledTimes(2);
321
+
322
+ dateNowSpy.mockRestore();
323
+ });
324
+
325
+ it('resets cooldown when agent window disappears', async () => {
326
+ loop.setParallelEnabled(true);
327
+
328
+ // Tick 1: executor-01 already visible, spawn executor for prompt 2
329
+ vi.mocked(listWindows).mockReturnValue([
330
+ { index: 0, name: 'hub', id: '@0' },
331
+ { index: 1, name: 'executor-01', id: '@1' },
332
+ ]);
333
+ vi.mocked(getSpawnedAgentRegistry).mockReturnValue(new Set(['executor-01']));
334
+ vi.mocked(pickNextPrompt).mockReturnValue(
335
+ pickerResult(makePrompt(2), { total: 3, pending: 2, inProgress: 1, done: 0, blocked: 0 }),
336
+ );
337
+ await loop.forceTick();
338
+ expect(callbacks.onSpawnExecutor).toHaveBeenCalledTimes(1);
339
+ expect(loop.getState().activeAgents).toContain('executor-01');
340
+
341
+ // Tick 2: executor-01 disappears → checkAgentWindows clears cooldown
342
+ // → new spawn succeeds immediately without waiting
343
+ vi.mocked(listWindows).mockReturnValue([{ index: 0, name: 'hub', id: '@0' }]);
344
+ vi.mocked(pickNextPrompt).mockReturnValue(
345
+ pickerResult(makePrompt(3), { total: 3, pending: 1, inProgress: 1, done: 1, blocked: 0 }),
346
+ );
347
+ await loop.forceTick();
348
+
349
+ // Cooldown was cleared by executor disappearance detection
350
+ expect(callbacks.onSpawnExecutor).toHaveBeenCalledTimes(2);
351
+ });
352
+ });
353
+
354
+ // ─── Emergent Planner Blocking ────────────────────────────────────────
355
+
356
+ describe('emergent planner blocking', () => {
357
+ it('blocks second emergent planner when one is already running', async () => {
358
+ // Emergent planner window already active
359
+ vi.mocked(listWindows).mockReturnValue([
360
+ { index: 0, name: 'hub', id: '@0' },
361
+ { index: 1, name: 'emergent-planner', id: '@1' },
362
+ ]);
363
+ vi.mocked(getSpawnedAgentRegistry).mockReturnValue(new Set(['emergent-planner']));
364
+
365
+ // Would normally trigger emergent planner spawn (no pending, no in_progress)
366
+ vi.mocked(pickNextPrompt).mockReturnValue(
367
+ pickerResult(null, { total: 5, pending: 0, inProgress: 0, done: 5, blocked: 0 }),
368
+ );
369
+
370
+ await loop.forceTick();
371
+
372
+ expect(callbacks.onSpawnEmergentPlanning).not.toHaveBeenCalled();
373
+ expect(callbacks.onSpawnExecutor).not.toHaveBeenCalled();
374
+ });
375
+ });
376
+
377
+ // ─── activeExecutorPrompts Reconciliation ─────────────────────────────
378
+
379
+ describe('activeExecutorPrompts reconciliation', () => {
380
+ it('removes orphaned prompt numbers when executor window disappears', async () => {
381
+ // Tick 1: spawn executor for prompt 1
382
+ vi.mocked(pickNextPrompt).mockReturnValue(
383
+ pickerResult(makePrompt(1), { total: 3, pending: 1, inProgress: 0, done: 2, blocked: 0 }),
384
+ );
385
+ await loop.forceTick();
386
+ expect(loop.getState().activeExecutorPrompts).toEqual([1]);
387
+ expect(loop.getState().lastExecutorSpawnTime).not.toBeNull();
388
+
389
+ // Tick 2: no executor-01 window (it died before tmux detected it)
390
+ // Reconciliation in checkAgentWindows cleans up orphaned prompt number
391
+ vi.mocked(pickNextPrompt).mockReturnValue(
392
+ pickerResult(null, { total: 3, pending: 0, inProgress: 1, done: 2, blocked: 0 }, 'Waiting'),
393
+ );
394
+ await loop.forceTick();
395
+
396
+ expect(loop.getState().activeExecutorPrompts).toEqual([]);
397
+ expect(loop.getState().lastExecutorSpawnTime).toBeNull();
398
+ });
399
+ });
400
+
401
+ // ─── Emergent Planner Exponential Backoff ────────────────────────────
402
+
403
+ describe('emergent planner exponential backoff', () => {
404
+ /** Configure mocks for the emergent planner path (no pending, no in_progress) */
405
+ function setupEmergentPath(doneCount: number) {
406
+ const donePrompts = Array.from({ length: doneCount }, (_, i) => makePrompt(i + 1, 'done'));
407
+ vi.mocked(loadAllPrompts).mockReturnValue(donePrompts);
408
+ vi.mocked(pickNextPrompt).mockReturnValue(
409
+ pickerResult(null, { total: doneCount, pending: 0, inProgress: 0, done: doneCount, blocked: 0 }),
410
+ );
411
+ }
412
+
413
+ it('increments emergentSpawnCount on unproductive spawn and applies 20s cooldown', async () => {
414
+ setupEmergentPath(5);
415
+
416
+ // Tick 1: first spawn — productive (snapshot count 5 > initial emergentLastPromptCount 0)
417
+ await loop.forceTick();
418
+ expect(callbacks.onSpawnEmergentPlanning).toHaveBeenCalledTimes(1);
419
+ expect(loop.getState().emergentSpawnCount).toBe(0);
420
+
421
+ // Fix time at 15s after spawn — past base 10s but under 20s backoff
422
+ const spawnTime = loop.getState().lastExecutorSpawnTime!;
423
+ const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(spawnTime + 15000);
424
+
425
+ // Tick 2: unproductive (count still 5) → emergentSpawnCount = 1, cooldown = 20s
426
+ await loop.forceTick();
427
+ expect(callbacks.onSpawnEmergentPlanning).toHaveBeenCalledTimes(1); // blocked by backoff
428
+ expect(loop.getState().emergentSpawnCount).toBe(1);
429
+ expect(callbacks.onLoopStatus).toHaveBeenCalledWith(
430
+ 'Emergent planner backoff: waiting 20s (1 unproductive spawns)',
431
+ );
432
+
433
+ dateNowSpy.mockRestore();
434
+ });
435
+
436
+ it('doubles cooldown with each unproductive attempt: 20s, 40s, 80s, 160s, capped at 160s', async () => {
437
+ setupEmergentPath(5);
438
+
439
+ // Tick 1: first spawn (productive, emergentSpawnCount = 0)
440
+ await loop.forceTick();
441
+ expect(callbacks.onSpawnEmergentPlanning).toHaveBeenCalledTimes(1);
442
+
443
+ // Advance time past base 10s cooldown but within each escalating backoff window
444
+ const spawnTime = loop.getState().lastExecutorSpawnTime!;
445
+ const dateNowSpy = vi.spyOn(Date, 'now');
446
+
447
+ dateNowSpy.mockReturnValue(spawnTime + 11000); // 11s: past base 10s, within 20s
448
+ await loop.forceTick(); // count → 1
449
+ expect(callbacks.onLoopStatus).toHaveBeenCalledWith(
450
+ 'Emergent planner backoff: waiting 20s (1 unproductive spawns)',
451
+ );
452
+
453
+ dateNowSpy.mockReturnValue(spawnTime + 21000); // 21s: past base, within 40s
454
+ await loop.forceTick(); // count → 2
455
+ expect(callbacks.onLoopStatus).toHaveBeenCalledWith(
456
+ 'Emergent planner backoff: waiting 40s (2 unproductive spawns)',
457
+ );
458
+
459
+ dateNowSpy.mockReturnValue(spawnTime + 41000); // 41s: past base, within 80s
460
+ await loop.forceTick(); // count → 3
461
+ expect(callbacks.onLoopStatus).toHaveBeenCalledWith(
462
+ 'Emergent planner backoff: waiting 80s (3 unproductive spawns)',
463
+ );
464
+
465
+ dateNowSpy.mockReturnValue(spawnTime + 81000); // 81s: past base, within 160s
466
+ await loop.forceTick(); // count → 4
467
+ expect(callbacks.onLoopStatus).toHaveBeenCalledWith(
468
+ 'Emergent planner backoff: waiting 160s (4 unproductive spawns)',
469
+ );
470
+
471
+ // count → 5, Math.min(5, 4) = 4, cooldown still 160s (capped at same time offset)
472
+ await loop.forceTick();
473
+ expect(callbacks.onLoopStatus).toHaveBeenCalledWith(
474
+ 'Emergent planner backoff: waiting 160s (5 unproductive spawns)',
475
+ );
476
+
477
+ // Only the initial spawn succeeded — all subsequent ticks blocked
478
+ expect(callbacks.onSpawnEmergentPlanning).toHaveBeenCalledTimes(1);
479
+
480
+ dateNowSpy.mockRestore();
481
+ });
482
+
483
+ it('resets backoff when new pending prompts appear externally', async () => {
484
+ setupEmergentPath(5);
485
+
486
+ // Tick 1: spawn emergent (productive, count = 0)
487
+ await loop.forceTick();
488
+ expect(callbacks.onSpawnEmergentPlanning).toHaveBeenCalledTimes(1);
489
+
490
+ // Tick 2 at +11s: unproductive → count = 1, backoff (cooldown 20s, 11s elapsed)
491
+ const spawnTime = loop.getState().lastExecutorSpawnTime!;
492
+ const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(spawnTime + 11000);
493
+ await loop.forceTick();
494
+ expect(loop.getState().emergentSpawnCount).toBe(1);
495
+
496
+ // External prompt appears: pending count increases from 0 to 1
497
+ const donePrompts = Array.from({ length: 5 }, (_, i) => makePrompt(i + 1, 'done'));
498
+ const pendingPrompt = makePrompt(6, 'pending');
499
+ vi.mocked(loadAllPrompts).mockReturnValue([...donePrompts, pendingPrompt]);
500
+ vi.mocked(pickNextPrompt).mockReturnValue(
501
+ pickerResult(pendingPrompt, { total: 6, pending: 1, inProgress: 0, done: 5, blocked: 0 }),
502
+ );
503
+
504
+ // Tick 3: checkPromptFiles detects pending increase → resets emergentSpawnCount
505
+ // Then checkPromptLoop picks pending prompt → spawns executor
506
+ await loop.forceTick();
507
+ expect(loop.getState().emergentSpawnCount).toBe(0);
508
+ expect(callbacks.onSpawnExecutor).toHaveBeenCalledWith(pendingPrompt);
509
+
510
+ dateNowSpy.mockRestore();
511
+ });
512
+
513
+ it('resets backoff when emergent planner produces new prompts', async () => {
514
+ setupEmergentPath(5);
515
+
516
+ // Tick 1: spawn emergent (productive, count = 0)
517
+ await loop.forceTick();
518
+ expect(callbacks.onSpawnEmergentPlanning).toHaveBeenCalledTimes(1);
519
+
520
+ // Tick 2 at +11s: past base cooldown, enters emergent path — unproductive → count = 1
521
+ const spawnTime = loop.getState().lastExecutorSpawnTime!;
522
+ const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(spawnTime + 11000);
523
+ await loop.forceTick();
524
+ expect(loop.getState().emergentSpawnCount).toBe(1);
525
+
526
+ // Emergent planner produced a new prompt (total count increases 5 → 6)
527
+ setupEmergentPath(6);
528
+
529
+ // Tick 3 at +21s: past base cooldown, productive (count 6 > emergentLastPromptCount 5)
530
+ // → count = 0, cooldown = 10s base, 21s elapsed → spawns
531
+ dateNowSpy.mockReturnValue(spawnTime + 21000);
532
+ await loop.forceTick();
533
+ expect(loop.getState().emergentSpawnCount).toBe(0);
534
+ expect(callbacks.onSpawnEmergentPlanning).toHaveBeenCalledTimes(2);
535
+
536
+ dateNowSpy.mockRestore();
537
+ });
538
+ });
539
+ });