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,542 @@
1
+ /**
2
+ * Orchestrator durable state — read/write `.worktrees/.orchestrator-state.json`.
3
+ *
4
+ * Schema (mirrors the live state file):
5
+ *
6
+ * {
7
+ * "batch": string,
8
+ * "updated": ISO timestamp,
9
+ * "groups": [
10
+ * {
11
+ * "name": string, // worktree name, e.g. "w1"
12
+ * "worktree": string, // ".worktrees/w1" (repo-relative)
13
+ * "branch": string, // "issues/w1-2026-07-31"
14
+ * "issues": number[],
15
+ * "task_file": string,
16
+ * "status": "spawned"|"running"|"done"|"failed"|"blocked"|"needs-human"|"orphaned"|string,
17
+ * "spawned": ISO timestamp,
18
+ * "last_activity": string | object, // "last_commit"/"note" seen live
19
+ * "commits": string[],
20
+ * "actions_taken": string[],
21
+ * "pending_review_findings": string[],
22
+ * "plan_first": { "mode": string, "status": "ok"|"failed"|"skipped",
23
+ * "report"?: string } // issue #49 plan-first outcome
24
+ * }
25
+ * ]
26
+ * }
27
+ *
28
+ * Validation is deliberately LOOSE (plain fs + JSON, no schema library):
29
+ * the file may have been written by the bash spawner, by a previous
30
+ * orchestrator instance, or be mid-write. We only guarantee the shape the
31
+ * watcher/CLI need: an object with a `groups` array.
32
+ */
33
+
34
+ import * as fs from "node:fs"
35
+ import * as fsp from "node:fs/promises"
36
+ import * as path from "node:path"
37
+ import { setTimeout as sleep } from "node:timers/promises"
38
+
39
+ /** One worktree group in the orchestrator state file. */
40
+ export interface OrchestratorGroup {
41
+ name: string
42
+ worktree?: string
43
+ branch?: string
44
+ issues?: number[]
45
+ /**
46
+ * The per-issue shape of this group's work (split.ts's issueShape: hot /
47
+ * split / coverage / test / docs / refactor / generic), one entry per
48
+ * issue, same order as `issues`. Written by orchestrate at dispatch time
49
+ * (issue #16) — the only moment the issue titles/bodies are in hand — so
50
+ * the cost-history recording that happens LATER (when the group reaches a
51
+ * terminal status, possibly in a separate orchestrate invocation) can
52
+ * stamp the group's record with its shape without needing issue content.
53
+ * Absent for groups spawned directly via spawn-parallel-worktrees.sh.
54
+ */
55
+ shapes?: string[]
56
+ /**
57
+ * Per-issue {title, body}, keyed by issue number as a string (JSON object
58
+ * keys are always strings), captured at dispatch time and persisted in the
59
+ * state file so the rework/QA/continuation task files — generated LATER
60
+ * from state alone, when the original issue objects are no longer in hand
61
+ * — can embed each issue's real title/body inline instead of telling the
62
+ * worker to run `gh issue view <n>` (which fails outright for synthetic
63
+ * --issues-json numbers). Written by orchestrate at dispatch time
64
+ * alongside `shapes`; absent for groups spawned directly via
65
+ * spawn-parallel-worktrees.sh.
66
+ */
67
+ issueBodies?: Record<string, { title: string; body?: string }>
68
+ task_file?: string
69
+ /**
70
+ * spawned | running | done | failed | blocked | needs-human | orphaned
71
+ * (loose: any string is tolerated). `needs-human` is a TERMINAL state
72
+ * set by the rework loop when a group exhausted its review-rework
73
+ * attempts — the underlying problem was never fixed and a human must
74
+ * look at it. It is deliberately distinct from `failed` (worker
75
+ * crash/stall) and from `blocked` (waiting on a decision answer, not a
76
+ * rework cycle). `orphaned` is a TERMINAL state written by
77
+ * `orchestrate status`'s read-side reconciliation (status.ts): the
78
+ * group's worktree is gone from disk and it never reached a terminal
79
+ * status, so the real outcome cannot be determined from markers alone —
80
+ * a human must investigate via git history. It is never guessed as
81
+ * `done`/`failed`; surfacing the ambiguity honestly is the point.
82
+ */
83
+ status: string
84
+ spawned?: string
85
+ last_status_change?: string
86
+ last_activity?: string | { last_commit?: string; note?: string; [key: string]: unknown }
87
+ commits?: string[]
88
+ actions_taken?: string[]
89
+ /**
90
+ * Set by `orchestrate cleanup --apply` once the group's worktree was
91
+ * removed after its branch was verified merged (local-ancestor or
92
+ * GitHub PR). The group entry itself is NEVER deleted — the round's
93
+ * history stays queryable after cleanup (same philosophy as the
94
+ * `orphaned` status).
95
+ */
96
+ cleaned_at?: string
97
+ pending_review_findings?: string[]
98
+ /** Worker exit code (written by run-worker.sh; recorded by the watcher). */
99
+ exit_code?: number
100
+ /** harness.log tail captured when the group completed. */
101
+ summary?: string
102
+ /** Set by the watcher's stall guard. */
103
+ stalled?: boolean
104
+ /**
105
+ * Set by `headlesscode orchestrate stop` when an operator stops a group's
106
+ * worker process tree mid-run (scripts/stop-worker.sh, issue #20): the
107
+ * group is patched to `needs-human` and this records WHEN the stop
108
+ * happened, so a human can tell a deliberately-stopped group apart from a
109
+ * crash or a rework-exhausted one.
110
+ */
111
+ stopped_at?: string
112
+ /** Review bookkeeping (written by the orchestrator after runReview). */
113
+ review_verdict?: string
114
+ reviewed_at?: string
115
+ /**
116
+ * Issue #34: absolute path to the review session's COMPLETE final report
117
+ * (`<worktree>/.headlesscode/reports/<sessionId>.md`), persisted alongside
118
+ * the verdict so the full reasoning behind it is one file-read away, not a
119
+ * re-run away. `review_verdict`/`pending_review_findings` stay as the
120
+ * lightweight parsed fields for quick scanning.
121
+ */
122
+ review_report?: string
123
+ /**
124
+ * Rework loop counter — how many rework cycles this group has already gone
125
+ * through after a review "finding" OR a QA "fail" verdict (issue #52: a
126
+ * real QA fail is reworked from its qa.evidence exactly like review
127
+ * findings, sharing the same --max-rework-cycles budget). Absent (== 0)
128
+ * when the group has never been reworked. Incremented by the orchestrator
129
+ * each time it re-spawns a worker on the SAME worktree to fix review/QA
130
+ * findings.
131
+ */
132
+ reworkCount?: number
133
+ /**
134
+ * Iteration-exhaustion continuation counter — how many times this group
135
+ * has already been re-spawned on the SAME worktree after a worker hit
136
+ * `--max-iterations` ("the task is bigger than one session", see
137
+ * plans/issues/02-orchestrate-iteration-plumbing.md). Absent (== 0) when
138
+ * the group's first attempt never hit the cap. Incremented by the
139
+ * orchestrator each time it continues a group past the cap; once it
140
+ * reaches the group's `--max-continuations` budget the group is marked
141
+ * `needs-human` (same terminal shape as reworkCount's cap).
142
+ */
143
+ continuationCount?: number
144
+ /**
145
+ * Issue #49 plan-first experiment: outcome of the short architect-mode
146
+ * planning session the spawner ran in this worktree BEFORE the code worker
147
+ * ("ok" = a plan was produced and appended to the worker's task file;
148
+ * "failed" = the plan session errored and the code worker ran without a
149
+ * plan — a fallback, never a round failure; "skipped" = reserved, not
150
+ * currently written). Absent when the round was not run with --plan-first.
151
+ */
152
+ plan_first?: { mode: string; status: "ok" | "failed" | "skipped"; report?: string }
153
+ /**
154
+ * QA bookkeeping (Phase 4, written by the orchestrator after runQa when
155
+ * `--qa` is passed): verdict is pass|fail|error; status is "done" for a
156
+ * pass, "failed" otherwise; evidence is the QA report's evidence section.
157
+ */
158
+ qa?: {
159
+ status: string
160
+ verdict: string
161
+ evidence: string
162
+ updated: string
163
+ /**
164
+ * Issue #34: absolute path to the QA session's COMPLETE final report
165
+ * (`<worktree>/.headlesscode/reports/<sessionId>.md`), persisted
166
+ * alongside the verdict so the full reasoning behind it is one
167
+ * file-read away, not a re-run away. `evidence` stays as the
168
+ * lightweight pre-extracted slice for quick scanning.
169
+ */
170
+ report?: string
171
+ }
172
+ /** PR metadata seen in the live state file. */
173
+ pr?: { number?: number; url?: string; state?: string }
174
+ /**
175
+ * Decision escalation (workstream 2): set by the watcher (watch.ts) while
176
+ * `<worktree>/.harness.needs-decision` is present — the worker's
177
+ * ask_followup_question call is blocked waiting for
178
+ * `<worktree>/.harness.decision-answer`. Mirrors the marker file's JSON
179
+ * shape. Cleared (and status flows back to "running") once the marker
180
+ * disappears, whether because a human/orchestrator answered it via
181
+ * `scripts/headlesscode-answer.sh` or because the worker's wait timed out
182
+ * and it fell back to autonomous decision.
183
+ */
184
+ blocked?: { question: string; suggestions?: string[]; askedAt?: string }
185
+ /**
186
+ * Cost/token monitoring (workstream 3): rolled up by the watcher
187
+ * (watch.ts) from the worker's `<worktree>/.headlesscode/usage/*.jsonl`
188
+ * once the group reaches a terminal state (done/failed). Summed across
189
+ * all usage records found for the worktree (defensive against a worktree
190
+ * running more than one session) — see `src/dashboard/aggregate.ts` for
191
+ * the same shape used dashboard-side.
192
+ */
193
+ usage?: {
194
+ costUsd: number
195
+ inputTokens: number
196
+ outputTokens: number
197
+ /** Subset of inputTokens served from the provider's prompt cache (0 when unreported). */
198
+ cachedTokens?: number
199
+ iterations: number
200
+ }
201
+ /**
202
+ * Deterministic post-hoc session log analysis (log-analysis.ts), run once
203
+ * a group reaches "done", independent of --review/--qa. Captures the same
204
+ * findings a human would get reading harness.log + events.jsonl by hand:
205
+ * tool-call/error stats, stall gaps, and repeated-command detection.
206
+ * `undefined` until analyzed; `false` means analysis ran but found no
207
+ * events dir (nothing to report — e.g. the group was cleaned up).
208
+ */
209
+ log_analysis?:
210
+ | {
211
+ findings: string[]
212
+ toolCallCounts: Record<string, number>
213
+ toolErrorCounts: Record<string, number>
214
+ analyzedAt: string
215
+ }
216
+ | false
217
+ /**
218
+ * Mandatory cost/token history recording (cost-history.ts) — timestamp
219
+ * of when this group's combined usage was appended to the repo's
220
+ * central `cost-history.jsonl`. Written exactly once, the first time
221
+ * the group reaches ANY terminal status (done/failed/needs-human,
222
+ * regardless of which code path got it there), by watchGroups' own
223
+ * polling loop rather than orchestrate's review/rework branches — a
224
+ * single, unconditional insertion point so no terminal outcome (a real
225
+ * failure, a rework-cap exhaustion, a continuation-cap exhaustion) can
226
+ * skip being recorded by falling through a branch that doesn't call it.
227
+ * `undefined` until recorded.
228
+ */
229
+ cost_recorded?: string
230
+ }
231
+
232
+ /**
233
+ * Preflight probe record persisted on the state file so the dashboard can
234
+ * surface it (issue #13): the result of the 1-token probe `orchestrate` runs
235
+ * BEFORE spawning any workers, using the exact model + provider pin a real
236
+ * worker uses. Written by orchestrateMain alongside `batch`; see
237
+ * src/llm/preflight.ts for the statuses.
238
+ */
239
+ export interface PreflightRecord {
240
+ /** One of PreflightStatus ("ok" | "no-api-key" | "invalid-key" | "balance" | "provider" | "network" | "other"). */
241
+ status: string
242
+ /** The model id the probe ran with. */
243
+ model: string
244
+ /** The OpenRouter base URL probed. */
245
+ baseUrl?: string
246
+ /** The single human-readable preflight line. */
247
+ line: string
248
+ /** Probe wall time, ms. */
249
+ latencyMs: number
250
+ /** Estimated USD cost of the 1-token probe itself. */
251
+ probeCostUsd: number
252
+ /** Order-of-magnitude round cost estimate (nominal profile × sessions × price). */
253
+ roundCostEstimateUsd?: number
254
+ }
255
+
256
+ /** The orchestrator state file document. */
257
+ export interface OrchestratorState {
258
+ batch?: string
259
+ updated?: string
260
+ groups: OrchestratorGroup[]
261
+ /** Issue #13 preflight probe result (present once orchestrate ran it). */
262
+ preflight?: PreflightRecord
263
+ /**
264
+ * Cost/token monitoring (workstream 3): round-level total, the sum of
265
+ * every group's `usage` field — CUMULATIVE across every round ever
266
+ * recorded in this state file (groups from prior rounds are retained for
267
+ * history/cleanup bookkeeping). Recomputed by the watcher each time a
268
+ * group's usage is rolled in, so "all rounds cost $X total" is
269
+ * answerable without summing client-side.
270
+ */
271
+ totalUsage?: {
272
+ costUsd: number
273
+ inputTokens: number
274
+ outputTokens: number
275
+ /** Subset of inputTokens served from the provider's prompt cache (0 when unreported). */
276
+ cachedTokens?: number
277
+ iterations: number
278
+ }
279
+ /**
280
+ * Issue #118: per-batch cost aggregate — the same shape as `totalUsage`
281
+ * but summed ONLY over the groups belonging to the CURRENT batch (their
282
+ * `spawned` ISO timestamp carries the batch prefix, e.g.
283
+ * `batch: "round-2026-08-17"` -> `spawned: "2026-08-17T…"`), so
284
+ * "what did THIS round cost?" is answerable even when the state file
285
+ * has accumulated many rounds' groups. Recomputed alongside `totalUsage`
286
+ * by the watcher; undefined when no group of the current batch has a
287
+ * usage record yet.
288
+ */
289
+ batchUsage?: {
290
+ costUsd: number
291
+ inputTokens: number
292
+ outputTokens: number
293
+ /** Subset of inputTokens served from the provider's prompt cache (0 when unreported). */
294
+ cachedTokens?: number
295
+ iterations: number
296
+ }
297
+ [key: string]: unknown
298
+ }
299
+
300
+ export function defaultState(batch = "unnamed"): OrchestratorState {
301
+ return { batch, updated: new Date().toISOString(), groups: [] }
302
+ }
303
+
304
+ /** Loosely validate a parsed state document; returns groups as an array. */
305
+ function sanitize(raw: unknown): OrchestratorState {
306
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
307
+ const obj = raw as Record<string, unknown>
308
+ const groups = Array.isArray(obj.groups)
309
+ ? (obj.groups as OrchestratorGroup[]).filter((g) => g !== null && typeof g === "object")
310
+ : []
311
+ return { ...obj, groups } as OrchestratorState
312
+ }
313
+ return defaultState()
314
+ }
315
+
316
+ /**
317
+ * Read + parse the state file. Missing or malformed files yield a fresh
318
+ * default state (never throws for missing files). Invalid JSON throws.
319
+ */
320
+ export async function loadState(statePath: string): Promise<OrchestratorState> {
321
+ let raw: string
322
+ try {
323
+ raw = await fsp.readFile(statePath, "utf-8")
324
+ } catch (error) {
325
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
326
+ return defaultState()
327
+ }
328
+ throw error
329
+ }
330
+ const parsed: unknown = JSON.parse(raw)
331
+ return sanitize(parsed)
332
+ }
333
+
334
+ /** Synchronous variant (handy for scripts); same semantics as loadState. */
335
+ export function loadStateSync(statePath: string): OrchestratorState {
336
+ let raw: string
337
+ try {
338
+ raw = fs.readFileSync(statePath, "utf-8")
339
+ } catch (error) {
340
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
341
+ return defaultState()
342
+ }
343
+ throw error
344
+ }
345
+ return sanitize(JSON.parse(raw))
346
+ }
347
+
348
+ /**
349
+ * Unique sibling temp path for an atomic state write. The pid + counter
350
+ * suffix keeps concurrent saves — even in-process, e.g. across watcher
351
+ * callbacks — from ever sharing a temp file, which would race their renames.
352
+ */
353
+ let stateWriteCounter = 0
354
+ function stateTmpPath(statePath: string): string {
355
+ return `${statePath}.tmp-${process.pid}-${++stateWriteCounter}`
356
+ }
357
+
358
+ /** Write the state file (pretty-printed + trailing newline), creating dirs. */
359
+ export async function saveState(statePath: string, state: OrchestratorState): Promise<void> {
360
+ const cleaned = sanitize(state)
361
+ cleaned.updated = new Date().toISOString()
362
+ await fsp.mkdir(path.dirname(statePath), { recursive: true })
363
+ // Atomic write: temp sibling + rename, so a crash mid-write can never
364
+ // truncate the one file treated as the round's source of truth (rename is
365
+ // atomic on the same filesystem — the temp lives next to the target).
366
+ const tmpPath = stateTmpPath(statePath)
367
+ await fsp.writeFile(tmpPath, JSON.stringify(cleaned, null, 2) + "\n", "utf-8")
368
+ await fsp.rename(tmpPath, statePath)
369
+ }
370
+
371
+ /** Synchronous variant of saveState. */
372
+ export function saveStateSync(statePath: string, state: OrchestratorState): void {
373
+ const cleaned = sanitize(state)
374
+ cleaned.updated = new Date().toISOString()
375
+ fs.mkdirSync(path.dirname(statePath), { recursive: true })
376
+ const tmpPath = stateTmpPath(statePath)
377
+ fs.writeFileSync(tmpPath, JSON.stringify(cleaned, null, 2) + "\n", "utf-8")
378
+ fs.renameSync(tmpPath, statePath)
379
+ }
380
+
381
+ /**
382
+ * Return a NEW state object with the named group patched (upsert by name).
383
+ * `patch` fields are shallow-merged over the existing group; a group that
384
+ * does not exist yet is created from `{ name, ...patch }`. The input state
385
+ * is not mutated.
386
+ */
387
+ export function updateGroup(
388
+ state: OrchestratorState,
389
+ name: string,
390
+ patch: Partial<Omit<OrchestratorGroup, "name">>,
391
+ ): OrchestratorState {
392
+ const groups = state.groups.map((g) => ({ ...g }))
393
+ const index = groups.findIndex((g) => g.name === name)
394
+ if (index >= 0) {
395
+ groups[index] = { ...groups[index], ...patch, name }
396
+ } else {
397
+ groups.push({ ...patch, name, status: patch.status ?? "spawned" })
398
+ }
399
+ return { ...state, groups, updated: new Date().toISOString() }
400
+ }
401
+
402
+ /**
403
+ * Serializes read-merge-write critical sections against the state file
404
+ * WITHIN this process. Needed now that review/QA for multiple groups can
405
+ * run concurrently (watchGroups no longer awaits one group's onGroupUpdate
406
+ * before starting the next) — without this, two concurrent callers doing
407
+ * loadState -> updateGroup -> saveState for DIFFERENT groups can interleave
408
+ * across their `await` points and race: both read the same on-disk
409
+ * snapshot, then whichever saves last clobbers the other's patch for its
410
+ * own group. Every merge point in cli.ts's onGroupUpdate must go through
411
+ * patchGroup below rather than reusing a stale in-memory state snapshot.
412
+ *
413
+ * This is an in-process JS Promise chain — it does NOT protect against a
414
+ * SECOND, separate `orchestrate` OS process racing the same state file (see
415
+ * acquireCrossProcessLock below for that half of the story; issue #43).
416
+ */
417
+ let stateWriteQueue: Promise<unknown> = Promise.resolve()
418
+
419
+ function withStateLock<T>(fn: () => Promise<T>): Promise<T> {
420
+ const run = stateWriteQueue.then(fn, fn)
421
+ stateWriteQueue = run.then(
422
+ () => undefined,
423
+ () => undefined,
424
+ )
425
+ return run
426
+ }
427
+
428
+ /** How long a lock dir may sit unrefreshed before another process may steal it (a crashed holder never got to release it). */
429
+ const LOCK_STALE_MS = 30_000
430
+ /** Poll interval while waiting for a held lock. */
431
+ const LOCK_RETRY_MS = 20
432
+ /** How long to wait for a lock before giving up loudly rather than hanging forever. */
433
+ const LOCK_ACQUIRE_TIMEOUT_MS = 15_000
434
+
435
+ /**
436
+ * Cross-process mutex for the state file, using the SAME atomic-mkdir
437
+ * convention already established in this codebase for other lock-like
438
+ * markers (`.harness.done` — "mkdir-based, atomic", see watch.ts's own
439
+ * doc comment). `fs.mkdirSync` without `recursive` is an atomic exclusive
440
+ * create at the OS level: exactly one concurrent caller (in this process OR
441
+ * a different one) can succeed; every other caller gets EEXIST and must
442
+ * wait or steal a stale lock.
443
+ *
444
+ * Fixes issue #43: `withStateLock` above only serializes callers within ONE
445
+ * Node process. Two separate `orchestrate` invocations against the same
446
+ * repo — a scenario the team deliberately held off on running before this
447
+ * lock existed — would otherwise have zero protection against the exact
448
+ * lost-update race #24's patchGroup was built to prevent at the in-process
449
+ * level, just one level up (process A reads, process B reads the same
450
+ * stale snapshot, A writes, B's write silently discards A's).
451
+ */
452
+ async function acquireCrossProcessLock(lockDir: string, timeoutMs = LOCK_ACQUIRE_TIMEOUT_MS): Promise<() => void> {
453
+ const deadline = Date.now() + timeoutMs
454
+ while (true) {
455
+ try {
456
+ fs.mkdirSync(lockDir)
457
+ return () => {
458
+ try {
459
+ fs.rmdirSync(lockDir)
460
+ } catch {
461
+ /* already gone (e.g. reclaimed as stale by another process) — fine */
462
+ }
463
+ }
464
+ } catch (err) {
465
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
466
+ throw err
467
+ }
468
+ // Reclaim a stale lock: its holder crashed (or was killed) before
469
+ // releasing it. Without this, one dead process permanently
470
+ // deadlocks every future orchestrate invocation against this repo.
471
+ try {
472
+ const heldFor = Date.now() - fs.statSync(lockDir).mtimeMs
473
+ if (heldFor > LOCK_STALE_MS) {
474
+ fs.rmdirSync(lockDir)
475
+ continue
476
+ }
477
+ } catch {
478
+ continue // lock vanished between our mkdir attempt and stat — retry now
479
+ }
480
+ if (Date.now() > deadline) {
481
+ throw new Error(
482
+ `patchGroup: could not acquire cross-process lock ${lockDir} within ${timeoutMs}ms ` +
483
+ `(another orchestrate process may be stuck — check for a stale ${lockDir} dir)`,
484
+ )
485
+ }
486
+ await sleep(LOCK_RETRY_MS)
487
+ }
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Atomically patch one group in the on-disk state file: reloads fresh,
493
+ * merges the patch, writes, all inside BOTH the in-process write lock and
494
+ * the cross-process file lock, so a concurrent patch for a different group
495
+ * (or the same group) — from this process OR a second orchestrate process
496
+ * against the same repo — can't be lost. Returns the full merged state.
497
+ * Prefer this over a manual loadState/updateGroup/saveState sequence
498
+ * anywhere concurrent writers are possible.
499
+ */
500
+ export async function patchGroup(
501
+ statePath: string,
502
+ name: string,
503
+ patch: Partial<Omit<OrchestratorGroup, "name">>,
504
+ ): Promise<OrchestratorState> {
505
+ return withStateLock(async () => {
506
+ const release = await acquireCrossProcessLock(`${statePath}.lock`)
507
+ try {
508
+ const fresh = await loadState(statePath)
509
+ const next = updateGroup(fresh, name, patch)
510
+ await saveState(statePath, next)
511
+ return next
512
+ } finally {
513
+ release()
514
+ }
515
+ })
516
+ }
517
+
518
+ /**
519
+ * General-purpose locked read-modify-write for the state file: reloads
520
+ * fresh, hands it to `mutator`, saves whatever `mutator` returns, all inside
521
+ * BOTH the in-process write lock and the cross-process file lock — same
522
+ * guarantee as patchGroup, for callers (e.g. the initial post-spawn state
523
+ * write in cli.ts, issue #80) that need to merge more than one group's worth
524
+ * of changes in a single transaction rather than patching one group at a
525
+ * time.
526
+ */
527
+ export async function mutateState(
528
+ statePath: string,
529
+ mutator: (state: OrchestratorState) => OrchestratorState | Promise<OrchestratorState>,
530
+ ): Promise<OrchestratorState> {
531
+ return withStateLock(async () => {
532
+ const release = await acquireCrossProcessLock(`${statePath}.lock`)
533
+ try {
534
+ const fresh = await loadState(statePath)
535
+ const next = await mutator(fresh)
536
+ await saveState(statePath, next)
537
+ return next
538
+ } finally {
539
+ release()
540
+ }
541
+ })
542
+ }