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,1003 @@
1
+ /**
2
+ * `headlesscode orchestrate cleanup` — deterministic, human-triggered worktree
3
+ * cleanup after a round's work is merged.
4
+ *
5
+ * Why this is a separate explicit command (never wired into the watcher's
6
+ * terminal-status transition): removing a worktree is the ONE operation in the
7
+ * whole pipeline with no undo, so it stays human-triggered — the same
8
+ * deliberate, separate-step philosophy as `headlesscode index` and the deploy
9
+ * gate.
10
+ *
11
+ * Eligibility — ALL gates below, computed FRESH every run (never cached from
12
+ * a prior round):
13
+ * 1. Worktree exists (a missing worktree is "already done", idempotent).
14
+ * 2. Group status is terminal (done|failed|needs-human|orphaned — see
15
+ * status.ts's isTerminalStatus). spawned/running/blocked is never touched.
16
+ * 3. Merge verified: `git merge-base --is-ancestor <branch> <base>` (exit 0)
17
+ * for local merges, or the GitHub PR `.merged` field when the group
18
+ * records pr.number (squash/rebase merges create a new hash — the local
19
+ * check alone would wrongly say "not merged"). If both are available and
20
+ * disagree, fail CLOSED (not merged). See merge-check.ts.
21
+ * 4. Worktree has no uncommitted changes: `git status --porcelain` shows
22
+ * only the harness's OWN untracked artifacts (run-worker.sh / run-qa.sh /
23
+ * the spawner / the executor write .harness.*, .qa.*, harness.log,
24
+ * qa.log, .headlesscode/, ORCHESTRATOR_TASK.md, .env, zoo-code) plus a
25
+ * small allowlist of known-safe regenerable residue workers routinely
26
+ * leave behind (a node_modules symlink/dir, __pycache__ dirs,
27
+ * *.pyc/*.pyo — issue #38). A human's untracked file or ANY tracked
28
+ * modification blocks cleanup — `git worktree remove` without --force is
29
+ * the final arbiter, and we never force.
30
+ * 5. No live worker process: <worktree>/.harness.pid and .qa.pid absent or
31
+ * the PID is not running (reuses watch.ts's isPidAlive liveness check —
32
+ * a stale pid FILE is not a running worker).
33
+ *
34
+ * Apply, per eligible group in state order (never reordered, so partial runs
35
+ * are resumable and idempotent):
36
+ * 1. Remove the harness's own untracked artifacts and the known-safe
37
+ * regenerable residue from the worktree. This step exists because `git
38
+ * worktree remove` (no --force) refuses on ANY untracked file — after a
39
+ * real round the harness leaves exactly those files, so without this the
40
+ * no-force removal the safety model requires would be impossible. Only
41
+ * the allowlisted paths are ever removed; if a human file raced in
42
+ * between the gate and this step, the no-force `git worktree remove`
43
+ * below refuses and the human file survives.
44
+ * 2. `git worktree remove <path>` (NO --force). A failure here means gate #4
45
+ * raced with something: that group's cleanup is aborted and reported,
46
+ * never forced.
47
+ * 3. `git branch -d <branch>` (lowercase -d — itself refuses to delete a
48
+ * branch not merged into HEAD/upstream: a second, independent safety net
49
+ * on top of gate #3). A failure here is reported and recorded in
50
+ * actions_taken, but is NOT a run failure: the irreversible operation
51
+ * (worktree removal) already succeeded, and re-running is a no-op.
52
+ * 4. Patch the group's state entry: add `cleaned_at` and append to
53
+ * `actions_taken`. The group entry itself is NEVER deleted — the round's
54
+ * history stays queryable (same philosophy as the `orphaned` status).
55
+ */
56
+
57
+ import { execFileSync } from "node:child_process"
58
+ import * as fs from "node:fs"
59
+ import * as path from "node:path"
60
+
61
+ import { createAppAuthClient } from "../github/app-auth.js"
62
+ import { checkMergedByAncestor, checkMergedByGitHubPr, type MergeCheckResult } from "./merge-check.js"
63
+ import { loadStateSync, saveStateSync, updateGroup, type OrchestratorGroup, type OrchestratorState } from "./state.js"
64
+ import { isTerminalStatus } from "./status.js"
65
+ import { groupWorktreePath, isPidAlive } from "./watch.js"
66
+ import {
67
+ branchSyncStatus,
68
+ ORCHESTRATE_SYNC_DISABLED_ENV,
69
+ syncBranchWithOrigin,
70
+ syncSummaryLines,
71
+ syncWarningLines,
72
+ TRIVIAL_DRIFT_AHEAD,
73
+ } from "./git-sync.js"
74
+
75
+ // ─── Harness-owned worktree artifacts ─────────────────────────────────────────
76
+ // Paths the harness itself creates inside a worktree (run-worker.sh, run-qa.sh,
77
+ // spawn-parallel-worktrees.sh, the executor, headlesscode-answer.sh). These are
78
+ // the ONLY untracked entries the dirty gate ignores and the only paths apply
79
+ // removes before `git worktree remove` (which refuses on ANY untracked file).
80
+ const HARNESS_ARTIFACTS = [
81
+ ".harness.pid",
82
+ ".harness.pgid", // process-group id written by run-worker.sh's setsid wrapper (issue #38)
83
+ ".harness.exit",
84
+ ".harness.done",
85
+ ".harness.needs-decision",
86
+ ".harness.decision-answer",
87
+ ".qa.pid",
88
+ ".qa.exit",
89
+ ".qa.done",
90
+ ".qa-task.md",
91
+ "harness.log",
92
+ "qa.log",
93
+ ".headlesscode",
94
+ "ORCHESTRATOR_TASK.md",
95
+ ".env",
96
+ "zoo-code",
97
+ ] as const
98
+
99
+ function isHarnessArtifact(relPath: string): boolean {
100
+ if ((HARNESS_ARTIFACTS as readonly string[]).includes(relPath)) {
101
+ return true
102
+ }
103
+ // A partially-tracked .headlesscode/ shows individual files, not the dir.
104
+ return relPath.startsWith(".headlesscode/")
105
+ }
106
+
107
+ /** Remove exactly the harness-owned artifact paths from a worktree. */
108
+ export function removeHarnessArtifacts(wtPath: string): void {
109
+ for (const name of HARNESS_ARTIFACTS) {
110
+ fs.rmSync(path.join(wtPath, name), { recursive: true, force: true })
111
+ }
112
+ }
113
+
114
+ // ─── Known-safe regenerable worktree artifacts ───────────────────────────────
115
+ // Untracked residue workers/reviewer/QA sessions routinely leave behind that is
116
+ // ALWAYS safe to delete — regenerable by the package manager or interpreter —
117
+ // and that target repos' own .gitignore conventions already treat as disposable
118
+ // (issue #38). A worker-created `node_modules` SYMLINK in particular does not
119
+ // match the dir-only `node_modules/` gitignore pattern, so it shows as
120
+ // untracked and previously blocked cleanup every round. Anything NOT matching
121
+ // here or in HARNESS_ARTIFACTS still blocks cleanup: genuine uncommitted work
122
+ // is never silently discarded.
123
+ const KNOWN_SAFE_ARTIFACT_PATTERNS = [
124
+ // node tooling dependencies — symlink or real dir, at any depth
125
+ (p: string): boolean => path.posix.basename(p) === "node_modules",
126
+ // Python bytecode caches — __pycache__ dirs (any depth) and stray .pyc/.pyo
127
+ (p: string): boolean => path.posix.basename(p) === "__pycache__",
128
+ (p: string): boolean => /\.(?:pyc|pyo)$/.test(path.posix.basename(p)),
129
+ ] as const
130
+
131
+ function isKnownSafeArtifact(relPath: string): boolean {
132
+ return KNOWN_SAFE_ARTIFACT_PATTERNS.some((match) => match(relPath))
133
+ }
134
+
135
+ // ─── Public result types ──────────────────────────────────────────────────────
136
+
137
+ /** Per-group cleanup eligibility (also the `cleanup` column in status). */
138
+ export type CleanupStatus =
139
+ | { status: "eligible"; via?: string; detail?: string }
140
+ | { status: "blocked"; reason: string; via?: string; detail?: string }
141
+ | { status: "done" }
142
+
143
+ /** GitHub wiring for the PR-merge check (only when group.pr?.number is set). */
144
+ export interface GhWiring {
145
+ owner: string
146
+ repo: string
147
+ getInstallationToken: () => Promise<string>
148
+ baseUrl?: string
149
+ fetchImpl?: typeof fetch
150
+ }
151
+
152
+ /**
153
+ * Injectable seams (tests). Defaults are the real git/fs operations; a test
154
+ * injects only what it needs to prove one gate/step in isolation.
155
+ */
156
+ export interface CleanupDeps {
157
+ /** Full merge-check dispatch (GitHub-first when pr.number is set). */
158
+ checkMerged?: (repo: string, group: OrchestratorGroup, baseBranch: string, gh?: GhWiring) => Promise<MergeCheckResult>
159
+ /** Blocking (non-harness) `git status --porcelain` entries; throws on git failure. */
160
+ worktreeStatus?: (wtPath: string) => string[]
161
+ removeHarnessArtifacts?: (wtPath: string) => void
162
+ /** Remove a worktree's untracked known-safe residue (node_modules, __pycache__, *.pyc/*.pyo). */
163
+ removeKnownSafeArtifacts?: (wtPath: string) => void
164
+ /** `git worktree remove <wtPath>` (no --force); throws on failure. */
165
+ removeWorktree?: (repo: string, wtPath: string) => void
166
+ /** `git branch -d <branch>`; throws on failure. */
167
+ deleteBranch?: (repo: string, branch: string) => void
168
+ writeState?: (statePath: string, state: OrchestratorState) => void
169
+ now?: () => string
170
+ }
171
+
172
+ // ─── Small git helpers ────────────────────────────────────────────────────────
173
+
174
+ /** The branch the round's work was merged into: --base or the repo's HEAD branch. */
175
+ export function resolveBaseBranch(repo: string, explicit?: string): string {
176
+ if (explicit !== undefined && explicit !== "") {
177
+ return explicit
178
+ }
179
+ try {
180
+ const branch = execFileSync("git", ["-C", repo, "symbolic-ref", "--short", "HEAD"], {
181
+ encoding: "utf-8",
182
+ stdio: ["ignore", "pipe", "ignore"],
183
+ timeout: 5000,
184
+ }).trim()
185
+ if (branch !== "") {
186
+ return branch
187
+ }
188
+ } catch {
189
+ // fall through to the error below
190
+ }
191
+ throw new Error("cannot resolve the repo's current branch (detached HEAD?) — pass --base <branch>")
192
+ }
193
+
194
+ /** A group's branch: state's `branch` field, else the worktree's checked-out branch. */
195
+ export function resolveGroupBranch(repo: string, group: OrchestratorGroup): string | undefined {
196
+ if (group.branch) {
197
+ return group.branch
198
+ }
199
+ try {
200
+ return execFileSync("git", ["-C", groupWorktreePath(repo, group), "symbolic-ref", "--short", "HEAD"], {
201
+ encoding: "utf-8",
202
+ stdio: ["ignore", "pipe", "ignore"],
203
+ timeout: 5000,
204
+ }).trim()
205
+ } catch {
206
+ return undefined
207
+ }
208
+ }
209
+
210
+ interface PorcelainEntry {
211
+ code: string
212
+ path: string
213
+ raw: string
214
+ }
215
+
216
+ /** Parse `git status --porcelain` output into {code, path, raw} entries. */
217
+ function parsePorcelain(out: string): PorcelainEntry[] {
218
+ const entries: PorcelainEntry[] = []
219
+ for (const line of out.split("\n")) {
220
+ if (line === "") {
221
+ continue
222
+ }
223
+ const code = line.slice(0, 2)
224
+ let file = line.slice(3).trim()
225
+ if (file.includes(" -> ")) {
226
+ file = file.split(" -> ").pop()!.trim()
227
+ }
228
+ entries.push({ code, path: file.replace(/\/+$/, ""), raw: line.trim() })
229
+ }
230
+ return entries
231
+ }
232
+
233
+ /** `git status --porcelain` for a worktree; throws on git failure. */
234
+ function gitStatusPorcelain(wtPath: string): string {
235
+ try {
236
+ return execFileSync("git", ["-C", wtPath, "status", "--porcelain"], {
237
+ encoding: "utf-8",
238
+ stdio: ["ignore", "pipe", "ignore"],
239
+ timeout: 10_000,
240
+ })
241
+ } catch (err) {
242
+ throw new Error(`cannot read worktree git status: ${err instanceof Error ? err.message : String(err)}`)
243
+ }
244
+ }
245
+
246
+ /**
247
+ * `git status --porcelain` entries that are NOT the harness's own untracked
248
+ * artifacts or known-safe regenerable residue (i.e. real uncommitted changes —
249
+ * tracked modifications or a human's untracked files). Empty = the worktree is
250
+ * safe to remove.
251
+ */
252
+ export function worktreeUncommitted(wtPath: string): string[] {
253
+ const blocking: string[] = []
254
+ for (const entry of parsePorcelain(gitStatusPorcelain(wtPath))) {
255
+ if (entry.code === "??" && (isHarnessArtifact(entry.path) || isKnownSafeArtifact(entry.path))) {
256
+ continue
257
+ }
258
+ blocking.push(entry.raw)
259
+ }
260
+ return blocking
261
+ }
262
+
263
+ /**
264
+ * Remove a worktree's UNTRACKED known-safe residue (node_modules symlink/dir,
265
+ * __pycache__ dirs, stray .pyc/.pyo) before `git worktree remove`, which
266
+ * refuses on ANY untracked file. Git-status-driven: only paths git reports as
267
+ * untracked are ever removed, so a repo that TRACKS one of these names is
268
+ * never touched; removing a node_modules symlink unlinks the symlink itself,
269
+ * never its target. Anything not matching the allowlist is left alone (and
270
+ * would have blocked cleanup at the dirty gate anyway).
271
+ */
272
+ export function removeKnownSafeArtifacts(wtPath: string): void {
273
+ let out: string
274
+ try {
275
+ out = gitStatusPorcelain(wtPath)
276
+ } catch {
277
+ return // the dirty gate already surfaced a status error — removal must not mask it
278
+ }
279
+ for (const entry of parsePorcelain(out)) {
280
+ if (entry.code !== "??" || !isKnownSafeArtifact(entry.path)) {
281
+ continue
282
+ }
283
+ fs.rmSync(path.join(wtPath, entry.path), { recursive: true, force: true })
284
+ }
285
+ }
286
+
287
+ // ─── Merge-check dispatch ─────────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Dispatch by which workflow produced the group: GitHub PR first when
291
+ * group.pr?.number is set AND gh wiring exists, else local-ancestor.
292
+ *
293
+ * Disagreement rule (fail closed): when BOTH checks are available, a GitHub
294
+ * "not merged" alongside a local "is ancestor" is a real contradiction — the
295
+ * recorded PR was never merged even though the branch's commits are in
296
+ * history — and is treated as NOT merged with the discrepancy surfaced. The
297
+ * reverse (GitHub merged / local not-ancestor) is the EXPECTED squash/rebase
298
+ * shape (a new hash), not a contradiction: GitHub's `.merged` is
299
+ * authoritative for PR merges.
300
+ */
301
+ export async function checkGroupMerged(
302
+ repo: string,
303
+ group: OrchestratorGroup,
304
+ baseBranch: string,
305
+ gh?: GhWiring,
306
+ ): Promise<MergeCheckResult> {
307
+ const branch = resolveGroupBranch(repo, group)
308
+ if (group.pr?.number && gh) {
309
+ const ghResult = await checkMergedByGitHubPr({
310
+ owner: gh.owner,
311
+ repo: gh.repo,
312
+ prNumber: group.pr.number,
313
+ getInstallationToken: gh.getInstallationToken,
314
+ ...(gh.baseUrl ? { baseUrl: gh.baseUrl } : {}),
315
+ ...(gh.fetchImpl ? { fetchImpl: gh.fetchImpl } : {}),
316
+ })
317
+ let local: MergeCheckResult | undefined
318
+ if (branch) {
319
+ try {
320
+ local = checkMergedByAncestor(repo, branch, baseBranch)
321
+ } catch {
322
+ local = undefined // branch gone / unreadable — rely on GitHub alone
323
+ }
324
+ }
325
+ if (ghResult.merged) {
326
+ return {
327
+ merged: true,
328
+ via: "github-pr",
329
+ detail:
330
+ ghResult.detail +
331
+ (local && !local.merged
332
+ ? ` (local ancestor check disagrees — expected for squash/rebase merges)`
333
+ : ""),
334
+ }
335
+ }
336
+ if (ghResult.via === "unknown") {
337
+ // The GitHub check is UNAVAILABLE (API/network error), not
338
+ // disagreeing. Fall back to the local ancestor check: if the
339
+ // branch's commits are provably in history, deletion is safe no
340
+ // matter what the PR state is. Otherwise the unknown blocks.
341
+ if (local && local.merged) {
342
+ return local
343
+ }
344
+ return ghResult
345
+ }
346
+ // GitHub definitively says NOT merged.
347
+ if (local && local.merged) {
348
+ return {
349
+ merged: false,
350
+ via: "unknown",
351
+ detail: `disagreement: GitHub PR #${group.pr.number} is NOT merged but branch ${branch} IS an ancestor of ${baseBranch} — failing closed`,
352
+ }
353
+ }
354
+ return { merged: false, via: ghResult.via, detail: ghResult.detail }
355
+ }
356
+ // No PR number (or no GitHub wiring): local-ancestor is the source of truth.
357
+ if (!branch) {
358
+ throw new Error(`cannot determine branch for group ${group.name}`)
359
+ }
360
+ return checkMergedByAncestor(repo, branch, baseBranch)
361
+ }
362
+
363
+ // ─── Gates ────────────────────────────────────────────────────────────────────
364
+
365
+ /** Gates 4–5 (dirty + live process) applied after the merge gate passed. */
366
+ function gateAfterMerge(wtPath: string, merged: MergeCheckResult, worktreeStatus: (wtPath: string) => string[]): CleanupStatus {
367
+ let changes: string[]
368
+ try {
369
+ changes = worktreeStatus(wtPath)
370
+ } catch (err) {
371
+ return { status: "blocked", reason: `cannot check worktree status: ${err instanceof Error ? err.message : String(err)}`, via: merged.via }
372
+ }
373
+ if (changes.length > 0) {
374
+ const shown = changes.slice(0, 3).join("; ")
375
+ return {
376
+ status: "blocked",
377
+ reason:
378
+ `merged (${merged.via}) but worktree has uncommitted changes: ` +
379
+ `${shown}${changes.length > 3 ? ` (+${changes.length - 3} more)` : ""}`,
380
+ via: merged.via,
381
+ }
382
+ }
383
+ if (isPidAlive(wtPath) || isPidAlive(wtPath, ".qa.pid")) {
384
+ return {
385
+ status: "blocked",
386
+ reason: `merged (${merged.via}) but worker process is still running`,
387
+ via: merged.via,
388
+ }
389
+ }
390
+ return { status: "eligible", via: merged.via, detail: merged.detail }
391
+ }
392
+
393
+ function sharedGates(wtPath: string, group: OrchestratorGroup): CleanupStatus | undefined {
394
+ if (!fs.existsSync(wtPath)) {
395
+ return { status: "done" }
396
+ }
397
+ if (!isTerminalStatus(group.status)) {
398
+ return { status: "blocked", reason: `status "${group.status}" is not terminal` }
399
+ }
400
+ return undefined
401
+ }
402
+
403
+ /**
404
+ * Full eligibility assessment (cleanup command path — may use the GitHub PR
405
+ * check when wired). Non-throwing: any failure becomes a blocked result.
406
+ */
407
+ export async function assessGroupCleanup(
408
+ repo: string,
409
+ group: OrchestratorGroup,
410
+ opts: { baseBranch: string; gh?: GhWiring; deps?: CleanupDeps },
411
+ ): Promise<CleanupStatus> {
412
+ const deps = opts.deps ?? {}
413
+ const checkMerged = deps.checkMerged ?? checkGroupMerged
414
+ const worktreeStatus = deps.worktreeStatus ?? worktreeUncommitted
415
+ const wtPath = groupWorktreePath(repo, group)
416
+
417
+ const shared = sharedGates(wtPath, group)
418
+ if (shared) {
419
+ return shared
420
+ }
421
+
422
+ let merged: MergeCheckResult
423
+ try {
424
+ merged = await checkMerged(repo, group, opts.baseBranch, opts.gh)
425
+ } catch (err) {
426
+ return { status: "blocked", reason: `cannot verify merge: ${err instanceof Error ? err.message : String(err)}`, via: "unknown" }
427
+ }
428
+ if (!merged.merged) {
429
+ return { status: "blocked", reason: `not merged (${merged.via}): ${merged.detail}`, via: merged.via, detail: merged.detail }
430
+ }
431
+ return gateAfterMerge(wtPath, merged, worktreeStatus)
432
+ }
433
+
434
+ /**
435
+ * Read-only eligibility for the `orchestrate status` cleanup column: the SAME
436
+ * gates as assessGroupCleanup minus the network (local-ancestor merge check
437
+ * only), synchronous and never throwing. A group with a pr.number that cannot
438
+ * be verified locally reports blocked with a pointer to the cleanup command.
439
+ */
440
+ export function assessGroupCleanupSync(repo: string, group: OrchestratorGroup, baseBranch: string): CleanupStatus {
441
+ const wtPath = groupWorktreePath(repo, group)
442
+
443
+ const shared = sharedGates(wtPath, group)
444
+ if (shared) {
445
+ return shared
446
+ }
447
+
448
+ let merged: MergeCheckResult
449
+ try {
450
+ const branch = resolveGroupBranch(repo, group)
451
+ if (!branch) {
452
+ return { status: "blocked", reason: "cannot determine branch" }
453
+ }
454
+ merged = checkMergedByAncestor(repo, branch, baseBranch)
455
+ } catch (err) {
456
+ return {
457
+ status: "blocked",
458
+ reason:
459
+ group.pr?.number
460
+ ? `PR #${group.pr.number} merge not verifiable locally — run "orchestrate cleanup --dry-run" for the GitHub check`
461
+ : `cannot verify merge: ${err instanceof Error ? err.message : String(err)}`,
462
+ }
463
+ }
464
+ if (!merged.merged) {
465
+ return {
466
+ status: "blocked",
467
+ reason:
468
+ group.pr?.number
469
+ ? `not merged locally (PR #${group.pr.number} may be squash-merged) — run "orchestrate cleanup --dry-run" for the GitHub check`
470
+ : `not merged (${merged.via}): ${merged.detail}`,
471
+ via: merged.via,
472
+ }
473
+ }
474
+ return gateAfterMerge(wtPath, merged, worktreeUncommitted)
475
+ }
476
+
477
+ // ─── Planning + applying ──────────────────────────────────────────────────────
478
+
479
+ export interface CleanupPlanEntry {
480
+ name: string
481
+ status: CleanupStatus
482
+ /**
483
+ * The branch resolved for this group (state `branch` field, else the
484
+ * worktree's checked-out branch). Carried from the plan so apply can
485
+ * delete it AFTER the worktree is gone (when the state never recorded a
486
+ * branch, the worktree was the only way to learn it).
487
+ */
488
+ branch?: string
489
+ }
490
+
491
+ export interface CleanupPlan {
492
+ repo: string
493
+ statePath: string
494
+ baseBranch: string
495
+ /** State-file order — never reordered, so partial runs are resumable. */
496
+ groups: CleanupPlanEntry[]
497
+ }
498
+
499
+ export interface CleanupRunResult {
500
+ plan: CleanupPlan
501
+ /** Groups whose worktree was removed this run. */
502
+ removed: string[]
503
+ /** Groups whose worktree removal failed (no branch delete / state patch). */
504
+ failed: Array<{ name: string; error: string }>
505
+ /** Groups whose state entry was patched with cleaned_at. */
506
+ statePatched: string[]
507
+ }
508
+
509
+ /** Compute the full plan for a state. Pure (git reads only), never mutates. */
510
+ export async function planCleanup(
511
+ repo: string,
512
+ state: OrchestratorState,
513
+ opts: { statePath: string; baseBranch: string; gh?: GhWiring; deps?: CleanupDeps },
514
+ ): Promise<CleanupPlan> {
515
+ const groups: CleanupPlanEntry[] = []
516
+ for (const group of state.groups) {
517
+ const status = await assessGroupCleanup(repo, group, opts)
518
+ const branch = status.status === "eligible" ? resolveGroupBranch(repo, group) : undefined
519
+ groups.push({ name: group.name, status, branch })
520
+ }
521
+ return { repo, statePath: opts.statePath, baseBranch: opts.baseBranch, groups }
522
+ }
523
+
524
+ /** Ensure a group's worktree path stays inside <repo>/.worktrees/ — cleanup
525
+ * deletes, so a hostile/rotten state file must never point it outside. */
526
+ function worktreePathInRepo(repo: string, wtPath: string): boolean {
527
+ const parent = path.join(repo, ".worktrees") + path.sep
528
+ return wtPath.startsWith(parent)
529
+ }
530
+
531
+ /**
532
+ * Execute cleanup for every eligible group (in state order): remove harness
533
+ * artifacts and known-safe residue → `git worktree remove` (no --force) →
534
+ * `git branch -d` → patch the state entry. A worktree-removal failure aborts
535
+ * ONLY that group (no branch delete, no state patch) and continues with the
536
+ * next.
537
+ */
538
+ export async function applyCleanup(
539
+ repo: string,
540
+ statePath: string,
541
+ opts: { baseBranch: string; gh?: GhWiring; deps?: CleanupDeps },
542
+ ): Promise<CleanupRunResult> {
543
+ const deps = {
544
+ removeHarnessArtifacts,
545
+ removeKnownSafeArtifacts,
546
+ removeWorktree: (r: string, wtPath: string): void => {
547
+ execFileSync("git", ["-C", r, "worktree", "remove", wtPath], { stdio: "ignore", timeout: 30_000 })
548
+ },
549
+ deleteBranch: (r: string, branch: string): void => {
550
+ execFileSync("git", ["-C", r, "branch", "-d", branch], { stdio: "ignore", timeout: 10_000 })
551
+ },
552
+ writeState: saveStateSync,
553
+ now: () => new Date().toISOString(),
554
+ ...opts.deps,
555
+ } as Required<
556
+ Pick<CleanupDeps, "removeHarnessArtifacts" | "removeKnownSafeArtifacts" | "removeWorktree" | "deleteBranch" | "writeState" | "now">
557
+ >
558
+ const state = loadStateSync(statePath)
559
+ const plan = await planCleanup(repo, state, { ...opts, statePath })
560
+
561
+ const removed: string[] = []
562
+ const failed: Array<{ name: string; error: string }> = []
563
+ const statePatched: string[] = []
564
+
565
+ for (const entry of plan.groups) {
566
+ if (entry.status.status !== "eligible") {
567
+ continue
568
+ }
569
+ const group = state.groups.find((g) => g.name === entry.name)
570
+ if (!group) {
571
+ continue
572
+ }
573
+ const wtPath = groupWorktreePath(repo, group)
574
+ if (!worktreePathInRepo(repo, wtPath)) {
575
+ failed.push({ name: entry.name, error: `worktree path escapes <repo>/.worktrees/: ${wtPath} — refusing` })
576
+ continue
577
+ }
578
+
579
+ // Step 1: drop the harness's own untracked artifacts and the known-safe
580
+ // regenerable residue so the no-force `git worktree remove` below can
581
+ // succeed (it refuses on ANY untracked file; the dirty gate already
582
+ // proved everything untracked is allowlisted).
583
+ try {
584
+ deps.removeHarnessArtifacts(wtPath)
585
+ deps.removeKnownSafeArtifacts(wtPath)
586
+ } catch (err) {
587
+ failed.push({
588
+ name: entry.name,
589
+ error: `removing worktree artifacts failed: ${err instanceof Error ? err.message : String(err)}`,
590
+ })
591
+ continue
592
+ }
593
+ // Step 2: remove the worktree, NO --force. If this fails the worktree
594
+ // got dirty/raced after the gate — abort THIS group, never force.
595
+ try {
596
+ deps.removeWorktree(repo, wtPath)
597
+ } catch (err) {
598
+ failed.push({ name: entry.name, error: `git worktree remove failed: ${err instanceof Error ? err.message : String(err)}` })
599
+ continue
600
+ }
601
+ removed.push(entry.name)
602
+
603
+ // Step 3: delete the branch with lowercase -d (refuses unless merged
604
+ // into HEAD/upstream — a second, independent safety net). A failure
605
+ // here is recorded, not fatal: the irreversible removal succeeded.
606
+ const branch = entry.branch ?? resolveGroupBranch(repo, group)
607
+ const actions: string[] = [...(group.actions_taken ?? [])]
608
+ actions.push(`worktree removed (merged via ${entry.status.via ?? "unknown"})`)
609
+ let branchNote = ""
610
+ if (branch) {
611
+ try {
612
+ deps.deleteBranch(repo, branch)
613
+ actions.push(`branch ${branch} deleted`)
614
+ branchNote = `; branch ${branch} deleted`
615
+ } catch (err) {
616
+ const msg = err instanceof Error ? err.message : String(err)
617
+ actions.push(`branch ${branch} delete FAILED (delete manually): ${msg}`)
618
+ branchNote = `; branch ${branch} delete FAILED — see actions_taken`
619
+ }
620
+ }
621
+
622
+ // Step 4: patch the state entry (reload fresh — the watcher or a
623
+ // concurrent status call may have rewritten the file mid-run). The
624
+ // entry itself is never deleted.
625
+ try {
626
+ const fresh = loadStateSync(statePath)
627
+ const patched = updateGroup(fresh, entry.name, {
628
+ cleaned_at: deps.now(),
629
+ actions_taken: actions,
630
+ last_activity: {
631
+ note: `cleaned: worktree removed (merged via ${entry.status.via ?? "unknown"})${branchNote}`,
632
+ },
633
+ })
634
+ deps.writeState(statePath, patched)
635
+ statePatched.push(entry.name)
636
+ } catch (err) {
637
+ failed.push({
638
+ name: entry.name,
639
+ error: `state patch failed: ${err instanceof Error ? err.message : String(err)}`,
640
+ })
641
+ }
642
+ }
643
+
644
+ return { plan, removed, failed, statePatched }
645
+ }
646
+
647
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
648
+
649
+ const CLEANUP_USAGE = `headlesscode orchestrate cleanup — remove merged worktrees after a round
650
+
651
+ Usage:
652
+ headlesscode orchestrate cleanup --repo <path> [--base <branch>] [--dry-run|--apply] [--json]
653
+ headlesscode orchestrate cleanup --repo <path> [--gh-installation-id <id>] [--dry-run|--apply]
654
+
655
+ Options:
656
+ --repo <path> Target repo root whose .worktrees/.orchestrator-state.json
657
+ is read (required) and whose worktrees live under
658
+ <repo>/.worktrees/<name>.
659
+ --base <branch> Branch the round's work was merged INTO (default: the
660
+ repo's current branch, via HEAD).
661
+ --dry-run Print which groups are cleanup-eligible / blocked and
662
+ why, touching NOTHING. The DEFAULT when neither
663
+ --dry-run nor --apply is given.
664
+ --apply Actually perform the cleanup. For each eligible group,
665
+ in state order: remove the worktree (git worktree
666
+ remove, NO --force), delete the branch (git branch -d —
667
+ a second safety net), and record cleaned_at +
668
+ actions_taken in the state file.
669
+ --json Machine-readable output (plan + results as JSON).
670
+ --gh-installation-id <id>
671
+ GitHub App installation id for the PR-merge check when
672
+ a group records pr.number (squash/rebase merges cannot
673
+ be detected locally). Owner/repo are derived from the
674
+ repo's 'origin' remote. Requires GITHUB_APP_ID +
675
+ GITHUB_APP_PRIVATE_KEY[_PATH] (docs/github-app-setup.md).
676
+ Without it, groups with a pr.number fall back to the
677
+ local ancestor check.
678
+ --help, -h Show this help and exit.
679
+
680
+ Safety model (all gates recomputed fresh, never cached):
681
+ - merge verified: git merge-base --is-ancestor <branch> <base> (exit 0), or
682
+ GitHub PR .merged when pr.number is recorded; disagreement fails closed
683
+ - worktree clean: git status --porcelain shows only the harness's own
684
+ untracked artifacts (.harness.*, .qa.*, harness.log, qa.log,
685
+ .headlesscode/, ORCHESTRATOR_TASK.md, .env, zoo-code) plus known-safe
686
+ regenerable residue (node_modules symlink/dir, __pycache__, *.pyc/*.pyo)
687
+ - no live worker: .harness.pid and .qa.pid absent or the PID is not running
688
+ - terminal status: done | failed | needs-human | orphaned
689
+
690
+ Anything failing a gate is a documented no-op (reported, never forced).
691
+ Exit codes:
692
+ 0 dry-run: plan printed; --apply: every eligible group cleaned (blocked
693
+ groups are not errors)
694
+ 1 --apply: at least one worktree removal or state write failed
695
+ 2 usage error
696
+ `
697
+
698
+ export interface CleanupCliOptions {
699
+ repo: string
700
+ base?: string
701
+ dryRun: boolean
702
+ apply: boolean
703
+ json: boolean
704
+ ghInstallationId?: string
705
+ help: boolean
706
+ }
707
+
708
+ export function parseCleanupArgs(argv: string[]): { options: CleanupCliOptions; error?: string } {
709
+ const options: CleanupCliOptions = {
710
+ repo: "",
711
+ base: undefined,
712
+ dryRun: false,
713
+ apply: false,
714
+ json: false,
715
+ ghInstallationId: undefined,
716
+ help: false,
717
+ }
718
+ for (let i = 0; i < argv.length; i++) {
719
+ const arg = argv[i]
720
+ const eq = arg.indexOf("=")
721
+ const flag = eq === -1 ? arg : arg.slice(0, eq)
722
+ const inlineValue = eq === -1 ? undefined : arg.slice(eq + 1)
723
+ const next = (): string | undefined => {
724
+ if (inlineValue !== undefined) {
725
+ return inlineValue
726
+ }
727
+ const v = argv[i + 1]
728
+ if (v === undefined || v.startsWith("--")) {
729
+ return undefined
730
+ }
731
+ i++
732
+ return v
733
+ }
734
+ switch (flag) {
735
+ case "--repo": {
736
+ const v = next()
737
+ if (v === undefined) {
738
+ return { options, error: "Missing value for --repo" }
739
+ }
740
+ options.repo = v
741
+ break
742
+ }
743
+ case "--base": {
744
+ const v = next()
745
+ if (v === undefined) {
746
+ return { options, error: "Missing value for --base" }
747
+ }
748
+ options.base = v
749
+ break
750
+ }
751
+ case "--dry-run":
752
+ options.dryRun = true
753
+ break
754
+ case "--apply":
755
+ options.apply = true
756
+ break
757
+ case "--json":
758
+ options.json = true
759
+ break
760
+ case "--gh-installation-id": {
761
+ const v = next()
762
+ if (v === undefined) {
763
+ return { options, error: "Missing value for --gh-installation-id" }
764
+ }
765
+ options.ghInstallationId = v
766
+ break
767
+ }
768
+ case "--help":
769
+ case "-h":
770
+ options.help = true
771
+ break
772
+ default:
773
+ return { options, error: `Unknown orchestrate cleanup argument: ${arg}` }
774
+ }
775
+ }
776
+ return { options }
777
+ }
778
+
779
+ export interface CleanupIo {
780
+ stdout?: (text: string) => void
781
+ stderr?: (text: string) => void
782
+ }
783
+
784
+ function repoOwnerRepoFromRemote(repo: string): { owner: string; repo: string } | undefined {
785
+ try {
786
+ const url = execFileSync("git", ["-C", repo, "remote", "get-url", "origin"], {
787
+ encoding: "utf-8",
788
+ stdio: ["ignore", "pipe", "ignore"],
789
+ timeout: 5000,
790
+ }).trim()
791
+ const match = url.match(/(?:github\.com[/:])([^/]+)\/([^/]+?)(?:\.git)?$/)
792
+ if (match) {
793
+ return { owner: match[1], repo: match[2] }
794
+ }
795
+ } catch {
796
+ // no origin remote — the GitHub check simply isn't available
797
+ }
798
+ return undefined
799
+ }
800
+
801
+ /** Build GitHub wiring from --gh-installation-id + env creds; undefined when
802
+ * the remote isn't a GitHub URL or the App credentials are missing. */
803
+ function buildGhWiring(repo: string, installationId: string): GhWiring | undefined {
804
+ const parsed = repoOwnerRepoFromRemote(repo)
805
+ if (!parsed) {
806
+ return undefined
807
+ }
808
+ const appId = process.env.GITHUB_APP_ID
809
+ const inlineKey = process.env.GITHUB_APP_PRIVATE_KEY
810
+ const keyPath = process.env.GITHUB_APP_PRIVATE_KEY_PATH
811
+ if (!appId || (!inlineKey && !keyPath)) {
812
+ return undefined
813
+ }
814
+ try {
815
+ const privateKey = inlineKey ?? fs.readFileSync(path.resolve(keyPath!), "utf-8")
816
+ const client = createAppAuthClient({
817
+ appId,
818
+ privateKey,
819
+ ...(process.env.GITHUB_API_BASE_URL ? { baseUrl: process.env.GITHUB_API_BASE_URL } : {}),
820
+ })
821
+ return {
822
+ owner: parsed.owner,
823
+ repo: parsed.repo,
824
+ getInstallationToken: () => client.getInstallationToken(installationId),
825
+ ...(process.env.GITHUB_API_BASE_URL ? { baseUrl: process.env.GITHUB_API_BASE_URL } : {}),
826
+ }
827
+ } catch {
828
+ return undefined
829
+ }
830
+ }
831
+
832
+ function shortStatus(status: CleanupStatus): string {
833
+ return status.status
834
+ }
835
+
836
+ export function formatCleanupText(
837
+ plan: CleanupPlan,
838
+ opts: { mode: "dry-run" | "apply"; result?: CleanupRunResult },
839
+ ): string {
840
+ const lines: string[] = []
841
+ lines.push(`── Orchestrator cleanup (${opts.mode}) ──`)
842
+ lines.push(`repo: ${plan.repo}`)
843
+ lines.push(`state file: ${plan.statePath}`)
844
+ lines.push(`base branch: ${plan.baseBranch}`)
845
+ lines.push("")
846
+ if (plan.groups.length === 0) {
847
+ lines.push("No orchestrator groups found in this state file (nothing to clean).")
848
+ } else {
849
+ lines.push(`${"GROUP".padEnd(14)}${"CLEANUP".padEnd(10)}DETAIL`)
850
+ for (const entry of plan.groups) {
851
+ const s = entry.status
852
+ let detail = ""
853
+ if (s.status === "eligible") {
854
+ detail = `merged via ${s.via ?? "unknown"}` + (s.detail ? ` (${s.detail})` : "")
855
+ } else if (s.status === "blocked") {
856
+ detail = s.reason
857
+ } else {
858
+ detail = "worktree already removed"
859
+ }
860
+ lines.push(`${entry.name.padEnd(14)}${shortStatus(s).padEnd(10)}${detail}`)
861
+ }
862
+ lines.push("")
863
+ const counts = { eligible: 0, blocked: 0, done: 0 }
864
+ for (const entry of plan.groups) {
865
+ counts[entry.status.status === "blocked" ? "blocked" : entry.status.status === "done" ? "done" : "eligible"]++
866
+ }
867
+ lines.push(
868
+ `${counts.eligible} eligible · ${counts.blocked} blocked · ${counts.done} already-done ` +
869
+ `(of ${plan.groups.length} group${plan.groups.length === 1 ? "" : "s"})`,
870
+ )
871
+ }
872
+ if (opts.mode === "apply" && opts.result) {
873
+ if (opts.result.removed.length > 0) {
874
+ lines.push(`removed: ${opts.result.removed.join(", ")}`)
875
+ }
876
+ if (opts.result.statePatched.length > 0) {
877
+ lines.push(`state patched: ${opts.result.statePatched.join(", ")}`)
878
+ }
879
+ for (const f of opts.result.failed) {
880
+ lines.push(`FAILED: ${f.name} — ${f.error}`)
881
+ }
882
+ } else {
883
+ lines.push("")
884
+ lines.push('Dry run — nothing was removed. Re-run with --apply to perform deletions.')
885
+ }
886
+ return lines.join("\n") + "\n"
887
+ }
888
+
889
+ export function cleanupJson(plan: CleanupPlan, opts: { mode: "dry-run" | "apply"; result?: CleanupRunResult }): string {
890
+ const groups = plan.groups.map((entry) => {
891
+ const s = entry.status
892
+ return {
893
+ name: entry.name,
894
+ status: s.status,
895
+ ...(s.status !== "done" && s.via ? { via: s.via } : {}),
896
+ ...(s.status === "blocked" && s.reason ? { reason: s.reason } : {}),
897
+ ...(s.status === "eligible" && s.detail ? { detail: s.detail } : {}),
898
+ }
899
+ })
900
+ const out: Record<string, unknown> = {
901
+ mode: opts.mode,
902
+ repo: plan.repo,
903
+ statePath: plan.statePath,
904
+ baseBranch: plan.baseBranch,
905
+ groups,
906
+ }
907
+ if (opts.result) {
908
+ out.removed = opts.result.removed
909
+ out.statePatched = opts.result.statePatched
910
+ out.failed = opts.result.failed
911
+ }
912
+ return JSON.stringify(out, null, 2) + "\n"
913
+ }
914
+
915
+ export async function cleanupMain(argv: string[], io: CleanupIo = {}): Promise<number> {
916
+ const writeOut = io.stdout ?? ((text: string) => process.stdout.write(text))
917
+ const writeErr = io.stderr ?? ((text: string) => process.stderr.write(text))
918
+
919
+ const { options, error } = parseCleanupArgs(argv)
920
+ if (error) {
921
+ writeErr(`headlesscode orchestrate cleanup: ${error}\n\n${CLEANUP_USAGE}`)
922
+ return 2
923
+ }
924
+ if (options.help) {
925
+ writeOut(CLEANUP_USAGE)
926
+ return 0
927
+ }
928
+ if (!options.repo) {
929
+ writeErr(`headlesscode orchestrate cleanup: --repo <path> is required\n\n${CLEANUP_USAGE}`)
930
+ return 2
931
+ }
932
+ if (options.apply && options.dryRun) {
933
+ writeErr(`headlesscode orchestrate cleanup: --apply and --dry-run are mutually exclusive\n\n${CLEANUP_USAGE}`)
934
+ return 2
935
+ }
936
+
937
+ const repo = path.resolve(options.repo)
938
+ try {
939
+ execFileSync("git", ["-C", repo, "rev-parse", "--git-dir"], { stdio: "ignore", timeout: 5000 })
940
+ } catch {
941
+ writeErr(`headlesscode orchestrate cleanup: not a git repo: ${repo}\n`)
942
+ return 2
943
+ }
944
+
945
+ let baseBranch: string
946
+ try {
947
+ baseBranch = resolveBaseBranch(repo, options.base)
948
+ } catch (err) {
949
+ writeErr(`headlesscode orchestrate cleanup: ${err instanceof Error ? err.message : String(err)}\n`)
950
+ return 2
951
+ }
952
+
953
+ const gh = options.ghInstallationId ? buildGhWiring(repo, options.ghInstallationId) : undefined
954
+ const statePath = path.join(repo, ".worktrees", ".orchestrator-state.json")
955
+ let state: OrchestratorState
956
+ try {
957
+ state = loadStateSync(statePath)
958
+ } catch (err) {
959
+ writeErr(`headlesscode orchestrate cleanup: cannot read state file ${statePath}: ${err instanceof Error ? err.message : String(err)}\n`)
960
+ return 1
961
+ }
962
+
963
+ const mode = options.apply ? "apply" : "dry-run"
964
+ if (mode === "apply") {
965
+ const result = await applyCleanup(repo, statePath, { baseBranch, gh })
966
+ if (options.json) {
967
+ writeOut(cleanupJson(result.plan, { mode, result }))
968
+ } else {
969
+ writeOut(formatCleanupText(result.plan, { mode, result }))
970
+ }
971
+ // Issue #25: cleanup --apply is the "a round just settled" checkpoint —
972
+ // reconcile local master with origin (fetch, push, fast-forward) so
973
+ // unpushed merge commits stop accumulating. Best-effort: failures are
974
+ // loud warnings, never a cleanup failure. Sync lines go to stderr so
975
+ // --json stdout stays machine-readable (see git-sync.ts).
976
+ if (process.env[ORCHESTRATE_SYNC_DISABLED_ENV]) {
977
+ const drift = branchSyncStatus(repo, baseBranch)
978
+ if (drift && drift.ahead > TRIVIAL_DRIFT_AHEAD) {
979
+ writeErr(
980
+ `headlesscode orchestrate cleanup: WARNING: local ${drift.branch} is ${drift.ahead} commit(s) ahead of ${drift.remoteRef} ` +
981
+ `(${ORCHESTRATE_SYNC_DISABLED_ENV} set — not pushing). Push it now: git -C ${repo} push origin ${drift.branch}\n`,
982
+ )
983
+ }
984
+ } else {
985
+ const sync = syncBranchWithOrigin(repo, baseBranch)
986
+ for (const line of syncSummaryLines(sync)) {
987
+ writeErr(`[sync] ${line}\n`)
988
+ }
989
+ for (const line of syncWarningLines(sync, repo)) {
990
+ writeErr(`headlesscode orchestrate cleanup: ${line}\n`)
991
+ }
992
+ }
993
+ return result.failed.length > 0 ? 1 : 0
994
+ }
995
+
996
+ const plan = await planCleanup(repo, state, { statePath, baseBranch, gh })
997
+ if (options.json) {
998
+ writeOut(cleanupJson(plan, { mode }))
999
+ } else {
1000
+ writeOut(formatCleanupText(plan, { mode }))
1001
+ }
1002
+ return 0
1003
+ }