freeharness 0.4.1 → 0.4.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/README.md +8 -7
- package/canonical/HARVEST.yaml +25 -14
- package/canonical/PROVENANCE.yaml +1 -1
- package/canonical/agents/README.md +77 -36
- package/canonical/agents/agentic-architect.md +2 -1
- package/canonical/agents/database-reviewer.md +6 -8
- package/canonical/agents/explorer-ai-engineer-analyst.md +2 -6
- package/canonical/agents/implementer.md +30 -0
- package/canonical/agents/pr-reviewer.md +9 -11
- package/canonical/agents/python-reviewer.md +7 -9
- package/canonical/agents/report-formatter.md +7 -3
- package/canonical/agents/security-reviewer.md +7 -8
- package/canonical/agents/test-writer.md +5 -2
- package/canonical/hooks/cost-prices.json +11 -9
- package/canonical/hooks/freeharness-config-guard.mjs +8072 -87
- package/canonical/hooks/freeharness-context-monitor.mjs +88 -54
- package/canonical/hooks/freeharness-converge.mjs +78 -23
- package/canonical/hooks/freeharness-cost-tracker.mjs +1129 -218
- package/canonical/hooks/freeharness-memory-guard.mjs +20 -23
- package/canonical/hooks/freeharness-precompact.mjs +217 -87
- package/canonical/hooks/freeharness-quota-tap.mjs +173 -0
- package/canonical/hooks/freeharness-reload-context.mjs +197 -0
- package/canonical/hooks/freeharness-repeat-tool.mjs +1 -1
- package/canonical/hooks/freeharness-sdd-context.mjs +970 -0
- package/canonical/hooks/freeharness-session-start +297 -59
- package/canonical/hooks/hooks.claude-code.json +1 -1
- package/canonical/hooks/hooks.config-guard.claude-code.json +3 -2
- package/canonical/hooks/hooks.context-monitor.claude-code.json +1 -1
- package/canonical/hooks/hooks.converge.claude-code.json +1 -1
- package/canonical/hooks/hooks.memory-guard.claude-code.json +3 -2
- package/canonical/hooks/hooks.reload-context.claude-code.json +15 -0
- package/canonical/openspec/schema.spec-driven.yaml +29 -8
- package/canonical/openspec/templates/follow-ups.md +3 -2
- package/canonical/openspec/templates/proposal.md +1 -1
- package/canonical/openspec/templates/tasks.md +8 -0
- package/canonical/rules/CLAUDE.md +5 -4
- package/canonical/rules/routing.md +10 -9
- package/canonical/skills/agent-evaluation/SKILL.md +17 -6
- package/canonical/skills/agent-evaluation/evals/cases.yaml +38 -23
- package/canonical/skills/agentic-architecture/SKILL.md +4 -0
- package/canonical/skills/agentic-architecture/evals/cases.yaml +27 -0
- package/canonical/skills/code-review/SKILL.md +9 -2
- package/canonical/skills/code-review/evals/cases.yaml +1118 -27
- package/canonical/skills/git-workflow/SKILL.md +2 -0
- package/canonical/skills/local-code/SKILL.md +2 -2
- package/canonical/skills/local-code/evals/cases.yaml +1 -1
- package/canonical/skills/report-formatting/SKILL.md +10 -2
- package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +3 -3
- package/canonical/skills/report-formatting/reference/HELPERS.md +10 -5
- package/canonical/skills/report-formatting/reference/RELEASE.md +1 -2
- package/canonical/skills/report-formatting/reference/TEMPLATES.md +3 -2
- package/canonical/skills/report-formatting/reference/authoring.md +25 -37
- package/canonical/skills/report-formatting/scripts/build-architecture.js +1 -1
- package/canonical/skills/report-formatting/scripts/build-md-claro.js +5 -8
- package/canonical/skills/report-formatting/scripts/check-env.js +0 -4
- package/canonical/skills/report-formatting/scripts/docmodel.js +3 -11
- package/canonical/skills/report-formatting/scripts/example-build.js +8 -10
- package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +8 -10
- package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +8 -10
- package/canonical/skills/report-formatting/scripts/theme-claro.js +39 -10
- package/canonical/skills/subagent-driven-development/SKILL.md +35 -28
- package/canonical/skills/subagent-driven-development/evals/cases.yaml +3496 -19
- package/canonical/skills/subagent-driven-development/reference/brief.md +185 -35
- package/canonical/skills/subagent-driven-development/reference/handoff.md +82 -2
- package/canonical/skills/subagent-driven-development/reference/loop.md +57 -0
- package/canonical/skills/subagent-driven-development/reference/trees.md +40 -0
- package/canonical/skills/subagent-driven-development/reference/workspace.md +31 -0
- package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +4771 -326
- package/dist/adapters/harness/claude-code.d.ts +19 -3
- package/dist/adapters/harness/claude-code.js +204 -39
- package/dist/adapters/harness/opencode.js +16 -2
- package/dist/adapters/harness/plugin.js +28 -17
- package/dist/adapters/memory/doctor.d.ts +174 -29
- package/dist/adapters/memory/doctor.js +758 -134
- package/dist/adapters/memory/migrate.d.ts +95 -0
- package/dist/adapters/memory/migrate.js +497 -0
- package/dist/adapters/memory/paths.d.ts +22 -0
- package/dist/adapters/memory/paths.js +85 -0
- package/dist/adapters/memory/vendored.d.ts +56 -29
- package/dist/adapters/memory/vendored.js +191 -154
- package/dist/adapters/sync/diff.js +4 -1
- package/dist/cli/cleanup-prompt.d.ts +35 -0
- package/dist/cli/cleanup-prompt.js +68 -0
- package/dist/cli/cleanup.d.ts +22 -24
- package/dist/cli/cleanup.js +182 -68
- package/dist/cli/cost-quota.d.ts +158 -0
- package/dist/cli/cost-quota.js +645 -0
- package/dist/cli/cost.d.ts +408 -46
- package/dist/cli/cost.js +1731 -57
- package/dist/cli/doctor.d.ts +52 -13
- package/dist/cli/doctor.js +193 -44
- package/dist/cli/hard-cleanup.d.ts +93 -0
- package/dist/cli/hard-cleanup.js +552 -0
- package/dist/cli/install-facts.d.ts +28 -0
- package/dist/cli/install-facts.js +66 -0
- package/dist/cli/json-key-edit.d.ts +18 -0
- package/dist/cli/json-key-edit.js +118 -0
- package/dist/cli/memory-import.d.ts +74 -0
- package/dist/cli/memory-import.js +469 -0
- package/dist/cli/plugin-validate.d.ts +12 -0
- package/dist/cli/plugin-validate.js +43 -0
- package/dist/cli/uninstall.d.ts +1 -1
- package/dist/cli/uninstall.js +2 -2
- package/dist/cli/upstream-launchers.d.ts +49 -0
- package/dist/cli/upstream-launchers.js +287 -0
- package/dist/index.js +247 -70
- package/dist/infra/fsutil.d.ts +31 -3
- package/dist/infra/fsutil.js +69 -6
- package/dist/infra/manifest.d.ts +2 -1
- package/dist/infra/manifest.js +3 -2
- package/dist/infra/prices.d.ts +18 -0
- package/dist/infra/prices.js +64 -0
- package/node_modules/@freeharness/core/dist/tiers.d.ts +10 -1
- package/node_modules/@freeharness/core/dist/tiers.js +24 -2
- package/node_modules/@freeharness/core/package.json +1 -1
- package/node_modules/@freeharness/sdd/PROVENANCE.yaml +279 -2
- package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +34 -8
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +7 -6
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +72 -64
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +48 -16
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +168 -70
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +36 -6
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +110 -49
- package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/list.js +57 -14
- package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.d.ts +7 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.js +118 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +3 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.js +94 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +17 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +182 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +2 -2
- package/node_modules/@freeharness/sdd/dist/src/shim/status.js +34 -5
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +49 -41
- package/node_modules/@freeharness/sdd/dist/src/shim/warnings.d.ts +21 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/warnings.js +66 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.d.ts +4 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.js +17 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +95 -22
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +14 -6
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +3 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +24 -4
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +4 -7
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +13 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +21 -6
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +80 -19
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +42 -22
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.d.ts +32 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.js +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +16 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +36 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +65 -30
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +8 -13
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +79 -102
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +4 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +50 -10
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +165 -37
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +24 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +55 -8
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +4 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +19 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +10 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +115 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +3 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +20 -20
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +75 -14
- package/node_modules/@freeharness/sdd/package.json +1 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +47 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +25 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +50 -11
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +48 -10
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +27 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +25 -12
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +17 -5
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +12 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +47 -33
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +17 -5
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +29 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +31 -13
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +75 -27
- package/package.json +6 -4
- package/provenance/canonical.PROVENANCE.yaml +1 -1
- package/provenance/memory.PROVENANCE.yaml +458 -6
- package/provenance/sdd.PROVENANCE.yaml +279 -2
- package/canonical/hooks/freeharness-auto-memory.mjs +0 -158
- package/canonical/hooks/hooks.auto-memory.claude-code.json +0 -16
package/dist/cli/cost.js
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import { isEffortLevel } from "@freeharness/core";
|
|
11
|
+
import { quotaCalibrationPath, readCalibration } from "./cost-quota.js";
|
|
12
|
+
import { resolveCanonicalDir } from "../infra/paths.js";
|
|
9
13
|
/** Usage log path: FREEHARNESS_COST_LOG when set, else ~/.claude/freeharness/cost/usage.jsonl (may not exist yet). */
|
|
10
14
|
export function costLogPath() {
|
|
11
15
|
return process.env.FREEHARNESS_COST_LOG || path.join(os.homedir(), ".claude", "freeharness", "cost", "usage.jsonl");
|
|
@@ -45,6 +49,27 @@ export function readLog(file) {
|
|
|
45
49
|
function emptyRow(key) {
|
|
46
50
|
return { key, sessions: 0, turns: 0, input: 0, output: 0, cache_read: 0, cache_write: 0, est_usd: 0 };
|
|
47
51
|
}
|
|
52
|
+
function isBucketLike(v) {
|
|
53
|
+
return typeof v === "object" && v !== null;
|
|
54
|
+
}
|
|
55
|
+
function isAgentUsageLike(v) {
|
|
56
|
+
return Boolean(v) && typeof v === "object" && typeof v.model === "string" && isBucketLike(v.usage);
|
|
57
|
+
}
|
|
58
|
+
function modelBuckets(byModel) {
|
|
59
|
+
return Object.entries(byModel ?? {}).filter((e) => isBucketLike(e[1]));
|
|
60
|
+
}
|
|
61
|
+
function newTally() {
|
|
62
|
+
return { storedEstimate: 0, withoutSplit: 0 };
|
|
63
|
+
}
|
|
64
|
+
function tally(t, priced) {
|
|
65
|
+
if (priced.storedEstimate)
|
|
66
|
+
t.storedEstimate += 1;
|
|
67
|
+
if (priced.missingSplit)
|
|
68
|
+
t.withoutSplit += 1;
|
|
69
|
+
}
|
|
70
|
+
function pricingMeta(table, loaded, storedEstimate, withoutSplit) {
|
|
71
|
+
return { table, loaded, stored_estimate: storedEstimate, without_split: withoutSplit };
|
|
72
|
+
}
|
|
48
73
|
function addBucket(row, b, usd) {
|
|
49
74
|
row.input += Number(b.input) || 0;
|
|
50
75
|
row.output += Number(b.output) || 0;
|
|
@@ -71,8 +96,40 @@ export function latestPerSession(records) {
|
|
|
71
96
|
}
|
|
72
97
|
return [...map.values()];
|
|
73
98
|
}
|
|
74
|
-
|
|
75
|
-
|
|
99
|
+
const MIN_SESSION_HOURS = 0.1;
|
|
100
|
+
function sessionKey(r, fallback) {
|
|
101
|
+
return r.transcript || r.session_id || fallback;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Hours between a session's first and last snapshot, floored so a single-snapshot session
|
|
105
|
+
* never divides by zero and a short one does not inflate the rate.
|
|
106
|
+
*/
|
|
107
|
+
function sessionHours(firstTs, lastTs) {
|
|
108
|
+
const span = (Date.parse(lastTs) - Date.parse(firstTs ?? lastTs)) / 3_600_000;
|
|
109
|
+
return Number.isFinite(span) ? Math.max(MIN_SESSION_HOURS, span) : MIN_SESSION_HOURS;
|
|
110
|
+
}
|
|
111
|
+
/** Prices one record's buckets (model -> bucket) against `prices`: summed `priceBucket` when non-empty, else a non-zero `storedUsd` (0 unloaded); a record with neither buckets nor a stored value contributes 0 and is not counted as a stored estimate. */
|
|
112
|
+
function priceRecord(buckets, storedUsd, prices, loaded) {
|
|
113
|
+
if (buckets.length === 0) {
|
|
114
|
+
if (storedUsd === 0)
|
|
115
|
+
return { usd: 0, missingSplit: false, storedEstimate: false, estimated: false };
|
|
116
|
+
return { usd: loaded ? storedUsd : 0, missingSplit: false, storedEstimate: true, estimated: false };
|
|
117
|
+
}
|
|
118
|
+
let usd = 0;
|
|
119
|
+
let missingSplit = false;
|
|
120
|
+
let estimated = false;
|
|
121
|
+
for (const [model, b] of buckets) {
|
|
122
|
+
if (!("cache_write_1h" in b))
|
|
123
|
+
missingSplit = true;
|
|
124
|
+
const match = priceMatch(prices, model);
|
|
125
|
+
usd += priceBucket(match.row, b);
|
|
126
|
+
if (match.estimated)
|
|
127
|
+
estimated = true;
|
|
128
|
+
}
|
|
129
|
+
return { usd, missingSplit, storedEstimate: false, estimated };
|
|
130
|
+
}
|
|
131
|
+
/** Aggregates usage lines into per-project and per-model rows plus a total, sorted by USD descending; USD comes from `priceRecord`. */
|
|
132
|
+
export function aggregate(records, filter = {}, log = costLogPath(), prices = loadPriceTable(costPricesPath()), pricesFile = costPricesPath()) {
|
|
76
133
|
const now = filter.now ?? new Date();
|
|
77
134
|
const since = filter.days !== undefined ? new Date(now.getTime() - filter.days * 86_400_000) : undefined;
|
|
78
135
|
let sessions = latestPerSession(records);
|
|
@@ -80,12 +137,27 @@ export function aggregate(records, filter = {}, log = costLogPath()) {
|
|
|
80
137
|
sessions = sessions.filter((r) => Date.parse(r.ts) >= since.getTime());
|
|
81
138
|
if (filter.project)
|
|
82
139
|
sessions = sessions.filter((r) => r.project === filter.project);
|
|
140
|
+
const loaded = Object.keys(prices).length > 0;
|
|
83
141
|
const byProject = new Map();
|
|
84
142
|
const byModel = new Map();
|
|
85
143
|
const total = emptyRow("total");
|
|
86
144
|
let last;
|
|
145
|
+
const t = newTally();
|
|
146
|
+
const firstTs = new Map();
|
|
147
|
+
for (const r of records) {
|
|
148
|
+
const key = sessionKey(r, "");
|
|
149
|
+
const prev = firstTs.get(key);
|
|
150
|
+
if (key && r.ts && (prev === undefined || r.ts < prev))
|
|
151
|
+
firstTs.set(key, r.ts);
|
|
152
|
+
}
|
|
153
|
+
const hoursOf = new Map();
|
|
154
|
+
let totalHours = 0;
|
|
155
|
+
const perHour = (cacheRead, hours) => Math.round(cacheRead / (hours || MIN_SESSION_HOURS));
|
|
87
156
|
for (const r of sessions) {
|
|
88
|
-
const
|
|
157
|
+
const models = modelBuckets(r.by_model);
|
|
158
|
+
const priced = priceRecord(models, Number(r.est_usd) || 0, prices, loaded);
|
|
159
|
+
tally(t, priced);
|
|
160
|
+
const usd = priced.usd;
|
|
89
161
|
const p = byProject.get(r.project) ?? emptyRow(r.project || "(unknown)");
|
|
90
162
|
p.sessions += 1;
|
|
91
163
|
p.turns += Number(r.turns) || 0;
|
|
@@ -94,11 +166,20 @@ export function aggregate(records, filter = {}, log = costLogPath()) {
|
|
|
94
166
|
total.sessions += 1;
|
|
95
167
|
total.turns += Number(r.turns) || 0;
|
|
96
168
|
addBucket(total, r.usage, usd);
|
|
97
|
-
const
|
|
169
|
+
const hours = sessionHours(firstTs.get(sessionKey(r, "")), r.ts);
|
|
170
|
+
hoursOf.set(p, (hoursOf.get(p) ?? 0) + hours);
|
|
171
|
+
totalHours += hours;
|
|
172
|
+
if (priced.estimated) {
|
|
173
|
+
p.price_estimated = true;
|
|
174
|
+
total.price_estimated = true;
|
|
175
|
+
}
|
|
98
176
|
for (const [model, b] of models) {
|
|
99
177
|
const m = byModel.get(model) ?? emptyRow(model);
|
|
100
178
|
m.sessions += 1;
|
|
101
|
-
|
|
179
|
+
const match = priceMatch(prices, model);
|
|
180
|
+
addBucket(m, b, priceBucket(match.row, b));
|
|
181
|
+
if (match.estimated)
|
|
182
|
+
m.price_estimated = true;
|
|
102
183
|
byModel.set(model, m);
|
|
103
184
|
}
|
|
104
185
|
if (!last || r.ts > last)
|
|
@@ -111,10 +192,11 @@ export function aggregate(records, filter = {}, log = costLogPath()) {
|
|
|
111
192
|
project: filter.project,
|
|
112
193
|
lines: records.length,
|
|
113
194
|
sessions: sessions.length,
|
|
114
|
-
byProject: finish(byProject.values()),
|
|
195
|
+
byProject: finish([...byProject.values()].map((p) => ({ ...p, cache_read_per_hour: perHour(p.cache_read, hoursOf.get(p) ?? 0) }))),
|
|
115
196
|
byModel: finish(byModel.values()),
|
|
116
|
-
total: { ...total, est_usd: round(total.est_usd) },
|
|
197
|
+
total: { ...total, est_usd: round(total.est_usd), cache_read_per_hour: perHour(total.cache_read, totalHours) },
|
|
117
198
|
last,
|
|
199
|
+
pricing: pricingMeta(pricesFile, loaded, t.storedEstimate, t.withoutSplit),
|
|
118
200
|
};
|
|
119
201
|
}
|
|
120
202
|
/** Formats a token count compactly (1.2k, 3.4M). */
|
|
@@ -125,9 +207,19 @@ export function fmtTokens(n) {
|
|
|
125
207
|
return `${(n / 1e3).toFixed(1)}k`;
|
|
126
208
|
return String(n);
|
|
127
209
|
}
|
|
210
|
+
/** First line of every text report: the price table and repricing counts, or a load-failure warning. */
|
|
211
|
+
function pricingLine(p) {
|
|
212
|
+
if (!p.loaded)
|
|
213
|
+
return priceWarnLine(p.table);
|
|
214
|
+
return `prices: ${p.table} (repriced from tokens; ${p.stored_estimate} from stored estimate, ${p.without_split} without cache-write split)`;
|
|
215
|
+
}
|
|
216
|
+
function priceWarnLine(table) {
|
|
217
|
+
return `WARN price table not loaded: ${table}; USD shown as 0`;
|
|
218
|
+
}
|
|
128
219
|
/** Renders the report as aligned text tables. */
|
|
129
220
|
export function formatReport(report) {
|
|
130
221
|
const lines = [];
|
|
222
|
+
lines.push(pricingLine(report.pricing));
|
|
131
223
|
const scope = [report.since ? `last ${Math.round((Date.now() - Date.parse(report.since)) / 86_400_000)} days` : "all time", report.project ? `project ${report.project}` : ""].filter(Boolean).join(", ");
|
|
132
224
|
lines.push(`cost report (${scope}): ${report.log}`);
|
|
133
225
|
lines.push(`lines ${report.lines}, sessions ${report.sessions}${report.last ? `, last entry ${report.last}` : ""}`);
|
|
@@ -138,10 +230,10 @@ export function formatReport(report) {
|
|
|
138
230
|
const table = (title, rows, showTurns) => {
|
|
139
231
|
lines.push("");
|
|
140
232
|
lines.push(title);
|
|
141
|
-
const head = [`${"".padEnd(28)}`, "sessions".padStart(8), showTurns ? "turns".padStart(6) : "", "input".padStart(9), "output".padStart(9), "cache_rd".padStart(9), "cache_wr".padStart(9), "est USD".padStart(10)].filter((c) => c !== "");
|
|
233
|
+
const head = [`${"".padEnd(28)}`, "sessions".padStart(8), showTurns ? "turns".padStart(6) : "", "input".padStart(9), "output".padStart(9), "cache_rd".padStart(9), "cache_wr".padStart(9), "est USD".padStart(10), showTurns ? "cache_rd/h".padStart(11) : ""].filter((c) => c !== "");
|
|
142
234
|
lines.push(` ${head.join(" ")}`);
|
|
143
235
|
for (const r of rows) {
|
|
144
|
-
const cells = [r.key.slice(0, 28).padEnd(28), String(r.sessions).padStart(8), showTurns ? String(r.turns).padStart(6) : "", fmtTokens(r.input).padStart(9), fmtTokens(r.output).padStart(9), fmtTokens(r.cache_read).padStart(9), fmtTokens(r.cache_write).padStart(9), r.est_usd.toFixed(4).padStart(10)].filter((c) => c !== "");
|
|
236
|
+
const cells = [r.key.slice(0, 28).padEnd(28), String(r.sessions).padStart(8), showTurns ? String(r.turns).padStart(6) : "", fmtTokens(r.input).padStart(9), fmtTokens(r.output).padStart(9), fmtTokens(r.cache_read).padStart(9), fmtTokens(r.cache_write).padStart(9), r.est_usd.toFixed(4).padStart(10), showTurns ? fmtTokens(r.cache_read_per_hour ?? 0).padStart(11) : "", r.price_estimated ? "(estimated)" : ""].filter((c) => c !== "");
|
|
145
237
|
lines.push(` ${cells.join(" ")}`);
|
|
146
238
|
}
|
|
147
239
|
};
|
|
@@ -170,7 +262,7 @@ export function costLogLine(file) {
|
|
|
170
262
|
const kb = (fs.statSync(file).size / 1024).toFixed(1);
|
|
171
263
|
const last = records[records.length - 1];
|
|
172
264
|
const sessions = latestPerSession(records).length;
|
|
173
|
-
return `OK cost log: ${records.length} entries (${sessions} sessions), ${kb} KB${last ? `, last ${last.ts} ${last.project} $${(Number(last.est_usd) || 0).toFixed(4)}` : ""} — ${file}`;
|
|
265
|
+
return `OK cost log: ${records.length} entries (${sessions} sessions), ${kb} KB${last ? `, last ${last.ts} ${last.project} $${(Number(last.est_usd) || 0).toFixed(4)}${last.price_estimated === true ? " (estimated)" : ""}` : ""} — ${file}`;
|
|
174
266
|
}
|
|
175
267
|
/** Tokens estimated from characters (4 chars per token). */
|
|
176
268
|
function charsToTokens(chars) {
|
|
@@ -179,9 +271,9 @@ function charsToTokens(chars) {
|
|
|
179
271
|
/**
|
|
180
272
|
* Ranks token sinks per project: sessions (or task classes) by total tokens with the
|
|
181
273
|
* tool-output share and the pruned bytes recovered, and tools by output. Sessions without a
|
|
182
|
-
* task class are `untagged` and counted
|
|
274
|
+
* task class are `untagged` and counted; USD is repriced per row like `aggregate`.
|
|
183
275
|
*/
|
|
184
|
-
export function sinks(records, filter = {}, log = costLogPath()) {
|
|
276
|
+
export function sinks(records, filter = {}, log = costLogPath(), prices = loadPriceTable(costPricesPath()), pricesFile = costPricesPath()) {
|
|
185
277
|
const now = filter.now ?? new Date();
|
|
186
278
|
const since = filter.days !== undefined ? new Date(now.getTime() - filter.days * 86_400_000) : undefined;
|
|
187
279
|
let sessions = latestPerSession(records);
|
|
@@ -190,9 +282,11 @@ export function sinks(records, filter = {}, log = costLogPath()) {
|
|
|
190
282
|
if (filter.project)
|
|
191
283
|
sessions = sessions.filter((r) => r.project === filter.project);
|
|
192
284
|
const top = filter.top && filter.top > 0 ? filter.top : 10;
|
|
285
|
+
const loaded = Object.keys(prices).length > 0;
|
|
193
286
|
const byProject = new Map();
|
|
194
287
|
let untagged = 0;
|
|
195
288
|
let prunedTotal = 0;
|
|
289
|
+
const t = newTally();
|
|
196
290
|
for (const r of sessions) {
|
|
197
291
|
const project = r.project || "(unknown)";
|
|
198
292
|
const cls = r.task_class && r.task_class.trim() ? r.task_class.trim() : "untagged";
|
|
@@ -207,7 +301,12 @@ export function sinks(records, filter = {}, log = costLogPath()) {
|
|
|
207
301
|
row.output += Number(r.usage.output) || 0;
|
|
208
302
|
row.tool_output_tokens += charsToTokens(r.tool_output_chars ?? 0);
|
|
209
303
|
row.pruned_bytes += Number(r.pruned_bytes) || 0;
|
|
210
|
-
|
|
304
|
+
const models = modelBuckets(r.by_model);
|
|
305
|
+
const priced = priceRecord(models, Number(r.est_usd) || 0, prices, loaded);
|
|
306
|
+
tally(t, priced);
|
|
307
|
+
row.est_usd += priced.usd;
|
|
308
|
+
if (priced.estimated)
|
|
309
|
+
row.price_estimated = true;
|
|
211
310
|
prunedTotal += Number(r.pruned_bytes) || 0;
|
|
212
311
|
p.rows.set(key, row);
|
|
213
312
|
for (const [tool, t] of Object.entries(r.by_tool ?? {})) {
|
|
@@ -280,11 +379,22 @@ export function sinks(records, filter = {}, log = costLogPath()) {
|
|
|
280
379
|
out.push({ project, rows, tools, notes });
|
|
281
380
|
}
|
|
282
381
|
out.sort((a, b) => b.rows.reduce((n, r) => n + r.tokens, 0) - a.rows.reduce((n, r) => n + r.tokens, 0) || a.project.localeCompare(b.project));
|
|
283
|
-
return {
|
|
382
|
+
return {
|
|
383
|
+
log,
|
|
384
|
+
since: since?.toISOString(),
|
|
385
|
+
project: filter.project,
|
|
386
|
+
by: filter.byTaskClass ? "task-class" : "session",
|
|
387
|
+
sessions: sessions.length,
|
|
388
|
+
untagged,
|
|
389
|
+
pruned_bytes: prunedTotal,
|
|
390
|
+
byProject: out,
|
|
391
|
+
pricing: pricingMeta(pricesFile, loaded, t.storedEstimate, t.withoutSplit),
|
|
392
|
+
};
|
|
284
393
|
}
|
|
285
394
|
/** Renders the sinks report. */
|
|
286
395
|
export function formatSinks(report) {
|
|
287
396
|
const lines = [];
|
|
397
|
+
lines.push(pricingLine(report.pricing));
|
|
288
398
|
const scope = [report.since ? `last ${Math.round((Date.now() - Date.parse(report.since)) / 86_400_000)} days` : "all time", report.project ? `project ${report.project}` : ""].filter(Boolean).join(", ");
|
|
289
399
|
lines.push(`cost sinks by ${report.by} (${scope}): ${report.log}`);
|
|
290
400
|
lines.push(`sessions ${report.sessions}, untagged ${report.untagged} (no Class: header in the active brief), pruned bytes recovered ${report.pruned_bytes}`);
|
|
@@ -298,7 +408,7 @@ export function formatSinks(report) {
|
|
|
298
408
|
lines.push(` ${"".padEnd(30)} ${"class".padEnd(12)} ${"sessions".padStart(8)} ${"tokens".padStart(9)} ${"output".padStart(9)} ${"tool-out".padStart(9)} ${"share%".padStart(7)} ${"pruned".padStart(9)} ${"est USD".padStart(9)}`);
|
|
299
409
|
for (const r of p.rows) {
|
|
300
410
|
const label = r.key === "observer_local" ? "observer (local)" : r.key;
|
|
301
|
-
lines.push(` ${label.slice(0, 30).padEnd(30)} ${r.task_class.slice(0, 12).padEnd(12)} ${String(r.sessions).padStart(8)} ${fmtTokens(r.tokens).padStart(9)} ${fmtTokens(r.output).padStart(9)} ${fmtTokens(r.tool_output_tokens).padStart(9)} ${r.tool_output_share.toFixed(1).padStart(7)} ${String(r.pruned_bytes).padStart(9)} ${r.est_usd.toFixed(4).padStart(9)}`);
|
|
411
|
+
lines.push(` ${label.slice(0, 30).padEnd(30)} ${r.task_class.slice(0, 12).padEnd(12)} ${String(r.sessions).padStart(8)} ${fmtTokens(r.tokens).padStart(9)} ${fmtTokens(r.output).padStart(9)} ${fmtTokens(r.tool_output_tokens).padStart(9)} ${r.tool_output_share.toFixed(1).padStart(7)} ${String(r.pruned_bytes).padStart(9)} ${r.est_usd.toFixed(4).padStart(9)}${r.price_estimated ? " (estimated)" : ""}`);
|
|
302
412
|
}
|
|
303
413
|
if (p.tools.length > 0) {
|
|
304
414
|
lines.push(" tools by output:");
|
|
@@ -356,26 +466,116 @@ function median(values) {
|
|
|
356
466
|
const mid = Math.floor(sorted.length / 2);
|
|
357
467
|
return sorted.length % 2 === 0 ? round((sorted[mid - 1] + sorted[mid]) / 2) : sorted[mid];
|
|
358
468
|
}
|
|
469
|
+
export const SESSION_CLASS_NAMES = ["sdd", "plan", "archive", "review", "debug", "untagged"];
|
|
470
|
+
/** Untagged over the measured records that carry a class, so legacy records cannot dilute the share the gate reads; null with none. */
|
|
471
|
+
export function untaggedShare(classes) {
|
|
472
|
+
const classed = SESSION_CLASS_NAMES.reduce((n, c) => n + classes[c], 0);
|
|
473
|
+
return classed === 0 ? null : classes.untagged / classed;
|
|
474
|
+
}
|
|
475
|
+
/** A record whose subagent figure is a discovery gap, not a measurement: Agent calls were made, no subagent tokens were read, and it predates `cc_version`. */
|
|
476
|
+
function subagentsUnread(r) {
|
|
477
|
+
return (Number(r.sinks?.agent_calls) || 0) > 0 && (Number(r.sinks?.subagents?.total) || 0) === 0 && !("cc_version" in r);
|
|
478
|
+
}
|
|
479
|
+
/** A record's activity class; an unknown or absent value with tokens is measured, absent with none is `no-model-calls`. */
|
|
480
|
+
function activityClass(r) {
|
|
481
|
+
if (r.activity === "no-transcript" || r.activity === "no-model-calls")
|
|
482
|
+
return r.activity;
|
|
483
|
+
if (r.activity !== undefined)
|
|
484
|
+
return "measured";
|
|
485
|
+
const tokens = (Number(r.sinks?.main?.total) || 0) + (Number(r.sinks?.subagents?.total) || 0);
|
|
486
|
+
return tokens > 0 ? "measured" : "no-model-calls";
|
|
487
|
+
}
|
|
488
|
+
/** Latest `SessionEnd` snapshot per `session_id`, for the by_model-less join. */
|
|
489
|
+
function lastSessionEnd(usage) {
|
|
490
|
+
const map = new Map();
|
|
491
|
+
for (const r of usage) {
|
|
492
|
+
if (r.event !== "SessionEnd")
|
|
493
|
+
continue;
|
|
494
|
+
const prev = map.get(r.session_id);
|
|
495
|
+
if (!prev || (r.ts ?? "") >= (prev.ts ?? ""))
|
|
496
|
+
map.set(r.session_id, r);
|
|
497
|
+
}
|
|
498
|
+
return map;
|
|
499
|
+
}
|
|
500
|
+
/** `by_model` plus `by_agent` entries, each keyed by its own model, for `priceRecord`. */
|
|
501
|
+
function priceableBuckets(byModel, byAgent) {
|
|
502
|
+
const buckets = modelBuckets(byModel);
|
|
503
|
+
for (const a of Object.values(byAgent ?? {})) {
|
|
504
|
+
if (isAgentUsageLike(a))
|
|
505
|
+
buckets.push([a.model, a.usage]);
|
|
506
|
+
}
|
|
507
|
+
return buckets;
|
|
508
|
+
}
|
|
509
|
+
/** Prices one telemetry record: its own buckets, else the joined snapshot's, else the stored `est_usd`. */
|
|
510
|
+
function priceTelemetryRecord(record, snapshots, prices, loaded) {
|
|
511
|
+
if (record.by_model !== undefined)
|
|
512
|
+
return priceRecord(priceableBuckets(record.by_model, record.sinks?.by_agent), Number(record.est_usd) || 0, prices, loaded);
|
|
513
|
+
const snap = snapshots.get(record.session_id);
|
|
514
|
+
const buckets = snap ? priceableBuckets(snap.by_model, snap.by_agent) : [];
|
|
515
|
+
return priceRecord(buckets, Number(record.est_usd) || 0, prices, loaded);
|
|
516
|
+
}
|
|
359
517
|
/**
|
|
360
|
-
* Groups telemetry records by
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
518
|
+
* Groups telemetry records by tagged `change` (`untagged` catch-all): session count and
|
|
519
|
+
* median tokens over `measured` records only; total tokens, est USD (repriced at read
|
|
520
|
+
* time) and the tier mix over every record. Every change with at least one record gets a
|
|
521
|
+
* row (sessions/medians 0 with none `measured`); ranked by total main tokens descending.
|
|
522
|
+
* `opts.days` keeps records whose `ts` falls in the last N days of `opts.now`; an unparseable
|
|
523
|
+
* `ts` cannot be placed in the window, so it is counted in `skippedTs` instead of guessed at.
|
|
364
524
|
*/
|
|
365
|
-
export function changes(records) {
|
|
525
|
+
export function changes(records, usage = readUsage(costLogPath()), prices = loadPriceTable(costPricesPath()), pricesFile = costPricesPath(), opts = {}) {
|
|
526
|
+
let skippedTs = 0;
|
|
527
|
+
if (opts.days !== undefined) {
|
|
528
|
+
const cutoff = (opts.now ?? Date.now()) - opts.days * 86_400_000;
|
|
529
|
+
records = records.filter((r) => {
|
|
530
|
+
const at = Date.parse(r.ts);
|
|
531
|
+
if (Number.isNaN(at)) {
|
|
532
|
+
skippedTs += 1;
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
return at >= cutoff;
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
const loaded = Object.keys(prices).length > 0;
|
|
539
|
+
const snapshots = lastSessionEnd(usage);
|
|
366
540
|
const groups = new Map();
|
|
541
|
+
const excluded = { "no-transcript": 0, "no-model-calls": 0 };
|
|
542
|
+
const classes = { sdd: 0, plan: 0, archive: 0, review: 0, debug: 0, untagged: 0, legacy: 0 };
|
|
367
543
|
for (const r of records) {
|
|
544
|
+
const cls = activityClass(r);
|
|
545
|
+
if (cls !== "measured")
|
|
546
|
+
excluded[cls] += 1;
|
|
547
|
+
else {
|
|
548
|
+
if (typeof r.session_class !== "string")
|
|
549
|
+
classes.legacy += 1;
|
|
550
|
+
else if (SESSION_CLASS_NAMES.includes(r.session_class))
|
|
551
|
+
classes[r.session_class] += 1;
|
|
552
|
+
else
|
|
553
|
+
classes.untagged += 1;
|
|
554
|
+
if (subagentsUnread(r))
|
|
555
|
+
excluded["subagents-unread"] = (excluded["subagents-unread"] ?? 0) + 1;
|
|
556
|
+
}
|
|
368
557
|
const key = r.change && r.change.trim() ? r.change.trim() : "untagged";
|
|
369
558
|
const arr = groups.get(key) ?? [];
|
|
370
559
|
arr.push(r);
|
|
371
560
|
groups.set(key, arr);
|
|
372
561
|
}
|
|
562
|
+
const t = newTally();
|
|
373
563
|
const rows = [];
|
|
374
564
|
for (const [change, recs] of groups) {
|
|
375
|
-
const
|
|
376
|
-
const
|
|
565
|
+
const measured = recs.filter((r) => activityClass(r) === "measured");
|
|
566
|
+
const mains = measured.map((r) => Number(r.sinks?.main?.total) || 0);
|
|
567
|
+
const subs = measured.filter((r) => !subagentsUnread(r)).map((r) => Number(r.sinks?.subagents?.total) || 0);
|
|
377
568
|
const totalObserver = recs.reduce((n, r) => n + (Number(r.sinks?.observer?.tokens) || 0), 0);
|
|
378
|
-
|
|
569
|
+
let est_usd = 0;
|
|
570
|
+
let estimated = false;
|
|
571
|
+
for (const r of recs) {
|
|
572
|
+
const priced = priceTelemetryRecord(r, snapshots, prices, loaded);
|
|
573
|
+
est_usd += priced.usd;
|
|
574
|
+
if (priced.estimated)
|
|
575
|
+
estimated = true;
|
|
576
|
+
tally(t, priced);
|
|
577
|
+
}
|
|
578
|
+
est_usd = round(est_usd);
|
|
379
579
|
const tierCounts = new Map();
|
|
380
580
|
for (const r of recs) {
|
|
381
581
|
if (!r.tier)
|
|
@@ -388,35 +588,57 @@ export function changes(records) {
|
|
|
388
588
|
.join(", ");
|
|
389
589
|
rows.push({
|
|
390
590
|
change,
|
|
391
|
-
sessions:
|
|
591
|
+
sessions: measured.length,
|
|
392
592
|
medianMain: median(mains),
|
|
393
|
-
totalMain:
|
|
593
|
+
totalMain: recs.reduce((n, r) => n + (Number(r.sinks?.main?.total) || 0), 0),
|
|
394
594
|
medianSubagents: median(subs),
|
|
395
|
-
totalSubagents:
|
|
595
|
+
totalSubagents: recs.filter((r) => !subagentsUnread(r)).reduce((n, r) => n + (Number(r.sinks?.subagents?.total) || 0), 0),
|
|
396
596
|
totalObserver,
|
|
397
597
|
est_usd,
|
|
398
598
|
tierMix,
|
|
599
|
+
...(estimated ? { price_estimated: true } : {}),
|
|
399
600
|
});
|
|
400
601
|
}
|
|
401
|
-
|
|
602
|
+
rows.sort((a, b) => b.totalMain - a.totalMain || a.change.localeCompare(b.change));
|
|
603
|
+
return { rows, pricing: pricingMeta(pricesFile, loaded, t.storedEstimate, t.withoutSplit), excluded, classes, skippedTs };
|
|
402
604
|
}
|
|
403
|
-
/** Renders the `--changes` report; `skipped` counts lines `readTelemetry` dropped (malformed or without `session_id`). */
|
|
404
|
-
export function formatChanges(rows, file, skipped) {
|
|
605
|
+
/** Renders the `--changes` report; `skipped` counts lines `readTelemetry` dropped (malformed or without `session_id`); `classes` adds the class line and `skippedTs` (set under `--days`) the unparseable-ts line. */
|
|
606
|
+
export function formatChanges(rows, file, skipped, pricing, excluded, classes, skippedTs) {
|
|
405
607
|
const lines = [];
|
|
608
|
+
lines.push(pricingLine(pricing));
|
|
406
609
|
lines.push(`cost report --changes: ${file}${skipped > 0 ? ` (${skipped} lines skipped)` : ""}`);
|
|
610
|
+
lines.push(`excluded sessions: no-transcript ${excluded["no-transcript"]}, no-model-calls ${excluded["no-model-calls"]}, subagents-unread ${excluded["subagents-unread"] ?? 0}`);
|
|
611
|
+
if (skippedTs !== undefined)
|
|
612
|
+
lines.push(`skipped records with an unparseable ts: ${skippedTs}`);
|
|
613
|
+
if (classes) {
|
|
614
|
+
const share = untaggedShare(classes);
|
|
615
|
+
const pct = share === null ? "n/a" : `${(share * 100).toFixed(1)}%`;
|
|
616
|
+
const mix = SESSION_CLASS_NAMES.map((c) => (c === "untagged" ? `untagged ${classes[c]} (${pct})` : `${c} ${classes[c]}`));
|
|
617
|
+
lines.push(`session classes: ${mix.join(", ")}, legacy ${classes.legacy}`);
|
|
618
|
+
}
|
|
407
619
|
if (rows.length === 0) {
|
|
408
620
|
lines.push("no sessions recorded yet");
|
|
409
621
|
return lines;
|
|
410
622
|
}
|
|
411
623
|
lines.push(` ${"change".padEnd(24)} ${"sessions".padStart(8)} ${"median main".padStart(12)} ${"total main".padStart(11)} ${"median sub".padStart(11)} ${"total sub".padStart(10)} ${"observer".padStart(9)} ${"est USD".padStart(9)} tier mix`);
|
|
412
624
|
for (const r of rows) {
|
|
413
|
-
lines.push(` ${r.change.slice(0, 24).padEnd(24)} ${String(r.sessions).padStart(8)} ${fmtTokens(r.medianMain).padStart(12)} ${fmtTokens(r.totalMain).padStart(11)} ${fmtTokens(r.medianSubagents).padStart(11)} ${fmtTokens(r.totalSubagents).padStart(10)} ${fmtTokens(r.totalObserver).padStart(9)} ${r.est_usd.toFixed(4).padStart(9)} ${r.tierMix || "-"}`);
|
|
625
|
+
lines.push(` ${r.change.slice(0, 24).padEnd(24)} ${String(r.sessions).padStart(8)} ${fmtTokens(r.medianMain).padStart(12)} ${fmtTokens(r.totalMain).padStart(11)} ${fmtTokens(r.medianSubagents).padStart(11)} ${fmtTokens(r.totalSubagents).padStart(10)} ${fmtTokens(r.totalObserver).padStart(9)} ${r.est_usd.toFixed(4).padStart(9)} ${r.tierMix || "-"}${r.price_estimated ? " (estimated)" : ""}`);
|
|
414
626
|
}
|
|
415
627
|
return lines;
|
|
416
628
|
}
|
|
629
|
+
/** USD for one bucket at `row`'s rates (0 with no row); mirrors the hook's `estimate`, deriving the 1-hour rate at 1.6x `cache_write` when absent. */
|
|
630
|
+
export function priceBucket(row, bucket) {
|
|
631
|
+
if (!row)
|
|
632
|
+
return 0;
|
|
633
|
+
const per = (n, price) => ((Number(n) || 0) * (Number(price) || 0)) / 1e6;
|
|
634
|
+
const write1h = Math.max(0, Math.min(Number(bucket.cache_write_1h) || 0, Number(bucket.cache_write) || 0));
|
|
635
|
+
const write5m = (Number(bucket.cache_write) || 0) - write1h;
|
|
636
|
+
const rate1h = row.cache_write_1h ?? (Number(row.cache_write) || 0) * 1.6;
|
|
637
|
+
return per(bucket.input, row.input) + per(bucket.output, row.output) + per(bucket.cache_read, row.cache_read) + per(write5m, row.cache_write) + per(write1h, rate1h);
|
|
638
|
+
}
|
|
417
639
|
/**
|
|
418
|
-
* Loads the price table
|
|
419
|
-
* yields an empty table so the estimate degrades to 0 instead of throwing.
|
|
640
|
+
* Loads the price table every `cost report` mode reprices from; a missing or malformed
|
|
641
|
+
* file yields an empty table so the estimate degrades to 0 instead of throwing.
|
|
420
642
|
* Mirrors `loadPrices` in canonical/hooks/freeharness-cost-tracker.mjs; kept as a
|
|
421
643
|
* separate copy since the hook is a standalone, dependency-free .mjs the CLI does not
|
|
422
644
|
* import from. Returns model name -> price row (per MTok).
|
|
@@ -430,41 +652,94 @@ export function loadPriceTable(file) {
|
|
|
430
652
|
return {};
|
|
431
653
|
}
|
|
432
654
|
}
|
|
655
|
+
/** An id new to the table (a new version, a retried alias) is priced at its family's rate, not
|
|
656
|
+
* at `default`, which misprices an opus call several times over. */
|
|
657
|
+
const PRICE_FAMILY_RE = /^claude-(?:opus|sonnet|haiku|fable)-/;
|
|
658
|
+
/** Leading numeric `-` segments of `key` after `prefix`: `claude-opus-5-5` gives [5, 5]. */
|
|
659
|
+
function versionSegments(key, prefix) {
|
|
660
|
+
const out = [];
|
|
661
|
+
for (const seg of key.slice(prefix.length).split("-")) {
|
|
662
|
+
if (!/^\d+$/.test(seg))
|
|
663
|
+
break;
|
|
664
|
+
out.push(Number(seg));
|
|
665
|
+
}
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
668
|
+
/** Positive when version `a` is newer than `b`; a missing segment counts lower, so 5-5 beats 5. */
|
|
669
|
+
function compareVersions(a, b) {
|
|
670
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
671
|
+
const x = i < a.length ? a[i] : -1;
|
|
672
|
+
const y = i < b.length ? b[i] : -1;
|
|
673
|
+
if (x !== y)
|
|
674
|
+
return x - y;
|
|
675
|
+
}
|
|
676
|
+
return 0;
|
|
677
|
+
}
|
|
433
678
|
/**
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
679
|
+
* Exact key, else the longest prefix key, else the id's family's newest key (estimated), else
|
|
680
|
+
* `default`. Mirrors `priceMatch` in canonical/hooks/freeharness-cost-tracker.mjs (same
|
|
681
|
+
* separate-copy rationale as `loadPriceTable`); a parity test holds the two together.
|
|
437
682
|
*/
|
|
438
|
-
function
|
|
439
|
-
|
|
440
|
-
|
|
683
|
+
export function priceMatch(prices, model) {
|
|
684
|
+
const has = (key) => Object.prototype.hasOwnProperty.call(prices, key) && Boolean(prices[key]);
|
|
685
|
+
if (has(model))
|
|
686
|
+
return { key: model, row: prices[model], estimated: false };
|
|
441
687
|
let best;
|
|
442
688
|
for (const key of Object.keys(prices)) {
|
|
443
689
|
if (key !== "default" && model.startsWith(key) && (!best || key.length > best.length))
|
|
444
690
|
best = key;
|
|
445
691
|
}
|
|
446
|
-
|
|
692
|
+
if (best)
|
|
693
|
+
return { key: best, row: prices[best], estimated: false };
|
|
694
|
+
const family = PRICE_FAMILY_RE.exec(model);
|
|
695
|
+
if (family) {
|
|
696
|
+
let newest;
|
|
697
|
+
let newestVersion = [];
|
|
698
|
+
for (const key of Object.keys(prices)) {
|
|
699
|
+
if (!key.startsWith(family[0]) || !has(key))
|
|
700
|
+
continue;
|
|
701
|
+
const version = versionSegments(key, family[0]);
|
|
702
|
+
const order = newest === undefined ? 1 : compareVersions(version, newestVersion);
|
|
703
|
+
if (order > 0 || (order === 0 && newest !== undefined && key.length > newest.length)) {
|
|
704
|
+
newest = key;
|
|
705
|
+
newestVersion = version;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (newest !== undefined)
|
|
709
|
+
return { key: newest, row: prices[newest], estimated: true };
|
|
710
|
+
}
|
|
711
|
+
return has("default") ? { key: "default", row: prices.default, estimated: false } : { key: null, row: undefined, estimated: false };
|
|
447
712
|
}
|
|
448
713
|
/**
|
|
449
|
-
* Ranks subagent dispatches by total tokens from each session's last `by_agent`
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
* and an est USD from the price table. Rows ranked by total tokens descending.
|
|
714
|
+
* Ranks subagent dispatches by total tokens from each session's last `by_agent` snapshot:
|
|
715
|
+
* per agent name, the model with the most tokens (ties broken by name, mirroring the hook's
|
|
716
|
+
* `mergeAgentUsage`), `dispatches`/`messages` summed across sessions (no `dispatches` field
|
|
717
|
+
* counts as 1), USD from `priceBucket` per entry summed per name. Ranked by tokens
|
|
718
|
+
* descending, paired with the pricing header `formatAgents` reads.
|
|
455
719
|
*/
|
|
456
|
-
export function agents(records, prices = loadPriceTable(costPricesPath())) {
|
|
720
|
+
export function agents(records, prices = loadPriceTable(costPricesPath()), pricesFile = costPricesPath()) {
|
|
457
721
|
const sessions = latestPerSession(records);
|
|
722
|
+
const loaded = Object.keys(prices).length > 0;
|
|
458
723
|
const byName = new Map();
|
|
724
|
+
let withoutSplit = 0;
|
|
459
725
|
for (const r of sessions) {
|
|
726
|
+
let sessionMissingSplit = false;
|
|
460
727
|
for (const [name, a] of Object.entries(r.by_agent ?? {})) {
|
|
461
|
-
|
|
728
|
+
if (!isAgentUsageLike(a))
|
|
729
|
+
continue;
|
|
730
|
+
const row = byName.get(name) ?? { model: a.model, bestTotal: -1, dispatches: 0, messages: 0, input: 0, output: 0, cache_read: 0, cache_write: 0, est_usd: 0, estimated: false };
|
|
462
731
|
row.dispatches += Number(a.dispatches) || 1;
|
|
463
732
|
row.messages += Number(a.messages) || 0;
|
|
464
733
|
row.input += Number(a.usage.input) || 0;
|
|
465
734
|
row.output += Number(a.usage.output) || 0;
|
|
466
735
|
row.cache_read += Number(a.usage.cache_read) || 0;
|
|
467
736
|
row.cache_write += Number(a.usage.cache_write) || 0;
|
|
737
|
+
const match = priceMatch(prices, a.model);
|
|
738
|
+
row.est_usd += priceBucket(match.row, a.usage);
|
|
739
|
+
if (match.estimated)
|
|
740
|
+
row.estimated = true;
|
|
741
|
+
if (!("cache_write_1h" in a.usage))
|
|
742
|
+
sessionMissingSplit = true;
|
|
468
743
|
const total = Number(a.usage.total) || 0;
|
|
469
744
|
if (total > row.bestTotal || (total === row.bestTotal && a.model.localeCompare(row.model) < 0)) {
|
|
470
745
|
row.bestTotal = total;
|
|
@@ -472,26 +747,25 @@ export function agents(records, prices = loadPriceTable(costPricesPath())) {
|
|
|
472
747
|
}
|
|
473
748
|
byName.set(name, row);
|
|
474
749
|
}
|
|
750
|
+
if (sessionMissingSplit)
|
|
751
|
+
withoutSplit += 1;
|
|
475
752
|
}
|
|
476
753
|
const rows = [];
|
|
477
754
|
for (const [name, r] of byName) {
|
|
478
755
|
const tokens = r.input + r.output + r.cache_read + r.cache_write;
|
|
479
|
-
|
|
480
|
-
const per = (n, p) => (n * (Number(p) || 0)) / 1e6;
|
|
481
|
-
const est_usd = price ? per(r.input, price.input) + per(r.output, price.output) + per(r.cache_read, price.cache_read) + per(r.cache_write, price.cache_write) : 0;
|
|
482
|
-
rows.push({ name, model: r.model, dispatches: r.dispatches, messages: r.messages, input: r.input, output: r.output, cache_read: r.cache_read, cache_write: r.cache_write, tokens, est_usd: round(est_usd) });
|
|
756
|
+
rows.push({ name, model: r.model, dispatches: r.dispatches, messages: r.messages, input: r.input, output: r.output, cache_read: r.cache_read, cache_write: r.cache_write, tokens, est_usd: round(r.est_usd), ...(r.estimated ? { price_estimated: true } : {}) });
|
|
483
757
|
}
|
|
484
|
-
|
|
758
|
+
rows.sort((a, b) => b.tokens - a.tokens || a.name.localeCompare(b.name));
|
|
759
|
+
return { rows, pricing: pricingMeta(pricesFile, loaded, 0, withoutSplit) };
|
|
485
760
|
}
|
|
486
|
-
/** Renders the `--agents` report; `
|
|
487
|
-
export function formatAgents(rows,
|
|
761
|
+
/** Renders the `--agents` report; `pricing.loaded: false` prints a warning and every row shows 0.00. */
|
|
762
|
+
export function formatAgents(rows, pricing) {
|
|
488
763
|
const lines = [];
|
|
764
|
+
lines.push(pricingLine(pricing));
|
|
489
765
|
lines.push("cost report --agents (ranked by total tokens):");
|
|
490
|
-
if (!priceTable.loaded)
|
|
491
|
-
lines.push(`price table not loaded (${priceTable.file}): est USD shown as 0.00`);
|
|
492
766
|
lines.push(` ${"agent".padEnd(30)} ${"model".padEnd(24)} ${"dispatch".padStart(8)} ${"messages".padStart(8)} ${"tokens".padStart(9)} ${"est USD".padStart(9)}`);
|
|
493
767
|
for (const r of rows) {
|
|
494
|
-
lines.push(` ${r.name.slice(0, 30).padEnd(30)} ${r.model.slice(0, 24).padEnd(24)} ${String(r.dispatches).padStart(8)} ${String(r.messages).padStart(8)} ${fmtTokens(r.tokens).padStart(9)} ${r.est_usd.toFixed(4).padStart(9)}`);
|
|
768
|
+
lines.push(` ${r.name.slice(0, 30).padEnd(30)} ${r.model.slice(0, 24).padEnd(24)} ${String(r.dispatches).padStart(8)} ${String(r.messages).padStart(8)} ${fmtTokens(r.tokens).padStart(9)} ${r.est_usd.toFixed(4).padStart(9)}${r.price_estimated ? " (estimated)" : ""}`);
|
|
495
769
|
}
|
|
496
770
|
return lines;
|
|
497
771
|
}
|
|
@@ -499,3 +773,1403 @@ export function formatAgents(rows, priceTable = { file: costPricesPath(), loaded
|
|
|
499
773
|
export function telemetryPath(dir = process.cwd()) {
|
|
500
774
|
return path.join(path.resolve(dir), ".freeharness", "telemetry.jsonl");
|
|
501
775
|
}
|
|
776
|
+
const FAMILIES = ["opus", "sonnet", "haiku", "fable", "other"];
|
|
777
|
+
/** The family a model id names as a whole `-`/`.`/`_`-delimited segment, so `claude-opusx-1` is not Opus. */
|
|
778
|
+
export function familyOf(model) {
|
|
779
|
+
const segments = model.toLowerCase().split(/[^a-z0-9]+/);
|
|
780
|
+
return FAMILIES.find((f) => f !== "other" && segments.includes(f)) ?? "other";
|
|
781
|
+
}
|
|
782
|
+
/** Sums buckets per model into the two groups per family, in `FAMILIES` order; `cache_write_1h` is part of `cache_write`, so it is never added twice. */
|
|
783
|
+
export function tokenGroups(usageByModel) {
|
|
784
|
+
const sums = new Map();
|
|
785
|
+
for (const [model, b] of Object.entries(usageByModel)) {
|
|
786
|
+
const family = familyOf(model);
|
|
787
|
+
const g = sums.get(family) ?? { cache_read: 0, fresh: 0 };
|
|
788
|
+
g.cache_read += Number(b.cache_read) || 0;
|
|
789
|
+
g.fresh += (Number(b.input) || 0) + (Number(b.cache_write) || 0) + (Number(b.output) || 0);
|
|
790
|
+
sums.set(family, g);
|
|
791
|
+
}
|
|
792
|
+
const out = {};
|
|
793
|
+
for (const f of FAMILIES) {
|
|
794
|
+
const g = sums.get(f);
|
|
795
|
+
if (g)
|
|
796
|
+
out[f] = g;
|
|
797
|
+
}
|
|
798
|
+
return out;
|
|
799
|
+
}
|
|
800
|
+
const UNCLASSIFIED = "unclassified";
|
|
801
|
+
/** Joins a change and task into an `AcceptanceMap` key. */
|
|
802
|
+
export function acceptanceKey(change, task) {
|
|
803
|
+
return `${change}\u0000${task}`;
|
|
804
|
+
}
|
|
805
|
+
const ROUTING_READ_CAP = 16 * 1024 * 1024;
|
|
806
|
+
/** The brief cap the cost tracker and the handoff validator read under. */
|
|
807
|
+
const BRIEF_READ_CAP = 1024 * 1024;
|
|
808
|
+
/** Whether a filesystem error means the file is absent rather than unreadable. */
|
|
809
|
+
function isNotFound(err) {
|
|
810
|
+
return err.code === "ENOENT";
|
|
811
|
+
}
|
|
812
|
+
/** A regular file's text within `ROUTING_READ_CAP`; a symlink, FIFO, device, oversize or unreadable file lands in `skipped`, an absent one does not. */
|
|
813
|
+
function readRegularFile(file, skipped) {
|
|
814
|
+
return readRegularEntry(file, skipped)?.text;
|
|
815
|
+
}
|
|
816
|
+
/** `readRegularFile` with the file's mtime, under `cap` bytes. */
|
|
817
|
+
function readRegularEntry(file, skipped, cap = ROUTING_READ_CAP) {
|
|
818
|
+
let st;
|
|
819
|
+
try {
|
|
820
|
+
st = fs.lstatSync(file);
|
|
821
|
+
}
|
|
822
|
+
catch (err) {
|
|
823
|
+
if (!isNotFound(err))
|
|
824
|
+
skipped.push(file);
|
|
825
|
+
return undefined;
|
|
826
|
+
}
|
|
827
|
+
if (!st.isFile() || st.size > cap) {
|
|
828
|
+
skipped.push(file);
|
|
829
|
+
return undefined;
|
|
830
|
+
}
|
|
831
|
+
try {
|
|
832
|
+
return { text: fs.readFileSync(file, "utf8"), mtimeMs: st.mtimeMs };
|
|
833
|
+
}
|
|
834
|
+
catch (err) {
|
|
835
|
+
if (!isNotFound(err))
|
|
836
|
+
skipped.push(file);
|
|
837
|
+
return undefined;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
/** Reads one change's `ledger.jsonl` into role-filtered lines; invalid JSON, an impl/fix/review line without `ts`, `task` or `model`, and a line with any other non-null role (or none) are malformed and counted; a `role: null` line (a non-SDD dispatch) is skipped uncounted. */
|
|
841
|
+
export function readLedgerLines(file, change) {
|
|
842
|
+
const lines = [];
|
|
843
|
+
let malformed = 0;
|
|
844
|
+
const skippedFiles = [];
|
|
845
|
+
const text = readRegularFile(file, skippedFiles);
|
|
846
|
+
if (text === undefined)
|
|
847
|
+
return { lines, malformed, skipped: skippedFiles.length > 0 };
|
|
848
|
+
for (const raw of text.split("\n")) {
|
|
849
|
+
if (!raw.trim())
|
|
850
|
+
continue;
|
|
851
|
+
let parsed;
|
|
852
|
+
try {
|
|
853
|
+
parsed = JSON.parse(raw);
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
malformed += 1;
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
if (!parsed || typeof parsed !== "object") {
|
|
860
|
+
malformed += 1;
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
const r = parsed;
|
|
864
|
+
if (r.role === null)
|
|
865
|
+
continue;
|
|
866
|
+
if (r.role !== "impl" && r.role !== "fix" && r.role !== "review") {
|
|
867
|
+
malformed += 1;
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
if (typeof r.ts !== "string" || !r.ts || typeof r.task !== "string" || !r.task || typeof r.model !== "string" || !r.model) {
|
|
871
|
+
malformed += 1;
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
const effortSource = r.effort_source === "payload" || r.effort_source === "transcript" || r.effort_source === "agent" ? r.effort_source : null;
|
|
875
|
+
lines.push({
|
|
876
|
+
change,
|
|
877
|
+
ts: r.ts,
|
|
878
|
+
role: r.role,
|
|
879
|
+
task: r.task,
|
|
880
|
+
model: r.model,
|
|
881
|
+
// The agent definition's effort applies only to plain subagent dispatches, so an agent-sourced effort is unverified and read as null.
|
|
882
|
+
effort: effortSource !== "agent" && isEffortLevel(r.effort) ? r.effort : null,
|
|
883
|
+
input: Number(r.input) || 0,
|
|
884
|
+
output: Number(r.output) || 0,
|
|
885
|
+
cache_read: Number(r.cache_read) || 0,
|
|
886
|
+
cache_write: Number(r.cache_write) || 0,
|
|
887
|
+
cache_write_1h: typeof r.cache_write_1h === "number" ? r.cache_write_1h : undefined,
|
|
888
|
+
agent_id: typeof r.agent_id === "string" ? r.agent_id : null,
|
|
889
|
+
late: r.late === true,
|
|
890
|
+
effort_source: effortSource,
|
|
891
|
+
transcript: typeof r.transcript === "string" ? r.transcript : undefined,
|
|
892
|
+
round: typeof r.round === "number" && Number.isFinite(r.round) ? r.round : null,
|
|
893
|
+
session: typeof r.session === "string" ? r.session : null,
|
|
894
|
+
cc_version: typeof r.cc_version === "string" ? r.cc_version : null,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
return { lines, malformed, skipped: false };
|
|
898
|
+
}
|
|
899
|
+
/** The vendored engine's `TASK_LINE_PATTERN`; not part of @freeharness/sdd's public surface, so kept as a local copy. */
|
|
900
|
+
const TASK_LINE_PATTERN = /^\s*(?:[-*+]|\d{1,9}[.)])\s*\[(?:\s*([^\]\s])\s*\](?![([])|\](?![([])|\s+\])\s*(.*)/;
|
|
901
|
+
/** A task line's leading `N.N` numbering. */
|
|
902
|
+
const TASK_ID_PATTERN = /^(\d+(?:\.\d+)+)(?=\s|$)/;
|
|
903
|
+
/** Task ids (leading `N.N` numbering) checked (`x`/`X`) in a tasks.md body; mirrors the vendored reader's marker set (dash, star, plus, ordered). */
|
|
904
|
+
export function checkedTaskIds(content) {
|
|
905
|
+
return taskLineIds(content).checked;
|
|
906
|
+
}
|
|
907
|
+
/** Every task line's id and the checked ones, from one pass over a tasks.md body. */
|
|
908
|
+
function taskLineIds(content) {
|
|
909
|
+
const all = new Set();
|
|
910
|
+
const checked = new Set();
|
|
911
|
+
for (const line of content.split("\n")) {
|
|
912
|
+
const m = line.match(TASK_LINE_PATTERN);
|
|
913
|
+
if (!m)
|
|
914
|
+
continue;
|
|
915
|
+
const id = m[2].trim().match(TASK_ID_PATTERN)?.[1];
|
|
916
|
+
if (!id)
|
|
917
|
+
continue;
|
|
918
|
+
all.add(id);
|
|
919
|
+
if ((m[1] ?? "").toLowerCase() === "x")
|
|
920
|
+
checked.add(id);
|
|
921
|
+
}
|
|
922
|
+
return { all, checked };
|
|
923
|
+
}
|
|
924
|
+
/** A dotted numeric id, the only shape that rolls up; linear on untrusted ledger text. */
|
|
925
|
+
const DOTTED_TASK_RE = /^\d+(?:\.\d+)+$/;
|
|
926
|
+
/** A dotted id below the `N.N` depth tasks.md numbers its tasks at. */
|
|
927
|
+
const SUB_TASK_RE = /^\d+(?:\.\d+){2,}$/;
|
|
928
|
+
/**
|
|
929
|
+
* The tasks.md task a dispatch's task id counts under, so splitting a task into sub-tasks never
|
|
930
|
+
* raises the task count: the id itself or its longest prefix (cut at a dot) listed as a task line.
|
|
931
|
+
* @param task The ledger task id.
|
|
932
|
+
* @param tasksMdIds Every task line id of the change's tasks.md.
|
|
933
|
+
* @returns The listed id, or null when no prefix is listed or the id is not dotted numbers.
|
|
934
|
+
*/
|
|
935
|
+
export function rollUpTask(task, tasksMdIds) {
|
|
936
|
+
if (!DOTTED_TASK_RE.test(task))
|
|
937
|
+
return null;
|
|
938
|
+
for (let id = task;; id = id.slice(0, id.lastIndexOf("."))) {
|
|
939
|
+
if (tasksMdIds.has(id))
|
|
940
|
+
return id;
|
|
941
|
+
if (!id.includes("."))
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
// The prompt brief path rule below mirrors briefPathFromText in
|
|
946
|
+
// canonical/hooks/freeharness-cost-tracker.mjs, a standalone hook the CLI cannot import, so the
|
|
947
|
+
// report files a dispatch under the change the tracker filed it under.
|
|
948
|
+
/** The tracker's transcript cap; a larger transcript names no brief there, so none here either. */
|
|
949
|
+
const PROMPT_TRANSCRIPT_CAP = 64 * 1024 * 1024;
|
|
950
|
+
const PROMPT_SCAN_CHARS = 16 * 1024;
|
|
951
|
+
const PROMPT_BRIEF_MARK = ".freeharness/sdd/";
|
|
952
|
+
const PROMPT_BRIEF_SUFFIX = ".brief.md";
|
|
953
|
+
const PROMPT_TOKEN_STOPS = new Set(['"', "'", "`", "(", ")", "<", ">", "[", "]"]);
|
|
954
|
+
const PROMPT_TOKEN_TRAIL = new Set([".", ",", ";", ":", "!", "?"]);
|
|
955
|
+
function promptTokenStop(ch) {
|
|
956
|
+
return PROMPT_TOKEN_STOPS.has(ch) || ch.trim() === "";
|
|
957
|
+
}
|
|
958
|
+
/** Kebab-case only, checked without a nested quantifier since the prompt is untrusted. */
|
|
959
|
+
function kebabSegment(s) {
|
|
960
|
+
return /^[a-z0-9-]+$/.test(s) && !s.startsWith("-") && !s.endsWith("-") && !s.includes("--");
|
|
961
|
+
}
|
|
962
|
+
function realPathOr(p) {
|
|
963
|
+
try {
|
|
964
|
+
return fs.realpathSync(p);
|
|
965
|
+
}
|
|
966
|
+
catch {
|
|
967
|
+
return p;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
/** A relative prefix, or the project root itself (lexical or real) followed by a slash. */
|
|
971
|
+
function promptRootAccepted(prefix, root) {
|
|
972
|
+
if (prefix === "" || prefix === "./")
|
|
973
|
+
return true;
|
|
974
|
+
if (!path.isAbsolute(prefix) || !prefix.endsWith("/") || !root)
|
|
975
|
+
return false;
|
|
976
|
+
const withSep = (p) => (p.endsWith("/") ? p : `${p}/`);
|
|
977
|
+
const resolved = path.resolve(root);
|
|
978
|
+
return prefix === withSep(resolved) || prefix === withSep(realPathOr(resolved));
|
|
979
|
+
}
|
|
980
|
+
/** The change of the first `.freeharness/sdd/<change>/<file>.brief.md` token in the prompt's leading slice; an invalid first match yields null so a prompt cannot steer past a refused path. */
|
|
981
|
+
function promptBriefFromText(text, root) {
|
|
982
|
+
if (!text)
|
|
983
|
+
return null;
|
|
984
|
+
const truncated = text.length > PROMPT_SCAN_CHARS;
|
|
985
|
+
const scan = truncated ? text.slice(0, PROMPT_SCAN_CHARS) : text;
|
|
986
|
+
let from = 0;
|
|
987
|
+
for (;;) {
|
|
988
|
+
const at = scan.indexOf(PROMPT_BRIEF_MARK, from);
|
|
989
|
+
if (at < 0)
|
|
990
|
+
return null;
|
|
991
|
+
let start = at;
|
|
992
|
+
while (start > from && !promptTokenStop(scan[start - 1]))
|
|
993
|
+
start -= 1;
|
|
994
|
+
const restStart = at + PROMPT_BRIEF_MARK.length;
|
|
995
|
+
let end = restStart;
|
|
996
|
+
while (end < scan.length && !promptTokenStop(scan[end]))
|
|
997
|
+
end += 1;
|
|
998
|
+
if (truncated && end === scan.length)
|
|
999
|
+
return null;
|
|
1000
|
+
let stop = end;
|
|
1001
|
+
while (stop > restStart && PROMPT_TOKEN_TRAIL.has(scan[stop - 1]))
|
|
1002
|
+
stop -= 1;
|
|
1003
|
+
if (stop - PROMPT_BRIEF_SUFFIX.length >= restStart && scan.endsWith(PROMPT_BRIEF_SUFFIX, stop)) {
|
|
1004
|
+
const token = scan.slice(start, stop);
|
|
1005
|
+
if (token.includes("..") || token.includes("\\"))
|
|
1006
|
+
return null;
|
|
1007
|
+
const parts = scan.slice(restStart, stop - PROMPT_BRIEF_SUFFIX.length).split("/");
|
|
1008
|
+
if (parts.length !== 2 || !kebabSegment(parts[0]) || !parts[1])
|
|
1009
|
+
return null;
|
|
1010
|
+
if (!promptRootAccepted(scan.slice(start, at), root))
|
|
1011
|
+
return null;
|
|
1012
|
+
return parts[0];
|
|
1013
|
+
}
|
|
1014
|
+
from = end;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/** A message's text as the tracker reads it: string content, else its text blocks joined. */
|
|
1018
|
+
function messageText(msg) {
|
|
1019
|
+
const content = msg && typeof msg === "object" ? msg.content : null;
|
|
1020
|
+
if (typeof content === "string")
|
|
1021
|
+
return content;
|
|
1022
|
+
if (!Array.isArray(content))
|
|
1023
|
+
return "";
|
|
1024
|
+
return content
|
|
1025
|
+
.filter((c) => Boolean(c) && c.type === "text" && typeof c.text === "string")
|
|
1026
|
+
.map((c) => c.text)
|
|
1027
|
+
.join("\n");
|
|
1028
|
+
}
|
|
1029
|
+
/** Claude Code's config directory: `CLAUDE_CONFIG_DIR`, else `~/.claude`. */
|
|
1030
|
+
function claudeConfigDir() {
|
|
1031
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
1032
|
+
}
|
|
1033
|
+
/** `file`'s real path when strictly under the real `<config dir>/projects` (the cost tracker's rule), else null: a project's ledger names its own transcript paths. */
|
|
1034
|
+
function containedTranscript(file) {
|
|
1035
|
+
try {
|
|
1036
|
+
const base = fs.realpathSync(path.resolve(claudeConfigDir(), "projects"));
|
|
1037
|
+
const real = fs.realpathSync(file);
|
|
1038
|
+
const rel = path.relative(base, real);
|
|
1039
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
|
|
1040
|
+
return null;
|
|
1041
|
+
return real;
|
|
1042
|
+
}
|
|
1043
|
+
catch {
|
|
1044
|
+
return null;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
/** A contained transcript's text through one fd that never follows a symlink leaf or blocks on a FIFO, bounded by the fstat size; null for an unusable file. The fd must be the checked real path's inode, so a parent swapped after the check cannot redirect the read. */
|
|
1048
|
+
function readTranscript(file) {
|
|
1049
|
+
const real = containedTranscript(file);
|
|
1050
|
+
if (real === null)
|
|
1051
|
+
return null;
|
|
1052
|
+
let fd;
|
|
1053
|
+
try {
|
|
1054
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK || 0));
|
|
1055
|
+
}
|
|
1056
|
+
catch {
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
try {
|
|
1060
|
+
const st = fs.fstatSync(fd);
|
|
1061
|
+
if (!st.isFile() || st.size > PROMPT_TRANSCRIPT_CAP)
|
|
1062
|
+
return null;
|
|
1063
|
+
const entry = fs.lstatSync(real);
|
|
1064
|
+
if (entry.dev !== st.dev || entry.ino !== st.ino)
|
|
1065
|
+
return null;
|
|
1066
|
+
const buf = Buffer.allocUnsafe(st.size);
|
|
1067
|
+
let total = 0;
|
|
1068
|
+
while (total < buf.length) {
|
|
1069
|
+
const n = fs.readSync(fd, buf, total, buf.length - total, total);
|
|
1070
|
+
if (n === 0)
|
|
1071
|
+
break;
|
|
1072
|
+
total += n;
|
|
1073
|
+
}
|
|
1074
|
+
return buf.toString("utf8", 0, total);
|
|
1075
|
+
}
|
|
1076
|
+
catch {
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
finally {
|
|
1080
|
+
fs.closeSync(fd);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* The change a dispatch's prompt names by its brief path, read from the first user message of its
|
|
1085
|
+
* transcript, so a dispatch in several ledgers counts where the cost tracker filed it.
|
|
1086
|
+
* @param transcriptPath The ledger line's `transcript` path.
|
|
1087
|
+
* @param projectRoot The project root an absolute prompt path must sit under; without it only a relative path counts.
|
|
1088
|
+
* @returns The named change, in flight or archived, or null for an unusable transcript or no valid path.
|
|
1089
|
+
*/
|
|
1090
|
+
export function promptBriefChange(transcriptPath, projectRoot) {
|
|
1091
|
+
if (typeof transcriptPath !== "string" || !transcriptPath)
|
|
1092
|
+
return null;
|
|
1093
|
+
const text = readTranscript(transcriptPath);
|
|
1094
|
+
if (text === null)
|
|
1095
|
+
return null;
|
|
1096
|
+
let from = 0;
|
|
1097
|
+
while (from < text.length) {
|
|
1098
|
+
const nl = text.indexOf("\n", from);
|
|
1099
|
+
const raw = text.slice(from, nl < 0 ? text.length : nl);
|
|
1100
|
+
from = nl < 0 ? text.length : nl + 1;
|
|
1101
|
+
if (!raw.trim())
|
|
1102
|
+
continue;
|
|
1103
|
+
let rec;
|
|
1104
|
+
try {
|
|
1105
|
+
rec = JSON.parse(raw);
|
|
1106
|
+
}
|
|
1107
|
+
catch {
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
if (!rec || typeof rec !== "object" || Array.isArray(rec))
|
|
1111
|
+
continue;
|
|
1112
|
+
const r = rec;
|
|
1113
|
+
if (r.type === "user")
|
|
1114
|
+
return promptBriefFromText(messageText(r.message), projectRoot);
|
|
1115
|
+
}
|
|
1116
|
+
return null;
|
|
1117
|
+
}
|
|
1118
|
+
/** Claude Code before this version recorded placeholder output tokens in subagent transcripts. */
|
|
1119
|
+
const CC_BAND_FLOOR = "2.1.307";
|
|
1120
|
+
/** The cost tracker's version pattern, so the report trusts exactly the values the tracker records. */
|
|
1121
|
+
const CC_VERSION_RE = /^(\d{1,4})\.(\d{1,4})\.(\d{1,6})(?:-[0-9A-Za-z.]{1,16})?$/;
|
|
1122
|
+
const CC_LATER = `${CC_BAND_FLOOR} or later`;
|
|
1123
|
+
function emptyBands() {
|
|
1124
|
+
return { [CC_LATER]: 0, older: 0, unknown: 0 };
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Whether a dispatch's subagent fresh tokens can be trusted: recorded on 2.1.307 or later. A
|
|
1128
|
+
* pre-release suffix is ignored, since it carries the same recording fix as its release.
|
|
1129
|
+
*/
|
|
1130
|
+
export function ccBand(version) {
|
|
1131
|
+
const m = typeof version === "string" ? CC_VERSION_RE.exec(version) : null;
|
|
1132
|
+
if (!m)
|
|
1133
|
+
return "unknown";
|
|
1134
|
+
const floor = CC_BAND_FLOOR.split(".").map(Number);
|
|
1135
|
+
for (let i = 0; i < floor.length; i++) {
|
|
1136
|
+
const d = Number(m[i + 1]) - floor[i];
|
|
1137
|
+
if (d !== 0)
|
|
1138
|
+
return d > 0 ? CC_LATER : "older";
|
|
1139
|
+
}
|
|
1140
|
+
return CC_LATER;
|
|
1141
|
+
}
|
|
1142
|
+
/** The last valid `version` of a user or assistant record of a contained transcript, for ledger lines written before they carried `cc_version`. */
|
|
1143
|
+
function transcriptCcVersion(transcriptPath) {
|
|
1144
|
+
const text = readTranscript(transcriptPath);
|
|
1145
|
+
if (text === null)
|
|
1146
|
+
return null;
|
|
1147
|
+
let last = null;
|
|
1148
|
+
for (const raw of text.split("\n")) {
|
|
1149
|
+
if (!raw.includes('"version"'))
|
|
1150
|
+
continue;
|
|
1151
|
+
let rec;
|
|
1152
|
+
try {
|
|
1153
|
+
rec = JSON.parse(raw);
|
|
1154
|
+
}
|
|
1155
|
+
catch {
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
if (!rec || typeof rec !== "object" || Array.isArray(rec))
|
|
1159
|
+
continue;
|
|
1160
|
+
const r = rec;
|
|
1161
|
+
if ((r.type === "user" || r.type === "assistant") && typeof r.version === "string" && CC_VERSION_RE.test(r.version))
|
|
1162
|
+
last = r.version;
|
|
1163
|
+
}
|
|
1164
|
+
return last;
|
|
1165
|
+
}
|
|
1166
|
+
/** A dispatch's band: its latest line's `cc_version`, else its transcript's version. */
|
|
1167
|
+
function dispatchBand(line, transcriptVersion) {
|
|
1168
|
+
const own = ccBand(line.cc_version ?? null);
|
|
1169
|
+
if (own !== "unknown" || !transcriptVersion || typeof line.transcript !== "string" || !line.transcript)
|
|
1170
|
+
return own;
|
|
1171
|
+
return ccBand(transcriptVersion(line.transcript));
|
|
1172
|
+
}
|
|
1173
|
+
const ARCHIVE_DATE_PREFIX = /^\d{4}-\d{2}-\d{2}$/;
|
|
1174
|
+
/** A file of the change directory (tasks.md, follow-ups.md): in-flight, else archived (exact dir name, else the first `YYYY-MM-DD-<change>` entry, never `model-routing-5` for `routing-5`). */
|
|
1175
|
+
function findChangeFile(projectRoot, change, name = "tasks.md") {
|
|
1176
|
+
const active = path.join(projectRoot, "openspec", "changes", change, name);
|
|
1177
|
+
if (fs.existsSync(active))
|
|
1178
|
+
return active;
|
|
1179
|
+
const archiveDir = path.join(projectRoot, "openspec", "changes", "archive");
|
|
1180
|
+
let entries;
|
|
1181
|
+
try {
|
|
1182
|
+
entries = fs
|
|
1183
|
+
.readdirSync(archiveDir, { withFileTypes: true })
|
|
1184
|
+
.filter((e) => e.isDirectory())
|
|
1185
|
+
.map((e) => e.name);
|
|
1186
|
+
}
|
|
1187
|
+
catch {
|
|
1188
|
+
return undefined;
|
|
1189
|
+
}
|
|
1190
|
+
const dirName = entries.includes(change) ? change : [...entries].filter((e) => e.endsWith(`-${change}`) && ARCHIVE_DATE_PREFIX.test(e.slice(0, -change.length - 1))).sort()[0];
|
|
1191
|
+
if (!dirName)
|
|
1192
|
+
return undefined;
|
|
1193
|
+
const file = path.join(archiveDir, dirName, name);
|
|
1194
|
+
return fs.existsSync(file) ? file : undefined;
|
|
1195
|
+
}
|
|
1196
|
+
/** The task ids a brief's `Task:` header lists (comma-separated, a trailing `(...)` note ignored). */
|
|
1197
|
+
function taskIdsFromBriefHeader(header) {
|
|
1198
|
+
return header
|
|
1199
|
+
.split(",")
|
|
1200
|
+
.map((part) => /^\s*(\d+(?:\.\d+)+)/.exec(part)?.[1])
|
|
1201
|
+
.filter((id) => Boolean(id));
|
|
1202
|
+
}
|
|
1203
|
+
/** An id token shaped like a dispatch task: digits and dots, an optional trailing batch `b`, or `final`. */
|
|
1204
|
+
const TASK_ID_RE = /^(?:\d+(?:\.\d+)*b?|final)$/;
|
|
1205
|
+
/** Whether a `Task:` header declares the task: exact match, a batch id token, or a `b` task
|
|
1206
|
+
* matching the batch's first token (else `2.10` wrongly matches `2.1`). Mirrors the cost
|
|
1207
|
+
* tracker's rule, so the report reads the brief the ledger attributed the dispatch to. */
|
|
1208
|
+
function matchesTaskHeader(headerValue, task) {
|
|
1209
|
+
const raw = headerValue.trim();
|
|
1210
|
+
if (raw === task)
|
|
1211
|
+
return true;
|
|
1212
|
+
const ids = raw
|
|
1213
|
+
.split(/[,\s]+/)
|
|
1214
|
+
.map((s) => s.trim())
|
|
1215
|
+
.filter((s) => TASK_ID_RE.test(s));
|
|
1216
|
+
if (ids.includes(task))
|
|
1217
|
+
return true;
|
|
1218
|
+
if (task.endsWith("b") && ids[0] === task.slice(0, -1))
|
|
1219
|
+
return true;
|
|
1220
|
+
return false;
|
|
1221
|
+
}
|
|
1222
|
+
/** The longest `Task:` or `Class:` line a brief is classified by. */
|
|
1223
|
+
const BRIEF_HEADER_LINE_CAP = 256;
|
|
1224
|
+
/** The most briefs read per change, so the kept headers stay bounded. */
|
|
1225
|
+
const BRIEF_COUNT_CAP = 500;
|
|
1226
|
+
/** A copy sharing no storage with `s`: V8 keeps a regex capture as a slice of its source, so a kept capture pins the whole brief text. */
|
|
1227
|
+
function detached(s) {
|
|
1228
|
+
return Buffer.from(s, "utf8").toString("utf8");
|
|
1229
|
+
}
|
|
1230
|
+
/** A brief's `Task:` and `Class:` values; `undefined` when either line exceeds `BRIEF_HEADER_LINE_CAP`. */
|
|
1231
|
+
function briefHeaders(text) {
|
|
1232
|
+
const task = /^Task:\s*(.+)$/m.exec(text);
|
|
1233
|
+
const cls = /^Class:[ \t]*(.*)$/m.exec(text);
|
|
1234
|
+
if ((task?.[0].length ?? 0) > BRIEF_HEADER_LINE_CAP || (cls?.[0].length ?? 0) > BRIEF_HEADER_LINE_CAP)
|
|
1235
|
+
return undefined;
|
|
1236
|
+
return { header: task ? detached(task[1]) : undefined, class: cls ? detached(cls[1].trim()) : undefined };
|
|
1237
|
+
}
|
|
1238
|
+
/** Every `*.brief.md` under `changeDir`, subdirectories included as the tracker walks them (a symlinked directory is not entered), read as `readRegularFile` reads under `BRIEF_READ_CAP`; a skipped brief, a brief with an over-cap header line, and a directory that exists but cannot be listed land in `skipped`, and briefs past `BRIEF_COUNT_CAP` in walk order land there once as a count. */
|
|
1239
|
+
function readChangeBriefs(changeDir, skipped) {
|
|
1240
|
+
const out = [];
|
|
1241
|
+
let seen = 0;
|
|
1242
|
+
const walk = (rel) => {
|
|
1243
|
+
const dir = path.join(changeDir, rel);
|
|
1244
|
+
let entries;
|
|
1245
|
+
try {
|
|
1246
|
+
entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
1247
|
+
}
|
|
1248
|
+
catch (err) {
|
|
1249
|
+
if (!isNotFound(err))
|
|
1250
|
+
skipped.push(dir);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
for (const entry of entries) {
|
|
1254
|
+
const name = path.join(rel, entry.name);
|
|
1255
|
+
if (entry.isDirectory()) {
|
|
1256
|
+
walk(name);
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
if (!entry.name.endsWith(".brief.md"))
|
|
1260
|
+
continue;
|
|
1261
|
+
if (++seen > BRIEF_COUNT_CAP)
|
|
1262
|
+
continue;
|
|
1263
|
+
const file = path.join(changeDir, name);
|
|
1264
|
+
const read = readRegularEntry(file, skipped, BRIEF_READ_CAP);
|
|
1265
|
+
if (!read)
|
|
1266
|
+
continue;
|
|
1267
|
+
const headers = briefHeaders(read.text);
|
|
1268
|
+
if (headers)
|
|
1269
|
+
out.push({ name, mtimeMs: read.mtimeMs, ...headers });
|
|
1270
|
+
else
|
|
1271
|
+
skipped.push(file);
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
1274
|
+
walk("");
|
|
1275
|
+
if (seen > BRIEF_COUNT_CAP)
|
|
1276
|
+
skipped.push(`${changeDir} (${seen - BRIEF_COUNT_CAP} over the ${BRIEF_COUNT_CAP}-brief cap)`);
|
|
1277
|
+
return out;
|
|
1278
|
+
}
|
|
1279
|
+
/** The newest brief whose `Task:` header declares `task` (the greater relative path on equal mtime, as the tracker picks); only a task-shaped id resolves a brief. */
|
|
1280
|
+
function briefForTask(briefs, task) {
|
|
1281
|
+
if (!TASK_ID_RE.test(task))
|
|
1282
|
+
return undefined;
|
|
1283
|
+
let best;
|
|
1284
|
+
for (const b of briefs) {
|
|
1285
|
+
if (b.header === undefined || !matchesTaskHeader(b.header, task))
|
|
1286
|
+
continue;
|
|
1287
|
+
if (!best || b.mtimeMs > best.mtimeMs || (b.mtimeMs === best.mtimeMs && b.name > best.name))
|
|
1288
|
+
best = b;
|
|
1289
|
+
}
|
|
1290
|
+
return best;
|
|
1291
|
+
}
|
|
1292
|
+
/** The task ids a brief's `Task:` header lists; empty without a brief or header. */
|
|
1293
|
+
function briefTaskIds(brief) {
|
|
1294
|
+
return brief?.header !== undefined ? taskIdsFromBriefHeader(brief.header) : [];
|
|
1295
|
+
}
|
|
1296
|
+
/** Whether a ledger task id is accepted: via its brief's `Task:` header when it lists numbered ids (a batch `2.1, 2.2 (batch)` needs every one checked), else the task id itself. */
|
|
1297
|
+
function resolveAcceptance(brief, checked, task) {
|
|
1298
|
+
const ids = briefTaskIds(brief);
|
|
1299
|
+
return ids.length > 0 ? ids.every((id) => checked.has(id)) : checked.has(task);
|
|
1300
|
+
}
|
|
1301
|
+
/** A brief's routing facts for a ledger task id (`2.1b` is declared by a header listing `2.1`). */
|
|
1302
|
+
function briefFacts(brief, task) {
|
|
1303
|
+
const id = /^\d+(?:\.\d+)+/.exec(task)?.[0];
|
|
1304
|
+
return { declared: id !== undefined && briefTaskIds(brief).includes(id), class: brief?.class || UNCLASSIFIED };
|
|
1305
|
+
}
|
|
1306
|
+
function byTs(a, b) {
|
|
1307
|
+
return a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0;
|
|
1308
|
+
}
|
|
1309
|
+
/** The earliest on-time line (else earliest line) in the change a cross-ledger dispatch's prompt names; the transcript is read only when the lines span several changes, so a single-ledger report never opens one. */
|
|
1310
|
+
function promptOwner(sorted, promptChange) {
|
|
1311
|
+
if (!promptChange || new Set(sorted.map((l) => l.change)).size < 2)
|
|
1312
|
+
return undefined;
|
|
1313
|
+
const transcript = sorted.find((l) => typeof l.transcript === "string" && l.transcript)?.transcript;
|
|
1314
|
+
const change = transcript ? promptChange(transcript) : null;
|
|
1315
|
+
const named = change === null ? [] : sorted.filter((l) => l.change === change);
|
|
1316
|
+
return named.find((l) => l.late !== true) ?? named[0];
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Collapses ledger lines to one per dispatch (`agent_id` across every ledger read).
|
|
1320
|
+
*
|
|
1321
|
+
* Lines are cumulative snapshots, so the latest one carries the dispatch's totals. Effort skips
|
|
1322
|
+
* late lines when another exists: a SubagentStop payload names the effort the dispatch ran at. A
|
|
1323
|
+
* dispatch in several changes' ledgers counts under the change its prompt's brief path names, the
|
|
1324
|
+
* tracker's own rule, when that change holds one of its lines. Otherwise a backfill copy filed
|
|
1325
|
+
* under another change whose brief also declares the task is always late, so the dispatch takes
|
|
1326
|
+
* the change and task of its earliest on-time line whose brief declares the task, else of its
|
|
1327
|
+
* earliest declaring line, else of its earliest line. The earliest ts is kept so "first impl
|
|
1328
|
+
* dispatch" means first recorded.
|
|
1329
|
+
*/
|
|
1330
|
+
function collapseDispatches(ledgers, briefs, promptChange) {
|
|
1331
|
+
const byAgent = new Map();
|
|
1332
|
+
const dispatches = [];
|
|
1333
|
+
for (const line of ledgers) {
|
|
1334
|
+
if (typeof line.agent_id !== "string") {
|
|
1335
|
+
dispatches.push([line]);
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
const group = byAgent.get(line.agent_id);
|
|
1339
|
+
if (group)
|
|
1340
|
+
group.push(line);
|
|
1341
|
+
else {
|
|
1342
|
+
const fresh = [line];
|
|
1343
|
+
byAgent.set(line.agent_id, fresh);
|
|
1344
|
+
dispatches.push(fresh);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
return dispatches.map((group) => {
|
|
1348
|
+
const sorted = [...group].sort(byTs);
|
|
1349
|
+
const declaring = sorted.filter((l) => briefs.get(acceptanceKey(l.change, l.task))?.declared === true);
|
|
1350
|
+
const owner = promptOwner(sorted, promptChange) ?? declaring.find((l) => l.late !== true) ?? declaring[0] ?? sorted[0];
|
|
1351
|
+
const latest = sorted[sorted.length - 1];
|
|
1352
|
+
const onTime = sorted.filter((l) => l.late !== true);
|
|
1353
|
+
const effortLine = onTime.length > 0 ? onTime[onTime.length - 1] : latest;
|
|
1354
|
+
return { ...latest, change: owner.change, task: owner.task, ts: sorted[0].ts, effort: effortLine.effort, effort_source: effortLine.effort_source ?? null };
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
const UNRECORDED = "unrecorded";
|
|
1358
|
+
/** An empty per-model sum without a prototype: its keys are model names read from the ledgers and telemetry, so one named `__proto__` stays an own key instead of reaching Object.prototype. */
|
|
1359
|
+
function bucketsByModel() {
|
|
1360
|
+
return Object.create(null);
|
|
1361
|
+
}
|
|
1362
|
+
/** Adds a bucket to its model's sum; fields are coerced, since telemetry buckets are untrusted file data. */
|
|
1363
|
+
function addTokens(byModel, model, add) {
|
|
1364
|
+
const b = Object.hasOwn(byModel, model) ? byModel[model] : { input: 0, output: 0, cache_read: 0, cache_write: 0 };
|
|
1365
|
+
b.input += Number(add.input) || 0;
|
|
1366
|
+
b.output += Number(add.output) || 0;
|
|
1367
|
+
b.cache_read += Number(add.cache_read) || 0;
|
|
1368
|
+
b.cache_write += Number(add.cache_write) || 0;
|
|
1369
|
+
byModel[model] = b;
|
|
1370
|
+
}
|
|
1371
|
+
/** A task's fix rounds: a re-dispatch inside one round counts once, a fix without a round is a round of its own. */
|
|
1372
|
+
function distinctFixRounds(lines) {
|
|
1373
|
+
const numbered = new Set();
|
|
1374
|
+
let unnumbered = 0;
|
|
1375
|
+
for (const line of lines) {
|
|
1376
|
+
if (line.role !== "fix")
|
|
1377
|
+
continue;
|
|
1378
|
+
if (typeof line.round === "number")
|
|
1379
|
+
numbered.add(line.round);
|
|
1380
|
+
else
|
|
1381
|
+
unnumbered += 1;
|
|
1382
|
+
}
|
|
1383
|
+
return numbered.size + unnumbered;
|
|
1384
|
+
}
|
|
1385
|
+
/** Whole tokens per accepted task; empty with zero accepted, so no family reads as a zero-cost measurement. */
|
|
1386
|
+
function perAccepted(groups, accepted) {
|
|
1387
|
+
const out = {};
|
|
1388
|
+
if (accepted === 0)
|
|
1389
|
+
return out;
|
|
1390
|
+
for (const [family, g] of Object.entries(groups)) {
|
|
1391
|
+
out[family] = { cache_read: Math.round(g.cache_read / accepted), fresh: Math.round(g.fresh / accepted) };
|
|
1392
|
+
}
|
|
1393
|
+
return out;
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Groups dispatches by task, assigns each task the (model, effort) of its first `impl` dispatch
|
|
1397
|
+
* (none excludes it), and aggregates per (model, effort, class): role counts, first-pass tasks,
|
|
1398
|
+
* `escalated` (a later impl/fix on another model), USD, and, over accepted tasks only, tokens by
|
|
1399
|
+
* family and distinct fix rounds.
|
|
1400
|
+
*
|
|
1401
|
+
* Sub-task dispatches count under the task `options.rollUp` maps them to, so splitting a task
|
|
1402
|
+
* never raises the task count; an id mapped to null counts in no row. A rolled-up task's class is
|
|
1403
|
+
* the brief class of the sub-task its first impl dispatch ran, the configuration that did the work.
|
|
1404
|
+
* @param options Roll-up map and prompt-path resolver; omitted, every id is its own task and
|
|
1405
|
+
* duplicate dispatches follow the declaring-brief rule.
|
|
1406
|
+
*/
|
|
1407
|
+
export function routingReport(ledgers, acceptance, prices, briefs = new Map(), options = {}) {
|
|
1408
|
+
return tallyRouting(ledgers, acceptance, prices, briefs, options).rows;
|
|
1409
|
+
}
|
|
1410
|
+
/** `routingReport` with the figures beside the rows. */
|
|
1411
|
+
function tallyRouting(ledgers, acceptance, prices, briefs, options) {
|
|
1412
|
+
const byTask = new Map();
|
|
1413
|
+
const unmatched = new Set();
|
|
1414
|
+
for (const line of collapseDispatches(ledgers, briefs, options.promptChange)) {
|
|
1415
|
+
const own = acceptanceKey(line.change, line.task);
|
|
1416
|
+
const task = options.rollUp?.has(own) ? options.rollUp.get(own) : line.task;
|
|
1417
|
+
if (task === null || task === undefined) {
|
|
1418
|
+
unmatched.add(own);
|
|
1419
|
+
continue;
|
|
1420
|
+
}
|
|
1421
|
+
const key = acceptanceKey(line.change, task);
|
|
1422
|
+
const agg = byTask.get(key) ?? { change: line.change, task, lines: [] };
|
|
1423
|
+
agg.lines.push(line);
|
|
1424
|
+
byTask.set(key, agg);
|
|
1425
|
+
}
|
|
1426
|
+
const rows = new Map();
|
|
1427
|
+
const reviews = new Map();
|
|
1428
|
+
const acceptedTasks = new Map();
|
|
1429
|
+
const acceptedByModel = bucketsByModel();
|
|
1430
|
+
const acceptedTaskRecords = [];
|
|
1431
|
+
const bandOf = new Map();
|
|
1432
|
+
const band = (line) => {
|
|
1433
|
+
let b = bandOf.get(line);
|
|
1434
|
+
if (b === undefined) {
|
|
1435
|
+
b = dispatchBand(line, options.transcriptVersion);
|
|
1436
|
+
bandOf.set(line, b);
|
|
1437
|
+
}
|
|
1438
|
+
return b;
|
|
1439
|
+
};
|
|
1440
|
+
for (const [taskKey, agg] of byTask) {
|
|
1441
|
+
// A review row needs no implementer configuration, so a review-only task (a final round) still counts.
|
|
1442
|
+
for (const line of agg.lines) {
|
|
1443
|
+
if (line.role !== "review")
|
|
1444
|
+
continue;
|
|
1445
|
+
const effort = line.effort ?? "unknown";
|
|
1446
|
+
const criterion = options.reviewCriteria?.get(acceptanceKey(line.change, line.task)) ?? UNRECORDED;
|
|
1447
|
+
const key = `${line.model}\u0000${effort}\u0000${criterion}`;
|
|
1448
|
+
const acc = reviews.get(key) ?? { model: line.model, effort, criterion, dispatches: 0, tasks: new Set(), byModel: bucketsByModel(), bands: emptyBands() };
|
|
1449
|
+
acc.dispatches += 1;
|
|
1450
|
+
acc.bands[band(line)] += 1;
|
|
1451
|
+
acc.tasks.add(taskKey);
|
|
1452
|
+
addTokens(acc.byModel, line.model, line);
|
|
1453
|
+
reviews.set(key, acc);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
for (const agg of byTask.values()) {
|
|
1457
|
+
const sorted = [...agg.lines].sort(byTs);
|
|
1458
|
+
const firstImpl = sorted.find((l) => l.role === "impl");
|
|
1459
|
+
if (!firstImpl)
|
|
1460
|
+
continue;
|
|
1461
|
+
const model = firstImpl.model;
|
|
1462
|
+
const effort = firstImpl.effort ?? "unknown";
|
|
1463
|
+
const taskKey = acceptanceKey(agg.change, agg.task);
|
|
1464
|
+
// A rolled-up task takes the class of the sub-task its first impl dispatch ran.
|
|
1465
|
+
const cls = briefs.get(acceptanceKey(firstImpl.change, firstImpl.task))?.class ?? UNCLASSIFIED;
|
|
1466
|
+
const rowKey = `${model}\u0000${effort}\u0000${cls}`;
|
|
1467
|
+
const row = rows.get(rowKey) ?? { model, effort, class: cls, tasks: 0, accepted: 0, firstPass: 0, impl: 0, fix: 0, review: 0, escalated: 0, usd: 0, estimated: false, acceptedUsd: 0, acceptedByModel: bucketsByModel(), acceptedFixRounds: 0, tasksOverOneRound: 0, acceptedTasks: new Map(), bands: emptyBands(), lowerBound: 0 };
|
|
1468
|
+
row.tasks += 1;
|
|
1469
|
+
const isAccepted = acceptance.get(taskKey) === true;
|
|
1470
|
+
if (isAccepted)
|
|
1471
|
+
row.accepted += 1;
|
|
1472
|
+
if (isAccepted && !sorted.some((l) => l.role === "fix"))
|
|
1473
|
+
row.firstPass += 1;
|
|
1474
|
+
let escalated = false;
|
|
1475
|
+
let taskUsd = 0;
|
|
1476
|
+
const taskBands = emptyBands();
|
|
1477
|
+
for (const line of sorted) {
|
|
1478
|
+
if (line.role === "impl")
|
|
1479
|
+
row.impl += 1;
|
|
1480
|
+
else if (line.role === "fix")
|
|
1481
|
+
row.fix += 1;
|
|
1482
|
+
else
|
|
1483
|
+
row.review += 1;
|
|
1484
|
+
if (line !== firstImpl && (line.role === "impl" || line.role === "fix") && line.model !== model)
|
|
1485
|
+
escalated = true;
|
|
1486
|
+
const lineBand = band(line);
|
|
1487
|
+
row.bands[lineBand] += 1;
|
|
1488
|
+
taskBands[lineBand] += 1;
|
|
1489
|
+
if (isAccepted && lineBand !== CC_LATER)
|
|
1490
|
+
row.lowerBound += 1;
|
|
1491
|
+
const match = priceMatch(prices, line.model);
|
|
1492
|
+
taskUsd += priceBucket(match.row, line);
|
|
1493
|
+
if (match.estimated)
|
|
1494
|
+
row.estimated = true;
|
|
1495
|
+
}
|
|
1496
|
+
row.usd += taskUsd;
|
|
1497
|
+
if (isAccepted) {
|
|
1498
|
+
row.acceptedUsd += taskUsd;
|
|
1499
|
+
const taskByModel = bucketsByModel();
|
|
1500
|
+
for (const line of sorted) {
|
|
1501
|
+
addTokens(row.acceptedByModel, line.model, line);
|
|
1502
|
+
addTokens(acceptedByModel, line.model, line);
|
|
1503
|
+
addTokens(taskByModel, line.model, line);
|
|
1504
|
+
}
|
|
1505
|
+
acceptedTaskRecords.push({ change: agg.change, task: agg.task, class: cls, firstTs: sorted[0].ts, dispatches: sorted.length, bands: taskBands, fullyLater: taskBands[CC_LATER] === sorted.length, tokens: tokenGroups(taskByModel) });
|
|
1506
|
+
for (const byChange of [acceptedTasks, row.acceptedTasks]) {
|
|
1507
|
+
const ids = byChange.get(agg.change) ?? new Set();
|
|
1508
|
+
ids.add(agg.task);
|
|
1509
|
+
byChange.set(agg.change, ids);
|
|
1510
|
+
}
|
|
1511
|
+
const fixRounds = distinctFixRounds(sorted);
|
|
1512
|
+
row.acceptedFixRounds += fixRounds;
|
|
1513
|
+
if (fixRounds > 1)
|
|
1514
|
+
row.tasksOverOneRound += 1;
|
|
1515
|
+
}
|
|
1516
|
+
if (escalated)
|
|
1517
|
+
row.escalated += 1;
|
|
1518
|
+
rows.set(rowKey, row);
|
|
1519
|
+
}
|
|
1520
|
+
const rowAccs = [...rows.values()].sort((a, b) => a.model.localeCompare(b.model) || a.effort.localeCompare(b.effort) || a.class.localeCompare(b.class));
|
|
1521
|
+
const out = rowAccs
|
|
1522
|
+
.map((r) => ({
|
|
1523
|
+
model: r.model,
|
|
1524
|
+
effort: r.effort,
|
|
1525
|
+
class: r.class,
|
|
1526
|
+
tasks: r.tasks,
|
|
1527
|
+
accepted: r.accepted,
|
|
1528
|
+
firstPass: r.firstPass,
|
|
1529
|
+
impl: r.impl,
|
|
1530
|
+
fix: r.fix,
|
|
1531
|
+
review: r.review,
|
|
1532
|
+
escalated: r.escalated,
|
|
1533
|
+
usd: round(r.usd),
|
|
1534
|
+
usdPerAccepted: r.accepted > 0 ? round(r.acceptedUsd / r.accepted) : null,
|
|
1535
|
+
...(r.estimated ? { price_estimated: true } : {}),
|
|
1536
|
+
tokensPerAccepted: perAccepted(tokenGroups(r.acceptedByModel), r.accepted),
|
|
1537
|
+
meanFixRounds: r.accepted > 0 ? round(r.acceptedFixRounds / r.accepted) : null,
|
|
1538
|
+
tasksOverOneRound: r.tasksOverOneRound,
|
|
1539
|
+
...(options.followUps ? { followUpsPerAccepted: followUpsPerAccepted(options.followUps, r.acceptedTasks, r.accepted) } : {}),
|
|
1540
|
+
}));
|
|
1541
|
+
const reviewAccs = [...reviews.values()].sort((a, b) => a.model.localeCompare(b.model) || a.effort.localeCompare(b.effort) || a.criterion.localeCompare(b.criterion));
|
|
1542
|
+
const reviewRows = reviewAccs.map((r) => ({ model: r.model, effort: r.effort, criterion: r.criterion, dispatches: r.dispatches, tasks: r.tasks.size, tokensPerTask: perAccepted(tokenGroups(r.byModel), r.tasks.size) }));
|
|
1543
|
+
const total = emptyBands();
|
|
1544
|
+
for (const b of bandOf.values())
|
|
1545
|
+
total[b] += 1;
|
|
1546
|
+
const versionBands = { rows: rowAccs.map((r) => r.bands), rowsLowerBound: rowAccs.map((r) => r.lowerBound), reviewRows: reviewAccs.map((r) => r.bands), total };
|
|
1547
|
+
acceptedTaskRecords.sort((a, b) => (a.firstTs < b.firstTs ? -1 : a.firstTs > b.firstTs ? 1 : a.change.localeCompare(b.change) || a.task.localeCompare(b.task)));
|
|
1548
|
+
return { rows: out, unmatchedSubTasks: unmatched.size, reviewRows, acceptedTasks, acceptedByModel, versionBands, acceptedTaskRecords };
|
|
1549
|
+
}
|
|
1550
|
+
/** A review package's first `Review tier:` line; the criterion must follow the tier at once, so a later line never stands in for a malformed first one. */
|
|
1551
|
+
const REVIEW_TIER_LINE = /^Review tier:[^\n]*$/m;
|
|
1552
|
+
const REVIEW_CRITERION = /^Review tier:[ \t]*[a-z]+[ \t]*-[ \t]*(S\d{1,3})(?![0-9A-Za-z])/;
|
|
1553
|
+
/** The `S<n>` criterion of `<task>.review.md` in a change's sdd directory, or `unrecorded`; only a task-shaped id names a file, so a ledger id never reaches outside the directory. */
|
|
1554
|
+
function reviewCriterion(changeDir, task, skipped) {
|
|
1555
|
+
if (!TASK_ID_RE.test(task))
|
|
1556
|
+
return UNRECORDED;
|
|
1557
|
+
const read = readRegularEntry(path.join(changeDir, `${task}.review.md`), skipped, BRIEF_READ_CAP);
|
|
1558
|
+
const line = read ? REVIEW_TIER_LINE.exec(read.text)?.[0] : undefined;
|
|
1559
|
+
return (line && REVIEW_CRITERION.exec(line)?.[1]) || UNRECORDED;
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* The main-transcript buckets per model of a telemetry record. A record without `by_model` takes
|
|
1563
|
+
* its session's snapshot `by_model`, as the cost report prices it, never `by_agent`, since the
|
|
1564
|
+
* ledgers already count the subagents; with no snapshot its `sinks.main` total stays under
|
|
1565
|
+
* `unknown`, so its tokens count under `other`, never dropped.
|
|
1566
|
+
*/
|
|
1567
|
+
function leadBuckets(record, snapshots) {
|
|
1568
|
+
const byModel = record.by_model ?? snapshots.get(record.session_id)?.by_model;
|
|
1569
|
+
const out = bucketsByModel();
|
|
1570
|
+
if (byModel && typeof byModel === "object" && !Array.isArray(byModel)) {
|
|
1571
|
+
for (const [model, b] of Object.entries(byModel))
|
|
1572
|
+
if (b && typeof b === "object")
|
|
1573
|
+
out[model] = b;
|
|
1574
|
+
return out;
|
|
1575
|
+
}
|
|
1576
|
+
const main = record.sinks?.main;
|
|
1577
|
+
if (main && typeof main === "object")
|
|
1578
|
+
out.unknown = main;
|
|
1579
|
+
return out;
|
|
1580
|
+
}
|
|
1581
|
+
/** The cost log's records, or none when it cannot be read: the snapshot join only refines the lead row, so a bad log must not fail the report. */
|
|
1582
|
+
function readUsageOrNone(file) {
|
|
1583
|
+
try {
|
|
1584
|
+
return readUsage(file);
|
|
1585
|
+
}
|
|
1586
|
+
catch {
|
|
1587
|
+
return [];
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
/** The telemetry records tagged with a reported change; a non-regular telemetry file lands in `skipped` unread, since a cloned project controls the path. */
|
|
1591
|
+
function leadRecords(projectRoot, changes, skipped) {
|
|
1592
|
+
const file = telemetryPath(projectRoot);
|
|
1593
|
+
try {
|
|
1594
|
+
if (!fs.lstatSync(file).isFile()) {
|
|
1595
|
+
skipped.push(file);
|
|
1596
|
+
return [];
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
catch (err) {
|
|
1600
|
+
if (!isNotFound(err))
|
|
1601
|
+
skipped.push(file);
|
|
1602
|
+
return [];
|
|
1603
|
+
}
|
|
1604
|
+
return readTelemetry(file).filter((r) => recordChanges(r).some((c) => changes.has(c)));
|
|
1605
|
+
}
|
|
1606
|
+
/** The changes a record worked on: its `changes` array when it has one (even empty), else its `change` tag, as records written before the array carry only the tag. */
|
|
1607
|
+
function recordChanges(record) {
|
|
1608
|
+
const listed = record.changes;
|
|
1609
|
+
if (Array.isArray(listed))
|
|
1610
|
+
return [...new Set(listed.filter((c) => typeof c === "string"))];
|
|
1611
|
+
return typeof record.change === "string" ? [record.change] : [];
|
|
1612
|
+
}
|
|
1613
|
+
function sessionAgents(lines) {
|
|
1614
|
+
const out = new Map();
|
|
1615
|
+
for (const line of lines) {
|
|
1616
|
+
if (typeof line.session !== "string" || typeof line.agent_id !== "string")
|
|
1617
|
+
continue;
|
|
1618
|
+
const ids = out.get(line.session) ?? new Set();
|
|
1619
|
+
ids.add(line.agent_id);
|
|
1620
|
+
out.set(line.session, ids);
|
|
1621
|
+
}
|
|
1622
|
+
return out;
|
|
1623
|
+
}
|
|
1624
|
+
/** Whole parts of `value` cut at rounded cumulative fractions of `weights`, so the parts always sum to the rounded value. */
|
|
1625
|
+
function splitWhole(value, weights) {
|
|
1626
|
+
const total = weights.reduce((a, b) => a + b, 0);
|
|
1627
|
+
let acc = 0;
|
|
1628
|
+
let prev = 0;
|
|
1629
|
+
return weights.map((w) => {
|
|
1630
|
+
acc += w;
|
|
1631
|
+
const cut = Math.round((value * acc) / total);
|
|
1632
|
+
const part = cut - prev;
|
|
1633
|
+
prev = cut;
|
|
1634
|
+
return part;
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
const BUCKET_FIELDS = ["input", "output", "cache_read", "cache_write", "cache_write_1h"];
|
|
1638
|
+
/**
|
|
1639
|
+
* A record's main-context buckets split across the changes it worked on, so a lead session that
|
|
1640
|
+
* worked on several changes counts its context once in total rather than once per change. Each
|
|
1641
|
+
* change's weight is the distinct dispatches the session ran in its ledger; with none anywhere the
|
|
1642
|
+
* shares are equal. Parts are whole tokens that sum exactly to the record's figures, whichever
|
|
1643
|
+
* changes a report reads.
|
|
1644
|
+
* @param agentsOf Each named change's dispatches per session.
|
|
1645
|
+
* @returns Per named change, its fraction (to scale a USD figure) and its buckets.
|
|
1646
|
+
*/
|
|
1647
|
+
export function leadShares(record, buckets, agentsOf) {
|
|
1648
|
+
const named = recordChanges(record);
|
|
1649
|
+
const out = new Map();
|
|
1650
|
+
if (named.length === 1)
|
|
1651
|
+
out.set(named[0], { share: 1, buckets });
|
|
1652
|
+
if (named.length < 2)
|
|
1653
|
+
return out;
|
|
1654
|
+
const counts = named.map((c) => agentsOf(c).get(record.session_id)?.size ?? 0);
|
|
1655
|
+
const sum = counts.reduce((a, b) => a + b, 0);
|
|
1656
|
+
const weights = sum === 0 ? named.map(() => 1) : counts;
|
|
1657
|
+
const parts = named.map((_, i) => ({ share: weights[i] / (sum === 0 ? named.length : sum), buckets: bucketsByModel() }));
|
|
1658
|
+
for (const [model, b] of Object.entries(buckets)) {
|
|
1659
|
+
const split = named.map(() => ({ input: 0, output: 0, cache_read: 0, cache_write: 0 }));
|
|
1660
|
+
for (const field of BUCKET_FIELDS) {
|
|
1661
|
+
if (field === "cache_write_1h" && typeof b.cache_write_1h !== "number")
|
|
1662
|
+
continue;
|
|
1663
|
+
splitWhole(Number(b[field]) || 0, weights).forEach((v, i) => (split[i][field] = v));
|
|
1664
|
+
}
|
|
1665
|
+
split.forEach((s, i) => (parts[i].buckets[model] = s));
|
|
1666
|
+
}
|
|
1667
|
+
named.forEach((c, i) => out.set(c, parts[i]));
|
|
1668
|
+
return out;
|
|
1669
|
+
}
|
|
1670
|
+
/** Mirrors `FOLLOW_UP_TYPES` of @freeharness/sdd's follow-up shim, which its public surface does not export. */
|
|
1671
|
+
const FOLLOW_UP_TYPES = ["defect", "hardening", "refactor", "test-gap", "docs", "design", "owner-step", "upstream"];
|
|
1672
|
+
const FOLLOW_UP_ITEM = /^\s*[-*]\s*\[([ xX])\]\s*([^\n]*)$/;
|
|
1673
|
+
const FOLLOW_UP_CLAIM = /^->\s*claimed by\s+\S+\s*$/;
|
|
1674
|
+
const FOLLOW_UP_TYPE = /^\[([a-z-]+)\]/;
|
|
1675
|
+
/** A `(from <task>)` label that names a task: dotted numbers and an optional letter (`6.3a` counts under 6.3). */
|
|
1676
|
+
const FOLLOW_UP_TASK = /^(\d+(?:\.\d+)+)[a-z]?$/;
|
|
1677
|
+
/**
|
|
1678
|
+
* The trimmed label of a suffix starting at `(from`, or null unless whitespace follows `from`, a
|
|
1679
|
+
* label without `)` precedes the first `)` and only whitespace trails it. Split by index, since a
|
|
1680
|
+
* pattern whose whitespace and label runs overlap backtracks quadratically on a long space run.
|
|
1681
|
+
*/
|
|
1682
|
+
function followUpFrom(suffix) {
|
|
1683
|
+
const rest = suffix.slice("(from".length);
|
|
1684
|
+
const close = rest.indexOf(")");
|
|
1685
|
+
if (close < 2 || !/\s/.test(rest[0]) || rest.slice(close + 1).trim() !== "")
|
|
1686
|
+
return null;
|
|
1687
|
+
return rest.slice(0, close).trim();
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Each follow-ups.md item's type and `(from <task>)` label, claimed or not, as the follow-up shim
|
|
1691
|
+
* reads them; the suffixes are cut at their last marker rather than by a lazy regex, which a long
|
|
1692
|
+
* hostile line would make quadratic.
|
|
1693
|
+
*/
|
|
1694
|
+
function followUpItems(text) {
|
|
1695
|
+
const items = [];
|
|
1696
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1697
|
+
const m = FOLLOW_UP_ITEM.exec(line);
|
|
1698
|
+
if (!m)
|
|
1699
|
+
continue;
|
|
1700
|
+
let body = m[2].trimEnd();
|
|
1701
|
+
if (m[1] !== " ") {
|
|
1702
|
+
const at = body.lastIndexOf("->");
|
|
1703
|
+
if (at >= 0 && FOLLOW_UP_CLAIM.test(body.slice(at)))
|
|
1704
|
+
body = body.slice(0, at).trimEnd();
|
|
1705
|
+
}
|
|
1706
|
+
const at = body.lastIndexOf("(from");
|
|
1707
|
+
const from = at >= 0 ? followUpFrom(body.slice(at)) : null;
|
|
1708
|
+
const tag = FOLLOW_UP_TYPE.exec(body)?.[1];
|
|
1709
|
+
items.push({ type: tag && FOLLOW_UP_TYPES.includes(tag) ? tag : "untyped", from });
|
|
1710
|
+
}
|
|
1711
|
+
return items;
|
|
1712
|
+
}
|
|
1713
|
+
/** Follow-ups whose label rolls up to one of the given accepted tasks (a row's, or the whole report's), per accepted task, by type in the shim's display order. */
|
|
1714
|
+
function followUpsPerAccepted(itemsByChange, acceptedTasks, accepted) {
|
|
1715
|
+
if (accepted === 0)
|
|
1716
|
+
return { total: null, byType: {} };
|
|
1717
|
+
const counts = new Map();
|
|
1718
|
+
let total = 0;
|
|
1719
|
+
for (const [change, items] of itemsByChange) {
|
|
1720
|
+
const ids = acceptedTasks.get(change);
|
|
1721
|
+
if (!ids)
|
|
1722
|
+
continue;
|
|
1723
|
+
for (const item of items) {
|
|
1724
|
+
const id = item.from === null ? undefined : FOLLOW_UP_TASK.exec(item.from)?.[1];
|
|
1725
|
+
if (id === undefined || rollUpTask(id, ids) === null)
|
|
1726
|
+
continue;
|
|
1727
|
+
total += 1;
|
|
1728
|
+
counts.set(item.type, (counts.get(item.type) ?? 0) + 1);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
const byType = {};
|
|
1732
|
+
for (const type of [...FOLLOW_UP_TYPES, "untyped"]) {
|
|
1733
|
+
const n = counts.get(type);
|
|
1734
|
+
if (n)
|
|
1735
|
+
byType[type] = round(n / accepted);
|
|
1736
|
+
}
|
|
1737
|
+
return { total: round(total / accepted), byType };
|
|
1738
|
+
}
|
|
1739
|
+
/** A fit whose median miss exceeds this many percentage points is too loose to quote unmarked. */
|
|
1740
|
+
const UNRELIABLE_RESIDUAL = 1;
|
|
1741
|
+
/**
|
|
1742
|
+
* Tokens per accepted task as a share of the seven-day window: tokens in millions times the
|
|
1743
|
+
* fitted percent-per-million weight, summed over families and groups.
|
|
1744
|
+
* @returns Null with zero accepted, or when a family with tokens has no fitted weight, since a
|
|
1745
|
+
* partial sum would understate the row.
|
|
1746
|
+
*/
|
|
1747
|
+
function sevenDayPct(tokens, weights, accepted) {
|
|
1748
|
+
if (accepted === 0)
|
|
1749
|
+
return null;
|
|
1750
|
+
let pct = 0;
|
|
1751
|
+
for (const [family, g] of Object.entries(tokens)) {
|
|
1752
|
+
if (g.cache_read === 0 && g.fresh === 0)
|
|
1753
|
+
continue;
|
|
1754
|
+
const w = weights[family];
|
|
1755
|
+
if (!w)
|
|
1756
|
+
return null;
|
|
1757
|
+
pct += (g.cache_read / 1e6) * w.cache_read + (g.fresh / 1e6) * w.fresh;
|
|
1758
|
+
}
|
|
1759
|
+
return round(pct);
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Discovers every change under `.freeharness/sdd/` with a `ledger.jsonl` (or just `changeFilter`),
|
|
1763
|
+
* resolves each task's acceptance against its tasks.md, and runs `routingReport` over the union.
|
|
1764
|
+
* The review packages, the telemetry tagged with a reported change and its follow-ups.md add the
|
|
1765
|
+
* review rows, the lead row and the per-accepted-task totals, since a gate counting dispatches
|
|
1766
|
+
* alone would pass by moving cost into the lead session. A quota calibration with a seven-day
|
|
1767
|
+
* window adds the window estimate to every row; an invalid one is ignored with a warning.
|
|
1768
|
+
*/
|
|
1769
|
+
export function routingReportForProject(projectRoot, changeFilter, prices = loadPriceTable(costPricesPath()), pricesFile = costPricesPath(), calibrationFile = quotaCalibrationPath()) {
|
|
1770
|
+
const project = readRoutingProject(projectRoot, changeFilter);
|
|
1771
|
+
const { acceptance, briefs, rollUp, malformedLines, missingTasksFiles, skipped, reported, reviewCriteria, followUps } = project;
|
|
1772
|
+
const tally = tallyRouting(project.ledgers, acceptance, prices, briefs, { rollUp, promptChange: project.promptChange, reviewCriteria, followUps, transcriptVersion: project.transcriptVersion });
|
|
1773
|
+
const leadByModel = bucketsByModel();
|
|
1774
|
+
const { records, parts } = leadAttribution(projectRoot, project, reported);
|
|
1775
|
+
for (const part of parts)
|
|
1776
|
+
for (const [model, b] of Object.entries(part.buckets))
|
|
1777
|
+
addTokens(leadByModel, model, b);
|
|
1778
|
+
const allByModel = bucketsByModel();
|
|
1779
|
+
for (const byModel of [tally.acceptedByModel, leadByModel])
|
|
1780
|
+
for (const [model, b] of Object.entries(byModel))
|
|
1781
|
+
addTokens(allByModel, model, b);
|
|
1782
|
+
const accepted = [...tally.acceptedTasks.values()].reduce((n, ids) => n + ids.size, 0);
|
|
1783
|
+
const { calibration, warning } = readCalibration(calibrationFile);
|
|
1784
|
+
const fit = calibration?.windows.seven_day;
|
|
1785
|
+
return {
|
|
1786
|
+
rows: fit ? tally.rows.map((r) => ({ ...r, sevenDayPctPerAccepted: sevenDayPct(r.tokensPerAccepted, fit.weights, r.accepted) })) : tally.rows,
|
|
1787
|
+
...(fit ? { sevenDay: { residual: fit.residual, unreliable: fit.residual > UNRELIABLE_RESIDUAL } } : {}),
|
|
1788
|
+
...(warning ? { calibrationWarning: warning } : {}),
|
|
1789
|
+
malformedLines,
|
|
1790
|
+
missingTasksFiles,
|
|
1791
|
+
skippedFiles: skipped.map((f) => path.relative(projectRoot, f)),
|
|
1792
|
+
pricing: { table: pricesFile, loaded: Object.keys(prices).length > 0 },
|
|
1793
|
+
unmatchedSubTasks: tally.unmatchedSubTasks,
|
|
1794
|
+
reviewRows: tally.reviewRows,
|
|
1795
|
+
lead: { sessions: records.length, tokens: tokenGroups(leadByModel) },
|
|
1796
|
+
totalsPerAccepted: {
|
|
1797
|
+
accepted,
|
|
1798
|
+
dispatches: perAccepted(tokenGroups(tally.acceptedByModel), accepted),
|
|
1799
|
+
lead: perAccepted(tokenGroups(leadByModel), accepted),
|
|
1800
|
+
total: perAccepted(tokenGroups(allByModel), accepted),
|
|
1801
|
+
},
|
|
1802
|
+
followUpsPerAccepted: followUpsPerAccepted(followUps, tally.acceptedTasks, accepted),
|
|
1803
|
+
versionBands: tally.versionBands,
|
|
1804
|
+
acceptedTaskRecords: tally.acceptedTaskRecords,
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
function readRoutingProject(projectRoot, changeFilter) {
|
|
1808
|
+
const root = path.join(projectRoot, ".freeharness", "sdd");
|
|
1809
|
+
let changeDirs;
|
|
1810
|
+
try {
|
|
1811
|
+
changeDirs = fs
|
|
1812
|
+
.readdirSync(root, { withFileTypes: true })
|
|
1813
|
+
.filter((e) => e.isDirectory())
|
|
1814
|
+
.map((e) => e.name)
|
|
1815
|
+
.sort();
|
|
1816
|
+
}
|
|
1817
|
+
catch {
|
|
1818
|
+
changeDirs = [];
|
|
1819
|
+
}
|
|
1820
|
+
const listedChanges = new Set(changeDirs);
|
|
1821
|
+
if (changeFilter)
|
|
1822
|
+
changeDirs = changeDirs.filter((c) => c === changeFilter);
|
|
1823
|
+
const ledgers = [];
|
|
1824
|
+
const acceptance = new Map();
|
|
1825
|
+
const briefs = new Map();
|
|
1826
|
+
const rollUp = new Map();
|
|
1827
|
+
let malformedLines = 0;
|
|
1828
|
+
const missingTasksFiles = [];
|
|
1829
|
+
const skipped = [];
|
|
1830
|
+
const reported = new Set();
|
|
1831
|
+
const reviewCriteria = new Map();
|
|
1832
|
+
const followUps = new Map();
|
|
1833
|
+
const linesByChange = new Map();
|
|
1834
|
+
for (const change of changeDirs) {
|
|
1835
|
+
const changeDir = path.join(root, change);
|
|
1836
|
+
const ledgerFile = path.join(changeDir, "ledger.jsonl");
|
|
1837
|
+
const { lines, malformed, skipped: ledgerSkipped } = readLedgerLines(ledgerFile, change);
|
|
1838
|
+
if (ledgerSkipped)
|
|
1839
|
+
skipped.push(ledgerFile);
|
|
1840
|
+
malformedLines += malformed;
|
|
1841
|
+
ledgers.push(...lines);
|
|
1842
|
+
linesByChange.set(change, lines);
|
|
1843
|
+
if (lines.length === 0)
|
|
1844
|
+
continue;
|
|
1845
|
+
reported.add(change);
|
|
1846
|
+
const tasksFile = findChangeFile(projectRoot, change);
|
|
1847
|
+
if (!tasksFile)
|
|
1848
|
+
missingTasksFiles.push(change);
|
|
1849
|
+
const tasksText = tasksFile ? readRegularFile(tasksFile, skipped) : undefined;
|
|
1850
|
+
const { all, checked } = tasksText !== undefined ? taskLineIds(tasksText) : { all: new Set(), checked: new Set() };
|
|
1851
|
+
const changeBriefs = readChangeBriefs(changeDir, skipped);
|
|
1852
|
+
const tasks = new Set(lines.map((l) => l.task));
|
|
1853
|
+
for (const task of tasks) {
|
|
1854
|
+
// A plain N.N id or a non-dotted id (a batch brief, `final`) is a task in its own right even when tasks.md does not list it; only a sub-task needs an ancestor.
|
|
1855
|
+
const rolled = rollUpTask(task, all) ?? (SUB_TASK_RE.test(task) ? null : task);
|
|
1856
|
+
rollUp.set(acceptanceKey(change, task), rolled);
|
|
1857
|
+
if (rolled !== null)
|
|
1858
|
+
acceptance.set(acceptanceKey(change, rolled), resolveAcceptance(briefForTask(changeBriefs, rolled), checked, rolled));
|
|
1859
|
+
briefs.set(acceptanceKey(change, task), briefFacts(briefForTask(changeBriefs, task), task));
|
|
1860
|
+
}
|
|
1861
|
+
for (const task of new Set(lines.filter((l) => l.role === "review").map((l) => l.task)))
|
|
1862
|
+
reviewCriteria.set(acceptanceKey(change, task), reviewCriterion(changeDir, task, skipped));
|
|
1863
|
+
const followUpsFile = findChangeFile(projectRoot, change, "follow-ups.md");
|
|
1864
|
+
const followUpsText = followUpsFile ? readRegularFile(followUpsFile, skipped) : undefined;
|
|
1865
|
+
if (followUpsText !== undefined)
|
|
1866
|
+
followUps.set(change, followUpItems(followUpsText));
|
|
1867
|
+
}
|
|
1868
|
+
const versions = new Map();
|
|
1869
|
+
const transcriptVersion = (t) => {
|
|
1870
|
+
if (!versions.has(t))
|
|
1871
|
+
versions.set(t, transcriptCcVersion(t));
|
|
1872
|
+
return versions.get(t) ?? null;
|
|
1873
|
+
};
|
|
1874
|
+
return { root, listedChanges, ledgers, acceptance, briefs, rollUp, malformedLines, missingTasksFiles, skipped, reported, reviewCriteria, followUps, linesByChange, promptChange: (t) => promptBriefChange(t, projectRoot), transcriptVersion };
|
|
1875
|
+
}
|
|
1876
|
+
/** The telemetry records tagged with any of `changes`, and each one's part for every such change it names. */
|
|
1877
|
+
function leadAttribution(projectRoot, project, changes) {
|
|
1878
|
+
const { root, listedChanges, linesByChange } = project;
|
|
1879
|
+
const records = leadRecords(projectRoot, changes, project.skipped);
|
|
1880
|
+
const snapshots = records.some((r) => r.by_model === undefined) ? lastSessionEnd(readUsageOrNone(costLogPath())) : new Map();
|
|
1881
|
+
// A share's weight needs every named change's ledger, filtered out or not; only a listed directory name is ever read, never a path a record supplies.
|
|
1882
|
+
const agents = new Map();
|
|
1883
|
+
const agentsOf = (change) => {
|
|
1884
|
+
let found = agents.get(change);
|
|
1885
|
+
if (!found) {
|
|
1886
|
+
const lines = linesByChange.get(change) ?? (listedChanges.has(change) ? readLedgerLines(path.join(root, change, "ledger.jsonl"), change).lines : []);
|
|
1887
|
+
found = sessionAgents(lines);
|
|
1888
|
+
agents.set(change, found);
|
|
1889
|
+
}
|
|
1890
|
+
return found;
|
|
1891
|
+
};
|
|
1892
|
+
const parts = [];
|
|
1893
|
+
for (const record of records) {
|
|
1894
|
+
for (const [change, part] of leadShares(record, leadBuckets(record, snapshots), agentsOf)) {
|
|
1895
|
+
if (changes.has(change))
|
|
1896
|
+
parts.push({ record, change, ...part });
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
return { records, parts };
|
|
1900
|
+
}
|
|
1901
|
+
/** A row's `--json` shape: `firstPass` and the token and round fields snake-cased, in column order. */
|
|
1902
|
+
function routingRowJson(row) {
|
|
1903
|
+
const { model, effort, class: cls, tasks, accepted, firstPass, tokensPerAccepted, sevenDayPctPerAccepted, meanFixRounds, tasksOverOneRound, followUpsPerAccepted: followUpFigures, ...rest } = row;
|
|
1904
|
+
const estimate = "sevenDayPctPerAccepted" in row ? { seven_day_pct_per_accepted: sevenDayPctPerAccepted ?? null } : {};
|
|
1905
|
+
return { model, effort, class: cls, tasks, accepted, first_pass: firstPass, ...rest, tokens_per_accepted: tokensPerAccepted, ...estimate, mean_fix_rounds: meanFixRounds, tasks_over_one_round: tasksOverOneRound, follow_ups_per_accepted: followUpsJson(followUpFigures) };
|
|
1906
|
+
}
|
|
1907
|
+
/** A follow-up figure's `--json` shape, null when the result carries none. */
|
|
1908
|
+
function followUpsJson(figures) {
|
|
1909
|
+
return figures ? { total: figures.total, by_type: figures.byType } : null;
|
|
1910
|
+
}
|
|
1911
|
+
/** The `--json` shape of a routing report: rows keyed as the data contract names them, the figures beside them after the existing fields. */
|
|
1912
|
+
export function routingReportJson(result) {
|
|
1913
|
+
const { reviewRows, lead, totalsPerAccepted, followUpsPerAccepted: followUpFigures, sevenDay, calibrationWarning, versionBands, acceptedTaskRecords, ...rest } = result;
|
|
1914
|
+
const bands = (list, i) => (list?.[i] ? { cc_version_bands: list[i] } : {});
|
|
1915
|
+
return {
|
|
1916
|
+
...rest,
|
|
1917
|
+
rows: result.rows.map((row, i) => ({ ...routingRowJson(row), ...bands(versionBands?.rows, i) })),
|
|
1918
|
+
review_rows: (reviewRows ?? []).map(({ tokensPerTask, ...row }, i) => ({ ...row, tokens_per_task: tokensPerTask, ...bands(versionBands?.reviewRows, i) })),
|
|
1919
|
+
lead: lead ?? null,
|
|
1920
|
+
totals_per_accepted: totalsPerAccepted ?? null,
|
|
1921
|
+
follow_ups_per_accepted: followUpsJson(followUpFigures),
|
|
1922
|
+
...(sevenDay ? { seven_day_estimate: sevenDay } : {}),
|
|
1923
|
+
...(calibrationWarning ? { calibration_warning: calibrationWarning } : {}),
|
|
1924
|
+
...(acceptedTaskRecords ? { accepted_tasks: acceptedTaskRecords.map((r) => ({ change: r.change, task: r.task, class: r.class, first_ts: r.firstTs, dispatches: r.dispatches, cc_version_bands: r.bands, fully_later: r.fullyLater, tokens: r.tokens })) } : {}),
|
|
1925
|
+
...(versionBands ? { cc_version_bands: versionBands.total } : {}),
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
/** The marker after a fresh figure whose dispatches include any recorded before the fix or on an unknown version. */
|
|
1929
|
+
function lowerBoundMark(k) {
|
|
1930
|
+
return k ? ` (lower bound: ${k} dispatches before ${CC_BAND_FLOOR} or unknown)` : "";
|
|
1931
|
+
}
|
|
1932
|
+
/** Drops C0/C1 controls, format characters (bidi overrides included) and line or paragraph separators so a hostile ledger, brief or directory name cannot drive or reorder the terminal. */
|
|
1933
|
+
function printable(s) {
|
|
1934
|
+
return s.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}]/gu, "");
|
|
1935
|
+
}
|
|
1936
|
+
/** Escapes DEL and C1 (U+007F to U+009F), which `JSON.stringify` leaves raw, as `\u00XX`; the parsed value is unchanged. */
|
|
1937
|
+
export function escapeJsonControls(json) {
|
|
1938
|
+
return json.replace(/[\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
1939
|
+
}
|
|
1940
|
+
/** Renders the `--routing` report; a notice replaces the table when no ledger has an `impl` dispatch, and the trailers print either way. */
|
|
1941
|
+
export function formatRoutingReport(result) {
|
|
1942
|
+
const { rows, malformedLines, missingTasksFiles, skippedFiles, pricing, unmatchedSubTasks = 0 } = result;
|
|
1943
|
+
const trailers = [];
|
|
1944
|
+
if (malformedLines > 0)
|
|
1945
|
+
trailers.push(`skipped malformed ledger lines: ${malformedLines}`);
|
|
1946
|
+
if (unmatchedSubTasks > 0)
|
|
1947
|
+
trailers.push(`unmatched sub-task ids: ${unmatchedSubTasks}`);
|
|
1948
|
+
if (missingTasksFiles.length > 0)
|
|
1949
|
+
trailers.push(`tasks.md not found: ${missingTasksFiles.map(printable).join(", ")}`);
|
|
1950
|
+
if (skippedFiles.length > 0)
|
|
1951
|
+
trailers.push(`skipped files (symlink or not a regular file, ledger over 16 MiB, brief over 1 MiB, brief past the 500-brief cap or with an over-cap header line, unlistable change directory, or unreadable): ${skippedFiles.map(printable).join(", ")}`);
|
|
1952
|
+
if (result.calibrationWarning)
|
|
1953
|
+
trailers.push(printable(result.calibrationWarning));
|
|
1954
|
+
if (rows.length === 0)
|
|
1955
|
+
return ["no impl dispatch recorded in any ledger yet", ...trailers];
|
|
1956
|
+
const lines = [];
|
|
1957
|
+
if (!pricing.loaded)
|
|
1958
|
+
lines.push(priceWarnLine(printable(pricing.table)));
|
|
1959
|
+
lines.push("cost report --routing (USD per accepted task by implementer configuration and task class):");
|
|
1960
|
+
lines.push(` ${"model".padEnd(24)} ${"effort".padEnd(9)} ${"class".padEnd(12)} ${"tasks".padStart(5)} ${"accepted".padStart(8)} ${"first-pass".padStart(10)} ${"impl".padStart(5)} ${"fix".padStart(4)} ${"review".padStart(6)} ${"escalated".padStart(9)} ${"usd".padStart(9)} ${"usd/accepted".padStart(12)}`);
|
|
1961
|
+
for (const [i, r] of rows.entries()) {
|
|
1962
|
+
const perAccepted = r.usdPerAccepted === null ? "-" : r.usdPerAccepted.toFixed(4);
|
|
1963
|
+
const mark = lowerBoundMark(result.versionBands?.rowsLowerBound[i]);
|
|
1964
|
+
lines.push(` ${printable(r.model).slice(0, 24).padEnd(24)} ${printable(r.effort).padEnd(9)} ${printable(r.class).slice(0, 12).padEnd(12)} ${String(r.tasks).padStart(5)} ${String(r.accepted).padStart(8)} ${String(r.firstPass).padStart(10)} ${String(r.impl).padStart(5)} ${String(r.fix).padStart(4)} ${String(r.review).padStart(6)} ${String(r.escalated).padStart(9)} ${r.usd.toFixed(4).padStart(9)} ${perAccepted.padStart(12)}${r.price_estimated ? " (estimated)" : ""}`);
|
|
1965
|
+
// Rendered under the row, never as columns, so the column set and order stay as they were.
|
|
1966
|
+
for (const [family, g] of Object.entries(r.tokensPerAccepted)) {
|
|
1967
|
+
if (g.cache_read > 0 || g.fresh > 0)
|
|
1968
|
+
lines.push(` ${family}: cache-read ${fmtTokens(g.cache_read)}/task, fresh ${fmtTokens(g.fresh)}/task${mark}`);
|
|
1969
|
+
}
|
|
1970
|
+
if ("sevenDayPctPerAccepted" in r) {
|
|
1971
|
+
const pct = r.sevenDayPctPerAccepted ?? null;
|
|
1972
|
+
lines.push(` est. 7-day window ${pct === null ? "-" : `${pct.toFixed(1)}%`} per accepted task${pct !== null && result.sevenDay?.unreliable ? " (unreliable)" : ""}`);
|
|
1973
|
+
}
|
|
1974
|
+
const meanRounds = r.meanFixRounds === null ? "-" : r.meanFixRounds.toFixed(1);
|
|
1975
|
+
lines.push(` mean fix rounds ${meanRounds}, tasks over one round ${r.tasksOverOneRound}`);
|
|
1976
|
+
if (r.followUpsPerAccepted)
|
|
1977
|
+
lines.push(` ${followUpsLine(r.followUpsPerAccepted)}`);
|
|
1978
|
+
}
|
|
1979
|
+
const total = result.versionBands?.total;
|
|
1980
|
+
const bandLine = total ? [`Claude Code version: ${CC_LATER} ${total[CC_LATER]}, older ${total.older}, unknown ${total.unknown} dispatches`] : [];
|
|
1981
|
+
return [...lines, ...formatRoutingFigures(result), ...bandLine, ...trailers];
|
|
1982
|
+
}
|
|
1983
|
+
/** The review rows, lead row, totals and follow-up lines below the implementer rows; each part prints only when the result carries it. */
|
|
1984
|
+
function formatRoutingFigures({ reviewRows = [], lead, totalsPerAccepted, followUpsPerAccepted: followUpFigures, versionBands }) {
|
|
1985
|
+
const lines = [];
|
|
1986
|
+
const nonZero = (groups) => Object.entries(groups).filter(([, g]) => g.cache_read > 0 || g.fresh > 0);
|
|
1987
|
+
if (reviewRows.length > 0) {
|
|
1988
|
+
lines.push("review rows (by reviewer configuration and Review tier criterion):");
|
|
1989
|
+
lines.push(` ${"model".padEnd(24)} ${"effort".padEnd(9)} ${"criterion".padEnd(12)} ${"dispatches".padStart(10)} ${"tasks".padStart(5)}`);
|
|
1990
|
+
for (const [i, r] of reviewRows.entries()) {
|
|
1991
|
+
const b = versionBands?.reviewRows[i];
|
|
1992
|
+
const mark = lowerBoundMark(b ? b.older + b.unknown : 0);
|
|
1993
|
+
lines.push(` ${printable(r.model).slice(0, 24).padEnd(24)} ${printable(r.effort).padEnd(9)} ${printable(r.criterion).slice(0, 12).padEnd(12)} ${String(r.dispatches).padStart(10)} ${String(r.tasks).padStart(5)}`);
|
|
1994
|
+
for (const [family, g] of nonZero(r.tokensPerTask))
|
|
1995
|
+
lines.push(` ${family}: cache-read ${fmtTokens(g.cache_read)}/task, fresh ${fmtTokens(g.fresh)}/task${mark}`);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
if (lead) {
|
|
1999
|
+
lines.push(`lead (main-session tokens of telemetry records tagged with the change; a session is attributed by its change tag only): ${lead.sessions} session${lead.sessions === 1 ? "" : "s"}`);
|
|
2000
|
+
for (const [family, g] of nonZero(lead.tokens))
|
|
2001
|
+
lines.push(` ${family}: cache-read ${fmtTokens(g.cache_read)}, fresh ${fmtTokens(g.fresh)}`);
|
|
2002
|
+
}
|
|
2003
|
+
if (totalsPerAccepted && totalsPerAccepted.accepted > 0) {
|
|
2004
|
+
lines.push("total per accepted task (dispatches and lead):");
|
|
2005
|
+
for (const [family, g] of nonZero(totalsPerAccepted.total)) {
|
|
2006
|
+
const fromLead = totalsPerAccepted.lead[family] ?? { cache_read: 0, fresh: 0 };
|
|
2007
|
+
lines.push(` ${family}: cache-read ${fmtTokens(g.cache_read)}/task (lead ${fmtTokens(fromLead.cache_read)}), fresh ${fmtTokens(g.fresh)}/task (lead ${fmtTokens(fromLead.fresh)})`);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
if (followUpFigures)
|
|
2011
|
+
lines.push(followUpsLine(followUpFigures));
|
|
2012
|
+
return lines;
|
|
2013
|
+
}
|
|
2014
|
+
/** A follow-up figure as one line, shared by the rows and the report-wide summary. */
|
|
2015
|
+
function followUpsLine({ total, byType }) {
|
|
2016
|
+
const split = Object.entries(byType).map(([type, n]) => `${type} ${n.toFixed(1)}`);
|
|
2017
|
+
return `follow-ups per accepted task ${total === null ? "-" : total.toFixed(1)}${split.length > 0 ? ` (${split.join(", ")})` : ""}`;
|
|
2018
|
+
}
|
|
2019
|
+
const RETRO_VALIDATOR = ["skills", "subagent-driven-development", "scripts", "validate-handoff.mjs"];
|
|
2020
|
+
/**
|
|
2021
|
+
* The ceiling and stop counts from the validator's own `ledgerSummary`, loaded by path at run time
|
|
2022
|
+
* so the CLI judges ceilings exactly as the handoff gate does without a build-time dependency.
|
|
2023
|
+
* @returns Null on any failure, which the line prints as `ceilings unknown`.
|
|
2024
|
+
*/
|
|
2025
|
+
async function ledgerCeilings(changeDir) {
|
|
2026
|
+
try {
|
|
2027
|
+
const mod = (await import(pathToFileURL(path.join(resolveCanonicalDir(), ...RETRO_VALIDATOR)).href));
|
|
2028
|
+
if (typeof mod.ledgerSummary !== "function")
|
|
2029
|
+
return null;
|
|
2030
|
+
const summary = mod.ledgerSummary(changeDir);
|
|
2031
|
+
const atOrOver = summary?.percentiles?.atOrOver;
|
|
2032
|
+
const stops = summary?.handoffs?.stops;
|
|
2033
|
+
if (!Array.isArray(atOrOver) || !stops || typeof stops !== "object")
|
|
2034
|
+
return null;
|
|
2035
|
+
const sum = Object.values(stops).reduce((n, v) => n + (typeof v === "number" && Number.isFinite(v) ? v : 0), 0);
|
|
2036
|
+
return { atOrOver: atOrOver.length, stops: sum };
|
|
2037
|
+
}
|
|
2038
|
+
catch {
|
|
2039
|
+
return null;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
function localDate(d) {
|
|
2043
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
2044
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
2045
|
+
}
|
|
2046
|
+
const mega = (n) => `${(n / 1e6).toFixed(1)}M`;
|
|
2047
|
+
const kilo = (n) => `${(n / 1e3).toFixed(1)}k`;
|
|
2048
|
+
/**
|
|
2049
|
+
* The retrospective line for one change. Dispatches are the routing collapse over every ledger,
|
|
2050
|
+
* kept where the collapse files them, so a dispatch copied into another change's ledger counts
|
|
2051
|
+
* once; the command reads only and writes nothing.
|
|
2052
|
+
* @param now The moment whose local date the line carries.
|
|
2053
|
+
*/
|
|
2054
|
+
export async function retroLine(projectRoot, change, now = new Date()) {
|
|
2055
|
+
const project = readRoutingProject(projectRoot);
|
|
2056
|
+
const changeDir = path.join(project.root, change);
|
|
2057
|
+
// Only a listed directory name is read, so a `--change` holding a path never leaves the sdd root.
|
|
2058
|
+
if (!project.listedChanges.has(change) || !fs.lstatSync(path.join(changeDir, "ledger.jsonl"), { throwIfNoEntry: false })) {
|
|
2059
|
+
return { ledger: false, change, line: `no dispatch ledger for ${printable(change)}` };
|
|
2060
|
+
}
|
|
2061
|
+
const prices = loadPriceTable(costPricesPath());
|
|
2062
|
+
const usdOf = (model, b) => priceBucket(priceMatch(prices, model).row, b);
|
|
2063
|
+
const dispatches = collapseDispatches(project.ledgers, project.briefs, project.promptChange).filter((l) => l.change === change);
|
|
2064
|
+
const roles = { impl: 0, review: 0, fix: 0 };
|
|
2065
|
+
const byModel = bucketsByModel();
|
|
2066
|
+
const byTask = new Map();
|
|
2067
|
+
const bands = emptyBands();
|
|
2068
|
+
let usd = 0;
|
|
2069
|
+
for (const line of dispatches) {
|
|
2070
|
+
roles[line.role] += 1;
|
|
2071
|
+
addTokens(byModel, line.model, line);
|
|
2072
|
+
usd += usdOf(line.model, line);
|
|
2073
|
+
bands[dispatchBand(line, project.transcriptVersion)] += 1;
|
|
2074
|
+
const task = project.rollUp.get(acceptanceKey(change, line.task)) ?? line.task;
|
|
2075
|
+
const lines = byTask.get(task) ?? [];
|
|
2076
|
+
lines.push(line);
|
|
2077
|
+
byTask.set(task, lines);
|
|
2078
|
+
}
|
|
2079
|
+
const groups = tokenGroups(byModel);
|
|
2080
|
+
const freshByFamily = {};
|
|
2081
|
+
for (const f of FAMILIES)
|
|
2082
|
+
if ((groups[f]?.fresh ?? 0) > 0)
|
|
2083
|
+
freshByFamily[f] = groups[f]?.fresh ?? 0;
|
|
2084
|
+
const cacheReadByFamily = {};
|
|
2085
|
+
for (const f of FAMILIES)
|
|
2086
|
+
if ((groups[f]?.cache_read ?? 0) > 0)
|
|
2087
|
+
cacheReadByFamily[f] = groups[f]?.cache_read ?? 0;
|
|
2088
|
+
const dispatchCacheRead = Object.values(groups).reduce((n, g) => n + g.cache_read, 0);
|
|
2089
|
+
const fresh = Object.values(groups).reduce((n, g) => n + g.fresh, 0);
|
|
2090
|
+
const fixRounds = [...byTask.values()].reduce((n, lines) => n + distinctFixRounds(lines), 0);
|
|
2091
|
+
const { records, parts } = leadAttribution(projectRoot, project, new Set([change]));
|
|
2092
|
+
const leadByModel = bucketsByModel();
|
|
2093
|
+
let leadUsd = 0;
|
|
2094
|
+
for (const part of parts) {
|
|
2095
|
+
for (const [model, b] of Object.entries(part.buckets)) {
|
|
2096
|
+
addTokens(leadByModel, model, b);
|
|
2097
|
+
leadUsd += usdOf(model, b);
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
const leadCacheRead = Object.values(tokenGroups(leadByModel)).reduce((n, g) => n + g.cache_read, 0);
|
|
2101
|
+
const counts = { sdd: 0, plan: 0, archive: 0, review: 0, debug: 0, untagged: 0, legacy: 0 };
|
|
2102
|
+
for (const r of records) {
|
|
2103
|
+
if (typeof r.session_class !== "string")
|
|
2104
|
+
counts.legacy += 1;
|
|
2105
|
+
else if (SESSION_CLASS_NAMES.includes(r.session_class))
|
|
2106
|
+
counts[r.session_class] += 1;
|
|
2107
|
+
}
|
|
2108
|
+
const leadClasses = {};
|
|
2109
|
+
for (const name of [...SESSION_CLASS_NAMES, "legacy"])
|
|
2110
|
+
if (counts[name] > 0)
|
|
2111
|
+
leadClasses[name] = counts[name];
|
|
2112
|
+
const ceilings = await ledgerCeilings(changeDir);
|
|
2113
|
+
const n = dispatches.length;
|
|
2114
|
+
const other = n - roles.impl - roles.review - roles.fix;
|
|
2115
|
+
const date = localDate(now);
|
|
2116
|
+
const paren = (items) => (items.length > 0 ? ` (${items.join(", ")})` : "");
|
|
2117
|
+
const line = [
|
|
2118
|
+
`- Measured ${date} (freeharness cost report --retro): ${n} dispatches (impl ${roles.impl}, review ${roles.review}, fix ${roles.fix}${other > 0 ? `, other ${other}` : ""})`,
|
|
2119
|
+
`dispatch cache-read ${mega(dispatchCacheRead)}${paren(Object.entries(cacheReadByFamily).map(([f, v]) => `${f} ${mega(v)}`))}`,
|
|
2120
|
+
`fresh ${kilo(fresh)}${paren(Object.entries(freshByFamily).map(([f, v]) => `${f} ${kilo(v)}`))}`,
|
|
2121
|
+
ceilings ? `${ceilings.atOrOver} tasks at or over their class ceiling, ${ceilings.stops} scope stops` : "ceilings unknown",
|
|
2122
|
+
`${fixRounds} fix rounds`,
|
|
2123
|
+
`${usd.toFixed(2)} USD dispatches est.`,
|
|
2124
|
+
`lead ${records.length} sessions ${mega(leadCacheRead)} cache-read ${leadUsd.toFixed(2)} USD est.${paren(Object.entries(leadClasses).map(([c, k]) => `${c} ${k}`))}`,
|
|
2125
|
+
`Claude Code ${CC_LATER} ${bands[CC_LATER]}/${n} dispatches`,
|
|
2126
|
+
].join(", ");
|
|
2127
|
+
return {
|
|
2128
|
+
ledger: true,
|
|
2129
|
+
change,
|
|
2130
|
+
line,
|
|
2131
|
+
date,
|
|
2132
|
+
dispatches: n,
|
|
2133
|
+
...roles,
|
|
2134
|
+
other,
|
|
2135
|
+
dispatchCacheRead,
|
|
2136
|
+
cacheReadByFamily,
|
|
2137
|
+
fresh,
|
|
2138
|
+
freshByFamily,
|
|
2139
|
+
tasksAtOrOverCeiling: ceilings?.atOrOver ?? null,
|
|
2140
|
+
scopeStops: ceilings?.stops ?? null,
|
|
2141
|
+
fixRounds,
|
|
2142
|
+
usd,
|
|
2143
|
+
leadSessions: records.length,
|
|
2144
|
+
leadCacheRead,
|
|
2145
|
+
leadUsd,
|
|
2146
|
+
leadClasses,
|
|
2147
|
+
ccVersionBands: bands,
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
/** The `--json` shape of the retrospective: the line first, every figure snake-cased beside it. */
|
|
2151
|
+
export function retroJson(r) {
|
|
2152
|
+
return {
|
|
2153
|
+
line: r.line,
|
|
2154
|
+
change: r.change,
|
|
2155
|
+
date: r.date,
|
|
2156
|
+
dispatches: r.dispatches,
|
|
2157
|
+
impl: r.impl,
|
|
2158
|
+
review: r.review,
|
|
2159
|
+
fix: r.fix,
|
|
2160
|
+
other: r.other,
|
|
2161
|
+
dispatch_cache_read: r.dispatchCacheRead,
|
|
2162
|
+
cache_read_by_family: r.cacheReadByFamily,
|
|
2163
|
+
fresh: r.fresh,
|
|
2164
|
+
fresh_by_family: r.freshByFamily,
|
|
2165
|
+
tasks_at_or_over_ceiling: r.tasksAtOrOverCeiling,
|
|
2166
|
+
scope_stops: r.scopeStops,
|
|
2167
|
+
fix_rounds: r.fixRounds,
|
|
2168
|
+
usd: round(r.usd),
|
|
2169
|
+
lead_sessions: r.leadSessions,
|
|
2170
|
+
lead_cache_read: r.leadCacheRead,
|
|
2171
|
+
lead_usd: round(r.leadUsd),
|
|
2172
|
+
lead_classes: r.leadClasses,
|
|
2173
|
+
cc_version_bands: r.ccVersionBands,
|
|
2174
|
+
};
|
|
2175
|
+
}
|