headlesscode 1.0.2

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 (232) hide show
  1. package/ATTRIBUTION.md +53 -0
  2. package/CODE_OF_CONDUCT.md +130 -0
  3. package/CONTRIBUTING.md +107 -0
  4. package/LICENSE +202 -0
  5. package/README.md +486 -0
  6. package/SECURITY.md +211 -0
  7. package/bin/headlesscode.mjs +83 -0
  8. package/package.json +63 -0
  9. package/shared/prompts/review-mode-prompt-short.md +93 -0
  10. package/shared/prompts/review-mode-prompt.md +281 -0
  11. package/shared/rules-code/rules.md +22 -0
  12. package/shared/stacks/cpp/rules.md +30 -0
  13. package/shared/stacks/fastapi/rules.md +30 -0
  14. package/shared/stacks/javascript/rules.md +37 -0
  15. package/shared/stacks/postgresql/rules.md +31 -0
  16. package/shared/stacks/python/rules.md +35 -0
  17. package/shared/stacks/react/rules.md +11 -0
  18. package/shared/stacks/typescript/rules.md +10 -0
  19. package/src/budget/budget.ts +221 -0
  20. package/src/budget/concurrency.ts +126 -0
  21. package/src/budget/cost.ts +309 -0
  22. package/src/budget/index.ts +8 -0
  23. package/src/checkpoints/cli.ts +256 -0
  24. package/src/checkpoints/service.ts +227 -0
  25. package/src/cli.ts +1535 -0
  26. package/src/cloud/docker-provider.ts +334 -0
  27. package/src/cloud/provider.ts +300 -0
  28. package/src/codeintel/call-graph.ts +78 -0
  29. package/src/codeintel/find-references.ts +123 -0
  30. package/src/codeintel/go-to-definition.ts +193 -0
  31. package/src/codeintel/handlers.ts +190 -0
  32. package/src/codeintel/import-graph.ts +173 -0
  33. package/src/codeintel/outline.ts +180 -0
  34. package/src/codeintel/position.ts +77 -0
  35. package/src/codeintel/program.ts +350 -0
  36. package/src/codeintel/rename-symbol.ts +213 -0
  37. package/src/codeintel/tools.ts +280 -0
  38. package/src/codemap/build.ts +135 -0
  39. package/src/codemap/cli.ts +190 -0
  40. package/src/codemap/extract.ts +339 -0
  41. package/src/codemap/files.ts +236 -0
  42. package/src/codemap/fingerprint.ts +65 -0
  43. package/src/codemap/flows.ts +62 -0
  44. package/src/codemap/html.ts +451 -0
  45. package/src/codemap/lock.ts +80 -0
  46. package/src/codemap/types.ts +101 -0
  47. package/src/codesearch/airunner-embedder.ts +185 -0
  48. package/src/codesearch/chunk.ts +339 -0
  49. package/src/codesearch/cli.ts +223 -0
  50. package/src/codesearch/embedder.ts +332 -0
  51. package/src/codesearch/files.ts +280 -0
  52. package/src/codesearch/index.ts +469 -0
  53. package/src/codesearch/ollama-embedder.ts +205 -0
  54. package/src/codesearch/search.ts +141 -0
  55. package/src/codesearch/types.ts +100 -0
  56. package/src/config/mode-models.ts +218 -0
  57. package/src/dashboard/aggregate.ts +364 -0
  58. package/src/dashboard/chat-thread.ts +141 -0
  59. package/src/dashboard/checkpoints.ts +124 -0
  60. package/src/dashboard/cli.ts +193 -0
  61. package/src/dashboard/codemap.ts +44 -0
  62. package/src/dashboard/files.ts +121 -0
  63. package/src/dashboard/page.ts +2803 -0
  64. package/src/dashboard/self-improvement-metrics.ts +282 -0
  65. package/src/dashboard/server.ts +1103 -0
  66. package/src/dashboard/session-launch.ts +310 -0
  67. package/src/dashboard/timeline.ts +273 -0
  68. package/src/dashboard/tool-exec.ts +107 -0
  69. package/src/dashboard/trend-cli.ts +141 -0
  70. package/src/dashboard/trend.ts +413 -0
  71. package/src/decision-proxy/cli.ts +261 -0
  72. package/src/decision-proxy/proxy.ts +569 -0
  73. package/src/deploy/gate-cli.ts +147 -0
  74. package/src/deploy/gate.ts +254 -0
  75. package/src/engine/condense.ts +512 -0
  76. package/src/engine/events.ts +428 -0
  77. package/src/engine/handoff.ts +71 -0
  78. package/src/engine/lazy-tools.ts +160 -0
  79. package/src/engine/local-explore.ts +653 -0
  80. package/src/engine/logger.ts +96 -0
  81. package/src/engine/loop.ts +5517 -0
  82. package/src/engine/parser.ts +347 -0
  83. package/src/engine/prompt.ts +860 -0
  84. package/src/engine/reports.ts +47 -0
  85. package/src/engine/stacks.ts +448 -0
  86. package/src/engine/types.ts +291 -0
  87. package/src/engine/usage.ts +186 -0
  88. package/src/github/app-auth.ts +161 -0
  89. package/src/github/cli.ts +448 -0
  90. package/src/github/installations.ts +133 -0
  91. package/src/github/pr.ts +321 -0
  92. package/src/github/provision.ts +118 -0
  93. package/src/github/push.ts +122 -0
  94. package/src/index-util.ts +50 -0
  95. package/src/index.ts +81 -0
  96. package/src/init/cli.ts +248 -0
  97. package/src/init/gitignore.ts +74 -0
  98. package/src/llm/ollama.ts +308 -0
  99. package/src/llm/openrouter.ts +868 -0
  100. package/src/llm/preflight.ts +367 -0
  101. package/src/llm/transcript-capture.ts +84 -0
  102. package/src/memory/embed.ts +110 -0
  103. package/src/memory/index.ts +22 -0
  104. package/src/memory/local.ts +259 -0
  105. package/src/memory/summarizer.ts +283 -0
  106. package/src/memory/types.ts +153 -0
  107. package/src/memory/uwuchat.ts +157 -0
  108. package/src/migrate/cli.ts +115 -0
  109. package/src/orchestrator/analyze-cli.ts +104 -0
  110. package/src/orchestrator/auto-split.ts +206 -0
  111. package/src/orchestrator/cleanup.ts +1003 -0
  112. package/src/orchestrator/cli.ts +3571 -0
  113. package/src/orchestrator/cost-estimate.ts +564 -0
  114. package/src/orchestrator/cost-history-cli.ts +242 -0
  115. package/src/orchestrator/cost-history.ts +397 -0
  116. package/src/orchestrator/git-sync.ts +250 -0
  117. package/src/orchestrator/index.ts +153 -0
  118. package/src/orchestrator/log-analysis.ts +0 -0
  119. package/src/orchestrator/merge-check.ts +108 -0
  120. package/src/orchestrator/pipeline.ts +411 -0
  121. package/src/orchestrator/resume.ts +1940 -0
  122. package/src/orchestrator/reviewer.ts +503 -0
  123. package/src/orchestrator/split.ts +296 -0
  124. package/src/orchestrator/state.ts +542 -0
  125. package/src/orchestrator/status.ts +697 -0
  126. package/src/orchestrator/verification-gate.ts +134 -0
  127. package/src/orchestrator/watch.ts +898 -0
  128. package/src/permissions/commands.ts +1083 -0
  129. package/src/permissions/config.ts +241 -0
  130. package/src/permissions/index.ts +12 -0
  131. package/src/permissions/protected-files.ts +96 -0
  132. package/src/permissions/store-protection.ts +272 -0
  133. package/src/project-store.ts +648 -0
  134. package/src/projects/cli.ts +382 -0
  135. package/src/qa/qa.ts +487 -0
  136. package/src/tools/browser/handler.ts +346 -0
  137. package/src/tools/browser/service.ts +406 -0
  138. package/src/tools/browser/smoke.ts +78 -0
  139. package/src/tools/browser/tool.ts +99 -0
  140. package/src/tools/executor.ts +2575 -0
  141. package/src/tools/language-detect.ts +183 -0
  142. package/src/tools/output-summarizer.ts +369 -0
  143. package/src/tools/run-tests.ts +302 -0
  144. package/src/tools/set-indentation-tool.ts +49 -0
  145. package/src/tools/test-selection.ts +160 -0
  146. package/src/vendor/tests/smoke.ts +103 -0
  147. package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
  148. package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
  149. package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
  150. package/src/vendor/zoo-code/shim/os-name.ts +18 -0
  151. package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
  152. package/src/vendor/zoo-code/shim/vscode.ts +76 -0
  153. package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
  154. package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
  155. package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
  156. package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
  157. package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
  158. package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
  159. package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
  160. package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
  161. package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
  162. package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
  163. package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
  164. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
  165. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
  166. package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
  167. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
  168. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
  169. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
  170. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
  171. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
  172. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
  173. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
  174. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
  175. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
  176. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
  177. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
  178. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
  179. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
  180. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
  181. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
  182. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
  183. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
  184. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
  185. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
  186. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
  187. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
  188. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
  189. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
  190. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
  191. package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
  192. package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
  193. package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
  194. package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
  195. package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
  196. package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
  197. package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
  198. package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
  199. package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
  200. package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
  201. package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
  202. package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
  203. package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
  204. package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
  205. package/src/vendor/zoo-code/src/shared/language.ts +43 -0
  206. package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
  207. package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
  208. package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
  209. package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
  210. package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
  211. package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
  212. package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
  213. package/src/vendor/zoo-code/src/utils/object.ts +18 -0
  214. package/src/vendor/zoo-code/src/utils/path.ts +94 -0
  215. package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
  216. package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
  217. package/src/vendor/zoo-code/types/global-settings.ts +19 -0
  218. package/src/vendor/zoo-code/types/index.ts +22 -0
  219. package/src/vendor/zoo-code/types/message.ts +375 -0
  220. package/src/vendor/zoo-code/types/mode.ts +241 -0
  221. package/src/vendor/zoo-code/types/todo.ts +19 -0
  222. package/src/vendor/zoo-code/types/tool-params.ts +116 -0
  223. package/src/vendor/zoo-code/types/tool.ts +67 -0
  224. package/src/vendor/zoo-code/types/vscode.ts +84 -0
  225. package/src/vision/describe.ts +242 -0
  226. package/src/vision/tool.ts +91 -0
  227. package/src/watcher/cli.ts +369 -0
  228. package/src/watcher/github.ts +304 -0
  229. package/src/watcher/index.ts +59 -0
  230. package/src/watcher/state.ts +254 -0
  231. package/src/watcher/watch.ts +562 -0
  232. package/tsconfig.json +18 -0
