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