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.
- package/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- 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
|
+
}
|