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,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
|
+
}
|