headlesscode 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/ATTRIBUTION.md +53 -0
  2. package/CODE_OF_CONDUCT.md +130 -0
  3. package/CONTRIBUTING.md +107 -0
  4. package/LICENSE +202 -0
  5. package/README.md +486 -0
  6. package/SECURITY.md +211 -0
  7. package/bin/headlesscode.mjs +83 -0
  8. package/package.json +63 -0
  9. package/shared/prompts/review-mode-prompt-short.md +93 -0
  10. package/shared/prompts/review-mode-prompt.md +281 -0
  11. package/shared/rules-code/rules.md +22 -0
  12. package/shared/stacks/cpp/rules.md +30 -0
  13. package/shared/stacks/fastapi/rules.md +30 -0
  14. package/shared/stacks/javascript/rules.md +37 -0
  15. package/shared/stacks/postgresql/rules.md +31 -0
  16. package/shared/stacks/python/rules.md +35 -0
  17. package/shared/stacks/react/rules.md +11 -0
  18. package/shared/stacks/typescript/rules.md +10 -0
  19. package/src/budget/budget.ts +221 -0
  20. package/src/budget/concurrency.ts +126 -0
  21. package/src/budget/cost.ts +309 -0
  22. package/src/budget/index.ts +8 -0
  23. package/src/checkpoints/cli.ts +256 -0
  24. package/src/checkpoints/service.ts +227 -0
  25. package/src/cli.ts +1535 -0
  26. package/src/cloud/docker-provider.ts +334 -0
  27. package/src/cloud/provider.ts +300 -0
  28. package/src/codeintel/call-graph.ts +78 -0
  29. package/src/codeintel/find-references.ts +123 -0
  30. package/src/codeintel/go-to-definition.ts +193 -0
  31. package/src/codeintel/handlers.ts +190 -0
  32. package/src/codeintel/import-graph.ts +173 -0
  33. package/src/codeintel/outline.ts +180 -0
  34. package/src/codeintel/position.ts +77 -0
  35. package/src/codeintel/program.ts +350 -0
  36. package/src/codeintel/rename-symbol.ts +213 -0
  37. package/src/codeintel/tools.ts +280 -0
  38. package/src/codemap/build.ts +135 -0
  39. package/src/codemap/cli.ts +190 -0
  40. package/src/codemap/extract.ts +339 -0
  41. package/src/codemap/files.ts +236 -0
  42. package/src/codemap/fingerprint.ts +65 -0
  43. package/src/codemap/flows.ts +62 -0
  44. package/src/codemap/html.ts +451 -0
  45. package/src/codemap/lock.ts +80 -0
  46. package/src/codemap/types.ts +101 -0
  47. package/src/codesearch/airunner-embedder.ts +185 -0
  48. package/src/codesearch/chunk.ts +339 -0
  49. package/src/codesearch/cli.ts +223 -0
  50. package/src/codesearch/embedder.ts +332 -0
  51. package/src/codesearch/files.ts +280 -0
  52. package/src/codesearch/index.ts +469 -0
  53. package/src/codesearch/ollama-embedder.ts +205 -0
  54. package/src/codesearch/search.ts +141 -0
  55. package/src/codesearch/types.ts +100 -0
  56. package/src/config/mode-models.ts +218 -0
  57. package/src/dashboard/aggregate.ts +364 -0
  58. package/src/dashboard/chat-thread.ts +141 -0
  59. package/src/dashboard/checkpoints.ts +124 -0
  60. package/src/dashboard/cli.ts +193 -0
  61. package/src/dashboard/codemap.ts +44 -0
  62. package/src/dashboard/files.ts +121 -0
  63. package/src/dashboard/page.ts +2803 -0
  64. package/src/dashboard/self-improvement-metrics.ts +282 -0
  65. package/src/dashboard/server.ts +1103 -0
  66. package/src/dashboard/session-launch.ts +310 -0
  67. package/src/dashboard/timeline.ts +273 -0
  68. package/src/dashboard/tool-exec.ts +107 -0
  69. package/src/dashboard/trend-cli.ts +141 -0
  70. package/src/dashboard/trend.ts +413 -0
  71. package/src/decision-proxy/cli.ts +261 -0
  72. package/src/decision-proxy/proxy.ts +569 -0
  73. package/src/deploy/gate-cli.ts +147 -0
  74. package/src/deploy/gate.ts +254 -0
  75. package/src/engine/condense.ts +512 -0
  76. package/src/engine/events.ts +428 -0
  77. package/src/engine/handoff.ts +71 -0
  78. package/src/engine/lazy-tools.ts +160 -0
  79. package/src/engine/local-explore.ts +653 -0
  80. package/src/engine/logger.ts +96 -0
  81. package/src/engine/loop.ts +5517 -0
  82. package/src/engine/parser.ts +347 -0
  83. package/src/engine/prompt.ts +860 -0
  84. package/src/engine/reports.ts +47 -0
  85. package/src/engine/stacks.ts +448 -0
  86. package/src/engine/types.ts +291 -0
  87. package/src/engine/usage.ts +186 -0
  88. package/src/github/app-auth.ts +161 -0
  89. package/src/github/cli.ts +448 -0
  90. package/src/github/installations.ts +133 -0
  91. package/src/github/pr.ts +321 -0
  92. package/src/github/provision.ts +118 -0
  93. package/src/github/push.ts +122 -0
  94. package/src/index-util.ts +50 -0
  95. package/src/index.ts +81 -0
  96. package/src/init/cli.ts +248 -0
  97. package/src/init/gitignore.ts +74 -0
  98. package/src/llm/ollama.ts +308 -0
  99. package/src/llm/openrouter.ts +868 -0
  100. package/src/llm/preflight.ts +367 -0
  101. package/src/llm/transcript-capture.ts +84 -0
  102. package/src/memory/embed.ts +110 -0
  103. package/src/memory/index.ts +22 -0
  104. package/src/memory/local.ts +259 -0
  105. package/src/memory/summarizer.ts +283 -0
  106. package/src/memory/types.ts +153 -0
  107. package/src/memory/uwuchat.ts +157 -0
  108. package/src/migrate/cli.ts +115 -0
  109. package/src/orchestrator/analyze-cli.ts +104 -0
  110. package/src/orchestrator/auto-split.ts +206 -0
  111. package/src/orchestrator/cleanup.ts +1003 -0
  112. package/src/orchestrator/cli.ts +3571 -0
  113. package/src/orchestrator/cost-estimate.ts +564 -0
  114. package/src/orchestrator/cost-history-cli.ts +242 -0
  115. package/src/orchestrator/cost-history.ts +397 -0
  116. package/src/orchestrator/git-sync.ts +250 -0
  117. package/src/orchestrator/index.ts +153 -0
  118. package/src/orchestrator/log-analysis.ts +0 -0
  119. package/src/orchestrator/merge-check.ts +108 -0
  120. package/src/orchestrator/pipeline.ts +411 -0
  121. package/src/orchestrator/resume.ts +1940 -0
  122. package/src/orchestrator/reviewer.ts +503 -0
  123. package/src/orchestrator/split.ts +296 -0
  124. package/src/orchestrator/state.ts +542 -0
  125. package/src/orchestrator/status.ts +697 -0
  126. package/src/orchestrator/verification-gate.ts +134 -0
  127. package/src/orchestrator/watch.ts +898 -0
  128. package/src/permissions/commands.ts +1083 -0
  129. package/src/permissions/config.ts +241 -0
  130. package/src/permissions/index.ts +12 -0
  131. package/src/permissions/protected-files.ts +96 -0
  132. package/src/permissions/store-protection.ts +272 -0
  133. package/src/project-store.ts +648 -0
  134. package/src/projects/cli.ts +382 -0
  135. package/src/qa/qa.ts +487 -0
  136. package/src/tools/browser/handler.ts +346 -0
  137. package/src/tools/browser/service.ts +406 -0
  138. package/src/tools/browser/smoke.ts +78 -0
  139. package/src/tools/browser/tool.ts +99 -0
  140. package/src/tools/executor.ts +2575 -0
  141. package/src/tools/language-detect.ts +183 -0
  142. package/src/tools/output-summarizer.ts +369 -0
  143. package/src/tools/run-tests.ts +302 -0
  144. package/src/tools/set-indentation-tool.ts +49 -0
  145. package/src/tools/test-selection.ts +160 -0
  146. package/src/vendor/tests/smoke.ts +103 -0
  147. package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
  148. package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
  149. package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
  150. package/src/vendor/zoo-code/shim/os-name.ts +18 -0
  151. package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
  152. package/src/vendor/zoo-code/shim/vscode.ts +76 -0
  153. package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
  154. package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
  155. package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
  156. package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
  157. package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
  158. package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
  159. package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
  160. package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
  161. package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
  162. package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
  163. package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
  164. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
  165. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
  166. package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
  167. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
  168. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
  169. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
  170. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
  171. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
  172. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
  173. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
  174. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
  175. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
  176. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
  177. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
  178. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
  179. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
  180. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
  181. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
  182. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
  183. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
  184. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
  185. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
  186. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
  187. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
  188. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
  189. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
  190. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
  191. package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
  192. package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
  193. package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
  194. package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
  195. package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
  196. package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
  197. package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
  198. package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
  199. package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
  200. package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
  201. package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
  202. package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
  203. package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
  204. package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
  205. package/src/vendor/zoo-code/src/shared/language.ts +43 -0
  206. package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
  207. package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
  208. package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
  209. package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
  210. package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
  211. package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
  212. package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
  213. package/src/vendor/zoo-code/src/utils/object.ts +18 -0
  214. package/src/vendor/zoo-code/src/utils/path.ts +94 -0
  215. package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
  216. package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
  217. package/src/vendor/zoo-code/types/global-settings.ts +19 -0
  218. package/src/vendor/zoo-code/types/index.ts +22 -0
  219. package/src/vendor/zoo-code/types/message.ts +375 -0
  220. package/src/vendor/zoo-code/types/mode.ts +241 -0
  221. package/src/vendor/zoo-code/types/todo.ts +19 -0
  222. package/src/vendor/zoo-code/types/tool-params.ts +116 -0
  223. package/src/vendor/zoo-code/types/tool.ts +67 -0
  224. package/src/vendor/zoo-code/types/vscode.ts +84 -0
  225. package/src/vision/describe.ts +242 -0
  226. package/src/vision/tool.ts +91 -0
  227. package/src/watcher/cli.ts +369 -0
  228. package/src/watcher/github.ts +304 -0
  229. package/src/watcher/index.ts +59 -0
  230. package/src/watcher/state.ts +254 -0
  231. package/src/watcher/watch.ts +562 -0
  232. package/tsconfig.json +18 -0