@@ -0,0 +1,562 @@
1
+ /**
2
+ * Phase 5 — GitHub issue watcher: poll a label, fan out into batches, spawn
3
+ * worktrees through the EXISTING bash spawner, and track idempotency in a
4
+ * durable state file.
5
+ *
6
+ * Sweep flow (per tick):
7
+ * 1. listIssues({ owner, repo, label: targetLabel, state: open }).
8
+ * 2. Partition: new (no entry / status pending / failed-with-retry) vs
9
+ * already processed (everything else — skipped this sweep).
10
+ * 3. Apply the maxPerSweep cap: the first `maxPerSweep` new issues are
11
+ * spawned; the rest are recorded as status "pending" so the NEXT sweep
12
+ * picks them up (a label storm never spawns more than N worktrees per
13
+ * sweep).
14
+ * 4. For each spawned issue (write-ahead ordering):
15
+ * a. markProcessed(status "spawned") + save ← durable BEFORE spawn
16
+ * b. splitIssues([issue]) -> specs
17
+ * c. writeTaskFiles(...) + spawn via
18
+ * scripts/spawn-parallel-worktrees.sh (bash -c — the bash script
19
+ * remains the ACTUAL spawner; no worktree creation in TS)
20
+ * d. markProcessed(status "done" | "failed") + save
21
+ * 5. Log the sweep (issues found / new / skipped / spawned / deferred).
22
+ *
23
+ * The state file is the idempotency source of truth: a restart loads it and
24
+ * continues; a crash between (a) and (d) leaves a "spawned" entry which the
25
+ * next restart treats as processed — a double-spawn is impossible.
26
+ */
27
+
28
+ import { spawnSync } from "node:child_process"
29
+ import * as fs from "node:fs"
30
+ import * as path from "node:path"
31
+ import { fileURLToPath } from "node:url"
32
+
33
+ import {
34
+ activeSessionCount,
35
+ ConcurrencyLimiter,
36
+ maxConcurrentSessionsFromEnv,
37
+ } from "../budget/concurrency.js"
38
+ import { Logger } from "../engine/logger.js"
39
+ import { splitIssues, type SplitIssue, type WorktreeSpec } from "../orchestrator/split.js"
40
+ import { loadStateSync } from "../orchestrator/state.js"
41
+ import { writeTaskFiles } from "../orchestrator/cli.js"
42
+ import { createGhClient, type GhClient, type GitHubIssue } from "./github.js"
43
+ import {
44
+ isProcessed,
45
+ loadWatcherState,
46
+ markProcessed,
47
+ saveWatcherState,
48
+ type WatcherIssueEntry,
49
+ type WatcherIssueStatus,
50
+ type WatcherState,
51
+ } from "./state.js"
52
+
53
+ /** The harness repo root (parent of src/watcher) — same pattern as the CLI. */
54
+ const HARNESS_ROOT = fileURLToPath(new URL("../..", import.meta.url))
55
+
56
+ export const DEFAULT_POLL_INTERVAL_MS = 60_000
57
+ export const DEFAULT_MAX_PER_SWEEP = 5
58
+
59
+ export interface SpawnBatch {
60
+ issue: GitHubIssue
61
+ /** Worktree groups from splitIssues([issue]). */
62
+ specs: WorktreeSpec[]
63
+ /** Local target repo root where worktrees are created. */
64
+ repoRoot: string
65
+ /** Batch id (watcher-<date>). */
66
+ batch: string
67
+ }
68
+
69
+ export interface SpawnResult {
70
+ ok: boolean
71
+ /** The exact bash command that was (or would be) run. */
72
+ command?: string
73
+ /** spawnSync exit status (null = spawn failed to launch). */
74
+ status?: number | null
75
+ error?: string
76
+ }
77
+
78
+ export interface WatcherBatchInfo {
79
+ issue: GitHubIssue
80
+ specs: WorktreeSpec[]
81
+ batch: string
82
+ statePath: string
83
+ /** Final recorded status after the batch processed: done | failed. */
84
+ status: WatcherIssueStatus
85
+ }
86
+
87
+ export interface WatcherConfig {
88
+ /** GitHub owner (e.g. "my-org"). */
89
+ owner: string
90
+ /** GitHub repo name (e.g. "my-repo"). */
91
+ repo: string
92
+ /** The label that triggers processing (e.g. "needs-agent"). */
93
+ targetLabel: string
94
+ /** GitHub token (GH_TOKEN / GITHUB_TOKEN). Never logged or persisted. */
95
+ token: string
96
+ /** Sweep interval (default 60000ms). */
97
+ pollIntervalMs?: number
98
+ /** GitHub API base URL (default: $GITHUB_API_BASE_URL or api.github.com). */
99
+ ghBaseUrl?: string
100
+ /** Durable state file (default: <repoRoot>/.worktrees/.watcher-state.json). */
101
+ stateFile?: string
102
+ /** Local target repo root where worktrees are spawned (default: cwd). */
103
+ repoRoot?: string
104
+ /** Called after each batch is processed (spawn done/failed). */
105
+ onBatch?: (info: WatcherBatchInfo) => void | Promise<void>
106
+ /** true = one sweep then return; false = continuous loop. */
107
+ runOnce?: boolean
108
+ /** Abort the loop (continuous mode clean shutdown). */
109
+ signal?: AbortSignal
110
+ /** Per-sweep cap on newly spawned issues (default 5). */
111
+ maxPerSweep?: number
112
+ /**
113
+ * Phase 6 GLOBAL cap on concurrent sessions across processes (default
114
+ * $HEADLESSCODE_MAX_CONCURRENT_SESSIONS or 3). Interplay with maxPerSweep:
115
+ * maxPerSweep bounds a single sweep's burst; this bounds the TOTAL number
116
+ * of running/spawned sessions seen across the orchestrator + watcher state
117
+ * files — a label storm can never push the fleet past the cap. Issues
118
+ * exceeding the cap stay 'pending' in watcher state (deferred exactly like
119
+ * maxPerSweep overflow) and are picked up on a later sweep.
120
+ */
121
+ maxConcurrentSessions?: number
122
+ /** Retry previously-failed issues on the next sweep (default: no). */
123
+ retryFailed?: boolean
124
+ /** Sweep + report without spawning or writing state. */
125
+ dryRun?: boolean
126
+ /** Injected GitHub client for tests (default: createGhClient(token)). */
127
+ gh?: GhClient
128
+ /** Injected spawn function for tests (default: the real bash spawner). */
129
+ spawn?: (batch: SpawnBatch) => SpawnResult | Promise<SpawnResult>
130
+ /** Harness mode for workers (forwarded to the spawner as ORCHESTRATOR_MODE). */
131
+ mode?: string
132
+ /** Memory dir for workers (forwarded to the spawner as HEADLESSCODE_MEMORY_DIR). */
133
+ memoryDir?: string
134
+ /** Phase 4 pass-through: recorded on the batch entry (see docs). */
135
+ qa?: boolean
136
+ /** Phase 4 pass-through: recorded on the batch entry (see docs). */
137
+ deploy?: boolean
138
+ logger?: Logger
139
+ }
140
+
141
+ export interface SweepResult {
142
+ issuesFound: number
143
+ newIssues: number
144
+ skipped: number
145
+ spawned: number
146
+ deferred: number
147
+ failures: number
148
+ state: WatcherState
149
+ /** Dry-run only: what WOULD be spawned per new issue. */
150
+ dryRunPlan?: Array<{ issue: GitHubIssue; specs: WorktreeSpec[] }>
151
+ error?: string
152
+ }
153
+
154
+ export interface WatchResult {
155
+ /** One entry per completed sweep. */
156
+ sweeps: SweepResult[]
157
+ /** Total batches spawned across all sweeps. */
158
+ spawned: number
159
+ /** 0 = ok; 1 = one or more spawns failed. */
160
+ exitCode: number
161
+ }
162
+
163
+ /**
164
+ * Run the watcher. Continuous mode loops until the abort signal fires;
165
+ * `runOnce` performs a single sweep and returns. State is loaded from disk at
166
+ * startup (restart-safe) and saved after every sweep.
167
+ */
168
+ export async function watchIssues(config: WatcherConfig): Promise<WatchResult> {
169
+ const owner = config.owner
170
+ const repo = config.repo
171
+ const targetLabel = config.targetLabel
172
+ const token = config.token
173
+ if (!token) {
174
+ throw new Error("watcher: no GitHub token (set GH_TOKEN or GITHUB_TOKEN)")
175
+ }
176
+ if (!owner || !repo || !targetLabel) {
177
+ throw new Error("watcher: owner, repo and targetLabel are required")
178
+ }
179
+
180
+ const repoRoot = path.resolve(config.repoRoot ?? process.cwd())
181
+ const statePath = path.resolve(config.stateFile ?? path.join(repoRoot, ".worktrees", ".watcher-state.json"))
182
+ const pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
183
+ const maxPerSweep = config.maxPerSweep ?? DEFAULT_MAX_PER_SWEEP
184
+ // Phase 6: the global concurrency cap (flag wins; else env; else 3). One
185
+ // in-process limiter per watchIssues run guards the sweep spawn loop; the
186
+ // durable-state view (activeSessionCount) guards the cross-process window.
187
+ const maxConcurrentSessions = config.maxConcurrentSessions ?? maxConcurrentSessionsFromEnv()
188
+ const limiter = new ConcurrencyLimiter(maxConcurrentSessions)
189
+ const gh = config.gh ?? createGhClient({ token, baseUrl: config.ghBaseUrl })
190
+ const logger = config.logger ?? new Logger({ level: "info" })
191
+ const spawn = config.spawn ?? ((batch: SpawnBatch): SpawnResult => defaultSpawn(batch, config))
192
+ const qa = config.qa ?? false
193
+ const deploy = config.deploy ?? false
194
+
195
+ let state = await loadWatcherState(statePath)
196
+ const sweeps: SweepResult[] = []
197
+ let spawnedTotal = 0
198
+ let exitCode = 0
199
+
200
+ for (;;) {
201
+ if (config.signal?.aborted) {
202
+ break
203
+ }
204
+
205
+ const sweep = await sweepOnce({
206
+ gh,
207
+ owner,
208
+ repo,
209
+ targetLabel,
210
+ state,
211
+ statePath,
212
+ repoRoot,
213
+ maxPerSweep,
214
+ maxConcurrentSessions,
215
+ limiter,
216
+ retryFailed: config.retryFailed,
217
+ dryRun: config.dryRun,
218
+ spawn,
219
+ onBatch: config.onBatch,
220
+ logger,
221
+ qa,
222
+ deploy,
223
+ })
224
+ state = sweep.state
225
+ sweeps.push(sweep)
226
+ spawnedTotal += sweep.spawned
227
+ // A list failure OR any failed spawn makes the run "error out"
228
+ // (runOnce exits 1; continuous mode logs and keeps going).
229
+ if (sweep.error || sweep.failures > 0) {
230
+ exitCode = 1
231
+ }
232
+
233
+ if (config.runOnce) {
234
+ break
235
+ }
236
+ await sleepOrAbort(pollIntervalMs, config.signal)
237
+ }
238
+
239
+ // Final save so lastSweep is durable even after a clean abort.
240
+ // Dry-run writes nothing at all.
241
+ if (!config.dryRun) {
242
+ await saveWatcherState(statePath, state)
243
+ }
244
+ return { sweeps, spawned: spawnedTotal, exitCode }
245
+ }
246
+
247
+ interface SweepInput {
248
+ gh: GhClient
249
+ owner: string
250
+ repo: string
251
+ targetLabel: string
252
+ state: WatcherState
253
+ statePath: string
254
+ repoRoot: string
255
+ maxPerSweep: number
256
+ /** Phase 6: global concurrent-session cap (see WatcherConfig). */
257
+ maxConcurrentSessions: number
258
+ /** Phase 6: in-process limiter guarding the sweep spawn loop. */
259
+ limiter: ConcurrencyLimiter
260
+ retryFailed?: boolean
261
+ dryRun?: boolean
262
+ spawn: (batch: SpawnBatch) => SpawnResult | Promise<SpawnResult>
263
+ onBatch?: (info: WatcherBatchInfo) => void | Promise<void>
264
+ logger: Logger
265
+ /** Recorded on each batch entry (pass-through for the follow-up round). */
266
+ qa: boolean
267
+ /** Recorded on each batch entry (pass-through for the follow-up round). */
268
+ deploy: boolean
269
+ }
270
+
271
+ /** One full sweep: list → partition → cap (per-sweep + global) → spawn → log. */
272
+ async function sweepOnce(input: SweepInput): Promise<SweepResult> {
273
+ const {
274
+ gh,
275
+ owner,
276
+ repo,
277
+ targetLabel,
278
+ statePath,
279
+ repoRoot,
280
+ maxPerSweep,
281
+ maxConcurrentSessions,
282
+ limiter,
283
+ retryFailed,
284
+ dryRun,
285
+ spawn,
286
+ logger,
287
+ } = input
288
+
289
+ let issues: GitHubIssue[]
290
+ try {
291
+ issues = await gh.listIssues({ owner, repo, label: targetLabel, state: "open" })
292
+ } catch (err) {
293
+ const error = `watcher sweep failed listing issues for ${owner}/${repo}: ${
294
+ err instanceof Error ? err.message : String(err)
295
+ }`
296
+ logger.error(error)
297
+ return { issuesFound: 0, newIssues: 0, skipped: 0, spawned: 0, deferred: 0, failures: 0, state: input.state, error }
298
+ }
299
+
300
+ // Deterministic processing order (ascending issue number).
301
+ issues = [...issues].sort((a, b) => a.number - b.number)
302
+
303
+ const newIssues = issues.filter((i) => !isProcessedForSweep(input.state, i.number, retryFailed))
304
+ const skipped = issues.length - newIssues.length
305
+
306
+ // ── Phase 6 GLOBAL cap (spec 6.3) ──────────────────────────────────────
307
+ // maxPerSweep bounds a single sweep's burst; the global cap bounds the
308
+ // TOTAL running/spawned sessions seen across the durable state files
309
+ // (orchestrator groups in spawned/running + watcher in-flight 'spawned'
310
+ // entries). Effective new spawns this sweep:
311
+ // min(maxPerSweep, maxConcurrentSessions - active)
312
+ // Issues beyond it stay 'pending' (same durable deferral as maxPerSweep
313
+ // overflow) and are picked up on a later sweep. Missing orchestrator state
314
+ // file → active = 0 (the cap still applies to this pipeline alone).
315
+ const orchPath = path.join(repoRoot, ".worktrees", ".orchestrator-state.json")
316
+ const orchState = loadStateSync(orchPath)
317
+ const active = activeSessionCount(orchState, input.state)
318
+ const available = Math.max(0, maxConcurrentSessions - active)
319
+ const toSpawn = newIssues.slice(0, Math.min(maxPerSweep, available))
320
+ const deferred = newIssues.slice(toSpawn.length)
321
+
322
+ // Accounting for the sweep log: how much of the deferral was maxPerSweep
323
+ // vs the global cap (both ≥ 0).
324
+ const maxPerSweepDeferred = newIssues.length - Math.min(newIssues.length, maxPerSweep)
325
+ const globalCapDeferred = deferred.length - maxPerSweepDeferred
326
+
327
+ let state = input.state
328
+ const dryRunPlan: SweepResult["dryRunPlan"] = dryRun ? [] : undefined
329
+ let spawned = 0
330
+ let failures = 0
331
+
332
+ // Naming collision avoidance (mirrors orchestrator/cli.ts's orchestrate
333
+ // path): seed from whatever `.worktrees/wN` dirs already exist — a
334
+ // still-running `orchestrate` round, or an earlier sweep this same
335
+ // process — then reserve each issue's assigned names as we go, so two
336
+ // issues spawned in the SAME sweep never both land on w1 either.
337
+ let occupiedNames: Set<string>
338
+ try {
339
+ occupiedNames = new Set(
340
+ fs
341
+ .readdirSync(path.join(repoRoot, ".worktrees"), { withFileTypes: true })
342
+ .filter((d) => d.isDirectory())
343
+ .map((d) => d.name),
344
+ )
345
+ } catch {
346
+ occupiedNames = new Set()
347
+ }
348
+
349
+ // Record deferred issues as "pending" so the next sweep picks them up
350
+ // (persisted by the per-issue saves below, or the final save). Dry-run
351
+ // writes nothing.
352
+ if (!dryRun) {
353
+ for (const issue of deferred) {
354
+ state = markProcessed(state, pendingEntry(issue, targetLabel))
355
+ }
356
+ }
357
+
358
+ for (const issue of toSpawn) {
359
+ const specs = splitIssues([issue], { occupiedNames })
360
+ for (const spec of specs) {
361
+ occupiedNames.add(spec.name)
362
+ }
363
+ if (dryRun) {
364
+ dryRunPlan!.push({ issue, specs })
365
+ continue
366
+ }
367
+
368
+ // In-process fail-fast guard (ConcurrencyLimiter). The durable-state
369
+ // view above already pre-capped toSpawn, so this only fires if the
370
+ // fleet picture changed mid-sweep (another process spawned); the issue
371
+ // is deferred to 'pending' rather than over-spawning.
372
+ const acq = limiter.acquire()
373
+ if (!acq.ok) {
374
+ state = markProcessed(state, pendingEntry(issue, targetLabel))
375
+ logger.warn(`watcher: issue #${issue.number} deferred by the global concurrency cap — ${acq.reason}`, {})
376
+ continue
377
+ }
378
+
379
+ try {
380
+ // ── Write-ahead: durable "spawned" entry BEFORE the spawn ──
381
+ const batch = batchId()
382
+ const spawnedAt = new Date().toISOString()
383
+ const entry: WatcherIssueEntry = {
384
+ number: issue.number,
385
+ title: issue.title,
386
+ label: targetLabel,
387
+ batch,
388
+ groups: specs,
389
+ spawnedAt,
390
+ status: "spawned",
391
+ updatedAt: spawnedAt,
392
+ ...(input.qa ? { qa: true } : {}),
393
+ ...(input.deploy ? { deploy: true } : {}),
394
+ }
395
+ state = markProcessed(state, entry)
396
+ await saveWatcherState(statePath, state)
397
+
398
+ const batchInfo = { issue, specs, repoRoot, batch }
399
+ let result: SpawnResult
400
+ try {
401
+ result = await spawn(batchInfo)
402
+ } catch (err) {
403
+ result = { ok: false, error: err instanceof Error ? err.message : String(err) }
404
+ }
405
+
406
+ if (result.ok) {
407
+ state = markProcessed(state, { ...entry, status: "done", updatedAt: new Date().toISOString() })
408
+ spawned++
409
+ logger.info(`watcher: spawned issue #${issue.number} (${specs.map((s) => s.name).join(", ")})`, {
410
+ batch,
411
+ groups: specs.map((s) => s.taskFile),
412
+ })
413
+ } else {
414
+ const error = result.error ?? `spawn exited ${result.status ?? "unknown"}`
415
+ state = markProcessed(state, { ...entry, status: "failed", error, updatedAt: new Date().toISOString() })
416
+ failures++
417
+ // Loud by design: a failed spawn is NOT silently retried (default).
418
+ // Re-run with --retry-failed to force a retry next sweep.
419
+ logger.error(`watcher: spawn FAILED for issue #${issue.number} — ${error}`, { batch })
420
+ }
421
+ await saveWatcherState(statePath, state)
422
+ await input.onBatch?.({ issue, specs, batch, statePath, status: result.ok ? "done" : "failed" })
423
+ } finally {
424
+ limiter.release()
425
+ }
426
+ }
427
+
428
+ state.lastSweep = new Date().toISOString()
429
+ if (!dryRun) {
430
+ await saveWatcherState(statePath, state)
431
+ }
432
+
433
+ logger.info(`watcher sweep: ${issues.length} issue(s) with label "${targetLabel}"`, {
434
+ found: issues.length,
435
+ new: newIssues.length,
436
+ skipped,
437
+ spawned,
438
+ deferred: dryRun ? newIssues.length - toSpawn.length : deferred.length,
439
+ global_cap_deferred: globalCapDeferred,
440
+ max_per_sweep_deferred: maxPerSweepDeferred,
441
+ active_sessions: active,
442
+ failures,
443
+ })
444
+
445
+ return {
446
+ issuesFound: issues.length,
447
+ newIssues: newIssues.length,
448
+ skipped,
449
+ spawned,
450
+ deferred: dryRun ? newIssues.length - toSpawn.length : deferred.length,
451
+ failures,
452
+ state,
453
+ ...(dryRunPlan ? { dryRunPlan } : {}),
454
+ }
455
+ }
456
+
457
+ /** isProcessed plus the retry-failed override (see state.ts). */
458
+ function isProcessedForSweep(state: WatcherState, number: number, retryFailed?: boolean): boolean {
459
+ return isProcessed(state, number, { retryFailed })
460
+ }
461
+
462
+ function pendingEntry(issue: GitHubIssue, label: string): WatcherIssueEntry {
463
+ return {
464
+ number: issue.number,
465
+ title: issue.title,
466
+ label,
467
+ status: "pending",
468
+ updatedAt: new Date().toISOString(),
469
+ }
470
+ }
471
+
472
+ function batchId(): string {
473
+ return `watcher-${new Date().toISOString().slice(0, 10)}`
474
+ }
475
+
476
+ /**
477
+ * The REAL spawn path: generate task files (reusing the orchestrator's
478
+ * builder) and run the existing bash spawner via `bash -c` — the same
479
+ * invocation pattern as src/orchestrator/cli.ts (spawnSync("bash", ["-c", …])
480
+ * with cwd = the target repo so `git rev-parse --show-toplevel` resolves).
481
+ * No worktree creation happens in TS; the bash script remains the spawner.
482
+ */
483
+ export function defaultSpawn(batch: SpawnBatch, config: Pick<WatcherConfig, "mode" | "memoryDir">): SpawnResult {
484
+ const issues: SplitIssue[] = [
485
+ { number: batch.issue.number, title: batch.issue.title, body: batch.issue.body },
486
+ ]
487
+
488
+ // 1. Task files under <repoRoot>/plans/parallel-tasks/ (the convention
489
+ // the spawner copies them into each worktree).
490
+ try {
491
+ writeTaskFiles(batch.repoRoot, batch.specs, issues)
492
+ } catch (err) {
493
+ return { ok: false, error: `task file generation failed: ${err instanceof Error ? err.message : String(err)}` }
494
+ }
495
+
496
+ // 2. Spawn via the existing bash script (env override allows the e2e to
497
+ // stub it; default is the real spawner).
498
+ const spawnScript =
499
+ process.env.HEADLESSCODE_SPAWN_SCRIPT ?? path.join(HARNESS_ROOT, "scripts", "spawn-parallel-worktrees.sh")
500
+ const triples = batch.specs
501
+ .map((spec) => `${spec.name}:${batch.specs.indexOf(spec)}:plans/parallel-tasks/${spec.taskFile}`)
502
+ .join(" ")
503
+ const command = `bash ${spawnScript} ${triples}`
504
+
505
+ // `bash -c <command>` (NOT spawnSync("bash", [command], { shell: true }),
506
+ // which would run `bash bash <script> …` and die — same gotcha the
507
+ // orchestrator CLI documents).
508
+ const result = spawnSync("bash", ["-c", command], {
509
+ cwd: batch.repoRoot,
510
+ env: {
511
+ ...process.env,
512
+ TARGET_REPO: batch.repoRoot,
513
+ ORCHESTRATOR_MODE: config.mode ?? "code",
514
+ HEADLESSCODE_PROJECT: path.basename(batch.repoRoot),
515
+ ...(config.memoryDir ? { HEADLESSCODE_MEMORY_DIR: path.resolve(config.memoryDir) } : {}),
516
+ },
517
+ stdio: "inherit",
518
+ })
519
+
520
+ if (result.status === 0) {
521
+ return { ok: true, command, status: 0 }
522
+ }
523
+ return {
524
+ ok: false,
525
+ command,
526
+ status: result.status ?? null,
527
+ error:
528
+ result.error !== undefined
529
+ ? `spawn script failed to launch: ${result.error.message}`
530
+ : `spawn script exited ${result.status}`,
531
+ }
532
+ }
533
+
534
+ /** Sleep for `ms` but resolve immediately when the signal aborts. */
535
+ function sleepOrAbort(ms: number, signal?: AbortSignal): Promise<void> {
536
+ return new Promise((resolve) => {
537
+ if (signal?.aborted) {
538
+ resolve()
539
+ return
540
+ }
541
+ // Issue #85: on the normal (timer-fires-first) path, `{once:true}` only
542
+ // stops the listener from firing TWICE — it does not detach it. Every
543
+ // poll of a long-running watch loop reuses the same `config.signal`, so
544
+ // without an explicit removeEventListener here each poll's listener
545
+ // stayed attached for the rest of the process's life — a slow leak over
546
+ // a long continuous watch.
547
+ const onAbort = () => {
548
+ clearTimeout(timer)
549
+ resolve()
550
+ }
551
+ const timer = setTimeout(() => {
552
+ signal?.removeEventListener("abort", onAbort)
553
+ resolve()
554
+ }, ms)
555
+ signal?.addEventListener("abort", onAbort, { once: true })
556
+ })
557
+ }
558
+
559
+ // Re-exported for callers that want the state helpers without importing
560
+ // state.js directly (e.g. tests asserting isProcessed semantics).
561
+ export { isProcessed } from "./state.js"
562
+ export type { WatcherIssueEntry, WatcherState } from "./state.js"
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "allowSyntheticDefaultImports": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+ "types": ["node"]
16
+ },
17
+ "include": ["src"]
18
+ }