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,242 @@
1
+ /**
2
+ * `headlesscode cost-history` subcommand — read-side for cost-history.ts's
3
+ * central `cost-history.jsonl` (per-group combined totals) and
4
+ * `session-cost-history.jsonl` (per-session, with outcome — the wasted-
5
+ * spend breakdown). Recording is automatic and mandatory (see watch.ts's
6
+ * `recordCostIfSettled`/`recordAllSessionCosts`); this is for looking at
7
+ * what's been recorded — filterable by issue number, printed as a
8
+ * human-readable table or raw JSON. `--by-shape` (issue #16) prints
9
+ * per-shape cost/iteration aggregates, including continuation/rework rates.
10
+ * Estimating a NEW task's cost from this history is cost-estimate.ts's job,
11
+ * surfaced by `orchestrate --dry-run`.
12
+ *
13
+ * headlesscode cost-history --repo <path> [--issue <n>] [--sessions] [--by-shape] [--json]
14
+ */
15
+
16
+ import { readCostHistory, readSessionCostHistory, type CostHistoryRecord, type SessionCostRecord } from "./cost-history.js"
17
+ import { aggregateByShape } from "./cost-estimate.js"
18
+
19
+ const COST_HISTORY_USAGE = `headlesscode cost-history — read recorded cost/token history for a repo
20
+
21
+ Usage:
22
+ headlesscode cost-history --repo <path> [options]
23
+
24
+ Options:
25
+ --repo <path> Target repo root (required)
26
+ --issue <n> Only show records covering this issue number (repeatable)
27
+ --sessions Show the per-session breakdown (with outcome: success/
28
+ error/budget/killed) instead of per-group totals —
29
+ this is where wasted spend (non-success outcomes)
30
+ is visible
31
+ --by-shape Show per-shape aggregates (samples, median cost, median
32
+ cost per issue, median iterations, continuation/rework
33
+ rates) instead of per-group rows — surfaces how much a
34
+ given issue SHAPE tends to cost and how often it needs
35
+ rework cycles (issue #16). Shapes are only recorded for
36
+ groups dispatched after issue #16
37
+ --json Print the raw records as JSON instead of a table
38
+ --help Show this help and exit
39
+ `
40
+
41
+ interface CostHistoryCliOptions {
42
+ repo: string
43
+ issues: number[]
44
+ sessions: boolean
45
+ byShape: boolean
46
+ json: boolean
47
+ }
48
+
49
+ export function parseCostHistoryArgs(argv: string[]): CostHistoryCliOptions | { help: true } | { error: string } {
50
+ let repo: string | undefined
51
+ const issues: number[] = []
52
+ let sessions = false
53
+ let byShape = false
54
+ let json = false
55
+
56
+ for (let i = 0; i < argv.length; i++) {
57
+ const arg = argv[i]
58
+ if (arg === "--help" || arg === "-h") {
59
+ return { help: true }
60
+ } else if (arg === "--repo") {
61
+ repo = argv[++i]
62
+ } else if (arg === "--issue") {
63
+ const raw = argv[++i]
64
+ const n = Number(raw)
65
+ if (!raw || !Number.isInteger(n)) {
66
+ return { error: `--issue must be an integer, got: ${raw}` }
67
+ }
68
+ issues.push(n)
69
+ } else if (arg === "--sessions") {
70
+ sessions = true
71
+ } else if (arg === "--by-shape") {
72
+ byShape = true
73
+ } else if (arg === "--json") {
74
+ json = true
75
+ } else {
76
+ return { error: `unknown argument: ${arg}` }
77
+ }
78
+ }
79
+
80
+ if (!repo) {
81
+ return { error: "--repo is required" }
82
+ }
83
+ return { repo, issues, sessions, byShape, json }
84
+ }
85
+
86
+ /** Human-readable duration, e.g. "37m", "1h 12m", "2d 3h". Undefined input renders as "?". */
87
+ function formatDuration(ms: number | undefined): string {
88
+ if (ms === undefined || !Number.isFinite(ms) || ms < 0) {
89
+ return "?"
90
+ }
91
+ const totalMinutes = Math.round(ms / 60000)
92
+ const days = Math.floor(totalMinutes / 1440)
93
+ const hours = Math.floor((totalMinutes % 1440) / 60)
94
+ const minutes = totalMinutes % 60
95
+ if (days > 0) {
96
+ return `${days}d ${hours}h`
97
+ }
98
+ if (hours > 0) {
99
+ return `${hours}h ${minutes}m`
100
+ }
101
+ return `${minutes}m`
102
+ }
103
+
104
+ function formatTable(records: CostHistoryRecord[], sessionRecords: SessionCostRecord[]): string {
105
+ if (records.length === 0) {
106
+ return "No cost history recorded for this repo yet."
107
+ }
108
+ const lines: string[] = []
109
+ lines.push(
110
+ `${"GROUP".padEnd(10)}${"ISSUES".padEnd(16)}${"STATUS".padEnd(14)}${"COST".padEnd(10)}${"ITER".padEnd(6)}${"TIME".padEnd(8)}CONT/REWORK · RECORDED`,
111
+ )
112
+ let totalCost = 0
113
+ let totalWallClockMs = 0
114
+ for (const r of records) {
115
+ totalCost += r.costUsd
116
+ totalWallClockMs += r.wallClockMs ?? 0
117
+ lines.push(
118
+ `${r.groupName.padEnd(10)}${r.issues.map((n) => `#${n}`).join(",").padEnd(16)}${r.status.padEnd(14)}` +
119
+ `$${r.costUsd.toFixed(4)}`.padEnd(10) +
120
+ `${String(r.iterations).padEnd(6)}${formatDuration(r.wallClockMs).padEnd(8)}${r.continuationCount}/${r.reworkCount} · ${r.recordedAt}`,
121
+ )
122
+ }
123
+ lines.push("")
124
+ lines.push(
125
+ `${records.length} record(s), total: $${totalCost.toFixed(4)}, total wall-clock (planning-to-completion): ${formatDuration(totalWallClockMs)}`,
126
+ )
127
+
128
+ // Wasted-spend summary, from the per-session breakdown: any session
129
+ // whose outcome was NOT "success" (errored, hit budget, or was killed
130
+ // before finishing) is spend that produced no useful outcome on its
131
+ // own — surfaced here so it's never silently invisible inside a
132
+ // group's combined total. Use --sessions to see which sessions.
133
+ const wasted = sessionRecords.filter((r) => r.status !== "success")
134
+ if (wasted.length > 0) {
135
+ const wastedCost = wasted.reduce((sum, r) => sum + r.costUsd, 0)
136
+ lines.push(
137
+ `${wasted.length} wasted session(s) (error/budget/killed): $${wastedCost.toFixed(4)} — see --sessions for detail`,
138
+ )
139
+ }
140
+ return lines.join("\n")
141
+ }
142
+
143
+ function formatSessionTable(records: SessionCostRecord[]): string {
144
+ if (records.length === 0) {
145
+ return "No session cost history recorded for this repo yet."
146
+ }
147
+ const lines: string[] = []
148
+ lines.push(
149
+ `${"GROUP".padEnd(10)}${"MODE".padEnd(18)}${"STATUS".padEnd(10)}${"COST".padEnd(10)}${"ITER".padEnd(6)}SESSION · RECORDED`,
150
+ )
151
+ let totalCost = 0
152
+ let wastedCost = 0
153
+ for (const r of records) {
154
+ totalCost += r.costUsd
155
+ if (r.status !== "success") {
156
+ wastedCost += r.costUsd
157
+ }
158
+ lines.push(
159
+ `${r.groupName.padEnd(10)}${r.mode.padEnd(18)}${r.status.padEnd(10)}` +
160
+ `$${r.costUsd.toFixed(4)}`.padEnd(10) +
161
+ `${String(r.iterations).padEnd(6)}${r.sessionId} · ${r.recordedAt}`,
162
+ )
163
+ }
164
+ lines.push("")
165
+ lines.push(`${records.length} session(s), total: $${totalCost.toFixed(4)}, wasted (non-success): $${wastedCost.toFixed(4)}`)
166
+ return lines.join("\n")
167
+ }
168
+
169
+ function formatShapeTable(stats: ReturnType<typeof aggregateByShape>, recordCount: number): string {
170
+ if (recordCount === 0) {
171
+ return "No cost history recorded for this repo yet."
172
+ }
173
+ if (stats.length === 0) {
174
+ return (
175
+ `${recordCount} record(s) on file, but none carry shapes — shapes are recorded for groups dispatched from now on (issue #16); ` +
176
+ `use --json to see the raw records.`
177
+ )
178
+ }
179
+ const lines: string[] = []
180
+ lines.push(
181
+ `${"SHAPE".padEnd(10)}${"N".padEnd(4)}${"COST MED".padEnd(12)}${"COST/ISSUE MED".padEnd(16)}${"ITER MED".padEnd(10)}CONT/REWORK`,
182
+ )
183
+ for (const s of stats) {
184
+ lines.push(
185
+ `${s.shape.padEnd(10)}${String(s.samples).padEnd(4)}` +
186
+ `${`$${s.costMedianUsd.toFixed(4)}`.padEnd(12)}` +
187
+ `${`$${s.costPerIssueMedianUsd.toFixed(4)}`.padEnd(16)}` +
188
+ `${String(s.iterationsMedian).padEnd(10)}${s.continuationRate.toFixed(1)} / ${s.reworkRate.toFixed(1)}`,
189
+ )
190
+ }
191
+ lines.push("")
192
+ lines.push(
193
+ `Median group cost by shape (per-issue normalized where a group spans multiple issues); continuation/rework = mean cycles per group.`,
194
+ )
195
+ return lines.join("\n")
196
+ }
197
+
198
+ export async function costHistoryCliMain(argv: string[]): Promise<number> {
199
+ const parsed = parseCostHistoryArgs(argv)
200
+ if ("help" in parsed) {
201
+ process.stdout.write(COST_HISTORY_USAGE)
202
+ return 0
203
+ }
204
+ if ("error" in parsed) {
205
+ process.stderr.write(`cost-history: ${parsed.error}\n\n${COST_HISTORY_USAGE}`)
206
+ return 2
207
+ }
208
+
209
+ let records = await readCostHistory(parsed.repo)
210
+ let sessionRecords = await readSessionCostHistory(parsed.repo)
211
+ if (parsed.issues.length > 0) {
212
+ const wanted = new Set(parsed.issues)
213
+ records = records.filter((r) => r.issues.some((n) => wanted.has(n)))
214
+ sessionRecords = sessionRecords.filter((r) => r.issues.some((n) => wanted.has(n)))
215
+ }
216
+
217
+ if (parsed.byShape) {
218
+ const stats = aggregateByShape(records)
219
+ if (parsed.json) {
220
+ process.stdout.write(JSON.stringify(stats, null, 2) + "\n")
221
+ } else {
222
+ process.stdout.write(formatShapeTable(stats, records.length) + "\n")
223
+ }
224
+ return 0
225
+ }
226
+
227
+ if (parsed.sessions) {
228
+ if (parsed.json) {
229
+ process.stdout.write(JSON.stringify(sessionRecords, null, 2) + "\n")
230
+ } else {
231
+ process.stdout.write(formatSessionTable(sessionRecords) + "\n")
232
+ }
233
+ return 0
234
+ }
235
+
236
+ if (parsed.json) {
237
+ process.stdout.write(JSON.stringify(records, null, 2) + "\n")
238
+ } else {
239
+ process.stdout.write(formatTable(records, sessionRecords) + "\n")
240
+ }
241
+ return 0
242
+ }
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Cost/token history — the CENTRAL project store's `cost-history.jsonl`
3
+ * (`~/.local/share/headlesscode/projects/<key>/cost-history.jsonl`, see
4
+ * src/project-store.ts). One record per orchestrator group that reaches a
5
+ * terminal status (done/failed/needs-human), keyed by the repo's
6
+ * git-common-dir so every worktree of a repo shares the same history file.
7
+ *
8
+ * Deliberately NOT posted to GitHub issues: a group can carry MULTIPLE
9
+ * issue numbers (see split.ts's same-shape batching), so a per-group total
10
+ * attached as an issue comment would either be duplicated across every
11
+ * issue in the group (misleading — reads as "this issue cost $X" when
12
+ * $X was actually spent across N issues) or arbitrarily attributed to
13
+ * one. A single structured record naming ALL of a group's issues avoids
14
+ * that ambiguity and stays queryable, which per-issue prose comments
15
+ * would not be.
16
+ *
17
+ * `group.usage` (read here) is already the CORRECT combined total across
18
+ * every session that ran on a group's worktree — inspectGroup's usage
19
+ * rollup (readWorktreeUsage) sums every `.headlesscode/usage/*.jsonl` file
20
+ * found, and a worktree gets a new usage file per session, so an original
21
+ * attempt plus any continuation/rework respawns on the SAME worktree are
22
+ * already combined by the time a group reaches its final terminal status.
23
+ *
24
+ * This is deliberately a plain append-only JSONL store, no query engine —
25
+ * matching the project's established idiom (mode-models.json, usage.ts,
26
+ * events.ts). Estimating future task cost FROM this history is implemented
27
+ * in cost-estimate.ts (issue #16): it aggregates these records by issue
28
+ * shape and `orchestrate --dry-run` reports the expected cost/iteration
29
+ * range for a new task; `headlesscode cost-history --by-shape` prints the
30
+ * per-shape aggregates. This module only records and reads raw records.
31
+ */
32
+
33
+ import * as fsp from "node:fs/promises"
34
+ import * as path from "node:path"
35
+
36
+ import { resolveProjectDataDir } from "../project-store.js"
37
+ import { usageDir, type UsageRecord } from "../engine/usage.js"
38
+ import type { OrchestratorGroup } from "./state.js"
39
+
40
+ /** One recorded group's combined cost/token/iteration totals. */
41
+ export interface CostHistoryRecord {
42
+ recordedAt: string
43
+ repo: string
44
+ groupName: string
45
+ /** Every GitHub issue number this group's work covered (see OrchestratorGroup.issues). */
46
+ issues: number[]
47
+ /**
48
+ * The per-issue shape (split.ts's issueShape: hot / split / coverage /
49
+ * test / docs / refactor / generic), one entry per issue in the same
50
+ * order as `issues` — captured at dispatch time (OrchestratorGroup.shapes)
51
+ * and copied here so cost-estimate.ts can match a NEW issue to similar
52
+ * past ones (issue #16). Undefined for records written before this field
53
+ * existed (or from groups spawned without shapes) — those records can
54
+ * only be matched by exact issue number, never by shape.
55
+ */
56
+ shapes?: string[]
57
+ /** Final status at recording time: done | failed | needs-human. */
58
+ status: string
59
+ branch?: string
60
+ costUsd: number
61
+ inputTokens: number
62
+ outputTokens: number
63
+ cachedTokens: number
64
+ iterations: number
65
+ /** How many auto-continuation/rework respawns happened on this group's worktree before it finished. */
66
+ continuationCount: number
67
+ reworkCount: number
68
+ /**
69
+ * When the group's FIRST worker session was spawned (OrchestratorGroup.spawned,
70
+ * i.e. round-dispatch time — the earliest point headlesscode itself has
71
+ * visibility into, not the GitHub issue's own createdAt). Undefined only
72
+ * for pre-existing groups recorded before this field was added.
73
+ */
74
+ spawnedAt?: string
75
+ /**
76
+ * Wall-clock time from spawnedAt to recordedAt (dispatch-to-completion),
77
+ * covering every continuation/rework/review/QA cycle in between — the
78
+ * metric that directly measures headlesscode's OWN pipeline efficiency,
79
+ * separate from cost. A bug that forces an extra rework cycle costs both
80
+ * money (costUsd) AND wall-clock time (this field); tracking both is what
81
+ * makes "did fixing bug X actually speed up the next run" answerable.
82
+ * Undefined when spawnedAt is unavailable.
83
+ */
84
+ wallClockMs?: number
85
+ }
86
+
87
+ /** Config file basename inside the central project data dir. */
88
+ export const COST_HISTORY_FILE = "cost-history.jsonl"
89
+
90
+ /** Absolute path of the central cost-history.jsonl for a workspace root. */
91
+ export function costHistoryFilePath(workspaceRoot: string): string {
92
+ return path.join(resolveProjectDataDir(workspaceRoot), COST_HISTORY_FILE)
93
+ }
94
+
95
+ /** Append one record (non-fatal on write failure is the CALLER's responsibility — this throws). */
96
+ export async function appendCostHistoryRecord(workspaceRoot: string, record: CostHistoryRecord): Promise<void> {
97
+ const file = costHistoryFilePath(workspaceRoot)
98
+ await fsp.mkdir(path.dirname(file), { recursive: true })
99
+ await fsp.appendFile(file, JSON.stringify(record) + "\n", "utf-8")
100
+ }
101
+
102
+ /**
103
+ * Read + loosely validate the cost-history file. Malformed lines are
104
+ * skipped; a missing file yields an empty array (never throws for ENOENT)
105
+ * — same idiom as `readEventsFile`/`readUsageFile`.
106
+ */
107
+ export async function readCostHistory(workspaceRoot: string): Promise<CostHistoryRecord[]> {
108
+ let raw: string
109
+ try {
110
+ raw = await fsp.readFile(costHistoryFilePath(workspaceRoot), "utf-8")
111
+ } catch (error) {
112
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
113
+ return []
114
+ }
115
+ throw error
116
+ }
117
+ const records: CostHistoryRecord[] = []
118
+ for (const line of raw.split("\n")) {
119
+ const trimmed = line.trim()
120
+ if (!trimmed) {
121
+ continue
122
+ }
123
+ try {
124
+ const parsed = JSON.parse(trimmed) as Partial<CostHistoryRecord>
125
+ if (
126
+ typeof parsed.recordedAt === "string" &&
127
+ typeof parsed.groupName === "string" &&
128
+ typeof parsed.costUsd === "number" &&
129
+ Array.isArray(parsed.issues)
130
+ ) {
131
+ records.push(parsed as CostHistoryRecord)
132
+ }
133
+ } catch {
134
+ // Loose validation: skip malformed / partially-written lines.
135
+ }
136
+ }
137
+ return records
138
+ }
139
+
140
+ /**
141
+ * Build a CostHistoryRecord from a terminal group and append it to the
142
+ * repo's central history. Returns `undefined` (records nothing) when the
143
+ * group has no `usage` — nothing meaningful to record (e.g. a group that
144
+ * failed before any session ever wrote a usage file).
145
+ */
146
+ export async function recordGroupCost(repoRoot: string, group: OrchestratorGroup): Promise<CostHistoryRecord | undefined> {
147
+ if (!group.usage) {
148
+ return undefined
149
+ }
150
+ const recordedAt = new Date().toISOString()
151
+ // group.spawned is NOT the original dispatch time — handleReviewVerdict/
152
+ // handleIterationExhaustion's reset patches explicitly overwrite it with
153
+ // "now" on every rework/continuation respawn (so the stall guard measures
154
+ // the CURRENT attempt, not a possibly-hours-old original one). Using it
155
+ // here would under-report wallClockMs to just the final cycle, hiding
156
+ // exactly the wasted time a bug-triggered extra cycle adds — the thing
157
+ // this metric exists to make visible. The true start is the EARLIEST
158
+ // startedAt across every session that ever ran on this worktree (usage
159
+ // files are never deleted across respawns), so derive it from there,
160
+ // falling back to group.spawned only if no usage data exists at all.
161
+ const worktreePath = path.resolve(repoRoot, group.worktree ?? `.worktrees/${group.name}`)
162
+ const spawnedAt = (await earliestSessionStart(worktreePath)) ?? group.spawned
163
+ const record: CostHistoryRecord = {
164
+ recordedAt,
165
+ repo: path.resolve(repoRoot),
166
+ groupName: group.name,
167
+ issues: group.issues ?? [],
168
+ shapes: group.shapes,
169
+ status: group.status,
170
+ branch: group.branch,
171
+ costUsd: group.usage.costUsd,
172
+ inputTokens: group.usage.inputTokens,
173
+ outputTokens: group.usage.outputTokens,
174
+ cachedTokens: group.usage.cachedTokens ?? 0,
175
+ iterations: group.usage.iterations,
176
+ continuationCount: group.continuationCount ?? 0,
177
+ reworkCount: group.reworkCount ?? 0,
178
+ spawnedAt,
179
+ wallClockMs: spawnedAt ? Date.parse(recordedAt) - Date.parse(spawnedAt) : undefined,
180
+ }
181
+ await appendCostHistoryRecord(repoRoot, record)
182
+ return record
183
+ }
184
+
185
+ /**
186
+ * The earliest `startedAt` across every session (completed `.jsonl` or
187
+ * orphaned `.live.json`) ever recorded in a worktree's usage dir — the
188
+ * true "work on this group began at" timestamp, immune to group.spawned
189
+ * being reset on every rework/continuation respawn. Returns undefined
190
+ * when the usage dir doesn't exist or contains no valid records.
191
+ */
192
+ async function earliestSessionStart(worktreePath: string): Promise<string | undefined> {
193
+ const dir = usageDir(worktreePath)
194
+ let entries: string[]
195
+ try {
196
+ entries = await fsp.readdir(dir)
197
+ } catch {
198
+ return undefined
199
+ }
200
+ let earliest: string | undefined
201
+ for (const file of entries) {
202
+ if (!file.endsWith(".jsonl") && !file.endsWith(".live.json")) {
203
+ continue
204
+ }
205
+ try {
206
+ const raw = await fsp.readFile(path.join(dir, file), "utf-8")
207
+ const firstLine = raw.trim().split("\n")[0]
208
+ const parsed = JSON.parse(firstLine) as { startedAt?: string }
209
+ if (typeof parsed.startedAt === "string" && (!earliest || parsed.startedAt < earliest)) {
210
+ earliest = parsed.startedAt
211
+ }
212
+ } catch {
213
+ continue
214
+ }
215
+ }
216
+ return earliest
217
+ }
218
+
219
+ /**
220
+ * One individual session's cost, with its own outcome — the per-session
221
+ * companion to CostHistoryRecord's per-GROUP combined total. Exists
222
+ * specifically so wasted spend (a session that errored, hit budget, or was
223
+ * killed before finishing) is visible and queryable on its own, not just
224
+ * silently folded into a group's final combined total. A real incident
225
+ * that motivated this: a worker respawned by a false-positive review bug
226
+ * ran to completion finding nothing to fix (real cost, zero useful
227
+ * outcome) — before this, that cost was invisible; the group-level total
228
+ * doesn't distinguish it from productive work.
229
+ */
230
+ export interface SessionCostRecord {
231
+ recordedAt: string
232
+ repo: string
233
+ groupName: string
234
+ issues: number[]
235
+ sessionId: string
236
+ /** The session's mode (code | deepseek-reviewer | qa-agent | ...) — the closest thing to a "role" label available. */
237
+ mode: string
238
+ /**
239
+ * success | error | budget — from the session's own final usage record
240
+ * (engine/usage.ts's UsageRecord.status) when it completed normally, or
241
+ * "killed" when only an orphaned `.live.json` snapshot was found (the
242
+ * session's own recordUsage()/removeLiveSnapshot() cleanup never ran —
243
+ * see usage.ts: that pair runs on EVERY normal completion path, success,
244
+ * error, or budget, so a leftover live snapshot means the process was
245
+ * killed or crashed before reaching it).
246
+ */
247
+ status: "success" | "error" | "budget" | "killed"
248
+ costUsd: number
249
+ inputTokens: number
250
+ outputTokens: number
251
+ cachedTokens: number
252
+ iterations: number
253
+ startedAt: string
254
+ endedAt?: string
255
+ }
256
+
257
+ /** Config file basename inside the central project data dir. */
258
+ export const SESSION_COST_HISTORY_FILE = "session-cost-history.jsonl"
259
+
260
+ /** Absolute path of the central session-cost-history.jsonl for a workspace root. */
261
+ export function sessionCostHistoryFilePath(workspaceRoot: string): string {
262
+ return path.join(resolveProjectDataDir(workspaceRoot), SESSION_COST_HISTORY_FILE)
263
+ }
264
+
265
+ /** Append one per-session record (non-fatal on write failure is the CALLER's responsibility — this throws). */
266
+ export async function appendSessionCostRecord(workspaceRoot: string, record: SessionCostRecord): Promise<void> {
267
+ const file = sessionCostHistoryFilePath(workspaceRoot)
268
+ await fsp.mkdir(path.dirname(file), { recursive: true })
269
+ await fsp.appendFile(file, JSON.stringify(record) + "\n", "utf-8")
270
+ }
271
+
272
+ /**
273
+ * Read + loosely validate the session-cost-history file. Malformed lines
274
+ * are skipped; a missing file yields an empty array (never throws for
275
+ * ENOENT) — same idiom as the group-level history.
276
+ */
277
+ export async function readSessionCostHistory(workspaceRoot: string): Promise<SessionCostRecord[]> {
278
+ let raw: string
279
+ try {
280
+ raw = await fsp.readFile(sessionCostHistoryFilePath(workspaceRoot), "utf-8")
281
+ } catch (error) {
282
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
283
+ return []
284
+ }
285
+ throw error
286
+ }
287
+ const records: SessionCostRecord[] = []
288
+ for (const line of raw.split("\n")) {
289
+ const trimmed = line.trim()
290
+ if (!trimmed) {
291
+ continue
292
+ }
293
+ try {
294
+ const parsed = JSON.parse(trimmed) as Partial<SessionCostRecord>
295
+ if (typeof parsed.recordedAt === "string" && typeof parsed.sessionId === "string" && typeof parsed.costUsd === "number") {
296
+ records.push(parsed as SessionCostRecord)
297
+ }
298
+ } catch {
299
+ // Loose validation: skip malformed / partially-written lines.
300
+ }
301
+ }
302
+ return records
303
+ }
304
+
305
+ /**
306
+ * Scan a worktree's `.headlesscode/usage/` dir for every session that has
307
+ * EVER run there (completed `.jsonl` records AND orphaned `.live.json`
308
+ * snapshots left behind by a killed/crashed session), and append a
309
+ * SessionCostRecord for each one not already recorded — idempotent via
310
+ * checking sessionIds already present in the history file, same pattern
311
+ * as `cost_recorded` gates re-recording at the group level. Returns every
312
+ * record appended THIS call (empty array if everything was already
313
+ * recorded, or the usage dir doesn't exist).
314
+ */
315
+ export async function recordAllSessionCosts(repoRoot: string, group: OrchestratorGroup): Promise<SessionCostRecord[]> {
316
+ const worktreePath = path.resolve(repoRoot, group.worktree ?? `.worktrees/${group.name}`)
317
+ const dir = usageDir(worktreePath)
318
+ let entries: string[]
319
+ try {
320
+ entries = await fsp.readdir(dir)
321
+ } catch {
322
+ return []
323
+ }
324
+
325
+ const existing = await readSessionCostHistory(repoRoot)
326
+ const alreadyRecorded = new Set(existing.map((r) => r.sessionId))
327
+
328
+ const completedIds = new Set(entries.filter((f) => f.endsWith(".jsonl")).map((f) => f.slice(0, -".jsonl".length)))
329
+ const newRecords: SessionCostRecord[] = []
330
+
331
+ for (const file of entries) {
332
+ if (file.endsWith(".jsonl")) {
333
+ const sessionId = file.slice(0, -".jsonl".length)
334
+ if (alreadyRecorded.has(sessionId)) {
335
+ continue
336
+ }
337
+ let usage: UsageRecord
338
+ try {
339
+ const raw = await fsp.readFile(path.join(dir, file), "utf-8")
340
+ usage = JSON.parse(raw.trim().split("\n")[0]) as UsageRecord
341
+ } catch {
342
+ continue
343
+ }
344
+ const record: SessionCostRecord = {
345
+ recordedAt: new Date().toISOString(),
346
+ repo: path.resolve(repoRoot),
347
+ groupName: group.name,
348
+ issues: group.issues ?? [],
349
+ sessionId,
350
+ mode: usage.mode,
351
+ status: usage.status,
352
+ costUsd: usage.costUsd,
353
+ inputTokens: usage.inputTokens,
354
+ outputTokens: usage.outputTokens,
355
+ cachedTokens: usage.cachedTokens ?? 0,
356
+ iterations: usage.iterations,
357
+ startedAt: usage.startedAt,
358
+ endedAt: usage.endedAt,
359
+ }
360
+ await appendSessionCostRecord(repoRoot, record)
361
+ newRecords.push(record)
362
+ } else if (file.endsWith(".live.json")) {
363
+ const sessionId = file.slice(0, -".live.json".length)
364
+ // A completed .jsonl for the same session means recordUsage() DID
365
+ // run (success/error/budget) — the live snapshot is just stale
366
+ // leftover from before that, not a sign of a killed session.
367
+ if (alreadyRecorded.has(sessionId) || completedIds.has(sessionId)) {
368
+ continue
369
+ }
370
+ let live: { mode: string; costUsd: number; inputTokens: number; outputTokens: number; cachedTokens?: number; iterations: number; startedAt: string }
371
+ try {
372
+ const raw = await fsp.readFile(path.join(dir, file), "utf-8")
373
+ live = JSON.parse(raw)
374
+ } catch {
375
+ continue
376
+ }
377
+ const record: SessionCostRecord = {
378
+ recordedAt: new Date().toISOString(),
379
+ repo: path.resolve(repoRoot),
380
+ groupName: group.name,
381
+ issues: group.issues ?? [],
382
+ sessionId,
383
+ mode: live.mode,
384
+ status: "killed",
385
+ costUsd: live.costUsd,
386
+ inputTokens: live.inputTokens,
387
+ outputTokens: live.outputTokens,
388
+ cachedTokens: live.cachedTokens ?? 0,
389
+ iterations: live.iterations,
390
+ startedAt: live.startedAt,
391
+ }
392
+ await appendSessionCostRecord(repoRoot, record)
393
+ newRecords.push(record)
394
+ }
395
+ }
396
+ return newRecords
397
+ }