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,1940 @@
1
+ /**
2
+ * Standalone review / rework / resume subcommands (issue #14) — expose the
3
+ * review and rework steps that normally only fire inside a full
4
+ * `orchestrate` spawn+watch round as independent entry points that pick up
5
+ * EXISTING state instead:
6
+ *
7
+ * headlesscode orchestrate review --repo <path> --issue <n>|--pr <n>|--group <name>
8
+ * — run the reviewer against an already-finished group's worktree and
9
+ * patch state exactly like the watch loop does.
10
+ * headlesscode orchestrate rework --repo <path> --issue <n>|--group <name>
11
+ * — re-spawn a worker on the SAME worktree to fix review findings
12
+ * (from state's pending_review_findings, or the issue's comments),
13
+ * using the SAME buildReworkTaskFileContent template as the
14
+ * automatic path.
15
+ * headlesscode orchestrate resume --repo <path> [--issue <n>|--pr <n>|--group <name>]
16
+ * — the full recovery pipeline for a stuck/interrupted round, per
17
+ * group: rebuild state from REAL on-disk markers → review →
18
+ * rework-if-needed (and continuation on iteration-exhaustion) → QA
19
+ * (--qa) → cost recording. With no target it resumes every group in
20
+ * the state file. Re-runnable: after a rework/continuation worker
21
+ * is spawned the group is left in-flight and the command reports;
22
+ * re-run it once the worker finishes to continue the chain.
23
+ *
24
+ * Every subcommand reuses the existing runReview / handleReviewVerdict /
25
+ * handleIterationExhaustion / runQaWithRetries logic rather than duplicating
26
+ * it — this is about exposing existing capability as a standalone entry
27
+ * point, not building new review/rework logic.
28
+ */
29
+
30
+ import { execFileSync, spawnSync } from "node:child_process"
31
+ import * as fs from "node:fs"
32
+ import * as path from "node:path"
33
+
34
+ import {
35
+ handleIterationExhaustion,
36
+ handleQaSessionError,
37
+ handleQaVerdict,
38
+ handleReviewSessionError,
39
+ handleReviewVerdict,
40
+ } from "./cli.js"
41
+ import { recordAllSessionCosts, recordGroupCost } from "./cost-history.js"
42
+ import { resolveModelForMode } from "../config/mode-models.js"
43
+ import { runQaWithRetries } from "../qa/qa.js"
44
+ import { HARNESS_ROOT, parseReviewResult, runReviewWithRetries, type ReviewResult } from "./reviewer.js"
45
+ import { loadStateSync, patchGroup, type OrchestratorGroup, type OrchestratorState } from "./state.js"
46
+ import { isTerminalStatus } from "./status.js"
47
+ import {
48
+ DEFAULT_STALL_TIMEOUT_MS,
49
+ groupWorktreePath,
50
+ inspectGroup,
51
+ isIterationExhaustion,
52
+ readWorktreeUsage,
53
+ } from "./watch.js"
54
+
55
+ // ─── Shared target parsing ───────────────────────────────────────────────────
56
+
57
+ /** Which group(s) a standalone subcommand targets: by issue, PR, or name. */
58
+ export interface ResumeTarget {
59
+ issue?: number
60
+ pr?: number
61
+ group?: string
62
+ }
63
+
64
+ /** One of --issue/--pr/--group is set (never more than one). */
65
+ function hasExactlyOneTarget(target: ResumeTarget): boolean {
66
+ const set = (t: ResumeTarget): number =>
67
+ (t.issue !== undefined ? 1 : 0) + (t.pr !== undefined ? 1 : 0) + (t.group !== undefined ? 1 : 0)
68
+ return set(target) === 1
69
+ }
70
+
71
+ /** Extract --issue/--pr/--group from an argv list; the rest is returned untouched. */
72
+ function parseTargetArgs(argv: string[]): { rest: string[]; target: ResumeTarget; error?: string } {
73
+ const target: ResumeTarget = {}
74
+ let error: string | undefined
75
+ const rest: string[] = []
76
+ const flagOf = (arg: string): string => {
77
+ const eq = arg.indexOf("=")
78
+ return eq === -1 ? arg : arg.slice(0, eq)
79
+ }
80
+ const inlineValueOf = (arg: string): string | undefined => {
81
+ const eq = arg.indexOf("=")
82
+ return eq === -1 ? undefined : arg.slice(eq + 1)
83
+ }
84
+ for (let i = 0; i < argv.length; i++) {
85
+ const arg = argv[i]
86
+ const flag = flagOf(arg)
87
+ const inlineValue = inlineValueOf(arg)
88
+ const next = (): string | undefined => {
89
+ if (inlineValue !== undefined) {
90
+ return inlineValue
91
+ }
92
+ const v = argv[i + 1]
93
+ if (v === undefined || v.startsWith("--")) {
94
+ return undefined
95
+ }
96
+ i++
97
+ return v
98
+ }
99
+ switch (flag) {
100
+ case "--issue": {
101
+ const v = next()
102
+ const n = v === undefined ? Number.NaN : Number(v)
103
+ if (!Number.isInteger(n) || n <= 0) {
104
+ error = "--issue requires a positive integer"
105
+ } else if (hasExactlyOneTarget(target)) {
106
+ error = "provide exactly one of --issue, --pr, --group"
107
+ } else {
108
+ target.issue = n
109
+ }
110
+ break
111
+ }
112
+ case "--pr": {
113
+ const v = next()
114
+ const n = v === undefined ? Number.NaN : Number(v)
115
+ if (!Number.isInteger(n) || n <= 0) {
116
+ error = "--pr requires a positive integer"
117
+ } else if (hasExactlyOneTarget(target)) {
118
+ error = "provide exactly one of --issue, --pr, --group"
119
+ } else {
120
+ target.pr = n
121
+ }
122
+ break
123
+ }
124
+ case "--group": {
125
+ const v = next()
126
+ if (v === undefined || v === "") {
127
+ error = "--group requires a non-empty group name"
128
+ } else if (hasExactlyOneTarget(target)) {
129
+ error = "provide exactly one of --issue, --pr, --group"
130
+ } else {
131
+ target.group = v
132
+ }
133
+ break
134
+ }
135
+ default:
136
+ rest.push(arg)
137
+ }
138
+ }
139
+ return { rest, target, error }
140
+ }
141
+
142
+ // ─── `orchestrate review` ────────────────────────────────────────────────────
143
+
144
+ const REVIEW_USAGE = `headlesscode orchestrate review — run the review step against an existing group
145
+
146
+ Usage:
147
+ headlesscode orchestrate review --repo <path> (--issue <n>|--pr <n>|--group <name>) [options]
148
+
149
+ Resolves the group's worktree (re-checking out its branch if the original was
150
+ cleaned up), rebuilds its state from real on-disk markers when stale, then runs
151
+ the reviewer exactly as the watch loop does, patching state the same way
152
+ (review_verdict / pending_review_findings / reviewed_at). Exit 0 when the
153
+ review is clean; 1 when it has findings, errored, or the group is not reviewable.
154
+
155
+ Options:
156
+ --repo <path> Target repo root (required)
157
+ --issue <n> Review the group handling issue <n>
158
+ --pr <n> Review the group for PR <n> (state pr or branch match)
159
+ --group <name> Review the group named <name> (e.g. w1)
160
+ --review-mode <slug> Review session mode (default: deepseek-reviewer)
161
+ --model <id> Explicit model override for the review session
162
+ --force-review Re-review even if the group already has a verdict
163
+ --dry-run Print the plan, change nothing
164
+ --help Show this help and exit
165
+ `
166
+
167
+ export interface ReviewCliOptions {
168
+ repo: string
169
+ target: ResumeTarget
170
+ reviewMode: string
171
+ model?: string
172
+ forceReview: boolean
173
+ dryRun: boolean
174
+ help: boolean
175
+ }
176
+
177
+ export function parseReviewArgs(argv: string[]): { options: ReviewCliOptions; error?: string } {
178
+ const options: ReviewCliOptions = {
179
+ repo: "",
180
+ target: {},
181
+ reviewMode: "deepseek-reviewer",
182
+ forceReview: false,
183
+ dryRun: false,
184
+ help: false,
185
+ }
186
+ const { rest, target, error } = parseTargetArgs(argv)
187
+ if (error) {
188
+ return { options, error }
189
+ }
190
+ options.target = target
191
+ for (let i = 0; i < rest.length; i++) {
192
+ const arg = rest[i]
193
+ const eq = arg.indexOf("=")
194
+ const flag = eq === -1 ? arg : arg.slice(0, eq)
195
+ const inlineValue = eq === -1 ? undefined : arg.slice(eq + 1)
196
+ const next = (): string | undefined => {
197
+ if (inlineValue !== undefined) {
198
+ return inlineValue
199
+ }
200
+ const v = rest[i + 1]
201
+ if (v === undefined || v.startsWith("--")) {
202
+ return undefined
203
+ }
204
+ i++
205
+ return v
206
+ }
207
+ switch (flag) {
208
+ case "--repo": {
209
+ const v = next()
210
+ if (v === undefined) {
211
+ return { options, error: "Missing value for --repo" }
212
+ }
213
+ options.repo = v
214
+ break
215
+ }
216
+ case "--review-mode": {
217
+ const v = next()
218
+ if (v === undefined) {
219
+ return { options, error: "Missing value for --review-mode" }
220
+ }
221
+ options.reviewMode = v
222
+ break
223
+ }
224
+ case "--model": {
225
+ const v = next()
226
+ if (v === undefined) {
227
+ return { options, error: "Missing value for --model" }
228
+ }
229
+ options.model = v
230
+ break
231
+ }
232
+ case "--force-review":
233
+ options.forceReview = true
234
+ break
235
+ case "--dry-run":
236
+ options.dryRun = true
237
+ break
238
+ case "--help":
239
+ case "-h":
240
+ options.help = true
241
+ break
242
+ default:
243
+ return { options, error: `Unknown review argument: ${arg}` }
244
+ }
245
+ }
246
+ return { options }
247
+ }
248
+
249
+ // ─── `orchestrate rework` ────────────────────────────────────────────────────
250
+
251
+ const REWORK_USAGE = `headlesscode orchestrate rework — re-spawn a worker to fix review/QA findings
252
+
253
+ Usage:
254
+ headlesscode orchestrate rework --repo <path> (--issue <n>|--pr <n>|--group <name>) [options]
255
+
256
+ Given an issue/PR/group that already has review findings (or a real QA fail —
257
+ issue #52), re-spawns a worker on the SAME worktree (re-checking out the
258
+ branch if the original was cleaned up) to fix them, using the SAME
259
+ buildReworkTaskFileContent / buildQaReworkTaskFileContent template the
260
+ automatic path uses. Review findings come from state's pending_review_findings
261
+ when present, else from the issue's GitHub comments; a QA-failed group with no
262
+ review findings is reworked from its recorded qa.evidence. Exit 0 when a worker
263
+ was spawned; 1 when the rework budget is exhausted (needs human) or there is
264
+ nothing to rework.
265
+
266
+ Options:
267
+ --repo <path> Target repo root (required)
268
+ --issue <n> Rework the group handling issue <n>
269
+ --pr <n> Rework the group for PR <n> (state pr or branch match)
270
+ --group <name> Rework the group named <name> (e.g. w1)
271
+ --mode <slug> Worker mode for the rework spawn (default: code)
272
+ --model <id> Explicit model override for the rework worker
273
+ --max-rework-cycles <n> Rework cap (default: 3)
274
+ --max-iterations <n> Per-session iteration cap for the rework worker
275
+ --memory-dir <path> Phase 3 memory dir forwarded to the rework worker
276
+ --dry-run Print the rework task + spawn command, spawn nothing
277
+ --help Show this help and exit
278
+ `
279
+
280
+ export interface ReworkCliOptions {
281
+ repo: string
282
+ target: ResumeTarget
283
+ mode: string
284
+ model?: string
285
+ maxReworkCycles: number
286
+ maxIterations?: number
287
+ memoryDir?: string
288
+ dryRun: boolean
289
+ help: boolean
290
+ }
291
+
292
+ export function parseReworkArgs(argv: string[]): { options: ReworkCliOptions; error?: string } {
293
+ const options: ReworkCliOptions = {
294
+ repo: "",
295
+ target: {},
296
+ mode: "code",
297
+ maxReworkCycles: 3,
298
+ dryRun: false,
299
+ help: false,
300
+ }
301
+ const { rest, target, error } = parseTargetArgs(argv)
302
+ if (error) {
303
+ return { options, error }
304
+ }
305
+ options.target = target
306
+ for (let i = 0; i < rest.length; i++) {
307
+ const arg = rest[i]
308
+ const eq = arg.indexOf("=")
309
+ const flag = eq === -1 ? arg : arg.slice(0, eq)
310
+ const inlineValue = eq === -1 ? undefined : arg.slice(eq + 1)
311
+ const next = (): string | undefined => {
312
+ if (inlineValue !== undefined) {
313
+ return inlineValue
314
+ }
315
+ const v = rest[i + 1]
316
+ if (v === undefined || v.startsWith("--")) {
317
+ return undefined
318
+ }
319
+ i++
320
+ return v
321
+ }
322
+ switch (flag) {
323
+ case "--repo": {
324
+ const v = next()
325
+ if (v === undefined) {
326
+ return { options, error: "Missing value for --repo" }
327
+ }
328
+ options.repo = v
329
+ break
330
+ }
331
+ case "--mode": {
332
+ const v = next()
333
+ if (v === undefined) {
334
+ return { options, error: "Missing value for --mode" }
335
+ }
336
+ options.mode = v
337
+ break
338
+ }
339
+ case "--model": {
340
+ const v = next()
341
+ if (v === undefined) {
342
+ return { options, error: "Missing value for --model" }
343
+ }
344
+ options.model = v
345
+ break
346
+ }
347
+ case "--max-rework-cycles": {
348
+ const parsed = parseIntFlag(rest, i, "--max-rework-cycles")
349
+ if (parsed.error) {
350
+ return { options, error: parsed.error }
351
+ }
352
+ options.maxReworkCycles = parsed.value!
353
+ i += parsed.consumed
354
+ break
355
+ }
356
+ case "--max-iterations": {
357
+ const parsed = parseIntFlag(rest, i, "--max-iterations")
358
+ if (parsed.error) {
359
+ return { options, error: parsed.error }
360
+ }
361
+ options.maxIterations = parsed.value
362
+ i += parsed.consumed
363
+ break
364
+ }
365
+ case "--memory-dir": {
366
+ const v = next()
367
+ if (v === undefined) {
368
+ return { options, error: "Missing value for --memory-dir" }
369
+ }
370
+ options.memoryDir = v
371
+ break
372
+ }
373
+ case "--dry-run":
374
+ options.dryRun = true
375
+ break
376
+ case "--help":
377
+ case "-h":
378
+ options.help = true
379
+ break
380
+ default:
381
+ return { options, error: `Unknown rework argument: ${arg}` }
382
+ }
383
+ }
384
+ return { options }
385
+ }
386
+
387
+ // ─── `orchestrate resume` ────────────────────────────────────────────────────
388
+
389
+ const RESUME_USAGE = `headlesscode orchestrate resume — recover a stuck/interrupted round from existing state
390
+
391
+ Usage:
392
+ headlesscode orchestrate resume --repo <path> (--issue <n>|--pr <n>|--group <name>) [options]
393
+ headlesscode orchestrate resume --repo <path> [options] (resume every group)
394
+
395
+ Per-group pipeline (issue #14):
396
+ 1. rebuild — re-derive status from REAL on-disk markers (.harness.done /
397
+ .harness.exit, or their absence); a worktree that was cleaned
398
+ up is re-checked out from its recorded branch
399
+ 2. review — run the reviewer on a "done" group (skipped when already
400
+ reviewed unless --force-review)
401
+ 3. rework — findings → re-spawn a worker on the SAME worktree (up to
402
+ --max-rework-cycles); a finding verdict left over from an
403
+ interrupted round is reworked from its recorded findings;
404
+ iteration-exhaustion → continuation
405
+ 4. QA — headless QA session when review passed and --qa is given
406
+ 5. cost — record the group's cost/tokens once it is settled
407
+
408
+ Re-runnable: after a rework/continuation worker is spawned the group is left
409
+ in-flight and resume reports; re-run it once the worker finishes.
410
+
411
+ Options:
412
+ --repo <path> Target repo root (required)
413
+ --issue <n> Resume the group(s) handling issue <n>
414
+ --pr <n> Resume the group(s) for PR <n> (state pr or branch match)
415
+ --group <name> Resume the group named <name> (e.g. w1)
416
+ --review-mode <slug> Review session mode (default: deepseek-reviewer)
417
+ --no-review Skip the review step (QA/cost only)
418
+ --qa Run a headless QA session after a clean review
419
+ --qa-mode <slug> QA session mode (default: qa-agent)
420
+ --mode <slug> Worker mode for rework/continuation spawns (default: code)
421
+ --model <id> Explicit model override for every role
422
+ --max-rework-cycles <n> Rework cap (default: 3)
423
+ --max-continuations <n> Iteration-exhaustion continuation cap (default: 3)
424
+ --max-iterations <n> Per-session iteration cap for spawned workers
425
+ --memory-dir <path> Phase 3 memory dir forwarded to spawned workers
426
+ --force-review Re-review a group even if it already has a verdict
427
+ --dry-run Print the pipeline plan, change nothing
428
+ --help Show this help and exit
429
+ `
430
+
431
+ export interface ResumeCliOptions {
432
+ repo: string
433
+ target: ResumeTarget
434
+ reviewMode: string
435
+ noReview: boolean
436
+ qa: boolean
437
+ qaMode: string
438
+ mode: string
439
+ model?: string
440
+ maxReworkCycles: number
441
+ maxContinuations: number
442
+ maxIterations?: number
443
+ memoryDir?: string
444
+ forceReview: boolean
445
+ dryRun: boolean
446
+ help: boolean
447
+ }
448
+
449
+ export function parseResumeArgs(argv: string[]): { options: ResumeCliOptions; error?: string } {
450
+ const options: ResumeCliOptions = {
451
+ repo: "",
452
+ target: {},
453
+ reviewMode: "deepseek-reviewer",
454
+ noReview: false,
455
+ qa: false,
456
+ qaMode: "qa-agent",
457
+ mode: "code",
458
+ maxReworkCycles: 3,
459
+ maxContinuations: 3,
460
+ dryRun: false,
461
+ forceReview: false,
462
+ help: false,
463
+ }
464
+ const { rest, target, error } = parseTargetArgs(argv)
465
+ if (error) {
466
+ return { options, error }
467
+ }
468
+ options.target = target
469
+ for (let i = 0; i < rest.length; i++) {
470
+ const arg = rest[i]
471
+ const eq = arg.indexOf("=")
472
+ const flag = eq === -1 ? arg : arg.slice(0, eq)
473
+ const inlineValue = eq === -1 ? undefined : arg.slice(eq + 1)
474
+ const next = (): string | undefined => {
475
+ if (inlineValue !== undefined) {
476
+ return inlineValue
477
+ }
478
+ const v = rest[i + 1]
479
+ if (v === undefined || v.startsWith("--")) {
480
+ return undefined
481
+ }
482
+ i++
483
+ return v
484
+ }
485
+ switch (flag) {
486
+ case "--repo": {
487
+ const v = next()
488
+ if (v === undefined) {
489
+ return { options, error: "Missing value for --repo" }
490
+ }
491
+ options.repo = v
492
+ break
493
+ }
494
+ case "--review-mode": {
495
+ const v = next()
496
+ if (v === undefined) {
497
+ return { options, error: "Missing value for --review-mode" }
498
+ }
499
+ options.reviewMode = v
500
+ break
501
+ }
502
+ case "--no-review":
503
+ options.noReview = true
504
+ break
505
+ case "--qa":
506
+ options.qa = true
507
+ break
508
+ case "--qa-mode": {
509
+ const v = next()
510
+ if (v === undefined) {
511
+ return { options, error: "Missing value for --qa-mode" }
512
+ }
513
+ options.qaMode = v
514
+ break
515
+ }
516
+ case "--mode": {
517
+ const v = next()
518
+ if (v === undefined) {
519
+ return { options, error: "Missing value for --mode" }
520
+ }
521
+ options.mode = v
522
+ break
523
+ }
524
+ case "--model": {
525
+ const v = next()
526
+ if (v === undefined) {
527
+ return { options, error: "Missing value for --model" }
528
+ }
529
+ options.model = v
530
+ break
531
+ }
532
+ case "--max-rework-cycles": {
533
+ const parsed = parseIntFlag(rest, i, "--max-rework-cycles")
534
+ if (parsed.error) {
535
+ return { options, error: parsed.error }
536
+ }
537
+ options.maxReworkCycles = parsed.value!
538
+ i += parsed.consumed
539
+ break
540
+ }
541
+ case "--max-continuations": {
542
+ const parsed = parseIntFlag(rest, i, "--max-continuations")
543
+ if (parsed.error) {
544
+ return { options, error: parsed.error }
545
+ }
546
+ options.maxContinuations = parsed.value!
547
+ i += parsed.consumed
548
+ break
549
+ }
550
+ case "--max-iterations": {
551
+ const parsed = parseIntFlag(rest, i, "--max-iterations")
552
+ if (parsed.error) {
553
+ return { options, error: parsed.error }
554
+ }
555
+ options.maxIterations = parsed.value
556
+ i += parsed.consumed
557
+ break
558
+ }
559
+ case "--memory-dir": {
560
+ const v = next()
561
+ if (v === undefined) {
562
+ return { options, error: "Missing value for --memory-dir" }
563
+ }
564
+ options.memoryDir = v
565
+ break
566
+ }
567
+ case "--force-review":
568
+ options.forceReview = true
569
+ break
570
+ case "--dry-run":
571
+ options.dryRun = true
572
+ break
573
+ case "--help":
574
+ case "-h":
575
+ options.help = true
576
+ break
577
+ default:
578
+ return { options, error: `Unknown resume argument: ${arg}` }
579
+ }
580
+ }
581
+ return { options }
582
+ }
583
+
584
+ // ─── Group resolution ────────────────────────────────────────────────────────
585
+
586
+ export interface ResolveTargetHooks {
587
+ /** PR-head-branch resolver (default: gh pr view --json headRefName). */
588
+ prHeadBranch?: (repo: string, pr: number) => string | undefined
589
+ }
590
+
591
+ /**
592
+ * Resolve which state groups a target selects. By issue number (group.issues),
593
+ * by group name, or by PR — first a direct group.pr?.number match, then a
594
+ * branch match against the PR's head branch (gh). Empty when nothing matches.
595
+ */
596
+ export function resolveTargetGroups(
597
+ state: OrchestratorState,
598
+ target: ResumeTarget,
599
+ opts: { repo: string; hooks?: ResolveTargetHooks } = { repo: "" },
600
+ ): OrchestratorGroup[] {
601
+ if (target.group !== undefined) {
602
+ return state.groups.filter((g) => g.name === target.group)
603
+ }
604
+ if (target.issue !== undefined) {
605
+ const issue = target.issue
606
+ return state.groups.filter((g) => (g.issues ?? []).includes(issue))
607
+ }
608
+ if (target.pr !== undefined) {
609
+ const pr = target.pr
610
+ const byPr = state.groups.filter((g) => g.pr?.number === pr)
611
+ if (byPr.length > 0) {
612
+ return byPr
613
+ }
614
+ const headBranch = (opts.hooks?.prHeadBranch ?? prHeadBranch)(opts.repo, pr)
615
+ if (headBranch) {
616
+ return state.groups.filter((g) => g.branch === headBranch)
617
+ }
618
+ return []
619
+ }
620
+ return []
621
+ }
622
+
623
+ /** The head branch of a GitHub PR (gh), or undefined when gh is unavailable. */
624
+ export function prHeadBranch(repo: string, pr: number): string | undefined {
625
+ try {
626
+ const out = execFileSync(
627
+ "gh",
628
+ ["pr", "view", String(pr), "--json", "headRefName", "--jq", ".headRefName"],
629
+ { cwd: repo, encoding: "utf-8", timeout: 30_000 },
630
+ ).trim()
631
+ return out === "" ? undefined : out
632
+ } catch {
633
+ return undefined
634
+ }
635
+ }
636
+
637
+ // ─── Rebuild from real on-disk markers ───────────────────────────────────────
638
+
639
+ /**
640
+ * Issue #14 (issue comment requirement): the FIRST step of any resume/review/
641
+ * rework run — rebuild a group's state from the REAL on-disk markers
642
+ * (.harness.done / .harness.exit / .harness.needs-decision, or their absence)
643
+ * when the local orchestrator state is stale relative to what actually
644
+ * happened. A stuck/interrupted round ALWAYS means local state and disk reality
645
+ * have diverged, so this is the PRIMARY (not edge) case.
646
+ *
647
+ * The pattern is exactly the one the issue calls out:
648
+ * inspectGroup(repo, {...group, status: "running"}, Date.now(), stallTimeout)
649
+ * — the forced "running" status is deliberate: it bypasses inspectGroup's
650
+ * terminal short-circuit so ANY entry (regardless of what the state file
651
+ * claims) is re-derived purely from markers.
652
+ *
653
+ * Pure: returns the patch (or undefined when markers change nothing); the
654
+ * caller persists via patchGroup. A worktree that is entirely gone — with
655
+ * nothing left to inspect — is surfaced as "orphaned" (never guessed as
656
+ * done/failed), mirroring status.ts's reconcileGroups.
657
+ */
658
+ export function rebuildPatchFromMarkers(
659
+ repo: string,
660
+ group: OrchestratorGroup,
661
+ now = Date.now(),
662
+ stallTimeoutMs = DEFAULT_STALL_TIMEOUT_MS,
663
+ ): Partial<Omit<OrchestratorGroup, "name">> | undefined {
664
+ const wtPath = groupWorktreePath(repo, group)
665
+ if (!fs.existsSync(wtPath)) {
666
+ return {
667
+ status: "orphaned",
668
+ last_activity: {
669
+ note: "orphaned: worktree removed before a terminal status — investigate via git history",
670
+ },
671
+ }
672
+ }
673
+ return inspectGroup(repo, { ...group, status: "running" }, now, stallTimeoutMs)
674
+ }
675
+
676
+ // ─── Worktree re-checkout ────────────────────────────────────────────────────
677
+
678
+ /**
679
+ * Re-checkout a group's branch into a fresh worktree when the original was
680
+ * cleaned up (issue #14: "or re-checkout the branch into a fresh worktree if
681
+ * the original was cleaned up"). Prefers recreating the local branch from
682
+ * origin/<branch> (the branch's committed, pushed state) and falls back to
683
+ * the local branch. `git worktree add -B` refuses when the branch is checked
684
+ * out in ANOTHER worktree, so this can never clobber a live worktree — a
685
+ * failure means the branch is busy elsewhere and the caller reports it.
686
+ */
687
+ export function recheckoutWorktree(repo: string, group: OrchestratorGroup): { ok: boolean; error?: string } {
688
+ const wtPath = groupWorktreePath(repo, group)
689
+ const branch = group.branch
690
+ if (!branch) {
691
+ return { ok: false, error: `group ${group.name} has no recorded branch to re-checkout` }
692
+ }
693
+ try {
694
+ fs.mkdirSync(path.dirname(wtPath), { recursive: true })
695
+ try {
696
+ execFileSync("git", ["-C", repo, "fetch", "origin", branch], { stdio: "ignore", timeout: 60_000 })
697
+ } catch {
698
+ // origin may be unreachable or the branch local-only — try local below.
699
+ }
700
+ try {
701
+ execFileSync("git", ["-C", repo, "worktree", "add", "-B", branch, wtPath, `origin/${branch}`], {
702
+ stdio: "ignore",
703
+ timeout: 60_000,
704
+ })
705
+ } catch {
706
+ execFileSync("git", ["-C", repo, "worktree", "add", "-B", branch, wtPath, branch], {
707
+ stdio: "ignore",
708
+ timeout: 60_000,
709
+ })
710
+ }
711
+ return { ok: true }
712
+ } catch (err) {
713
+ return { ok: false, error: err instanceof Error ? err.message : String(err) }
714
+ }
715
+ }
716
+
717
+ // ─── Findings from issue comments (rework fallback) ──────────────────────────
718
+
719
+ /**
720
+ * Extract review findings from GitHub issue-comment bodies using the SAME
721
+ * parser the orchestrator uses for a live review session (parseReviewResult
722
+ * knows the reviewer's report format). Pure + unit-testable; gh is only
723
+ * needed by fetchFindingsFromIssueComments.
724
+ */
725
+ export function findingsFromCommentBodies(bodies: string[]): string[] {
726
+ const findings: string[] = []
727
+ for (const body of bodies) {
728
+ if (typeof body !== "string" || body.trim() === "") {
729
+ continue
730
+ }
731
+ const parsed = parseReviewResult(body)
732
+ if (parsed.verdict === "finding") {
733
+ findings.push(...parsed.findings)
734
+ }
735
+ }
736
+ return [...new Set(findings)]
737
+ }
738
+
739
+ /**
740
+ * Fallback findings source for `orchestrate rework` when the group no longer
741
+ * has pending_review_findings in state (issue #14: "read from the issue
742
+ * comments, or from pending_review_findings in state if still present").
743
+ * Returns [] when gh is unavailable or no comment parses as a finding report
744
+ * — callers fall back to the rework template's no-findings text.
745
+ */
746
+ export function fetchFindingsFromIssueComments(repo: string, issue: number): string[] {
747
+ let raw: string
748
+ try {
749
+ raw = execFileSync(
750
+ "gh",
751
+ ["issue", "view", String(issue), "--json", "comments", "--jq", "[.comments[] | .body]"],
752
+ { cwd: repo, encoding: "utf-8", timeout: 30_000 },
753
+ )
754
+ } catch {
755
+ return []
756
+ }
757
+ try {
758
+ const bodies: unknown = JSON.parse(raw)
759
+ if (!Array.isArray(bodies)) {
760
+ return []
761
+ }
762
+ return findingsFromCommentBodies(bodies.filter((b): b is string => typeof b === "string"))
763
+ } catch {
764
+ return []
765
+ }
766
+ }
767
+
768
+ // ─── Pipeline steps ──────────────────────────────────────────────────────────
769
+
770
+ /** Reload a group fresh from disk; falls back to the given snapshot. */
771
+ function reloadedGroup(statePath: string, name: string, fallback: OrchestratorGroup): OrchestratorGroup {
772
+ try {
773
+ return loadStateSync(statePath).groups.find((g) => g.name === name) ?? fallback
774
+ } catch {
775
+ return fallback
776
+ }
777
+ }
778
+
779
+ // Tier 1/Tier 2 verification gates live in verification-gate.ts (no
780
+ // dependency on cli.js), so both this file and orchestrator/cli.ts can use
781
+ // them without a circular import. Re-exported here for backward
782
+ // compatibility with existing imports (e.g. resume.test.ts).
783
+ export { HARNESS_ARTIFACT_RE, hasRealWorktreeChanges, hasRealVerificationActivity } from "./verification-gate.js"
784
+ import { hasRealWorktreeChanges, hasRealVerificationActivity } from "./verification-gate.js"
785
+
786
+ export interface ReviewStepOptions {
787
+ repo: string
788
+ statePath: string
789
+ group: OrchestratorGroup
790
+ reviewMode: string
791
+ reviewerModel?: string
792
+ forceReview: boolean
793
+ dryRun: boolean
794
+ write: (text: string) => void
795
+ }
796
+
797
+ export interface ReviewStepResult {
798
+ group: OrchestratorGroup
799
+ /** The review outcome (undefined when the step was skipped). */
800
+ result?: ReviewResult
801
+ skipped: boolean
802
+ message: string
803
+ }
804
+
805
+ /**
806
+ * Review a done group exactly as the watch loop's onGroupUpdate does:
807
+ * runReviewWithRetries → session-error → handleReviewSessionError
808
+ * (needs-human, NEVER a rework spawn); real verdict → review_verdict /
809
+ * pending_review_findings / reviewed_at / last_activity patched. Skips groups
810
+ * that already have a verdict unless forceReview.
811
+ */
812
+ export async function runReviewStep(opts: ReviewStepOptions): Promise<ReviewStepResult> {
813
+ const { repo, statePath, group, reviewMode, reviewerModel, forceReview, dryRun, write } = opts
814
+ if (group.status !== "done") {
815
+ return { group, skipped: true, message: `${group.name} is ${group.status} — review only runs on a "done" group` }
816
+ }
817
+ if (group.review_verdict !== undefined && !forceReview) {
818
+ return {
819
+ group,
820
+ skipped: true,
821
+ message: `${group.name} was already reviewed (verdict ${group.review_verdict}); use --force-review to re-review`,
822
+ }
823
+ }
824
+ if (forceReview && !dryRun) {
825
+ // A forced re-review starts from a clean slate — drop the old verdict.
826
+ await patchGroup(statePath, group.name, {
827
+ review_verdict: undefined,
828
+ pending_review_findings: undefined,
829
+ reviewed_at: undefined,
830
+ })
831
+ }
832
+ const wtPath = groupWorktreePath(repo, group)
833
+ write(`reviewing ${group.name} (branch ${group.branch ?? "?"})...\n`)
834
+ if (dryRun) {
835
+ write(` dry-run: would run a headless review session (mode ${reviewMode}, model ${reviewerModel ?? "(default)"}) against ${wtPath}\n`)
836
+ return { group, skipped: false, message: "dry-run: review not run" }
837
+ }
838
+ // See hasRealWorktreeChanges's doc comment: a "clean" verdict is
839
+ // structurally impossible with zero real changes, checked directly
840
+ // against git — never asked of (or trusted from) the review LLM
841
+ // itself. Skips the review session entirely rather than spend a call
842
+ // that has nothing real to verify.
843
+ if (!hasRealWorktreeChanges(wtPath)) {
844
+ const stateAfter = await patchGroup(statePath, group.name, {
845
+ review_verdict: "finding",
846
+ pending_review_findings: [
847
+ "No real changes found in the worktree (empty diff vs the tracked upstream, no uncommitted changes outside harness bookkeeping files) — there is nothing for a review to verify. Automatic fail: a \"clean\" verdict is structurally impossible with zero changes.",
848
+ ],
849
+ reviewed_at: new Date().toISOString(),
850
+ last_activity: { note: "review skipped: worktree has no real changes (automatic fail)" },
851
+ })
852
+ const updated = stateAfter.groups.find((g) => g.name === group.name) ?? group
853
+ write(
854
+ `AUTOMATIC FAIL: ${group.name}'s worktree has no real changes — skipping the review session (a "clean" verdict is structurally impossible with an empty diff)\n`,
855
+ )
856
+ return { group: updated, skipped: false, message: "no real changes → automatic fail (review session never ran)" }
857
+ }
858
+ const result = await runReviewWithRetries({
859
+ workspaceRoot: wtPath,
860
+ mode: reviewMode,
861
+ model: reviewerModel,
862
+ issues: group.issues,
863
+ })
864
+ if (result.verdict === "error") {
865
+ const stateAfter = await patchGroup(statePath, group.name, handleReviewSessionError(result))
866
+ const updated = stateAfter.groups.find((g) => g.name === group.name) ?? group
867
+ write(`NEEDS-HUMAN: ${group.name}'s review session failed repeatedly — ${result.summary}\n`)
868
+ return { group: updated, result, skipped: false, message: "review session error → needs-human" }
869
+ }
870
+ // See hasRealVerificationActivity's doc comment (Tier 2 of the
871
+ // 2026-08-28 incident fix): a "clean" claim backed by zero real,
872
+ // successful execute_command results in the session's OWN transcript
873
+ // is downgraded to a finding rather than trusted — the exact gap that
874
+ // let a fabricated review through even with the required structured
875
+ // verdict line.
876
+ let effectiveResult = result
877
+ if (result.verdict === "clean" && !hasRealVerificationActivity(wtPath, result.reportPath)) {
878
+ write(
879
+ ` note: verdict was "clean" but the session's own transcript shows no real, successful execute_command result — downgrading to a finding rather than trust an unverified claim\n`,
880
+ )
881
+ effectiveResult = {
882
+ ...result,
883
+ verdict: "finding",
884
+ findings: [
885
+ ...result.findings,
886
+ "Review declared \"clean\" but its own transcript shows no real, successful execute_command result — nothing to substantiate the verdict was actually run. Treated as a finding rather than trusted.",
887
+ ],
888
+ }
889
+ }
890
+ const stateAfter = await patchGroup(statePath, group.name, {
891
+ review_verdict: effectiveResult.verdict,
892
+ pending_review_findings: effectiveResult.findings,
893
+ reviewed_at: new Date().toISOString(),
894
+ last_activity: { note: `reviewed: verdict=${effectiveResult.verdict} (${effectiveResult.findings.length} finding(s))` },
895
+ })
896
+ const updated = stateAfter.groups.find((g) => g.name === group.name) ?? group
897
+ write(`review verdict: ${effectiveResult.verdict} (${effectiveResult.findings.length} finding(s))\n`)
898
+ return { group: updated, result: effectiveResult, skipped: false, message: `reviewed: ${effectiveResult.verdict}` }
899
+ }
900
+
901
+ export type ReworkOutcome = "spawned" | "cap" | "not-applicable" | "already-running" | "dry-run"
902
+
903
+ export interface ReworkStepOptions {
904
+ repo: string
905
+ statePath: string
906
+ group: OrchestratorGroup
907
+ mode: string
908
+ model?: string
909
+ maxReworkCycles: number
910
+ maxIterations?: number
911
+ memoryDir?: string
912
+ /** Findings to fix (default: group.pending_review_findings ?? []). */
913
+ findings?: string[]
914
+ dryRun: boolean
915
+ write: (text: string) => void
916
+ }
917
+
918
+ export interface ReworkStepResult {
919
+ outcome: ReworkOutcome
920
+ group: OrchestratorGroup
921
+ message: string
922
+ }
923
+
924
+ /**
925
+ * Rework step: given a group with review findings, build the rework task via
926
+ * the SAME buildReworkTaskFileContent template the automatic path uses (through
927
+ * handleReviewVerdict), write it, and re-spawn a worker on the SAME worktree
928
+ * via the same handleReviewVerdict decision (which also resets the group to
929
+ * "running" and clears review/QA/cost artifacts for a fresh cycle). At the
930
+ * rework cap the group is marked needs-human — identical to the watch loop.
931
+ *
932
+ * Issue #52: a group that FAILED QA (real verdict, not session error) has
933
+ * actionable evidence even with no review findings — the same rework goes
934
+ * through handleQaVerdict (feeding `qa.evidence` into the task file instead
935
+ * of review findings), so `orchestrate rework` against a QA-failed group is
936
+ * no longer a no-op. Review findings win if both are present (the reviewer
937
+ * is the more specific source).
938
+ */
939
+ export async function runReworkStep(opts: ReworkStepOptions): Promise<ReworkStepResult> {
940
+ const { repo, statePath, group, mode, model, maxReworkCycles, maxIterations, memoryDir, dryRun, write } = opts
941
+ if (group.status === "running" || group.status === "spawned" || group.status === "blocked") {
942
+ return {
943
+ outcome: "already-running",
944
+ group,
945
+ message: `${group.name} is ${group.status} — a worker is in flight; nothing to rework`,
946
+ }
947
+ }
948
+ const findings = opts.findings ?? group.pending_review_findings ?? []
949
+ const qaFail = group.qa?.verdict === "fail" && findings.length === 0
950
+ const result: ReviewResult = {
951
+ verdict: "finding",
952
+ findings,
953
+ summary: "standalone rework trigger (orchestrate rework/resume)",
954
+ }
955
+ const decision = qaFail
956
+ ? handleQaVerdict(
957
+ group,
958
+ {
959
+ verdict: "fail",
960
+ evidence: group.qa?.evidence ?? "",
961
+ summary: "standalone QA-fail rework trigger (orchestrate rework/resume)",
962
+ },
963
+ repo,
964
+ maxReworkCycles,
965
+ mode,
966
+ model,
967
+ maxIterations,
968
+ )
969
+ : handleReviewVerdict(group, result, repo, maxReworkCycles, mode, model, maxIterations)
970
+ if (!decision.shouldSpawn) {
971
+ // Cap reached: terminal needs-human (findings stay recorded).
972
+ if (dryRun) {
973
+ write(` dry-run: would mark ${group.name} needs-human (rework cap ${maxReworkCycles} reached)\n`)
974
+ } else {
975
+ await patchGroup(statePath, group.name, decision.patch)
976
+ }
977
+ return {
978
+ outcome: "cap",
979
+ group: dryRun ? group : reloadedGroup(statePath, group.name, group),
980
+ message: `${group.name} exhausted its rework budget (cap ${maxReworkCycles}) — needs human`,
981
+ }
982
+ }
983
+ if (!decision.taskFilePath || !decision.taskContent || !decision.spawnCommand) {
984
+ return { outcome: "not-applicable", group, message: "no rework decision produced" }
985
+ }
986
+ write(`rework cycle ${decision.newReworkCount}: re-spawning worker on the same worktree...\n`)
987
+ if (dryRun) {
988
+ write(` dry-run: would write ${path.relative(repo, decision.taskFilePath)} and run:\n ${decision.spawnCommand}\n`)
989
+ return { outcome: "dry-run", group, message: "dry-run: rework task + spawn command ready" }
990
+ }
991
+ fs.mkdirSync(path.dirname(decision.taskFilePath), { recursive: true })
992
+ fs.writeFileSync(decision.taskFilePath, decision.taskContent, "utf-8")
993
+ write(`wrote ${path.relative(repo, decision.taskFilePath)}\n`)
994
+ const spawnResult = spawnSync("bash", ["-c", decision.spawnCommand], {
995
+ cwd: repo,
996
+ env: {
997
+ ...process.env,
998
+ HEADLESSCODE_ROOT: HARNESS_ROOT,
999
+ ...(memoryDir ? { HEADLESSCODE_MEMORY_DIR: path.resolve(memoryDir) } : {}),
1000
+ },
1001
+ stdio: "inherit",
1002
+ })
1003
+ if (spawnResult.status !== 0) {
1004
+ // The rework worker could not be launched — surface as needing a human
1005
+ // instead of leaving a dangling "running" group (same as the watch loop).
1006
+ await patchGroup(statePath, group.name, {
1007
+ status: "needs-human",
1008
+ reworkCount: decision.newReworkCount,
1009
+ last_activity: { note: `rework spawn failed after ${decision.newReworkCount} attempt(s); needs human` },
1010
+ })
1011
+ return {
1012
+ outcome: "cap",
1013
+ group: reloadedGroup(statePath, group.name, group),
1014
+ message: `rework worker spawn failed (exit ${spawnResult.status}) — marked needs-human`,
1015
+ }
1016
+ }
1017
+ const stateAfter = await patchGroup(statePath, group.name, decision.patch)
1018
+ const updated = stateAfter.groups.find((g) => g.name === group.name) ?? group
1019
+ return {
1020
+ outcome: "spawned",
1021
+ group: updated,
1022
+ message: `rework cycle ${decision.newReworkCount}: worker spawned on the same worktree`,
1023
+ }
1024
+ }
1025
+
1026
+ export interface QaStepOptions {
1027
+ repo: string
1028
+ statePath: string
1029
+ group: OrchestratorGroup
1030
+ qaMode: string
1031
+ qaModel?: string
1032
+ dryRun: boolean
1033
+ write: (text: string) => void
1034
+ }
1035
+
1036
+ export interface QaStepResult {
1037
+ group: OrchestratorGroup
1038
+ skipped: boolean
1039
+ message: string
1040
+ }
1041
+
1042
+ /**
1043
+ * QA step — the Phase 4 twin of runReviewStep, mirroring the watch loop's QA
1044
+ * block: runQaWithRetries → session-error → handleQaSessionError
1045
+ * (needs-human); real verdict → qa {status, verdict, evidence, updated}.
1046
+ */
1047
+ export async function runQaStep(opts: QaStepOptions): Promise<QaStepResult> {
1048
+ const { repo, statePath, group, qaMode, qaModel, dryRun, write } = opts
1049
+ if (group.status !== "done" || group.qa !== undefined) {
1050
+ return {
1051
+ group,
1052
+ skipped: true,
1053
+ message: `${group.name} QA skipped (status ${group.status}, qa ${group.qa?.verdict ?? "unset"})`,
1054
+ }
1055
+ }
1056
+ const wtPath = groupWorktreePath(repo, group)
1057
+ write(`QA ${group.name} (branch ${group.branch ?? "?"})...\n`)
1058
+ if (dryRun) {
1059
+ write(` dry-run: would run a headless QA session (mode ${qaMode}, model ${qaModel ?? "(default)"}) against ${wtPath}\n`)
1060
+ return { group, skipped: false, message: "dry-run: QA not run" }
1061
+ }
1062
+ // See hasRealWorktreeChanges's doc comment: same automatic-fail gate as
1063
+ // runReviewStep, checked directly against git before the QA LLM session
1064
+ // ever runs — a "pass" verdict is structurally impossible with zero
1065
+ // real changes.
1066
+ if (!hasRealWorktreeChanges(wtPath)) {
1067
+ const stateAfter = await patchGroup(statePath, group.name, {
1068
+ qa: {
1069
+ status: "failed",
1070
+ verdict: "fail",
1071
+ evidence:
1072
+ "No real changes found in the worktree (empty diff vs the tracked upstream, no uncommitted changes outside harness bookkeeping files) — there is nothing for QA to verify. Automatic fail: a \"pass\" verdict is structurally impossible with zero changes.",
1073
+ updated: new Date().toISOString(),
1074
+ },
1075
+ last_activity: { note: "QA skipped: worktree has no real changes (automatic fail)" },
1076
+ })
1077
+ const updated = stateAfter.groups.find((g) => g.name === group.name) ?? group
1078
+ write(
1079
+ `AUTOMATIC FAIL: ${group.name}'s worktree has no real changes — skipping the QA session (a "pass" verdict is structurally impossible with an empty diff)\n`,
1080
+ )
1081
+ return { group: updated, skipped: false, message: "no real changes → automatic fail (QA session never ran)" }
1082
+ }
1083
+ const qaResult = await runQaWithRetries({ workspaceRoot: wtPath, mode: qaMode, model: qaModel })
1084
+ if (qaResult.verdict === "error") {
1085
+ const stateAfter = await patchGroup(statePath, group.name, handleQaSessionError(qaResult))
1086
+ const updated = stateAfter.groups.find((g) => g.name === group.name) ?? group
1087
+ write(`NEEDS-HUMAN: ${group.name}'s QA session failed repeatedly — ${qaResult.summary}\n`)
1088
+ return { group: updated, skipped: false, message: "QA session error → needs-human" }
1089
+ }
1090
+ // See hasRealVerificationActivity's doc comment (Tier 2 of the
1091
+ // 2026-08-28 incident fix): same downgrade as runReviewStep — a "pass"
1092
+ // claim backed by zero real, successful execute_command results in the
1093
+ // session's own transcript is not trusted.
1094
+ let effectiveVerdict = qaResult.verdict
1095
+ let effectiveEvidence = qaResult.evidence
1096
+ if (qaResult.verdict === "pass" && !hasRealVerificationActivity(wtPath, qaResult.reportPath)) {
1097
+ write(
1098
+ ` note: verdict was "pass" but the session's own transcript shows no real, successful execute_command result — downgrading to fail rather than trust an unverified claim\n`,
1099
+ )
1100
+ effectiveVerdict = "fail"
1101
+ effectiveEvidence =
1102
+ `QA declared "pass" but its own transcript shows no real, successful execute_command result — ` +
1103
+ `nothing to substantiate the verdict was actually run. Treated as fail rather than trusted.\n\n` +
1104
+ `Original evidence: ${qaResult.evidence}`
1105
+ }
1106
+ const qaStatus = effectiveVerdict === "pass" ? "done" : "failed"
1107
+ const stateAfter = await patchGroup(statePath, group.name, {
1108
+ qa: {
1109
+ status: qaStatus,
1110
+ verdict: effectiveVerdict,
1111
+ evidence: effectiveEvidence.slice(0, 4000),
1112
+ updated: new Date().toISOString(),
1113
+ },
1114
+ last_activity: { note: `QA: verdict=${effectiveVerdict}` },
1115
+ })
1116
+ const updated = stateAfter.groups.find((g) => g.name === group.name) ?? group
1117
+ write(`QA verdict: ${effectiveVerdict} (status ${qaStatus})\n`)
1118
+ return { group: updated, skipped: false, message: `QA: ${effectiveVerdict}` }
1119
+ }
1120
+
1121
+ export interface RecordCostOptions {
1122
+ repo: string
1123
+ statePath: string
1124
+ group: OrchestratorGroup
1125
+ reviewEnabled: boolean
1126
+ qaEnabled: boolean
1127
+ write: (text: string) => void
1128
+ }
1129
+
1130
+ /** Mirror of watchGroups' isSettled for a single group. */
1131
+ function isSettledForRecording(group: OrchestratorGroup, reviewEnabled: boolean, qaEnabled: boolean): boolean {
1132
+ if (group.status !== "done") {
1133
+ return isTerminalStatus(group.status)
1134
+ }
1135
+ const reviewSettled = !reviewEnabled || group.review_verdict !== undefined
1136
+ const qaSettled = !qaEnabled || group.qa !== undefined
1137
+ return reviewSettled && qaSettled
1138
+ }
1139
+
1140
+ /**
1141
+ * Mirror of watchGroups' recordCostIfSettled for a SINGLE group: fire the
1142
+ * one-shot cost/token recording exactly once per group, the first time it's
1143
+ * observed settled, recomputing usage FRESH from the worktree (so review/QA
1144
+ * sessions' cost is included). Non-fatal on failure — warn, never abort.
1145
+ */
1146
+ export async function recordSettledCost(opts: RecordCostOptions): Promise<void> {
1147
+ const { repo, statePath, group, reviewEnabled, qaEnabled, write } = opts
1148
+ if (group.cost_recorded !== undefined || !isSettledForRecording(group, reviewEnabled, qaEnabled)) {
1149
+ return
1150
+ }
1151
+ const wtPath = groupWorktreePath(repo, group)
1152
+ const freshUsage = readWorktreeUsage(wtPath)
1153
+ try {
1154
+ await recordGroupCost(repo, { ...group, usage: freshUsage })
1155
+ } catch (err) {
1156
+ write(`cost recording for ${group.name} failed: ${err instanceof Error ? err.message : String(err)}\n`)
1157
+ }
1158
+ try {
1159
+ await recordAllSessionCosts(repo, group)
1160
+ } catch (err) {
1161
+ write(`per-session cost recording for ${group.name} failed: ${err instanceof Error ? err.message : String(err)}\n`)
1162
+ }
1163
+ await patchGroup(statePath, group.name, { cost_recorded: new Date().toISOString() })
1164
+ }
1165
+
1166
+ // ─── Per-group resume pipeline ───────────────────────────────────────────────
1167
+
1168
+ export type ResumeOutcome = "settled" | "in-flight" | "needs-human" | "failed" | "orphaned" | "error"
1169
+
1170
+ export interface ResumeGroupOptions {
1171
+ repo: string
1172
+ statePath: string
1173
+ group: OrchestratorGroup
1174
+ reviewMode: string
1175
+ qaMode: string
1176
+ reviewEnabled: boolean
1177
+ qaEnabled: boolean
1178
+ mode: string
1179
+ /** Resolved worker model for rework/continuation spawns. */
1180
+ workerModel?: string
1181
+ /** Resolved reviewer model. */
1182
+ reviewerModel?: string
1183
+ /** Resolved QA model. */
1184
+ qaModel?: string
1185
+ maxReworkCycles: number
1186
+ maxContinuations: number
1187
+ maxIterations?: number
1188
+ memoryDir?: string
1189
+ forceReview: boolean
1190
+ dryRun: boolean
1191
+ write: (text: string) => void
1192
+ }
1193
+
1194
+ export interface ResumeGroupResult {
1195
+ outcome: ResumeOutcome
1196
+ group: OrchestratorGroup
1197
+ message: string
1198
+ }
1199
+
1200
+ /**
1201
+ * Terminal needs-human outcome with the group's cost recorded (parity with
1202
+ * watchGroups). Dry-run never persists — the cost step is reported, not run.
1203
+ */
1204
+ async function needsHumanOutcome(
1205
+ opts: ResumeGroupOptions,
1206
+ group: OrchestratorGroup,
1207
+ message: string,
1208
+ ): Promise<ResumeGroupResult> {
1209
+ if (!opts.dryRun) {
1210
+ await recordSettledCost({
1211
+ repo: opts.repo,
1212
+ statePath: opts.statePath,
1213
+ group,
1214
+ reviewEnabled: opts.reviewEnabled,
1215
+ qaEnabled: opts.qaEnabled,
1216
+ write: opts.write,
1217
+ })
1218
+ }
1219
+ return { outcome: "needs-human", group, message }
1220
+ }
1221
+
1222
+ /**
1223
+ * One full pipeline pass for a single group (issue #14 comment requirement):
1224
+ * rebuild → review → rework-if-needed / continuation → QA → cost recording.
1225
+ *
1226
+ * The rebuild step is the FIRST thing that happens — a stuck/interrupted
1227
+ * round always means the state file and disk reality have diverged, so the
1228
+ * group's status is re-derived from real markers before any decision is made
1229
+ * (rebuildPatchFromMarkers, the issue's first-class path — not something
1230
+ * rebuilt ad hoc each time). A worktree that was cleaned up is re-checked out
1231
+ * from its recorded branch first (a missing worktree has no markers to
1232
+ * rebuild from).
1233
+ *
1234
+ * A finding verdict left over from an interrupted round (the review ran, the
1235
+ * rework spawn never did — issue #14's exact recovery scenario) is reworked
1236
+ * from its recorded findings without burning a fresh review session.
1237
+ *
1238
+ * After a rework/continuation worker is spawned the group is left in-flight
1239
+ * (status running) and this returns "in-flight": the operator re-runs resume
1240
+ * once the worker finishes to continue the chain.
1241
+ */
1242
+ export async function resumeGroup(opts: ResumeGroupOptions): Promise<ResumeGroupResult> {
1243
+ const { repo, statePath, reviewEnabled, qaEnabled, dryRun, write } = opts
1244
+ let group = opts.group
1245
+ const wtPath = groupWorktreePath(repo, group)
1246
+
1247
+ // Cost recording is part of the pipeline but never runs in dry-run —
1248
+ // nothing in this function may persist when dryRun is set.
1249
+ const recordCost = async (g: OrchestratorGroup): Promise<void> => {
1250
+ if (dryRun) {
1251
+ return
1252
+ }
1253
+ await recordSettledCost({ repo, statePath, group: g, reviewEnabled, qaEnabled, write })
1254
+ }
1255
+
1256
+ // 1. Worktree missing → re-checkout the branch (issue #14: "or re-checkout
1257
+ // the branch into a fresh worktree if the original was cleaned up").
1258
+ // This must precede the marker rebuild: a missing worktree has NO
1259
+ // markers to rebuild from, and a fresh re-checkout deliberately has no
1260
+ // .harness.done either (it would trip the stall guard) — the branch's
1261
+ // committed state IS the ground truth, so the group is marked done.
1262
+ let recheckedOut = false
1263
+ if (!fs.existsSync(wtPath)) {
1264
+ if (group.branch) {
1265
+ if (dryRun) {
1266
+ write(
1267
+ `[resume] ${group.name}: worktree missing — dry-run: would re-checkout branch ${group.branch} into ${wtPath}\n`,
1268
+ )
1269
+ recheckedOut = true
1270
+ if (group.status !== "done") {
1271
+ group = { ...group, status: "done" }
1272
+ }
1273
+ } else {
1274
+ write(`[resume] ${group.name}: worktree missing — re-checking out branch ${group.branch}...\n`)
1275
+ const result = recheckoutWorktree(repo, group)
1276
+ if (!result.ok) {
1277
+ const patch = {
1278
+ status: "orphaned",
1279
+ last_activity: { note: `orphaned: worktree gone and branch re-checkout failed — ${result.error}` },
1280
+ }
1281
+ await patchGroup(statePath, group.name, patch)
1282
+ write(`[resume] ${group.name}: cannot re-checkout worktree (${result.error}) — orphaned\n`)
1283
+ return { outcome: "orphaned", group, message: `cannot re-checkout ${group.name}'s branch: ${result.error}` }
1284
+ }
1285
+ write(`[resume] ${group.name}: re-checked out ${wtPath}\n`)
1286
+ recheckedOut = true
1287
+ }
1288
+ if (group.status !== "done") {
1289
+ const patch: Partial<Omit<OrchestratorGroup, "name">> = {
1290
+ status: "done",
1291
+ last_activity: { note: `worktree re-created for resume (branch ${group.branch}) — marked done for review` },
1292
+ }
1293
+ if (!dryRun) {
1294
+ const stateAfter = await patchGroup(statePath, group.name, patch)
1295
+ group = stateAfter.groups.find((g) => g.name === group.name) ?? group
1296
+ } else {
1297
+ group = { ...group, ...patch }
1298
+ }
1299
+ }
1300
+ } else {
1301
+ return { outcome: "orphaned", group, message: `${group.name} has no worktree and no recorded branch` }
1302
+ }
1303
+ }
1304
+
1305
+ // 2. Rebuild: re-derive the group's state from REAL on-disk markers (issue
1306
+ // #14 comment — the first-class path, never rebuilt ad hoc). Skipped for
1307
+ // a group just re-checked out above (its markers legitimately don't
1308
+ // exist yet).
1309
+ if (!recheckedOut) {
1310
+ const rebuildPatch = rebuildPatchFromMarkers(repo, group)
1311
+ if (rebuildPatch) {
1312
+ write(`[resume] ${group.name}: state said "${group.status}" — rebuilding from disk markers...\n`)
1313
+ if (!dryRun) {
1314
+ const stateAfter = await patchGroup(statePath, group.name, rebuildPatch)
1315
+ group = stateAfter.groups.find((g) => g.name === group.name) ?? group
1316
+ } else {
1317
+ group = { ...group, ...rebuildPatch }
1318
+ }
1319
+ const note =
1320
+ typeof rebuildPatch.last_activity === "object" &&
1321
+ rebuildPatch.last_activity !== null &&
1322
+ "note" in rebuildPatch.last_activity
1323
+ ? (rebuildPatch.last_activity.note as string)
1324
+ : `rebuild: ${rebuildPatch.status ?? "no change"}`
1325
+ write(`[resume] ${group.name}: ${note}\n`)
1326
+ }
1327
+ }
1328
+
1329
+ // 3. In-flight: a live worker owns the group — nothing for us to do.
1330
+ if (group.status === "running" || group.status === "spawned" || group.status === "blocked") {
1331
+ return {
1332
+ outcome: "in-flight",
1333
+ group,
1334
+ message: `${group.name} is ${group.status} — worker in flight; re-run resume when it finishes`,
1335
+ }
1336
+ }
1337
+
1338
+ // 4. Failed: continuation on iteration-exhaustion (same automatic path as
1339
+ // the watch loop — handleIterationExhaustion).
1340
+ if (group.status === "failed") {
1341
+ if (isIterationExhaustion(group.summary)) {
1342
+ const decision = handleIterationExhaustion(
1343
+ group,
1344
+ repo,
1345
+ opts.maxContinuations,
1346
+ opts.mode,
1347
+ opts.workerModel,
1348
+ opts.maxIterations,
1349
+ )
1350
+ if (decision.shouldSpawn && decision.taskFilePath && decision.taskContent && decision.spawnCommand) {
1351
+ write(`[resume] ${group.name}: worker hit the iteration cap — continuation ${decision.newContinuationCount}...\n`)
1352
+ if (dryRun) {
1353
+ write(` dry-run: would write ${path.relative(repo, decision.taskFilePath)} and run:\n ${decision.spawnCommand}\n`)
1354
+ return { outcome: "in-flight", group, message: `dry-run: continuation ${decision.newContinuationCount} ready for ${group.name}` }
1355
+ }
1356
+ fs.mkdirSync(path.dirname(decision.taskFilePath), { recursive: true })
1357
+ fs.writeFileSync(decision.taskFilePath, decision.taskContent, "utf-8")
1358
+ const spawnResult = spawnSync("bash", ["-c", decision.spawnCommand], {
1359
+ cwd: repo,
1360
+ env: {
1361
+ ...process.env,
1362
+ HEADLESSCODE_ROOT: HARNESS_ROOT,
1363
+ ...(opts.memoryDir ? { HEADLESSCODE_MEMORY_DIR: path.resolve(opts.memoryDir) } : {}),
1364
+ },
1365
+ stdio: "inherit",
1366
+ })
1367
+ if (spawnResult.status !== 0) {
1368
+ await patchGroup(statePath, group.name, {
1369
+ status: "needs-human",
1370
+ continuationCount: decision.newContinuationCount,
1371
+ last_activity: { note: `continuation spawn failed after ${decision.newContinuationCount} attempt(s); needs human` },
1372
+ })
1373
+ return needsHumanOutcome(
1374
+ opts,
1375
+ reloadedGroup(statePath, group.name, group),
1376
+ `continuation spawn failed (exit ${spawnResult.status}) — marked needs-human`,
1377
+ )
1378
+ }
1379
+ const stateAfter = await patchGroup(statePath, group.name, decision.patch)
1380
+ const updated = stateAfter.groups.find((g) => g.name === group.name) ?? group
1381
+ return { outcome: "in-flight", group: updated, message: `continuation ${decision.newContinuationCount} spawned on ${group.name}` }
1382
+ }
1383
+ if (decision.patch.status === "needs-human") {
1384
+ if (!dryRun) {
1385
+ await patchGroup(statePath, group.name, decision.patch)
1386
+ }
1387
+ return needsHumanOutcome(
1388
+ opts,
1389
+ dryRun ? group : reloadedGroup(statePath, group.name, group),
1390
+ `${group.name} exhausted its continuation budget (cap ${opts.maxContinuations}) — needs human`,
1391
+ )
1392
+ }
1393
+ }
1394
+ await recordCost(group)
1395
+ return { outcome: "failed", group, message: `${group.name} failed (exit ${group.exit_code ?? "?"}) and is not continuable` }
1396
+ }
1397
+
1398
+ // 5. Done: review → rework-if-needed → QA → cost.
1399
+ if (group.status === "done") {
1400
+ let current = group
1401
+ // Issue #52: a real QA "fail" (verdict "fail", distinct from a
1402
+ // session "error") is actionable NEW WORK, exactly like a review
1403
+ // finding — re-spawn a worker on the same worktree to fix the QA
1404
+ // evidence, up to the rework cap. Cost is NOT recorded here: the
1405
+ // rework reset clears the one-shot gate and the cycle's real total
1406
+ // is recorded when it settles.
1407
+ const reworkQaFail = async (g: OrchestratorGroup): Promise<ResumeGroupResult> => {
1408
+ const rework = await runReworkStep({
1409
+ repo,
1410
+ statePath,
1411
+ group: g,
1412
+ mode: opts.mode,
1413
+ model: opts.workerModel,
1414
+ maxReworkCycles: opts.maxReworkCycles,
1415
+ maxIterations: opts.maxIterations,
1416
+ memoryDir: opts.memoryDir,
1417
+ dryRun,
1418
+ write,
1419
+ })
1420
+ if (rework.outcome === "spawned" || rework.outcome === "dry-run") {
1421
+ return { outcome: "in-flight", group: rework.group, message: rework.message }
1422
+ }
1423
+ return needsHumanOutcome(opts, rework.group, rework.message)
1424
+ }
1425
+ // An ALREADY-recorded QA fail (status done + qa.verdict fail): the
1426
+ // rework never spawned — interrupted round, or state written by the
1427
+ // pre-#52 code that silently settled as done. Recover from the
1428
+ // recorded qa.evidence without burning a fresh QA session (same
1429
+ // shape as the review-finding recovery below). forceReview opts the
1430
+ // operator into a fresh review instead.
1431
+ if (qaEnabled && current.qa?.verdict === "fail" && !opts.forceReview) {
1432
+ return reworkQaFail(current)
1433
+ }
1434
+ if (reviewEnabled) {
1435
+ // A leftover review-session-error verdict (status done is an
1436
+ // inconsistent-state edge case — handleReviewSessionError normally
1437
+ // sets needs-human) must never be reported as settled.
1438
+ if (current.review_verdict === "error" && !opts.forceReview) {
1439
+ return needsHumanOutcome(
1440
+ opts,
1441
+ current,
1442
+ `${current.name} has a review-session-error verdict — a human should investigate`,
1443
+ )
1444
+ }
1445
+ if (current.review_verdict === "finding" && !opts.forceReview) {
1446
+ // Interrupted between a finding review and its rework spawn
1447
+ // (issue #14's exact recovery scenario): rework the RECORDED
1448
+ // findings without burning a fresh review session.
1449
+ const rework = await runReworkStep({
1450
+ repo,
1451
+ statePath,
1452
+ group: current,
1453
+ mode: opts.mode,
1454
+ model: opts.workerModel,
1455
+ maxReworkCycles: opts.maxReworkCycles,
1456
+ maxIterations: opts.maxIterations,
1457
+ memoryDir: opts.memoryDir,
1458
+ findings: current.pending_review_findings ?? [],
1459
+ dryRun,
1460
+ write,
1461
+ })
1462
+ if (rework.outcome === "spawned" || rework.outcome === "dry-run") {
1463
+ return { outcome: "in-flight", group: rework.group, message: rework.message }
1464
+ }
1465
+ return needsHumanOutcome(opts, rework.group, rework.message)
1466
+ }
1467
+ const review = await runReviewStep({
1468
+ repo,
1469
+ statePath,
1470
+ group: current,
1471
+ reviewMode: opts.reviewMode,
1472
+ reviewerModel: opts.reviewerModel,
1473
+ forceReview: opts.forceReview,
1474
+ dryRun,
1475
+ write,
1476
+ })
1477
+ current = review.group
1478
+ if (review.result?.verdict === "error") {
1479
+ return needsHumanOutcome(opts, current, review.message)
1480
+ }
1481
+ if (review.result?.verdict === "finding") {
1482
+ const rework = await runReworkStep({
1483
+ repo,
1484
+ statePath,
1485
+ group: current,
1486
+ mode: opts.mode,
1487
+ model: opts.workerModel,
1488
+ maxReworkCycles: opts.maxReworkCycles,
1489
+ maxIterations: opts.maxIterations,
1490
+ memoryDir: opts.memoryDir,
1491
+ findings: review.result.findings,
1492
+ dryRun,
1493
+ write,
1494
+ })
1495
+ if (rework.outcome === "spawned" || rework.outcome === "dry-run") {
1496
+ return { outcome: "in-flight", group: rework.group, message: rework.message }
1497
+ }
1498
+ return needsHumanOutcome(opts, rework.group, rework.message)
1499
+ }
1500
+ }
1501
+ if (qaEnabled && current.qa === undefined && (!reviewEnabled || current.review_verdict === "clean")) {
1502
+ const qaStep = await runQaStep({
1503
+ repo,
1504
+ statePath,
1505
+ group: current,
1506
+ qaMode: opts.qaMode,
1507
+ qaModel: opts.qaModel,
1508
+ dryRun,
1509
+ write,
1510
+ })
1511
+ current = qaStep.group
1512
+ if (current.qa?.verdict === "error") {
1513
+ return needsHumanOutcome(opts, current, qaStep.message)
1514
+ }
1515
+ if (current.qa?.verdict === "fail") {
1516
+ // A just-returned QA fail is reworked, not settled as a
1517
+ // "failed" group with the top-level status still "done"
1518
+ // (issue #52's silent-settle bug in the resume path).
1519
+ return reworkQaFail(current)
1520
+ }
1521
+ }
1522
+ await recordCost(current)
1523
+ return {
1524
+ outcome: "settled",
1525
+ group: current,
1526
+ message: `${current.name} is settled (review ${current.review_verdict ?? "n/a"})`,
1527
+ }
1528
+ }
1529
+
1530
+ // 6. needs-human / orphaned / any other terminal state.
1531
+ if (group.status === "needs-human") {
1532
+ await recordCost(group)
1533
+ return { outcome: "needs-human", group, message: `${group.name} needs a human (see state for pending_review_findings)` }
1534
+ }
1535
+ return { outcome: "orphaned", group, message: `${group.name} is in an unresumable state (${group.status})` }
1536
+ }
1537
+
1538
+ // ─── CLI mains ───────────────────────────────────────────────────────────────
1539
+
1540
+ export interface ResumeIo {
1541
+ stdout?: (text: string) => void
1542
+ stderr?: (text: string) => void
1543
+ }
1544
+
1545
+ function isGitRepo(repo: string): boolean {
1546
+ try {
1547
+ execFileSync("git", ["-C", repo, "rev-parse", "--git-dir"], { stdio: "ignore", timeout: 5000 })
1548
+ return true
1549
+ } catch {
1550
+ return false
1551
+ }
1552
+ }
1553
+
1554
+ function describeTarget(target: ResumeTarget): string {
1555
+ if (target.issue !== undefined) {
1556
+ return `issue #${target.issue}`
1557
+ }
1558
+ if (target.pr !== undefined) {
1559
+ return `PR #${target.pr}`
1560
+ }
1561
+ if (target.group !== undefined) {
1562
+ return `group "${target.group}"`
1563
+ }
1564
+ return "(none)"
1565
+ }
1566
+
1567
+ /** Resolve the target group(s) from state; fails loudly when none match. */
1568
+ function resolveGroupsOrFail(
1569
+ repo: string,
1570
+ statePath: string,
1571
+ target: ResumeTarget,
1572
+ defaultToAll: boolean,
1573
+ writeErr: (t: string) => void,
1574
+ ): { groups?: OrchestratorGroup[]; error?: string } {
1575
+ let state: OrchestratorState
1576
+ try {
1577
+ state = loadStateSync(statePath)
1578
+ } catch (err) {
1579
+ writeErr(
1580
+ `headlesscode orchestrate: cannot read state file ${statePath}: ${err instanceof Error ? err.message : String(err)}\n`,
1581
+ )
1582
+ return { error: "cannot read state" }
1583
+ }
1584
+ const noTarget = target.issue === undefined && target.pr === undefined && target.group === undefined
1585
+ const groups = noTarget ? (defaultToAll ? state.groups : []) : resolveTargetGroups(state, target, { repo })
1586
+ if (groups.length === 0) {
1587
+ return {
1588
+ error: noTarget
1589
+ ? `no groups to resume in ${statePath}`
1590
+ : `no group in ${statePath} matches --issue/--pr/--group ${describeTarget(target)}`,
1591
+ }
1592
+ }
1593
+ return { groups }
1594
+ }
1595
+
1596
+ /** Ensure a group's worktree exists, re-checking-out its branch when cleaned up. */
1597
+ function ensureWorktreeForGroup(
1598
+ repo: string,
1599
+ group: OrchestratorGroup,
1600
+ writeOut: (t: string) => void,
1601
+ dryRun = false,
1602
+ ): { ok: boolean; error?: string } {
1603
+ const wtPath = groupWorktreePath(repo, group)
1604
+ if (fs.existsSync(wtPath)) {
1605
+ return { ok: true }
1606
+ }
1607
+ if (!group.branch) {
1608
+ return { ok: false, error: `${group.name} has no worktree and no recorded branch` }
1609
+ }
1610
+ if (dryRun) {
1611
+ writeOut(`[orchestrate] dry-run: would re-checkout branch ${group.branch} into ${wtPath}\n`)
1612
+ return { ok: true }
1613
+ }
1614
+ const result = recheckoutWorktree(repo, group)
1615
+ if (!result.ok) {
1616
+ return { ok: false, error: `cannot re-checkout ${group.name}'s branch: ${result.error}` }
1617
+ }
1618
+ writeOut(`[orchestrate] re-checked out branch ${group.branch} into ${wtPath}\n`)
1619
+ return { ok: true }
1620
+ }
1621
+
1622
+ export async function reviewMain(argv: string[], io: ResumeIo = {}): Promise<number> {
1623
+ const writeOut = io.stdout ?? ((text: string) => process.stdout.write(text))
1624
+ const writeErr = io.stderr ?? ((text: string) => process.stderr.write(text))
1625
+
1626
+ const { options, error } = parseReviewArgs(argv)
1627
+ if (error) {
1628
+ writeErr(`headlesscode orchestrate review: ${error}\n\n${REVIEW_USAGE}`)
1629
+ return 2
1630
+ }
1631
+ if (options.help) {
1632
+ writeOut(REVIEW_USAGE)
1633
+ return 0
1634
+ }
1635
+ if (!options.repo) {
1636
+ writeErr(`headlesscode orchestrate review: --repo <path> is required\n\n${REVIEW_USAGE}`)
1637
+ return 2
1638
+ }
1639
+ if (!hasExactlyOneTarget(options.target)) {
1640
+ writeErr(`headlesscode orchestrate review: provide exactly one of --issue <n>, --pr <n>, --group <name>\n\n${REVIEW_USAGE}`)
1641
+ return 2
1642
+ }
1643
+ const repo = path.resolve(options.repo)
1644
+ if (!isGitRepo(repo)) {
1645
+ writeErr(`headlesscode orchestrate review: not a git repo: ${repo}\n`)
1646
+ return 2
1647
+ }
1648
+ const statePath = path.join(repo, ".worktrees", ".orchestrator-state.json")
1649
+ const resolved = resolveGroupsOrFail(repo, statePath, options.target, false, writeErr)
1650
+ if (resolved.error || !resolved.groups) {
1651
+ writeErr(`headlesscode orchestrate review: ${resolved.error}\n`)
1652
+ return 1
1653
+ }
1654
+ if (resolved.groups.length > 1) {
1655
+ writeErr(
1656
+ `headlesscode orchestrate review: target matches ${resolved.groups.length} groups ` +
1657
+ `(${resolved.groups.map((g) => g.name).join(", ")}) — narrow it down\n`,
1658
+ )
1659
+ return 1
1660
+ }
1661
+ let current = resolved.groups[0]!
1662
+
1663
+ // Worktree first: a cleaned-up group's branch is re-checked out (the
1664
+ // branch's committed state is the review target; a fresh re-checkout has
1665
+ // no markers to rebuild from, so it is treated as done below).
1666
+ const worktreeExisted = fs.existsSync(groupWorktreePath(repo, current))
1667
+ if (!worktreeExisted) {
1668
+ const ensured = ensureWorktreeForGroup(repo, current, writeOut, options.dryRun)
1669
+ if (!ensured.ok) {
1670
+ writeErr(`headlesscode orchestrate review: ${ensured.error}\n`)
1671
+ return 1
1672
+ }
1673
+ if (current.status !== "done") {
1674
+ const patch = {
1675
+ status: "done",
1676
+ last_activity: { note: `worktree re-created for standalone review (branch ${current.branch}) — marked done` },
1677
+ }
1678
+ if (options.dryRun) {
1679
+ current = { ...current, ...patch }
1680
+ } else {
1681
+ const stateAfter = await patchGroup(statePath, current.name, patch)
1682
+ current = stateAfter.groups.find((g) => g.name === current.name) ?? current
1683
+ }
1684
+ }
1685
+ } else {
1686
+ // Rebuild stale state from real markers before reviewing.
1687
+ const rebuildPatch = rebuildPatchFromMarkers(repo, current)
1688
+ if (rebuildPatch && rebuildPatch.status !== undefined) {
1689
+ if (options.dryRun) {
1690
+ current = { ...current, ...rebuildPatch }
1691
+ writeOut(`[review] ${current.name}: (dry-run) would rebuild state from markers → ${rebuildPatch.status}\n`)
1692
+ } else {
1693
+ const stateAfter = await patchGroup(statePath, current.name, rebuildPatch)
1694
+ current = stateAfter.groups.find((g) => g.name === current.name) ?? current
1695
+ writeOut(`[review] ${current.name}: rebuilt state from markers → ${current.status}\n`)
1696
+ }
1697
+ }
1698
+ }
1699
+ if (current.status !== "done") {
1700
+ writeErr(
1701
+ `headlesscode orchestrate review: ${current.name} is ${current.status} — review only runs on a "done" group. ` +
1702
+ `Use "orchestrate resume" to drive the group through the full pipeline.\n`,
1703
+ )
1704
+ return 1
1705
+ }
1706
+ const reviewerModel = resolveModelForMode({
1707
+ workspaceRoot: repo,
1708
+ mode: options.reviewMode,
1709
+ explicitModel: options.model,
1710
+ env: process.env,
1711
+ })
1712
+ const review = await runReviewStep({
1713
+ repo,
1714
+ statePath,
1715
+ group: current,
1716
+ reviewMode: options.reviewMode,
1717
+ reviewerModel,
1718
+ forceReview: options.forceReview,
1719
+ dryRun: options.dryRun,
1720
+ write: writeOut,
1721
+ })
1722
+ if (review.result?.verdict === "error") {
1723
+ writeErr(`headlesscode orchestrate review: ${review.message}\n`)
1724
+ return 1
1725
+ }
1726
+ // A finding verdict — whether just produced or already recorded from an
1727
+ // interrupted round — means the group is NOT clean; exit 1 either way.
1728
+ if (review.result?.verdict === "finding" || review.group.review_verdict === "finding") {
1729
+ const findingsCount =
1730
+ review.result?.findings.length ?? review.group.pending_review_findings?.length ?? 0
1731
+ writeErr(
1732
+ `headlesscode orchestrate review: ${findingsCount} finding(s) — ` +
1733
+ `run "headlesscode orchestrate rework" (or resume) to fix them, or fix manually\n`,
1734
+ )
1735
+ return 1
1736
+ }
1737
+ // A settled group's review cost is now final — record it (review sessions
1738
+ // write their own usage files that a fresh rollup picks up). Dry-run
1739
+ // never persists.
1740
+ if (!options.dryRun) {
1741
+ await recordSettledCost({ repo, statePath, group: review.group, reviewEnabled: true, qaEnabled: false, write: writeOut })
1742
+ }
1743
+ writeOut(`[review] ${review.group.name}: ${review.message}\n`)
1744
+ return 0
1745
+ }
1746
+
1747
+ export async function reworkMain(argv: string[], io: ResumeIo = {}): Promise<number> {
1748
+ const writeOut = io.stdout ?? ((text: string) => process.stdout.write(text))
1749
+ const writeErr = io.stderr ?? ((text: string) => process.stderr.write(text))
1750
+
1751
+ const { options, error } = parseReworkArgs(argv)
1752
+ if (error) {
1753
+ writeErr(`headlesscode orchestrate rework: ${error}\n\n${REWORK_USAGE}`)
1754
+ return 2
1755
+ }
1756
+ if (options.help) {
1757
+ writeOut(REWORK_USAGE)
1758
+ return 0
1759
+ }
1760
+ if (!options.repo) {
1761
+ writeErr(`headlesscode orchestrate rework: --repo <path> is required\n\n${REWORK_USAGE}`)
1762
+ return 2
1763
+ }
1764
+ if (!hasExactlyOneTarget(options.target)) {
1765
+ writeErr(`headlesscode orchestrate rework: provide exactly one of --issue <n>, --pr <n>, --group <name>\n\n${REWORK_USAGE}`)
1766
+ return 2
1767
+ }
1768
+ const repo = path.resolve(options.repo)
1769
+ if (!isGitRepo(repo)) {
1770
+ writeErr(`headlesscode orchestrate rework: not a git repo: ${repo}\n`)
1771
+ return 2
1772
+ }
1773
+ const statePath = path.join(repo, ".worktrees", ".orchestrator-state.json")
1774
+ const resolved = resolveGroupsOrFail(repo, statePath, options.target, false, writeErr)
1775
+ if (resolved.error || !resolved.groups) {
1776
+ writeErr(`headlesscode orchestrate rework: ${resolved.error}\n`)
1777
+ return 1
1778
+ }
1779
+ if (resolved.groups.length > 1) {
1780
+ writeErr(
1781
+ `headlesscode orchestrate rework: target matches ${resolved.groups.length} groups ` +
1782
+ `(${resolved.groups.map((g) => g.name).join(", ")}) — narrow it down\n`,
1783
+ )
1784
+ return 1
1785
+ }
1786
+ const group = resolved.groups[0]!
1787
+
1788
+ const ensured = ensureWorktreeForGroup(repo, group, writeOut, options.dryRun)
1789
+ if (!ensured.ok) {
1790
+ writeErr(`headlesscode orchestrate rework: ${ensured.error}\n`)
1791
+ return 1
1792
+ }
1793
+
1794
+ // Findings: state's pending_review_findings first, then the issue's
1795
+ // comments (issue #14). Empty findings fall back to the rework template's
1796
+ // no-findings text (the worker re-audits the previous diff itself).
1797
+ let findings = group.pending_review_findings ?? []
1798
+ let findingsSource = "state (pending_review_findings)"
1799
+ if (findings.length === 0 && options.target.issue !== undefined) {
1800
+ findings = fetchFindingsFromIssueComments(repo, options.target.issue)
1801
+ findingsSource = findings.length > 0 ? "issue comments" : "issue comments (none found)"
1802
+ }
1803
+ if (findings.length === 0) {
1804
+ writeOut(
1805
+ `[rework] no recorded findings (${findingsSource}) — using the rework template's ` +
1806
+ `no-findings fallback; the worker will re-audit the previous diff\n`,
1807
+ )
1808
+ }
1809
+ const workerModel = resolveModelForMode({
1810
+ workspaceRoot: repo,
1811
+ mode: options.mode,
1812
+ explicitModel: options.model,
1813
+ env: process.env,
1814
+ })
1815
+ const step = await runReworkStep({
1816
+ repo,
1817
+ statePath,
1818
+ group,
1819
+ mode: options.mode,
1820
+ model: workerModel,
1821
+ maxReworkCycles: options.maxReworkCycles,
1822
+ maxIterations: options.maxIterations,
1823
+ memoryDir: options.memoryDir,
1824
+ findings,
1825
+ dryRun: options.dryRun,
1826
+ write: writeOut,
1827
+ })
1828
+ if (step.outcome === "cap") {
1829
+ writeErr(`headlesscode orchestrate rework: ${step.message}\n`)
1830
+ return 1
1831
+ }
1832
+ writeOut(`[rework] ${step.message}\n`)
1833
+ return 0
1834
+ }
1835
+
1836
+ export async function resumeMain(argv: string[], io: ResumeIo = {}): Promise<number> {
1837
+ const writeOut = io.stdout ?? ((text: string) => process.stdout.write(text))
1838
+ const writeErr = io.stderr ?? ((text: string) => process.stderr.write(text))
1839
+
1840
+ const { options, error } = parseResumeArgs(argv)
1841
+ if (error) {
1842
+ writeErr(`headlesscode orchestrate resume: ${error}\n\n${RESUME_USAGE}`)
1843
+ return 2
1844
+ }
1845
+ if (options.help) {
1846
+ writeOut(RESUME_USAGE)
1847
+ return 0
1848
+ }
1849
+ if (!options.repo) {
1850
+ writeErr(`headlesscode orchestrate resume: --repo <path> is required\n\n${RESUME_USAGE}`)
1851
+ return 2
1852
+ }
1853
+ const repo = path.resolve(options.repo)
1854
+ if (!isGitRepo(repo)) {
1855
+ writeErr(`headlesscode orchestrate resume: not a git repo: ${repo}\n`)
1856
+ return 2
1857
+ }
1858
+ const statePath = path.join(repo, ".worktrees", ".orchestrator-state.json")
1859
+ const resolved = resolveGroupsOrFail(repo, statePath, options.target, true, writeErr)
1860
+ if (resolved.error || !resolved.groups) {
1861
+ writeErr(`headlesscode orchestrate resume: ${resolved.error}\n`)
1862
+ return 1
1863
+ }
1864
+
1865
+ const reviewerModel = resolveModelForMode({
1866
+ workspaceRoot: repo,
1867
+ mode: options.reviewMode,
1868
+ explicitModel: options.model,
1869
+ env: process.env,
1870
+ })
1871
+ const qaModel = resolveModelForMode({
1872
+ workspaceRoot: repo,
1873
+ mode: options.qaMode,
1874
+ explicitModel: options.model,
1875
+ env: process.env,
1876
+ })
1877
+ const workerModel = resolveModelForMode({
1878
+ workspaceRoot: repo,
1879
+ mode: options.mode,
1880
+ explicitModel: options.model,
1881
+ env: process.env,
1882
+ })
1883
+
1884
+ let bad = 0
1885
+ for (const group of resolved.groups) {
1886
+ const result = await resumeGroup({
1887
+ repo,
1888
+ statePath,
1889
+ group,
1890
+ reviewMode: options.reviewMode,
1891
+ qaMode: options.qaMode,
1892
+ reviewEnabled: !options.noReview,
1893
+ qaEnabled: options.qa,
1894
+ mode: options.mode,
1895
+ workerModel,
1896
+ reviewerModel,
1897
+ qaModel,
1898
+ maxReworkCycles: options.maxReworkCycles,
1899
+ maxContinuations: options.maxContinuations,
1900
+ maxIterations: options.maxIterations,
1901
+ memoryDir: options.memoryDir,
1902
+ forceReview: options.forceReview,
1903
+ dryRun: options.dryRun,
1904
+ write: writeOut,
1905
+ })
1906
+ writeOut(`[resume] ${result.message}\n`)
1907
+ if (
1908
+ result.outcome === "needs-human" ||
1909
+ result.outcome === "failed" ||
1910
+ result.outcome === "orphaned" ||
1911
+ result.outcome === "error"
1912
+ ) {
1913
+ bad++
1914
+ }
1915
+ }
1916
+ if (bad > 0) {
1917
+ writeErr(
1918
+ `headlesscode orchestrate resume: ${bad} group(s) need a human or failed — see ${statePath} ` +
1919
+ `for review_verdict/pending_review_findings\n`,
1920
+ )
1921
+ return 1
1922
+ }
1923
+ return 0
1924
+ }
1925
+
1926
+ /** Read a positive integer flag value via the argv cursor (shared by the parsers). */
1927
+ function parseIntFlag(argv: string[], i: number, flag: string): { value: number | undefined; error?: string; consumed: number } {
1928
+ const eq = argv[i].indexOf("=")
1929
+ const inlineValue = eq === -1 ? undefined : argv[i].slice(eq + 1)
1930
+ const v = inlineValue ?? argv[i + 1]
1931
+ if (v === undefined || (inlineValue === undefined && v.startsWith("--"))) {
1932
+ return { value: undefined, error: `Missing value for ${flag}`, consumed: 0 }
1933
+ }
1934
+ const n = Number(v)
1935
+ const consumed = inlineValue === undefined ? 1 : 0
1936
+ if (!Number.isInteger(n) || n <= 0) {
1937
+ return { value: undefined, error: `${flag} requires a positive integer`, consumed }
1938
+ }
1939
+ return { value: n, consumed }
1940
+ }