@@ -0,0 +1,697 @@
1
+ /**
2
+ * `headlesscode orchestrate status` — read-side status command for EXTERNAL
3
+ * callers (an interactive orchestrator agent checking on a round from outside
4
+ * the watching process). Reads `.worktrees/.orchestrator-state.json` via
5
+ * loadStateSync (state.ts) — it never re-implements parsing and does NOT
6
+ * touch watch.ts's own live-monitoring loop.
7
+ *
8
+ * Reconciliation: before reporting (and on EVERY poll while waiting), every
9
+ * group whose `status` is non-terminal is checked against real worktree
10
+ * markers (`.harness.done` + `.harness.exit`), and when the ground truth
11
+ * proves a different status the state file is patched back via
12
+ * updateGroup/saveStateSync (state.ts):
13
+ * - `.harness.done` present -> "done" (exit 0) / "failed" (non-zero)
14
+ * - worktree entirely gone -> "orphaned" (terminal — evidence is gone,
15
+ * a human must investigate via git history)
16
+ * - worktree present, no marker -> left alone (genuinely still running)
17
+ * This closes the gap where a direct-path round (spawn-parallel-worktrees.sh,
18
+ * no watcher) left group entries stuck on "running" forever.
19
+ *
20
+ * Issue #116: the --wait form NEVER persists its reconciliation while a live
21
+ * watcher owns the state (watchGroups' heartbeat file under
22
+ * `<statePath>.watcher` is fresh AND written by a DIFFERENT process — the
23
+ * wait never writes that file itself, so its own PID can never masquerade as
24
+ * a live watcher; the different-PID check exists so a watcher-less wait
25
+ * keeps its persist-through behavior instead of locking itself out from its
26
+ * second poll onward) — it reconciles in-memory for terminality checks only.
27
+ * Persisting there would pre-empt the watcher, which then short-circuits the
28
+ * group as already-terminal and silently skips log analysis + the automated
29
+ * review pass. The one-shot form (and --wait against a watcher-less round)
30
+ * keeps the persist-through behavior: for a direct-path round with no
31
+ * watcher, `status` is the state file's only writer and must patch stale
32
+ * entries back.
33
+ *
34
+ * Two forms:
35
+ * - one-shot: print a compact per-group summary (or the reconciled state
36
+ * with --json) and exit.
37
+ * - --wait: block inside this ONE process until every group reaches a
38
+ * terminal status ("done" | "failed" | "needs-human" | "orphaned" —
39
+ * "blocked" is deliberately NOT terminal: it is a decision-escalation
40
+ * wait state that keeps this command waiting) or --timeout-ms elapses,
41
+ * then print the same summary plus a one-line verdict.
42
+ *
43
+ * Polling the state file (not fs.watch) is a deliberate choice: the file is
44
+ * written by watch.ts, which itself polls at DEFAULT_POLL_INTERVAL_MS, so a
45
+ * faster read interval buys nothing; fs.watch on a regular file is
46
+ * unreliable when the writer replaces the file via rename rather than
47
+ * truncating in place (and the file may not exist at wait-start);
48
+ * fs.watchFile is itself just polling with extra steps. A plain poll loop
49
+ * matches the existing watch.ts idiom and is trivially testable.
50
+ */
51
+
52
+ import * as fs from "node:fs"
53
+ import * as path from "node:path"
54
+ import { setTimeout as sleep } from "node:timers/promises"
55
+
56
+ import type { CleanupStatus } from "./cleanup.js"
57
+ import {
58
+ defaultState,
59
+ loadStateSync,
60
+ saveStateSync,
61
+ updateGroup,
62
+ type OrchestratorGroup,
63
+ type OrchestratorState,
64
+ } from "./state.js"
65
+ import { DEFAULT_POLL_INTERVAL_MS, DEFAULT_STALL_TIMEOUT_MS, groupWorktreePath } from "./watch.js"
66
+
67
+ /**
68
+ * The statuses that end a round for a group. `blocked` is NOT here on
69
+ * purpose: a group waiting on a decision answer is not finished, and
70
+ * `--wait` must keep waiting rather than exit early (see state.ts's
71
+ * OrchestratorGroup.status doc comment). `orphaned` IS here: the group's
72
+ * worktree is gone, so there is nothing left to wait FOR — the evidence is
73
+ * gone, and reconciliation (reconcileGroups) has already surfaced the case
74
+ * for a human to investigate rather than silently guessing an outcome.
75
+ */
76
+ export const TERMINAL_STATUSES = ["done", "failed", "needs-human", "orphaned"] as const
77
+
78
+ export function isTerminalStatus(status: string): boolean {
79
+ return TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number])
80
+ }
81
+
82
+ /**
83
+ * Default `--timeout-ms`: 2h, matching watch.ts's stall guard
84
+ * (DEFAULT_STALL_TIMEOUT_MS). A round whose groups sit non-terminal for
85
+ * longer than that is flagged stalled by the watcher anyway, so waiting past
86
+ * it for terminality is pointless — this is the ceiling and the default.
87
+ */
88
+ export const DEFAULT_STATUS_TIMEOUT_MS = DEFAULT_STALL_TIMEOUT_MS
89
+
90
+ /**
91
+ * Default state-file poll interval for `--wait`: 5s, matching watch.ts's
92
+ * DEFAULT_POLL_INTERVAL_MS — the watcher writes the file at that rate, so a
93
+ * shorter interval cannot observe anything earlier.
94
+ */
95
+ export const DEFAULT_STATUS_POLL_INTERVAL_MS = DEFAULT_POLL_INTERVAL_MS
96
+
97
+ // ─── Summary shaping ────────────────────────────────────────────────────────
98
+
99
+ export interface StatusGroupRow {
100
+ name: string
101
+ status: string
102
+ lastActivity: string
103
+ reviewVerdict?: string
104
+ findingsCount: number
105
+ usageCostUsd?: number
106
+ exitCode?: number
107
+ stalled: boolean
108
+ blockedQuestion?: string
109
+ qaVerdict?: string
110
+ /**
111
+ * Cleanup eligibility for TERMINAL groups (computed by the status CLI via
112
+ * cleanup.ts's assessGroupCleanupSync — the same gates the cleanup command
113
+ * uses, minus the network). eligible | blocked (<reason>) | done. Undefined
114
+ * for non-terminal groups (never touched by cleanup).
115
+ */
116
+ cleanup?: CleanupStatus
117
+ /**
118
+ * Auto-continuation/rework activity (undefined/0 = never happened). A
119
+ * real gap this surfaces: a caller watching a round from the outside had
120
+ * no visibility into whether auto-continuation after --max-iterations
121
+ * was actually firing, so they resorted to manually re-invoking
122
+ * run-worker.sh against the same worktree instead of trusting the
123
+ * documented auto-continue behavior. Both counts already lived on
124
+ * OrchestratorGroup (continuationCount/reworkCount) — this just threads
125
+ * them through to the human/agent-facing status output.
126
+ */
127
+ continuationCount?: number
128
+ reworkCount?: number
129
+ /**
130
+ * Issue #49 plan-first outcome (undefined = round was not run with
131
+ * --plan-first): the architect-mode planning session that ran before this
132
+ * group's code worker, and whether a plan actually landed in the worker's
133
+ * task file.
134
+ */
135
+ planFirst?: OrchestratorGroup["plan_first"]
136
+ }
137
+
138
+ export interface StatusCounts {
139
+ total: number
140
+ done: number
141
+ failed: number
142
+ needsHuman: number
143
+ blocked: number
144
+ running: number
145
+ /** Worktree gone before a terminal status — surfaced for a human (terminal). */
146
+ orphaned: number
147
+ other: number
148
+ }
149
+
150
+ export interface StatusSummary {
151
+ batch?: string
152
+ updated?: string
153
+ groups: StatusGroupRow[]
154
+ counts: StatusCounts
155
+ totalUsage?: OrchestratorState["totalUsage"]
156
+ /** Issue #118: usage summed over only the current batch's groups (undefined when none yet). */
157
+ batchUsage?: OrchestratorState["batchUsage"]
158
+ }
159
+
160
+ function lastActivityText(group: OrchestratorGroup): string {
161
+ const la = group.last_activity
162
+ if (typeof la === "string") {
163
+ return la
164
+ }
165
+ if (la !== null && typeof la === "object") {
166
+ return la.note ?? la.last_commit ?? ""
167
+ }
168
+ return ""
169
+ }
170
+
171
+ /** Shape the loaded state into the compact per-group rows the CLI prints. */
172
+ export function buildStatusSummary(state: OrchestratorState): StatusSummary {
173
+ const counts: StatusCounts = {
174
+ total: state.groups.length,
175
+ done: 0,
176
+ failed: 0,
177
+ needsHuman: 0,
178
+ blocked: 0,
179
+ running: 0,
180
+ orphaned: 0,
181
+ other: 0,
182
+ }
183
+ const groups = state.groups.map((g) => {
184
+ switch (g.status) {
185
+ case "done":
186
+ counts.done++
187
+ break
188
+ case "failed":
189
+ counts.failed++
190
+ break
191
+ case "needs-human":
192
+ counts.needsHuman++
193
+ break
194
+ case "blocked":
195
+ counts.blocked++
196
+ break
197
+ case "running":
198
+ case "spawned":
199
+ counts.running++
200
+ break
201
+ case "orphaned":
202
+ counts.orphaned++
203
+ break
204
+ default:
205
+ counts.other++
206
+ break
207
+ }
208
+ return {
209
+ name: g.name,
210
+ status: g.status,
211
+ lastActivity: lastActivityText(g),
212
+ reviewVerdict: g.review_verdict,
213
+ findingsCount: g.pending_review_findings?.length ?? 0,
214
+ usageCostUsd: g.usage?.costUsd,
215
+ exitCode: g.exit_code,
216
+ stalled: g.stalled === true,
217
+ blockedQuestion: g.blocked?.question,
218
+ qaVerdict: g.qa?.verdict,
219
+ continuationCount: g.continuationCount,
220
+ reworkCount: g.reworkCount,
221
+ planFirst: g.plan_first,
222
+ }
223
+ })
224
+ return {
225
+ batch: state.batch,
226
+ updated: state.updated,
227
+ groups,
228
+ counts,
229
+ totalUsage: state.totalUsage,
230
+ batchUsage: state.batchUsage,
231
+ }
232
+ }
233
+
234
+ /**
235
+ * One-line verdict for the `--wait` form, e.g. "3/3 groups done, 0 failed"
236
+ * or "timed out after 2 of 3 groups done". Orphaned groups are called out
237
+ * explicitly (they are terminal but NOT done — a human must investigate).
238
+ */
239
+ export function verdictLine(summary: StatusSummary, timedOut: boolean, allDone: boolean): string {
240
+ const c = summary.counts
241
+ if (c.total === 0) {
242
+ return "nothing to wait for — no groups in state"
243
+ }
244
+ if (timedOut) {
245
+ const tail: string[] = []
246
+ if (c.failed > 0 || c.needsHuman > 0) {
247
+ tail.push(`${c.failed} failed, ${c.needsHuman} needs-human`)
248
+ }
249
+ if (c.orphaned > 0) {
250
+ tail.push(`${c.orphaned} orphaned`)
251
+ }
252
+ return `timed out after ${c.done} of ${c.total} group${c.total === 1 ? "" : "s"} done${tail.length > 0 ? ` (${tail.join("; ")})` : ""}`
253
+ }
254
+ if (allDone) {
255
+ return `${c.total}/${c.total} groups done, 0 failed`
256
+ }
257
+ // All terminal, but not all done → failed and/or needs-human and/or orphaned present.
258
+ const tail: string[] = []
259
+ if (c.failed > 0) {
260
+ tail.push(`${c.failed} failed`)
261
+ }
262
+ if (c.needsHuman > 0) {
263
+ tail.push(`${c.needsHuman} needs-human`)
264
+ }
265
+ if (c.orphaned > 0) {
266
+ tail.push(`${c.orphaned} orphaned`)
267
+ }
268
+ return `all ${c.total} group${c.total === 1 ? "" : "s"} terminal, but ${tail.length > 0 ? tail.join(" / ") : "something unresolved"}`
269
+ }
270
+
271
+ /** Human-readable status block (one-shot form when timedOut/allDone are omitted). */
272
+ export function formatStatusText(
273
+ summary: StatusSummary,
274
+ opts: {
275
+ repo: string
276
+ statePath: string
277
+ timedOut?: boolean
278
+ allDone?: boolean
279
+ elapsedMs?: number
280
+ /** Group names this call reconciled from stale markers (shown when non-empty). */
281
+ reconciled?: string[]
282
+ },
283
+ ): string {
284
+ const c = summary.counts
285
+ const lines: string[] = []
286
+ lines.push(
287
+ `── Orchestrator status${opts.elapsedMs !== undefined ? ` (waited ${Math.round(opts.elapsedMs)}ms)` : ""} ──`,
288
+ )
289
+ lines.push(`repo: ${opts.repo}`)
290
+ lines.push(`state file: ${opts.statePath}`)
291
+ lines.push(`batch: ${summary.batch ?? "(none)"}`)
292
+ lines.push(`updated: ${summary.updated ?? "(never)"}`)
293
+ if (summary.totalUsage) {
294
+ const u = summary.totalUsage
295
+ lines.push(
296
+ `total usage: $${u.costUsd.toFixed(4)} · ${u.inputTokens} in / ${u.outputTokens} out tokens` +
297
+ (u.iterations > 0 ? ` · ${u.iterations} iterations` : ""),
298
+ )
299
+ }
300
+ if (summary.batchUsage) {
301
+ const u = summary.batchUsage
302
+ lines.push(
303
+ `batch usage: $${u.costUsd.toFixed(4)} · ${u.inputTokens} in / ${u.outputTokens} out tokens` +
304
+ (u.iterations > 0 ? ` · ${u.iterations} iterations` : "") +
305
+ " (this round only)",
306
+ )
307
+ }
308
+
309
+ if (c.total === 0) {
310
+ lines.push("")
311
+ lines.push("No orchestrator groups found in this state file (nothing to report / wait for).")
312
+ } else {
313
+ lines.push("")
314
+ lines.push(`${"GROUP".padEnd(14)}${"STATUS".padEnd(12)}${"CLEANUP".padEnd(12)}LAST ACTIVITY · REVIEW · QA · USAGE`)
315
+ for (const g of summary.groups) {
316
+ const bits: string[] = []
317
+ if (g.lastActivity) {
318
+ bits.push(g.lastActivity)
319
+ }
320
+ if (g.reviewVerdict) {
321
+ bits.push(`review:${g.reviewVerdict}${g.findingsCount > 0 ? `(${g.findingsCount})` : ""}`)
322
+ }
323
+ if (g.qaVerdict) {
324
+ bits.push(`qa:${g.qaVerdict}`)
325
+ }
326
+ if (g.continuationCount !== undefined && g.continuationCount > 0) {
327
+ bits.push(`continued:${g.continuationCount}`)
328
+ }
329
+ if (g.reworkCount !== undefined && g.reworkCount > 0) {
330
+ bits.push(`rework:${g.reworkCount}`)
331
+ }
332
+ if (g.planFirst !== undefined) {
333
+ bits.push(`plan-first:${g.planFirst.status}${g.planFirst.status === "ok" ? " (plan in task file)" : ""}`)
334
+ }
335
+ if (g.usageCostUsd !== undefined) {
336
+ bits.push(`$${g.usageCostUsd.toFixed(4)}`)
337
+ }
338
+ if (g.stalled) {
339
+ bits.push("STALLED")
340
+ }
341
+ if (g.blockedQuestion) {
342
+ bits.push(`blocked: ${g.blockedQuestion}`)
343
+ }
344
+ if (g.cleanup?.status === "blocked") {
345
+ bits.push(`cleanup: ${g.cleanup.reason}`)
346
+ }
347
+ const cleanupCell =
348
+ g.cleanup === undefined ? "-" : g.cleanup.status === "eligible" ? "eligible" : g.cleanup.status === "done" ? "done" : "blocked"
349
+ lines.push(`${g.name.padEnd(14)}${g.status.padEnd(12)}${cleanupCell.padEnd(12)}${bits.join(" · ")}`)
350
+ }
351
+ lines.push("")
352
+ lines.push(
353
+ `${c.total} group${c.total === 1 ? "" : "s"}: ${c.done} done · ${c.failed} failed · ` +
354
+ `${c.needsHuman} needs-human · ${c.blocked} blocked · ${c.running} running/spawned` +
355
+ (c.orphaned > 0 ? ` · ${c.orphaned} orphaned` : "") +
356
+ (c.other > 0 ? ` · ${c.other} other` : ""),
357
+ )
358
+ }
359
+
360
+ if (opts.reconciled !== undefined && opts.reconciled.length > 0) {
361
+ lines.push("")
362
+ lines.push(`reconciled: ${opts.reconciled.join(", ")} (stale status patched from worktree markers)`)
363
+ }
364
+
365
+ if (opts.timedOut !== undefined || opts.allDone !== undefined) {
366
+ lines.push("")
367
+ lines.push(`verdict: ${verdictLine(summary, opts.timedOut ?? false, opts.allDone ?? false)}`)
368
+ }
369
+ return lines.join("\n") + "\n"
370
+ }
371
+
372
+ // ─── Read-side reconciliation ────────────────────────────────────────────────
373
+
374
+ export interface ReconcileHooks {
375
+ /** Worktree existence check (default: fs.existsSync). */
376
+ worktreeExists?: (worktreePath: string) => boolean
377
+ /** `.harness.done` marker check (default: directory at <wt>/.harness.done). */
378
+ hasDoneMarker?: (worktreePath: string) => boolean
379
+ /** `.harness.exit` reader (default: parse <wt>/.harness.exit, undefined when absent/invalid). */
380
+ readExitCode?: (worktreePath: string) => number | undefined
381
+ }
382
+
383
+ export interface ReconcileResult {
384
+ /** The reconciled state (unchanged when nothing was patched). */
385
+ state: OrchestratorState
386
+ /** Group names whose status/exit_code were patched by this pass. */
387
+ reconciled: string[]
388
+ }
389
+
390
+ /**
391
+ * Reconcile a state's non-terminal groups against REAL worktree markers.
392
+ * This is the fix for the 2026-08-02 `--wait` hang: a worker that genuinely
393
+ * finished (`.harness.done` present, process dead) but whose state entry
394
+ * still said "running", and groups from long-merged rounds whose worktrees
395
+ * were removed while the state still said "running". Only positive evidence
396
+ * triggers a patch — a worktree that still exists without a `.harness.done`
397
+ * marker is left alone (genuinely still running, not ours to second-guess).
398
+ *
399
+ * Pure: returns a new state + the names patched; does NOT persist. Callers
400
+ * persist via saveStateSync when `reconciled` is non-empty.
401
+ */
402
+ export function reconcileGroups(state: OrchestratorState, repo: string, hooks: ReconcileHooks = {}): ReconcileResult {
403
+ const worktreeExists = hooks.worktreeExists ?? ((p: string): boolean => fs.existsSync(p))
404
+ const hasDoneMarker = hooks.hasDoneMarker ?? ((p: string): boolean => {
405
+ try {
406
+ return fs.statSync(path.join(p, ".harness.done")).isDirectory()
407
+ } catch {
408
+ return false
409
+ }
410
+ })
411
+ const readExitCode = hooks.readExitCode ?? ((p: string): number | undefined => {
412
+ try {
413
+ const raw = fs.readFileSync(path.join(p, ".harness.exit"), "utf-8").trim()
414
+ const code = Number(raw)
415
+ return Number.isFinite(code) ? code : undefined
416
+ } catch {
417
+ return undefined
418
+ }
419
+ })
420
+
421
+ let current = state
422
+ const reconciled: string[] = []
423
+ for (const group of state.groups) {
424
+ if (isTerminalStatus(group.status)) {
425
+ continue
426
+ }
427
+ const wtPath = groupWorktreePath(repo, group)
428
+ if (hasDoneMarker(wtPath)) {
429
+ // Ground truth exists: the worker finished. Mirror watch.ts's
430
+ // mapping (exit 0 → done, anything else/unknown → failed).
431
+ const exitCode = readExitCode(wtPath)
432
+ const status = exitCode === 0 ? "done" : "failed"
433
+ current = updateGroup(current, group.name, {
434
+ status,
435
+ ...(exitCode !== undefined ? { exit_code: exitCode } : {}),
436
+ last_activity: {
437
+ note:
438
+ exitCode === 0
439
+ ? "reconciled from .harness markers: worker completed cleanly (exit 0)"
440
+ : `reconciled from .harness markers: worker exited ${exitCode ?? "with unknown code"}`,
441
+ },
442
+ })
443
+ reconciled.push(group.name)
444
+ } else if (!worktreeExists(wtPath)) {
445
+ // Worktree entirely gone, never reached a terminal status: the
446
+ // outcome is genuinely unknowable from markers — surface it as
447
+ // "orphaned" for a human instead of silently guessing done.
448
+ current = updateGroup(current, group.name, {
449
+ status: "orphaned",
450
+ last_activity: {
451
+ note: "orphaned: worktree removed before a terminal status — investigate via git history",
452
+ },
453
+ })
454
+ reconciled.push(group.name)
455
+ }
456
+ // else: worktree still present, no .harness.done → still in progress;
457
+ // absence of evidence is not evidence of absence — leave as-is.
458
+ }
459
+ return { state: current, reconciled }
460
+ }
461
+
462
+ // ─── Blocking wait ───────────────────────────────────────────────────────────
463
+
464
+ export interface WaitForTerminalOptions {
465
+ /** Max wall-clock wait (default DEFAULT_STATUS_TIMEOUT_MS = 2h). */
466
+ timeoutMs?: number
467
+ /** State-file poll interval (default DEFAULT_STATUS_POLL_INTERVAL_MS = 5s). */
468
+ pollIntervalMs?: number
469
+ /** Abort the wait early (returns the current state, not timed out). */
470
+ signal?: AbortSignal
471
+ /** Injectable state reader (default: loadStateSync on statePath). */
472
+ readState?: () => OrchestratorState
473
+ /**
474
+ * Read-side reconciliation applied to EVERY polled state (not just the
475
+ * first — a group can finish mid-wait) BEFORE the terminality check.
476
+ * Return the (possibly patched) state plus the names reconciled; the
477
+ * loop accumulates the union across polls into the result's
478
+ * `reconciled` list.
479
+ */
480
+ reconcile?: (state: OrchestratorState) => ReconcileResult
481
+ /**
482
+ * Called immediately when a group transitions to a terminal status
483
+ * (done/failed/needs-human/orphaned) during the wait — fires once per
484
+ * group per transition. Does NOT fire for non-terminal transitions
485
+ * (blocked, running, spawned). Never called for groups that were already
486
+ * terminal before the wait started (including groups whose initial
487
+ * "running" entry was stale state the FIRST reconciliation fixed to
488
+ * terminal — those were always terminal, just stale in the file).
489
+ * May return a promise; the wait loop awaits it so transitions are
490
+ * processed sequentially (e.g. a --on-group-terminal hook command).
491
+ */
492
+ onGroupTerminal?: (group: OrchestratorGroup) => void | Promise<void>
493
+ }
494
+
495
+ export interface WaitForTerminalResult {
496
+ state: OrchestratorState
497
+ /** Every group reached status "done" (all terminal AND nothing failed). */
498
+ allDone: boolean
499
+ timedOut: boolean
500
+ elapsedMs: number
501
+ /** Group names reconciled (patched to done/failed/orphaned) across all polls. */
502
+ reconciled: string[]
503
+ }
504
+
505
+ /**
506
+ * Block until every group in the state file is terminal (or the timeout
507
+ * elapses, or the signal aborts). An empty state file (no round yet) returns
508
+ * immediately — there is nothing to wait for, and hanging on a wrong path
509
+ * would be worse than returning a clear "no groups" result.
510
+ *
511
+ * Transient JSON parse errors are treated as "not readable yet": the loop
512
+ * retries on the next poll instead of crashing the wait. This tolerance is
513
+ * NOT because the writer is non-atomic — saveStateSync (state.ts) writes to
514
+ * a pid+counter-suffixed temp sibling and renames it over the target, so a
515
+ * concurrent reader never observes a partially-written file. It's defensive
516
+ * anyway: a reader could still race a rename on filesystems/platforms where
517
+ * rename isn't atomic, or hit a transient ENOENT between the old file being
518
+ * gone and the new one appearing, so treating a parse failure as "retry, not
519
+ * crash" costs nothing and remains correct.
520
+ */
521
+ export async function waitForTerminalState(
522
+ statePath: string,
523
+ opts: WaitForTerminalOptions = {},
524
+ ): Promise<WaitForTerminalResult> {
525
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_STATUS_TIMEOUT_MS
526
+ const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_STATUS_POLL_INTERVAL_MS
527
+ const readState = opts.readState ?? ((): OrchestratorState => loadStateSync(statePath))
528
+ const start = Date.now()
529
+ const deadline = start + timeoutMs
530
+
531
+ const readTolerant = (): OrchestratorState | null => {
532
+ try {
533
+ return readState()
534
+ } catch {
535
+ return null
536
+ }
537
+ }
538
+
539
+ const reconciled: string[] = []
540
+ const reconciledSeen = new Set<string>()
541
+
542
+ // Apply read-side reconciliation to a freshly-read state and record any
543
+ // patched group names (deduped across polls). Returns the ReconcileResult
544
+ // (state + names patched) so callers can decide whether to persist.
545
+ const applyReconcile = (s: OrchestratorState): ReconcileResult => {
546
+ if (!opts.reconcile) {
547
+ return { state: s, reconciled: [] }
548
+ }
549
+ const result = opts.reconcile(s)
550
+ for (const name of result.reconciled) {
551
+ if (!reconciledSeen.has(name)) {
552
+ reconciledSeen.add(name)
553
+ reconciled.push(name)
554
+ }
555
+ }
556
+ return result
557
+ }
558
+
559
+ // The 2026-08-18 bug (issue #116): a `status --wait` running CONCURRENTLY
560
+ // with a live orchestrate round used to persist its read-side
561
+ // reconciliation straight back to the shared state file. The watcher's
562
+ // next poll then loaded the group as already-terminal and short-circuited
563
+ // on it — log analysis and the automated review pass never fired (groups
564
+ // w13/w14 settled as "done" with review_verdict/log_analysis absent).
565
+ // The watcher is the only process that owns review/QA/continuation
566
+ // workflows; a read-side `status` must never pre-empt it.
567
+ //
568
+ // Watcher heartbeat: the live watching process (watchGroups, watch.ts)
569
+ // stamps its own PID under <statePath>.watcher (a plain file write, no
570
+ // lock — the point is not exclusivity but honesty: if the watcher wrote
571
+ // the file RECENTLY, it is alive and owns the state). The wait NEVER
572
+ // writes this file itself — a wait's own PID must never masquerade as a
573
+ // live watcher (rework cycle 2 finding: be11a6d reintroduced the exact
574
+ // #116 pre-emption bug by stamping the wait's PID every poll, clobbering
575
+ // the real watcher's fresh heartbeat and unlocking the persist). A round
576
+ // with no watcher at all (direct-path spawn-parallel-worktrees.sh rounds,
577
+ // which `status` exists to reconcile for) leaves no heartbeat file, so
578
+ // the wait keeps its persist-through behavior in that case.
579
+ //
580
+ // Staleness is measured against the WATCHER's heartbeat cadence
581
+ // (DEFAULT_POLL_INTERVAL_MS — the watcher stamps the file once per poll),
582
+ // NOT this wait's own poll interval: a --wait polled faster than the
583
+ // watcher (e.g. --poll-interval-ms 1000 against a 5s watcher) must not
584
+ // declare the watcher dead between the watcher's refreshes, or it would
585
+ // unlock the exact #116 persist again (rework cycle 2 finding: the
586
+ // rework-2 mid-wait test with a 25ms poll exposed this — 3×25ms expired
587
+ // before the live watcher's 5s heartbeat had refreshed).
588
+ const WATCHER_STALENESS_MS = 3 * DEFAULT_POLL_INTERVAL_MS
589
+
590
+ const liveWatcher = (): boolean => {
591
+ try {
592
+ const stat = fs.statSync(`${statePath}.watcher`)
593
+ if (Date.now() - stat.mtimeMs >= WATCHER_STALENESS_MS) {
594
+ return false
595
+ }
596
+ // The heartbeat file must carry a DIFFERENT process's PID to
597
+ // count: a wait must never mistake its own earlier write for a
598
+ // live watcher (the reviewer's finding on the first #116 fix).
599
+ const pid = Number(fs.readFileSync(`${statePath}.watcher`, "utf-8"))
600
+ return Number.isFinite(pid) && pid !== process.pid
601
+ } catch {
602
+ return false
603
+ }
604
+ }
605
+
606
+ // A single reconcile pass: apply the hook, and ONLY persist when no
607
+ // live watcher owns the state file (see liveWatcher above). Never
608
+ // persisted while a watch loop is running — that is exactly the race
609
+ // #116 fixes (the watcher's next poll would short-circuit the group as
610
+ // already-terminal and silently skip log analysis + the automated
611
+ // review).
612
+ const reconcilePass = (s: OrchestratorState): OrchestratorState => {
613
+ const result = applyReconcile(s)
614
+ if (result.reconciled.length > 0 && !liveWatcher()) {
615
+ try {
616
+ saveStateSync(statePath, result.state)
617
+ } catch {
618
+ // A read-side reconcile must never crash the wait; the next
619
+ // poll re-attempts.
620
+ }
621
+ }
622
+ return result.state
623
+ }
624
+
625
+ let state = readTolerant()
626
+ if (state !== null) {
627
+ state = reconcilePass(state)
628
+ }
629
+
630
+ // Previously-seen status per group, seeded from the FIRST reconciled
631
+ // state (same pattern as watchGroups). The onGroupTerminal callback only
632
+ // fires on a genuine transition INTO a terminal status — never for a
633
+ // group that was already terminal when the wait started, including
634
+ // groups whose initial "running" entry was stale state the first
635
+ // reconciliation fixed to terminal (they were always terminal, just
636
+ // stale in the file). Comparison happens AFTER reconciliation on every
637
+ // poll, so a reconciliation-driven status change is only seen as a
638
+ // transition when the PREVIOUS poll had already observed the
639
+ // (reconciled) non-terminal truth — i.e. a real, live finish detected
640
+ // via worktree markers appearing mid-wait.
641
+ const lastSeenStatus = new Map<string, string>()
642
+ if (state !== null) {
643
+ for (const g of state.groups) {
644
+ lastSeenStatus.set(g.name, g.status)
645
+ }
646
+ }
647
+
648
+ const notifyTerminal = async (groups: OrchestratorGroup[]): Promise<void> => {
649
+ for (const g of groups) {
650
+ const prev = lastSeenStatus.get(g.name) ?? g.status
651
+ if (opts.onGroupTerminal && isTerminalStatus(g.status) && !isTerminalStatus(prev)) {
652
+ await opts.onGroupTerminal(g)
653
+ }
654
+ lastSeenStatus.set(g.name, g.status)
655
+ }
656
+ }
657
+
658
+ while (true) {
659
+ if (opts.signal?.aborted) {
660
+ return {
661
+ state: state ?? defaultState(),
662
+ allDone: false,
663
+ timedOut: false,
664
+ elapsedMs: Date.now() - start,
665
+ reconciled,
666
+ }
667
+ }
668
+ if (state !== null) {
669
+ if (state.groups.length === 0) {
670
+ return { state, allDone: false, timedOut: false, elapsedMs: Date.now() - start, reconciled }
671
+ }
672
+ if (state.groups.every((g) => isTerminalStatus(g.status))) {
673
+ return {
674
+ state,
675
+ allDone: state.groups.every((g) => g.status === "done"),
676
+ timedOut: false,
677
+ elapsedMs: Date.now() - start,
678
+ reconciled,
679
+ }
680
+ }
681
+ }
682
+ if (Date.now() >= deadline) {
683
+ let finalState = readTolerant() ?? state ?? defaultState()
684
+ finalState = reconcilePass(finalState)
685
+ // The final read can observe a transition that landed right at the
686
+ // deadline — report it before returning the timed-out result.
687
+ await notifyTerminal(finalState.groups)
688
+ return { state: finalState, allDone: false, timedOut: true, elapsedMs: Date.now() - start, reconciled }
689
+ }
690
+ await sleep(pollIntervalMs)
691
+ state = readTolerant()
692
+ if (state !== null) {
693
+ state = reconcilePass(state)
694
+ await notifyTerminal(state.groups)
695
+ }
696
+ }
697
+ }