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
|
@@ -12,9 +12,15 @@ import { fileURLToPath } from "node:url";
|
|
|
12
12
|
|
|
13
13
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
|
|
15
|
-
/** Per-session subagent scan bounds (never read an unbounded directory). */
|
|
15
|
+
/** Per-session subagent scan bounds (never read an unbounded directory); a long SDD session holds hundreds of transcripts. */
|
|
16
16
|
const MAX_SUBAGENT_FILE_BYTES = 64 * 1024 * 1024;
|
|
17
|
-
const MAX_SUBAGENT_FILES =
|
|
17
|
+
export const MAX_SUBAGENT_FILES = 2000;
|
|
18
|
+
const MAX_SUBAGENT_CACHE_BYTES = 8 * 1024 * 1024;
|
|
19
|
+
/** Per-file read caps: a file past its cap is treated as absent rather than read inside the hook timeout. */
|
|
20
|
+
const MAX_MAIN_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
|
|
21
|
+
const MAX_USAGE_LOG_BYTES = 256 * 1024 * 1024;
|
|
22
|
+
const MAX_LEDGER_BYTES = 16 * 1024 * 1024;
|
|
23
|
+
const MAX_BRIEF_BYTES = 1024 * 1024;
|
|
18
24
|
|
|
19
25
|
/** Usage log path: FREEHARNESS_COST_LOG override, else ~/.claude/freeharness/cost/usage.jsonl. */
|
|
20
26
|
function logPath() {
|
|
@@ -46,44 +52,137 @@ function loadPrices(file) {
|
|
|
46
52
|
}
|
|
47
53
|
}
|
|
48
54
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
/** An id new to the table (a new version, a retried alias) is priced at its family's rate, not
|
|
56
|
+
* at `default`, which misprices an opus call several times over. */
|
|
57
|
+
const PRICE_FAMILY_RE = /^claude-(?:opus|sonnet|haiku|fable)-/;
|
|
58
|
+
|
|
59
|
+
/** Leading numeric `-` segments of `key` after `prefix`: `claude-opus-5-5` gives [5, 5]. */
|
|
60
|
+
function versionSegments(key, prefix) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const seg of key.slice(prefix.length).split("-")) {
|
|
63
|
+
if (!/^\d+$/.test(seg)) break;
|
|
64
|
+
out.push(Number(seg));
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Positive when version `a` is newer than `b`; a missing segment counts lower, so 5-5 beats 5. */
|
|
70
|
+
function compareVersions(a, b) {
|
|
71
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
72
|
+
const x = i < a.length ? a[i] : -1;
|
|
73
|
+
const y = i < b.length ? b[i] : -1;
|
|
74
|
+
if (x !== y) return x - y;
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Exact key, else the longest prefix key, else the id's family's newest key (estimated), else
|
|
80
|
+
* `default`. `priceMatch` in packages/cli/src/cli/cost.ts is a copy: this hook imports nothing
|
|
81
|
+
* from the CLI, and a parity test holds the two together.
|
|
82
|
+
* @returns {{key: string|null, row: object|undefined, estimated: boolean}} key null when no row matches.
|
|
83
|
+
*/
|
|
84
|
+
export function priceMatch(prices, model) {
|
|
85
|
+
const has = (key) => Object.prototype.hasOwnProperty.call(prices, key) && Boolean(prices[key]);
|
|
86
|
+
if (has(model)) return { key: model, row: prices[model], estimated: false };
|
|
52
87
|
let best;
|
|
53
88
|
for (const key of Object.keys(prices)) {
|
|
54
89
|
if (key !== "default" && model.startsWith(key) && (!best || key.length > best.length)) best = key;
|
|
55
90
|
}
|
|
56
|
-
return best
|
|
91
|
+
if (best) return { key: best, row: prices[best], estimated: false };
|
|
92
|
+
const family = PRICE_FAMILY_RE.exec(model);
|
|
93
|
+
if (family) {
|
|
94
|
+
let newest;
|
|
95
|
+
let newestVersion = [];
|
|
96
|
+
for (const key of Object.keys(prices)) {
|
|
97
|
+
if (!key.startsWith(family[0]) || !has(key)) continue;
|
|
98
|
+
const version = versionSegments(key, family[0]);
|
|
99
|
+
const order = newest === undefined ? 1 : compareVersions(version, newestVersion);
|
|
100
|
+
if (order > 0 || (order === 0 && key.length > newest.length)) {
|
|
101
|
+
newest = key;
|
|
102
|
+
newestVersion = version;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (newest !== undefined) return { key: newest, row: prices[newest], estimated: true };
|
|
106
|
+
}
|
|
107
|
+
return has("default") ? { key: "default", row: prices.default, estimated: false } : { key: null, row: undefined, estimated: false };
|
|
57
108
|
}
|
|
58
109
|
|
|
59
|
-
/** Estimated USD for one bucket at `row`'s per-MTok prices (0 when no row). */
|
|
60
|
-
function estimate(u, row) {
|
|
110
|
+
/** Estimated USD for one bucket at `row`'s per-MTok prices (0 when no row); `cache_write_1h` prices at the row's 1-hour rate, derived at 1.6x `cache_write` when the row has none. */
|
|
111
|
+
export function estimate(u, row) {
|
|
61
112
|
if (!row) return 0;
|
|
62
|
-
const per = (n, price) => (n * (Number(price) || 0)) / 1e6;
|
|
63
|
-
|
|
113
|
+
const per = (n, price) => ((Number(n) || 0) * (Number(price) || 0)) / 1e6;
|
|
114
|
+
const write1h = Math.max(0, Math.min(Number(u.cache_write_1h) || 0, Number(u.cache_write) || 0));
|
|
115
|
+
const write5m = (Number(u.cache_write) || 0) - write1h;
|
|
116
|
+
const rate1h = row.cache_write_1h ?? (Number(row.cache_write) || 0) * 1.6;
|
|
117
|
+
return per(u.input, row.input) + per(u.output, row.output) + per(u.cache_read, row.cache_read) + per(write5m, row.cache_write) + per(write1h, rate1h);
|
|
64
118
|
}
|
|
65
119
|
|
|
66
|
-
/** @returns {{input:number,output:number,cache_read:number,cache_write:number,messages:number}} Zeroed bucket. */
|
|
120
|
+
/** @returns {{input:number,output:number,cache_read:number,cache_write:number,cache_write_1h:number,messages:number}} Zeroed bucket. */
|
|
67
121
|
function bucket() {
|
|
68
|
-
return { input: 0, output: 0, cache_read: 0, cache_write: 0, messages: 0 };
|
|
122
|
+
return { input: 0, output: 0, cache_read: 0, cache_write: 0, cache_write_1h: 0, messages: 0 };
|
|
69
123
|
}
|
|
70
124
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
125
|
+
/** Bytes per `readSync` call of the bounded read. */
|
|
126
|
+
const READ_CHUNK = 1024 * 1024;
|
|
127
|
+
|
|
128
|
+
/** Text of `file` through one fd opened without following a symlink leaf or blocking on a FIFO; the fstat on that same fd decides, so the checked file is the one read. The fstat size is only a fast refusal: a file still growing is bounded by the bytes actually read, and one byte past `maxBytes` yields "too-large". One buffer sized from the fstat holds the text, so peak memory stays near the file size rather than a multiple of the cap. `reason` is "missing" (ENOENT), "unreadable" (symlink, non-regular, I/O error) or "too-large". */
|
|
129
|
+
function readContained(file, maxBytes) {
|
|
130
|
+
let fd;
|
|
131
|
+
try {
|
|
132
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK || 0));
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return { text: null, reason: errCode(e) === "ENOENT" ? "missing" : "unreadable" };
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const stat = fs.fstatSync(fd);
|
|
138
|
+
if (!stat.isFile()) return { text: null, reason: "unreadable" };
|
|
139
|
+
if (stat.size > maxBytes) return { text: null, reason: "too-large" };
|
|
140
|
+
let buf = Buffer.allocUnsafe(stat.size + 1);
|
|
141
|
+
let total = 0;
|
|
142
|
+
for (;;) {
|
|
143
|
+
if (total === buf.length) {
|
|
144
|
+
if (total > maxBytes) return { text: null, reason: "too-large" };
|
|
145
|
+
const grown = Buffer.allocUnsafe(Math.min(buf.length * 2, maxBytes + 1));
|
|
146
|
+
buf.copy(grown, 0, 0, total);
|
|
147
|
+
buf = grown;
|
|
148
|
+
}
|
|
149
|
+
const n = fs.readSync(fd, buf, total, Math.min(READ_CHUNK, buf.length - total), total);
|
|
150
|
+
if (n === 0) break;
|
|
151
|
+
total += n;
|
|
152
|
+
}
|
|
153
|
+
return { text: buf.toString("utf8", 0, total), reason: null };
|
|
154
|
+
} catch {
|
|
155
|
+
return { text: null, reason: "unreadable" };
|
|
156
|
+
} finally {
|
|
157
|
+
fs.closeSync(fd);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const CC_VERSION_RE = /^\d{1,4}\.\d{1,4}\.\d{1,6}(-[0-9A-Za-z.]{1,16})?$/;
|
|
162
|
+
|
|
163
|
+
/** A transcript `version` verbatim, or null: clients before 2.1.307 wrote placeholder subagent output tokens, so a cost needs a trusted version. */
|
|
164
|
+
export function ccVersionOf(value) {
|
|
165
|
+
return typeof value === "string" && CC_VERSION_RE.test(value) ? value : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Sums a transcript's token usage per model. Assistant messages may share one `message.id`; the last line per id wins, so nothing is counted twice. `effort` and `ccVersion` are the last valid values, the ones the run used. An unusable file yields `found: false` and the `reason` ("no-transcript" or "too-large"). */
|
|
169
|
+
function sumTranscript(file, maxBytes) {
|
|
170
|
+
// Tool and model names come from the untrusted transcript: without a prototype, `__proto__` or
|
|
171
|
+
// `constructor` is an ordinary key instead of a path into Object.prototype.
|
|
172
|
+
const byModel = Object.create(null);
|
|
173
|
+
const byTool = Object.create(null);
|
|
75
174
|
let turns = 0;
|
|
76
175
|
let lines = 0;
|
|
77
176
|
let contextTokens = 0;
|
|
78
177
|
let contextModel = "";
|
|
79
178
|
let toolOutputChars = 0;
|
|
80
179
|
let toolCalls = 0;
|
|
81
|
-
let
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
180
|
+
let effort = null;
|
|
181
|
+
let firstUserText = null;
|
|
182
|
+
let ccVersion = null;
|
|
183
|
+
const signals = { commands: [], skills: [], openspec: [] };
|
|
184
|
+
const { text, reason } = readContained(file, maxBytes);
|
|
185
|
+
if (text === null) return { byModel, byTool, turns, lines, contextTokens, contextModel, toolOutputChars, toolCalls, effort, firstUserText, ccVersion, signals, found: false, reason: reason === "too-large" ? "too-large" : "no-transcript" };
|
|
87
186
|
const seen = new Map(); // message.id -> { model, usage }
|
|
88
187
|
const toolNames = new Map(); // tool_use id -> name
|
|
89
188
|
let anon = 0;
|
|
@@ -96,7 +195,11 @@ function sumTranscript(file) {
|
|
|
96
195
|
} catch {
|
|
97
196
|
continue;
|
|
98
197
|
}
|
|
99
|
-
|
|
198
|
+
if (!rec || typeof rec !== "object" || Array.isArray(rec)) continue;
|
|
199
|
+
const msg = rec.message;
|
|
200
|
+
if (rec.type === "user" || rec.type === "assistant") ccVersion = ccVersionOf(rec.version) ?? ccVersion;
|
|
201
|
+
if (rec.type === "user" && firstUserText === null) firstUserText = messageText(msg);
|
|
202
|
+
addSignals(rec, signals);
|
|
100
203
|
if (rec.type === "user" && msg) {
|
|
101
204
|
const content = msg.content;
|
|
102
205
|
const isToolResult = Array.isArray(content) && content.length > 0 && content.every((c) => c && c.type === "tool_result");
|
|
@@ -114,7 +217,9 @@ function sumTranscript(file) {
|
|
|
114
217
|
}
|
|
115
218
|
continue;
|
|
116
219
|
}
|
|
117
|
-
if (
|
|
220
|
+
if (rec.type === "assistant" && typeof rec.effort === "string" && EFFORT_LEVELS.has(rec.effort)) effort = rec.effort;
|
|
221
|
+
// Request blobs repeat the conversation's tool_use blocks; only the assistant record is the call.
|
|
222
|
+
if (rec.type === "assistant" && msg && Array.isArray(msg.content)) {
|
|
118
223
|
for (const c of msg.content) {
|
|
119
224
|
if (c && c.type === "tool_use" && typeof c.name === "string") {
|
|
120
225
|
toolCalls += 1;
|
|
@@ -138,13 +243,112 @@ function sumTranscript(file) {
|
|
|
138
243
|
}
|
|
139
244
|
for (const { model, usage } of seen.values()) {
|
|
140
245
|
const b = (byModel[model] ??= bucket());
|
|
246
|
+
const cacheWrite = Number(usage.cache_creation_input_tokens) || 0;
|
|
247
|
+
const raw1h = usage.cache_creation && typeof usage.cache_creation === "object" ? Number(usage.cache_creation.ephemeral_1h_input_tokens) || 0 : 0;
|
|
141
248
|
b.input += Number(usage.input_tokens) || 0;
|
|
142
249
|
b.output += Number(usage.output_tokens) || 0;
|
|
143
250
|
b.cache_read += Number(usage.cache_read_input_tokens) || 0;
|
|
144
|
-
b.cache_write +=
|
|
251
|
+
b.cache_write += cacheWrite;
|
|
252
|
+
b.cache_write_1h += Math.max(0, Math.min(raw1h, cacheWrite));
|
|
145
253
|
b.messages += 1;
|
|
146
254
|
}
|
|
147
|
-
return { byModel, byTool, turns, lines, contextTokens, contextModel, toolOutputChars, toolCalls, found: true };
|
|
255
|
+
return { byModel, byTool, turns, lines, contextTokens, contextModel, toolOutputChars, toolCalls, effort, firstUserText, ccVersion, signals, found: true };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A message's text: a string content as is, else its text blocks joined; "" for any other shape. */
|
|
259
|
+
function messageText(msg) {
|
|
260
|
+
const content = msg && typeof msg === "object" ? msg.content : null;
|
|
261
|
+
if (typeof content === "string") return content;
|
|
262
|
+
if (!Array.isArray(content)) return "";
|
|
263
|
+
return content
|
|
264
|
+
.filter((c) => c && c.type === "text" && typeof c.text === "string")
|
|
265
|
+
.map((c) => c.text)
|
|
266
|
+
.join("\n");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const COMMAND_NAME_RE = /<command-name>\/?([A-Za-z0-9:_.-]{1,64})<\/command-name>/;
|
|
270
|
+
const MAX_SIGNALS = 200;
|
|
271
|
+
const MAX_SIGNAL_ARG = 256;
|
|
272
|
+
|
|
273
|
+
const signalArg = (v) => (typeof v === "string" && v.length <= MAX_SIGNAL_ARG ? v : null);
|
|
274
|
+
|
|
275
|
+
/** A lazy regex rescans to the end once per unclosed opener; two indexOf scans stay linear. */
|
|
276
|
+
function commandArg(text) {
|
|
277
|
+
const open = text.indexOf("<command-args>");
|
|
278
|
+
const close = open < 0 ? -1 : text.indexOf("</command-args>", open + 14);
|
|
279
|
+
return close < 0 ? null : signalArg(text.slice(open + 14, close));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** `openspec` interleaves both kinds in transcript order, since the last invocation names the change. */
|
|
283
|
+
function pushSignal(into, kind, prefix, signal) {
|
|
284
|
+
if (into[kind].length < MAX_SIGNALS) into[kind].push(signal);
|
|
285
|
+
if (signal.name.startsWith(prefix) && into.openspec.length < MAX_SIGNALS) into.openspec.push(signal);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Adds one record's workflow signals to `into`. Only user-typed command elements and Skill calls
|
|
289
|
+
* count: tool_result and assistant text quote files and output, which anyone can forge. */
|
|
290
|
+
function addSignals(rec, into) {
|
|
291
|
+
const content = rec.message && typeof rec.message === "object" ? rec.message.content : null;
|
|
292
|
+
if (rec.type === "user") {
|
|
293
|
+
const texts = typeof content === "string" ? [content] : Array.isArray(content) ? content.filter((c) => c && c.type === "text" && typeof c.text === "string").map((c) => c.text) : [];
|
|
294
|
+
for (const text of texts) {
|
|
295
|
+
const m = text.includes("<command-name>") ? COMMAND_NAME_RE.exec(text) : null;
|
|
296
|
+
if (m) pushSignal(into, "commands", "opsx:", { name: m[1], arg: commandArg(text) });
|
|
297
|
+
}
|
|
298
|
+
} else if (rec.type === "assistant" && Array.isArray(content)) {
|
|
299
|
+
for (const c of content) {
|
|
300
|
+
const input = c && c.type === "tool_use" && c.name === "Skill" && c.input && typeof c.input === "object" ? c.input : null;
|
|
301
|
+
if (input && typeof input.skill === "string" && input.skill.length >= 1 && input.skill.length <= 64) pushSignal(into, "skills", "openspec-", { name: input.skill, arg: signalArg(input.args) });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Command and Skill signals of parsed transcript records, the first 200 of each. */
|
|
307
|
+
export function sessionSignals(records) {
|
|
308
|
+
const into = { commands: [], skills: [], openspec: [] };
|
|
309
|
+
for (const rec of Array.isArray(records) ? records : []) if (rec && typeof rec === "object" && !Array.isArray(rec)) addSignals(rec, into);
|
|
310
|
+
return into;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const SESSION_TRIGGERS = Object.freeze(
|
|
314
|
+
Object.assign(Object.create(null), {
|
|
315
|
+
"subagent-driven-development": "sdd",
|
|
316
|
+
"opsx:apply": "sdd",
|
|
317
|
+
"openspec-apply-change": "sdd",
|
|
318
|
+
"opsx:propose": "plan",
|
|
319
|
+
"opsx:update": "plan",
|
|
320
|
+
"opsx:explore": "plan",
|
|
321
|
+
"openspec-propose": "plan",
|
|
322
|
+
"openspec-update-change": "plan",
|
|
323
|
+
"openspec-explore": "plan",
|
|
324
|
+
"opsx:archive": "archive",
|
|
325
|
+
"opsx:sync": "archive",
|
|
326
|
+
"openspec-archive-change": "archive",
|
|
327
|
+
"openspec-sync-specs": "archive",
|
|
328
|
+
"code-review": "review",
|
|
329
|
+
"security-review": "review",
|
|
330
|
+
review: "review",
|
|
331
|
+
"systematic-debugging": "debug",
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
const SESSION_CLASS_ORDER = Object.freeze(["sdd", "plan", "archive", "review", "debug"]);
|
|
335
|
+
|
|
336
|
+
/** The session's workflow classes from its signals and its ledger dispatch counts per role. */
|
|
337
|
+
export function classifySession(signals, roleCounts) {
|
|
338
|
+
const hit = new Set();
|
|
339
|
+
for (const list of [signals?.commands, signals?.skills]) {
|
|
340
|
+
if (!Array.isArray(list)) continue;
|
|
341
|
+
for (const s of list) if (s && typeof s.name === "string" && SESSION_TRIGGERS[s.name]) hit.add(SESSION_TRIGGERS[s.name]);
|
|
342
|
+
}
|
|
343
|
+
const counts = roleCounts && typeof roleCounts === "object" ? roleCounts : {};
|
|
344
|
+
if (["impl", "fix", "review"].some((r) => Number(counts[r]) > 0)) hit.add("sdd");
|
|
345
|
+
const session_classes = SESSION_CLASS_ORDER.filter((c) => hit.has(c));
|
|
346
|
+
return { session_class: session_classes[0] ?? "untagged", session_classes };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Effort level a subagent transcript ran at; null for an unusable or over-cap file, or no known level. The hook itself takes the effort from `sumTranscript`'s single pass; this export isolates that read for tests. */
|
|
350
|
+
export function effortFromTranscript(file) {
|
|
351
|
+
return sumTranscript(file, MAX_SUBAGENT_FILE_BYTES).effort;
|
|
148
352
|
}
|
|
149
353
|
|
|
150
354
|
/** Character count of a tool_result content. */
|
|
@@ -162,12 +366,15 @@ function modelSuffix(model) {
|
|
|
162
366
|
return m ? m[1] : null;
|
|
163
367
|
}
|
|
164
368
|
|
|
165
|
-
/** Token window: an explicit `[1m]` suffix means 1,000,000, else the price row's `window`, defaulting to 200,000.
|
|
369
|
+
/** Token window: an explicit `[1m]` suffix means 1,000,000, else the price row's `window`, defaulting to 200,000.
|
|
370
|
+
* The family step is skipped: a newer model's window would overstate an older model's, so such
|
|
371
|
+
* an id takes the `default` row's window instead. */
|
|
166
372
|
export function windowFor(model, prices) {
|
|
167
373
|
const raw = typeof model === "string" ? model : "";
|
|
168
374
|
if (/\[1m\]$/i.test(raw)) return 1_000_000;
|
|
169
375
|
const base = raw.replace(/\[[^\]]*\]$/, "");
|
|
170
|
-
const
|
|
376
|
+
const match = priceMatch(prices, base || raw);
|
|
377
|
+
const row = match.estimated ? prices.default : match.row;
|
|
171
378
|
const w = row && Number.isInteger(row.window) && row.window > 0 ? row.window : null;
|
|
172
379
|
return w ?? 200_000;
|
|
173
380
|
}
|
|
@@ -177,6 +384,20 @@ function userConfigDir() {
|
|
|
177
384
|
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
178
385
|
}
|
|
179
386
|
|
|
387
|
+
/** True only when `file` resolves strictly under the real `<config>/projects` and still is the inode `stat` (taken from the open descriptor) names, so a parent swapped after the open cannot redirect a transcript read. */
|
|
388
|
+
export function underProjectsDir(file, stat) {
|
|
389
|
+
try {
|
|
390
|
+
const base = fs.realpathSync(path.resolve(userConfigDir(), "projects"));
|
|
391
|
+
const real = fs.realpathSync(file);
|
|
392
|
+
const rel = path.relative(base, real);
|
|
393
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return false;
|
|
394
|
+
const entry = fs.lstatSync(real);
|
|
395
|
+
return entry.dev === stat.dev && entry.ino === stat.ino;
|
|
396
|
+
} catch {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
180
401
|
/** settings.json's `model` field, or null when absent, unreadable or unset. */
|
|
181
402
|
function settingsModel(file) {
|
|
182
403
|
try {
|
|
@@ -192,7 +413,12 @@ function configuredModel(cwd) {
|
|
|
192
413
|
return settingsModel(path.join(cwd, ".claude", "settings.json")) ?? settingsModel(path.join(userConfigDir(), "settings.json"));
|
|
193
414
|
}
|
|
194
415
|
|
|
195
|
-
/**
|
|
416
|
+
/** Current Claude Code transcript name, `agent-<hex id>.jsonl`; its name lives only in the meta. Tried first, and it never overlaps the legacy shape (no `-` in the id). */
|
|
417
|
+
const CURRENT_TRANSCRIPT_RE = /^agent-([A-Za-z0-9]+)\.jsonl$/;
|
|
418
|
+
/** Older builds embedded the agent name: `agent-<name>-<hash>.jsonl`. */
|
|
419
|
+
const LEGACY_TRANSCRIPT_RE = /^agent-(.+)-[^-]+\.jsonl$/;
|
|
420
|
+
|
|
421
|
+
/** Subagent transcripts under `<dirname>/<session-id>/subagents/` in either name shape, sorted by file name; `name` is the legacy parsed name, else null. Empty when the directory is missing. */
|
|
196
422
|
export function subagentTranscripts(transcriptPath) {
|
|
197
423
|
if (!transcriptPath) return [];
|
|
198
424
|
const dir = path.dirname(transcriptPath);
|
|
@@ -206,11 +432,16 @@ export function subagentTranscripts(transcriptPath) {
|
|
|
206
432
|
}
|
|
207
433
|
const out = [];
|
|
208
434
|
for (const entry of entries) {
|
|
209
|
-
|
|
210
|
-
if (!
|
|
211
|
-
|
|
435
|
+
let name = null;
|
|
436
|
+
if (!CURRENT_TRANSCRIPT_RE.test(entry)) {
|
|
437
|
+
const legacy = LEGACY_TRANSCRIPT_RE.exec(entry);
|
|
438
|
+
if (!legacy) continue;
|
|
439
|
+
name = legacy[1];
|
|
440
|
+
}
|
|
441
|
+
const stem = entry.slice(0, -".jsonl".length);
|
|
442
|
+
out.push({ id: stem.slice("agent-".length), name, file: path.join(subDir, entry), metaFile: path.join(subDir, `${stem}.meta.json`) });
|
|
212
443
|
}
|
|
213
|
-
return out.sort((a, b) => a.
|
|
444
|
+
return out.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
214
445
|
}
|
|
215
446
|
|
|
216
447
|
/** Merges per-model sums into one `by_agent` bucket, picking the model with the most tokens (ties break alphabetically). */
|
|
@@ -224,6 +455,7 @@ function mergeAgentUsage(byModel) {
|
|
|
224
455
|
total.output += u.output;
|
|
225
456
|
total.cache_read += u.cache_read;
|
|
226
457
|
total.cache_write += u.cache_write;
|
|
458
|
+
total.cache_write_1h += u.cache_write_1h;
|
|
227
459
|
total.messages += u.messages;
|
|
228
460
|
const t = u.input + u.output + u.cache_read + u.cache_write;
|
|
229
461
|
if (t > bestTotal) {
|
|
@@ -233,7 +465,7 @@ function mergeAgentUsage(byModel) {
|
|
|
233
465
|
}
|
|
234
466
|
return {
|
|
235
467
|
model,
|
|
236
|
-
usage: { input: total.input, output: total.output, cache_read: total.cache_read, cache_write: total.cache_write, total: total.input + total.output + total.cache_read + total.cache_write },
|
|
468
|
+
usage: { input: total.input, output: total.output, cache_read: total.cache_read, cache_write: total.cache_write, cache_write_1h: total.cache_write_1h, total: total.input + total.output + total.cache_read + total.cache_write },
|
|
237
469
|
messages: total.messages,
|
|
238
470
|
};
|
|
239
471
|
}
|
|
@@ -243,8 +475,35 @@ function sanitizeTag(raw) {
|
|
|
243
475
|
return String(raw).trim().replace(/[^\w.\-/ ,()]/g, "").slice(0, 64).trim();
|
|
244
476
|
}
|
|
245
477
|
|
|
246
|
-
/**
|
|
247
|
-
export function
|
|
478
|
+
/** True when `<cwd>/openspec/changes/<dir>` is a directory; false for `archive`, an unsafe segment, or a stat error. */
|
|
479
|
+
export function inFlight(cwd, dir) {
|
|
480
|
+
if (typeof dir !== "string" || !dir || dir === "archive") return false;
|
|
481
|
+
if (!safeChangeSegment(path.join(cwd, ".freeharness", "sdd"), dir)) return false;
|
|
482
|
+
const changesRoot = path.join(cwd, "openspec", "changes");
|
|
483
|
+
const target = path.join(changesRoot, dir);
|
|
484
|
+
let stat;
|
|
485
|
+
try {
|
|
486
|
+
stat = fs.lstatSync(target);
|
|
487
|
+
} catch {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
if (!stat.isSymbolicLink()) return stat.isDirectory();
|
|
491
|
+
let real;
|
|
492
|
+
try {
|
|
493
|
+
real = fs.realpathSync(target);
|
|
494
|
+
} catch {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
if (!containedIn(realPathOr(changesRoot), real)) return false;
|
|
498
|
+
try {
|
|
499
|
+
return fs.statSync(real).isDirectory();
|
|
500
|
+
} catch {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Tags a session from the newest `*.brief.md` under an in-flight change, or under `only` when given: `Change:`, `Task:`, `Tier:`, `Class:`, sanitized. No in-flight brief yields every field null and `task_class: "untagged"`. */
|
|
506
|
+
export function briefTags(cwd, only = null) {
|
|
248
507
|
const root = path.join(cwd, ".freeharness", "sdd");
|
|
249
508
|
let newest = null;
|
|
250
509
|
let newestMtime = -Infinity;
|
|
@@ -274,15 +533,19 @@ export function briefTags(cwd) {
|
|
|
274
533
|
}
|
|
275
534
|
}
|
|
276
535
|
};
|
|
277
|
-
|
|
278
|
-
const empty = { change: null, task: null, tier: null, task_class: "untagged" };
|
|
279
|
-
if (!newest) return empty;
|
|
280
|
-
let text;
|
|
536
|
+
let topEntries;
|
|
281
537
|
try {
|
|
282
|
-
|
|
538
|
+
topEntries = fs.readdirSync(root, { withFileTypes: true });
|
|
283
539
|
} catch {
|
|
284
|
-
|
|
540
|
+
topEntries = [];
|
|
285
541
|
}
|
|
542
|
+
for (const top of topEntries) {
|
|
543
|
+
if (top.isDirectory() && (only === null || top.name === only) && inFlight(cwd, top.name)) walk(path.join(root, top.name));
|
|
544
|
+
}
|
|
545
|
+
const empty = { change: null, task: null, tier: null, task_class: "untagged" };
|
|
546
|
+
if (!newest) return empty;
|
|
547
|
+
const { text } = readContained(newest, MAX_BRIEF_BYTES);
|
|
548
|
+
if (text === null) return empty;
|
|
286
549
|
const field = (key) => {
|
|
287
550
|
const m = new RegExp(`^${key}:\\s*(.+)$`, "m").exec(text);
|
|
288
551
|
return m ? sanitizeTag(m[1]) : null;
|
|
@@ -312,6 +575,96 @@ function realPathOr(p) {
|
|
|
312
575
|
}
|
|
313
576
|
}
|
|
314
577
|
|
|
578
|
+
/** A walk-up past this depth is a loop or a hostile path, never a project. */
|
|
579
|
+
const MAX_ROOT_LEVELS = 64;
|
|
580
|
+
|
|
581
|
+
/** A marker reached through a symlink could name another tree as this project. */
|
|
582
|
+
function realDirChain(dir, parts) {
|
|
583
|
+
let p = dir;
|
|
584
|
+
for (const part of parts) {
|
|
585
|
+
p = path.join(p, part);
|
|
586
|
+
try {
|
|
587
|
+
if (!fs.lstatSync(p).isDirectory()) return false;
|
|
588
|
+
} catch {
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function holdsProjectMarker(dir) {
|
|
596
|
+
return realDirChain(dir, ["openspec", "changes"]) || realDirChain(dir, [".freeharness", "sdd"]);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Git pointer files are one short line; a larger one is forged. */
|
|
600
|
+
const MAX_GIT_POINTER_BYTES = 4 * 1024;
|
|
601
|
+
|
|
602
|
+
/** The single line of a git pointer file, or null. */
|
|
603
|
+
function pointerLine(file) {
|
|
604
|
+
const { text } = readContained(file, MAX_GIT_POINTER_BYTES);
|
|
605
|
+
if (text === null) return null;
|
|
606
|
+
const line = text.trimEnd();
|
|
607
|
+
return line && !line.includes("\n") ? line : null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* A task worktree checks out openspec/changes/ but not the gitignored workspace, so its records belong to the main worktree.
|
|
612
|
+
* Only a directory lexically under a real `<main>/.worktrees/` maps, so a forged `.git` can name only a root that already holds it, and a symlinked `.worktrees` cannot pull an outside tree in.
|
|
613
|
+
* @param {string} dir Real path of a directory holding a project marker.
|
|
614
|
+
* @returns {string | null} Real path of the main worktree; null on any read or parse failure.
|
|
615
|
+
*/
|
|
616
|
+
export function mainWorktreeOf(dir) {
|
|
617
|
+
try {
|
|
618
|
+
const dotGit = path.join(dir, ".git");
|
|
619
|
+
if (!fs.lstatSync(dotGit).isFile()) return null;
|
|
620
|
+
const m = /^gitdir: (.+)$/.exec(pointerLine(dotGit) ?? "");
|
|
621
|
+
if (!m) return null;
|
|
622
|
+
const gitDir = fs.realpathSync(path.resolve(dir, m[1]));
|
|
623
|
+
const common = pointerLine(path.join(gitDir, "commondir"));
|
|
624
|
+
if (common === null) return null;
|
|
625
|
+
const main = path.dirname(fs.realpathSync(path.resolve(gitDir, common)));
|
|
626
|
+
const trees = path.join(main, ".worktrees");
|
|
627
|
+
if (!fs.lstatSync(trees).isDirectory() || !containedIn(trees, path.resolve(dir))) return null;
|
|
628
|
+
return holdsProjectMarker(main) ? main : null;
|
|
629
|
+
} catch {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* A hook payload's `cwd` is the lead shell's directory, which may sit anywhere inside the project, so the workspace is found above it.
|
|
636
|
+
* CLAUDE_PROJECT_DIR wins only as the cwd or an ancestor of it, so the environment cannot point a dispatch at an unrelated tree.
|
|
637
|
+
* @param {string} cwd Payload cwd.
|
|
638
|
+
* @returns {string | null} Real path of the project root, a task worktree's being its main worktree; null when the cwd is not an absolute string or its real path cannot be resolved, when no marker holds at or above it up to the filesystem root, or when none holds within MAX_ROOT_LEVELS levels.
|
|
639
|
+
*/
|
|
640
|
+
export function projectRootFor(cwd) {
|
|
641
|
+
if (typeof cwd !== "string" || !path.isAbsolute(cwd)) return null;
|
|
642
|
+
let real;
|
|
643
|
+
try {
|
|
644
|
+
real = fs.realpathSync(cwd);
|
|
645
|
+
} catch {
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
const preferred = process.env.CLAUDE_PROJECT_DIR;
|
|
649
|
+
if (typeof preferred === "string" && path.isAbsolute(preferred)) {
|
|
650
|
+
let dir = null;
|
|
651
|
+
try {
|
|
652
|
+
dir = fs.realpathSync(preferred);
|
|
653
|
+
} catch {
|
|
654
|
+
dir = null;
|
|
655
|
+
}
|
|
656
|
+
if (dir !== null && (dir === real || containedIn(dir, real)) && holdsProjectMarker(dir)) return mainWorktreeOf(dir) ?? dir;
|
|
657
|
+
}
|
|
658
|
+
let dir = real;
|
|
659
|
+
for (let level = 0; level < MAX_ROOT_LEVELS; level += 1) {
|
|
660
|
+
if (holdsProjectMarker(dir)) return mainWorktreeOf(dir) ?? dir;
|
|
661
|
+
const parent = path.dirname(dir);
|
|
662
|
+
if (parent === dir) return null;
|
|
663
|
+
dir = parent;
|
|
664
|
+
}
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
|
|
315
668
|
/** Resolves a finished subagent's transcript. The untrusted `agent_id` must be a safe shape, and the resolved path must stay inside `subagents` both lexically and after `realpathSync`. */
|
|
316
669
|
export function subagentTranscriptFor(transcriptPath, agentId, payload) {
|
|
317
670
|
if (!transcriptPath || !agentId || !AGENT_ID_RE.test(agentId)) return null;
|
|
@@ -341,22 +694,41 @@ function agentMetaPathFor(transcriptFile) {
|
|
|
341
694
|
return `${ext ? transcriptFile.slice(0, -ext.length) : transcriptFile}.meta.json`;
|
|
342
695
|
}
|
|
343
696
|
|
|
344
|
-
/** A
|
|
345
|
-
|
|
697
|
+
/** A meta is a few hundred bytes; the cap bounds what a hostile file in the subagents directory can cost. */
|
|
698
|
+
const MAX_AGENT_META_BYTES = 64 * 1024;
|
|
699
|
+
|
|
700
|
+
/** The meta's fields plus why it gave none: `reason` is null for a JSON object, "missing", or "unreadable" (symlink, non-regular, over cap, invalid or non-object JSON). */
|
|
701
|
+
function loadAgentMeta(file) {
|
|
702
|
+
const empty = { name: null, agentType: null, description: null, customAgentType: null };
|
|
703
|
+
const { text, reason } = readContained(file, MAX_AGENT_META_BYTES);
|
|
704
|
+
if (text === null) return { meta: empty, reason: reason === "missing" ? "missing" : "unreadable" };
|
|
346
705
|
let parsed;
|
|
347
706
|
try {
|
|
348
|
-
parsed = JSON.parse(
|
|
707
|
+
parsed = JSON.parse(text);
|
|
349
708
|
} catch {
|
|
350
|
-
return {
|
|
709
|
+
return { meta: empty, reason: "unreadable" };
|
|
351
710
|
}
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
return { name, agentType };
|
|
711
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { meta: empty, reason: "unreadable" };
|
|
712
|
+
const field = (key) => (typeof parsed[key] === "string" && parsed[key] ? parsed[key] : null);
|
|
713
|
+
return { meta: { name: field("name"), agentType: field("agentType"), description: field("description"), customAgentType: field("customAgentType") }, reason: null };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Dispatch name, agent type, description and agent definition (`customAgentType`, null when built-in); every field null when the meta cannot be read. */
|
|
717
|
+
export function readAgentMeta(file) {
|
|
718
|
+
return loadAgentMeta(file).meta;
|
|
355
719
|
}
|
|
356
720
|
|
|
357
|
-
/** Dispatch name from `
|
|
721
|
+
/** Dispatch name from a meta `description`: Claude Code fills it from the dispatching prompt, so it is untrusted and only a full dispatch-pattern match, after one optional `Dispatch: ` prefix, counts. */
|
|
722
|
+
function descriptionDispatchName(description) {
|
|
723
|
+
if (typeof description !== "string") return null;
|
|
724
|
+
const candidate = description.startsWith("Dispatch: ") ? description.slice("Dispatch: ".length) : description;
|
|
725
|
+
return DISPATCH_RE.test(candidate) ? candidate : null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Dispatch name from `agent-<candidate>-<hash>.jsonl` when no meta names it; kept only if it, or with one leading `a` stripped, matches the dispatch pattern. A current-shape name carries only an id, so it never names a dispatch. */
|
|
358
729
|
function fallbackDispatchName(base) {
|
|
359
|
-
|
|
730
|
+
if (CURRENT_TRANSCRIPT_RE.test(base)) return null;
|
|
731
|
+
const m = LEGACY_TRANSCRIPT_RE.exec(base);
|
|
360
732
|
if (!m) return null;
|
|
361
733
|
const candidate = m[1];
|
|
362
734
|
if (DISPATCH_RE.test(candidate)) return candidate;
|
|
@@ -373,8 +745,8 @@ export function parseDispatchName(name) {
|
|
|
373
745
|
return { role: m[1], task: m[2] === "final" ? "final" : m[2].replace(/_/g, "."), round: m[3] ? Number(m[3]) : null };
|
|
374
746
|
}
|
|
375
747
|
|
|
376
|
-
/** Every `*.brief.md` under `.freeharness/sdd
|
|
377
|
-
function changeBriefs(root) {
|
|
748
|
+
/** Every `*.brief.md` under an in-flight `.freeharness/sdd/` directory, with change directory and mtime; empty when the root is absent. */
|
|
749
|
+
function changeBriefs(root, cwd) {
|
|
378
750
|
const out = [];
|
|
379
751
|
let topEntries;
|
|
380
752
|
try {
|
|
@@ -383,7 +755,7 @@ function changeBriefs(root) {
|
|
|
383
755
|
return out;
|
|
384
756
|
}
|
|
385
757
|
for (const top of topEntries) {
|
|
386
|
-
if (!top.isDirectory()) continue;
|
|
758
|
+
if (!top.isDirectory() || !inFlight(cwd, top.name)) continue;
|
|
387
759
|
const change = top.name;
|
|
388
760
|
const walk = (dir) => {
|
|
389
761
|
let entries;
|
|
@@ -435,30 +807,113 @@ export function safeChangeSegment(root, change) {
|
|
|
435
807
|
return containedIn(root, path.resolve(root, change));
|
|
436
808
|
}
|
|
437
809
|
|
|
438
|
-
/**
|
|
439
|
-
|
|
810
|
+
/** Change segment of a prompt brief path: kebab-case only, stricter than `safeChangeSegment` since the prompt is free text. Checked without a nested quantifier, as the text is untrusted. */
|
|
811
|
+
function kebabSegment(s) {
|
|
812
|
+
return typeof s === "string" && /^[a-z0-9-]+$/.test(s) && !s.startsWith("-") && !s.endsWith("-") && !s.includes("--");
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Leading characters of a prompt the brief-path scan reads: the dispatch prompt names its brief near the top, and the bound keeps an untrusted message from spending the hook's timeout. */
|
|
816
|
+
const PROMPT_SCAN_CHARS = 16 * 1024;
|
|
817
|
+
const PROMPT_BRIEF_MARK = ".freeharness/sdd/";
|
|
818
|
+
const PROMPT_BRIEF_SUFFIX = ".brief.md";
|
|
819
|
+
/** Characters that end a path token besides whitespace; `<` among them so a `<change>` placeholder never matches. */
|
|
820
|
+
const PROMPT_TOKEN_STOPS = new Set(['"', "'", "`", "(", ")", "<", ">", "[", "]"]);
|
|
821
|
+
/** Sentence punctuation trimmed from a token's end, so a path closing a sentence still names its brief. */
|
|
822
|
+
const PROMPT_TOKEN_TRAIL = new Set([".", ",", ";", ":", "!", "?"]);
|
|
823
|
+
|
|
824
|
+
function promptTokenStop(ch) {
|
|
825
|
+
return PROMPT_TOKEN_STOPS.has(ch) || ch.trim() === "";
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Whether a prompt path prefix names this project: relative (empty or `./`), the project root itself, lexical or real, or the shell's spelling of it: a lexical ancestor of the shell cwd that resolves to the root. */
|
|
829
|
+
function promptRootAccepted(prefix, root, cwd) {
|
|
830
|
+
if (prefix === "" || prefix === "./") return true;
|
|
831
|
+
if (!path.isAbsolute(prefix) || !prefix.endsWith("/") || typeof root !== "string" || !root) return false;
|
|
832
|
+
const withSep = (p) => (p.endsWith("/") ? p : `${p}/`);
|
|
833
|
+
const base = path.resolve(root);
|
|
834
|
+
if (prefix === withSep(base) || prefix === withSep(realPathOr(base))) return true;
|
|
835
|
+
if (typeof cwd !== "string" || !path.isAbsolute(cwd) || !withSep(path.resolve(cwd)).startsWith(prefix)) return false;
|
|
836
|
+
return realPathOr(prefix) === realPathOr(base);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** The brief a dispatch prompt names, from the first path token holding `.freeharness/sdd/` and ending in `.brief.md`: the dispatch prompt carries the change, which a task id shared by two changes cannot. An invalid first match yields null rather than a later match, so a prompt cannot steer past a refused path. The scan walks each character a bounded number of times over the leading slice, since a backtracking pattern on untrusted text can outrun the hook timeout. */
|
|
840
|
+
export function briefPathFromText(text, root, cwd = root) {
|
|
841
|
+
if (typeof text !== "string" || !text) return null;
|
|
842
|
+
const truncated = text.length > PROMPT_SCAN_CHARS;
|
|
843
|
+
const scan = truncated ? text.slice(0, PROMPT_SCAN_CHARS) : text;
|
|
844
|
+
let from = 0;
|
|
845
|
+
for (;;) {
|
|
846
|
+
const at = scan.indexOf(PROMPT_BRIEF_MARK, from);
|
|
847
|
+
if (at < 0) return null;
|
|
848
|
+
let start = at;
|
|
849
|
+
while (start > from && !promptTokenStop(scan[start - 1])) start -= 1;
|
|
850
|
+
const restStart = at + PROMPT_BRIEF_MARK.length;
|
|
851
|
+
let end = restStart;
|
|
852
|
+
while (end < scan.length && !promptTokenStop(scan[end])) end += 1;
|
|
853
|
+
// A token cut by the bound has an unknown end, so it cannot be a match.
|
|
854
|
+
if (truncated && end === scan.length) return null;
|
|
855
|
+
let stop = end;
|
|
856
|
+
while (stop > restStart && PROMPT_TOKEN_TRAIL.has(scan[stop - 1])) stop -= 1;
|
|
857
|
+
if (stop - PROMPT_BRIEF_SUFFIX.length >= restStart && scan.endsWith(PROMPT_BRIEF_SUFFIX, stop)) {
|
|
858
|
+
const token = scan.slice(start, stop);
|
|
859
|
+
if (token.includes("..") || token.includes("\\")) return null;
|
|
860
|
+
const parts = scan.slice(restStart, stop - PROMPT_BRIEF_SUFFIX.length).split("/");
|
|
861
|
+
if (parts.length !== 2 || !kebabSegment(parts[0]) || !parts[1]) return null;
|
|
862
|
+
if (!promptRootAccepted(scan.slice(start, at), root, cwd)) return null;
|
|
863
|
+
return { change: parts[0], file: `${parts[1]}${PROMPT_BRIEF_SUFFIX}` };
|
|
864
|
+
}
|
|
865
|
+
from = end;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** The brief named by a subagent transcript's first user message, or null for an unusable transcript or no user line. The hook takes the text from `sumTranscript`'s single pass; this export isolates that read. */
|
|
870
|
+
export function briefPathFromPrompt(transcriptFile, cwd) {
|
|
871
|
+
return briefPathFromText(sumTranscript(transcriptFile, MAX_SUBAGENT_FILE_BYTES).firstUserText, cwd);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** Whether a prompt brief decides the ledger: its change is in flight and the brief is a regular file there; else the task rule applies. */
|
|
875
|
+
function promptBriefApplies(cwd, brief) {
|
|
876
|
+
if (!brief || typeof brief.change !== "string" || typeof brief.file !== "string") return false;
|
|
877
|
+
if (!kebabSegment(brief.change) ||!brief.file.endsWith(".brief.md") || brief.file.includes("/") || brief.file.includes("\\")) return false;
|
|
878
|
+
if (!inFlight(cwd, brief.change)) return false;
|
|
879
|
+
try {
|
|
880
|
+
return fs.lstatSync(path.join(cwd, ".freeharness", "sdd", brief.change, brief.file)).isFile();
|
|
881
|
+
} catch {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Ledger file for a dispatch: the prompt's brief change when it applies; else the in-flight change whose brief declares the task id, newest when several match; null when none does, so another change's dispatch never lands in the active one. Only a dispatch without a task falls back to the newest in-flight brief's change. */
|
|
887
|
+
export function ledgerPathFor(cwd, task, tags, promptBrief = null) {
|
|
440
888
|
const root = path.join(cwd, ".freeharness", "sdd");
|
|
889
|
+
if (promptBriefApplies(cwd, promptBrief)) return path.join(root, promptBrief.change, "ledger.jsonl");
|
|
441
890
|
if (task) {
|
|
442
891
|
let best = null;
|
|
443
|
-
for (const b of changeBriefs(root)) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
text = fs.readFileSync(b.file, "utf8");
|
|
447
|
-
} catch {
|
|
448
|
-
continue;
|
|
449
|
-
}
|
|
892
|
+
for (const b of changeBriefs(root, cwd)) {
|
|
893
|
+
const { text } = readContained(b.file, MAX_BRIEF_BYTES);
|
|
894
|
+
if (text === null) continue;
|
|
450
895
|
const m = /^Task:\s*(.+)$/m.exec(text);
|
|
451
896
|
if (!m || !matchesTaskHeader(m[1], task)) continue;
|
|
452
897
|
if (!best || b.mtimeMs > best.mtimeMs || (b.mtimeMs === best.mtimeMs && b.file > best.file)) best = b;
|
|
453
898
|
}
|
|
454
|
-
|
|
899
|
+
return best ? path.join(root, best.change, "ledger.jsonl") : null;
|
|
455
900
|
}
|
|
456
|
-
if (tags && tags.change &&
|
|
901
|
+
if (tags && tags.change && inFlight(cwd, tags.change)) return path.join(root, tags.change, "ledger.jsonl");
|
|
457
902
|
return null;
|
|
458
903
|
}
|
|
459
904
|
|
|
460
|
-
|
|
461
|
-
|
|
905
|
+
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
906
|
+
|
|
907
|
+
/** `payload.effort.level` when it is one of the five known levels, else null. */
|
|
908
|
+
function effortFromPayload(payload) {
|
|
909
|
+
const effort = payload && typeof payload === "object" ? payload.effort : null;
|
|
910
|
+
const level = effort && typeof effort === "object" && !Array.isArray(effort) ? effort.level : null;
|
|
911
|
+
return typeof level === "string" && EFFORT_LEVELS.has(level) ? level : null;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** One dispatch ledger line, or a skip reason for a missing payload, transcript or brief. Model
|
|
915
|
+
* is the transcript's dominant model, never the meta alias. `late` marks a Stop backfill line. */
|
|
916
|
+
export function ledgerLine(payload, cwd, prices, { late = false } = {}) {
|
|
462
917
|
const transcriptPath = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
|
|
463
918
|
const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
|
|
464
919
|
if (!agentId) return { ok: false, reason: "missing-payload" };
|
|
@@ -466,33 +921,43 @@ export function ledgerLine(payload, cwd, prices) {
|
|
|
466
921
|
const file = subagentTranscriptFor(transcriptPath, agentId, payload);
|
|
467
922
|
if (!file) return { ok: false, reason: "missing-payload" };
|
|
468
923
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
size = fs.statSync(file).size;
|
|
472
|
-
} catch {
|
|
473
|
-
return { ok: false, reason: "no-transcript", file };
|
|
474
|
-
}
|
|
475
|
-
if (size > MAX_SUBAGENT_FILE_BYTES) return { ok: false, reason: "too-large", file };
|
|
476
|
-
|
|
477
|
-
const sums = sumTranscript(file);
|
|
478
|
-
if (!sums.found) return { ok: false, reason: "no-transcript", file };
|
|
924
|
+
const sums = sumTranscript(file, MAX_SUBAGENT_FILE_BYTES);
|
|
925
|
+
if (!sums.found) return { ok: false, reason: sums.reason, file };
|
|
479
926
|
|
|
480
927
|
const merged = mergeAgentUsage(sums.byModel);
|
|
481
|
-
const
|
|
928
|
+
const match = priceMatch(prices, merged.model);
|
|
929
|
+
const est = estimate(merged.usage, match.row);
|
|
482
930
|
|
|
483
931
|
const meta = readAgentMeta(agentMetaPathFor(file));
|
|
484
|
-
|
|
485
|
-
|
|
932
|
+
// A plain (unnamed) dispatch names itself on its prompt's first line. The prompt is untrusted,
|
|
933
|
+
// so only the exact `Dispatch: <name>` form with a name of the dispatch pattern counts.
|
|
934
|
+
const promptLine = typeof sums.firstUserText === "string" ? /^Dispatch: (\S+)$/.exec(sums.firstUserText.split(/\r?\n/, 1)[0]) : null;
|
|
935
|
+
const promptName = promptLine && DISPATCH_RE.test(promptLine[1]) ? promptLine[1] : null;
|
|
936
|
+
const name = meta.name || promptName || descriptionDispatchName(meta.description) || fallbackDispatchName(path.basename(file));
|
|
937
|
+
const agentType = meta.customAgentType ?? meta.agentType ?? (typeof payload.agent_type === "string" && payload.agent_type ? payload.agent_type : null);
|
|
486
938
|
const parsed = parseDispatchName(name);
|
|
487
939
|
|
|
488
940
|
const tags = briefTags(cwd);
|
|
489
|
-
const
|
|
941
|
+
const shellCwd = typeof payload.cwd === "string" ? payload.cwd : cwd;
|
|
942
|
+
const ledgerFile = ledgerPathFor(cwd, parsed.task, tags, briefPathFromText(sums.firstUserText, cwd, shellCwd));
|
|
490
943
|
if (!ledgerFile) {
|
|
491
944
|
const root = path.join(cwd, ".freeharness", "sdd");
|
|
492
|
-
const unsafe = Boolean(tags.change) && !safeChangeSegment(root, tags.change);
|
|
945
|
+
const unsafe = !parsed.task && Boolean(tags.change) && !safeChangeSegment(root, tags.change);
|
|
493
946
|
return { ok: false, reason: unsafe ? "unsafe-change" : "no-brief" };
|
|
494
947
|
}
|
|
495
948
|
|
|
949
|
+
// `late` has no payload; the transcript records the level the dispatch ran at. An in-process
|
|
950
|
+
// team dispatch inherits the session effort, so the agent definition's frontmatter is no source.
|
|
951
|
+
let effort = null;
|
|
952
|
+
let effort_source = null;
|
|
953
|
+
if (late) {
|
|
954
|
+
effort = sums.effort;
|
|
955
|
+
if (effort) effort_source = "transcript";
|
|
956
|
+
} else {
|
|
957
|
+
effort = effortFromPayload(payload);
|
|
958
|
+
if (effort) effort_source = "payload";
|
|
959
|
+
}
|
|
960
|
+
|
|
496
961
|
const line = {
|
|
497
962
|
ts: new Date().toISOString(),
|
|
498
963
|
session: typeof payload.session_id === "string" ? payload.session_id : "",
|
|
@@ -503,32 +968,116 @@ export function ledgerLine(payload, cwd, prices) {
|
|
|
503
968
|
task: parsed.task,
|
|
504
969
|
round: parsed.round,
|
|
505
970
|
model: merged.model,
|
|
971
|
+
cc_version: sums.ccVersion,
|
|
972
|
+
effort,
|
|
973
|
+
effort_source,
|
|
974
|
+
// A snapshot of the transcript's cumulative totals, not a delta; readers take the latest
|
|
975
|
+
// line per agent_id.
|
|
506
976
|
input: merged.usage.input,
|
|
507
977
|
cache_read: merged.usage.cache_read,
|
|
508
978
|
cache_write: merged.usage.cache_write,
|
|
979
|
+
cache_write_1h: merged.usage.cache_write_1h,
|
|
509
980
|
output: merged.usage.output,
|
|
510
981
|
est_usd: round(est),
|
|
511
|
-
|
|
982
|
+
...(match.estimated ? { price_estimated: true } : {}),
|
|
983
|
+
late,
|
|
512
984
|
transcript: file,
|
|
513
985
|
resumed: false,
|
|
514
986
|
};
|
|
515
987
|
return { ok: true, file: ledgerFile, line };
|
|
516
988
|
}
|
|
517
989
|
|
|
518
|
-
/**
|
|
519
|
-
|
|
520
|
-
|
|
990
|
+
/** A thrown fs error's `E...` code only: its message carries an absolute path. */
|
|
991
|
+
function errCode(e) {
|
|
992
|
+
const code = e && typeof e === "object" && "code" in e ? String(e.code) : "";
|
|
993
|
+
return /^E[A-Z0-9_]+$/.test(code) ? code : "EUNKNOWN";
|
|
521
994
|
}
|
|
522
995
|
|
|
523
|
-
/**
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
996
|
+
/** Refusal unless `.freeharness`, `sdd` and `changeDir` are real directories, `changeDir`
|
|
997
|
+
* directly under `<realpath(cwd)>/.freeharness/sdd`. */
|
|
998
|
+
function changeDirContained(cwd, changeDir) {
|
|
999
|
+
const root = path.join(cwd, ".freeharness", "sdd");
|
|
1000
|
+
if (path.dirname(changeDir) !== root) return { ok: false, reason: "outside-workspace" };
|
|
1001
|
+
let real;
|
|
527
1002
|
try {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
1003
|
+
for (const dir of [path.dirname(root), root, changeDir]) {
|
|
1004
|
+
const stat = fs.lstatSync(dir);
|
|
1005
|
+
if (stat.isSymbolicLink()) return { ok: false, reason: "symlinked-dir" };
|
|
1006
|
+
if (!stat.isDirectory()) return { ok: false, reason: "not-dir" };
|
|
1007
|
+
}
|
|
1008
|
+
real = { root: path.join(fs.realpathSync(cwd), ".freeharness", "sdd"), dir: fs.realpathSync(changeDir) };
|
|
1009
|
+
} catch (e) {
|
|
1010
|
+
return { ok: false, reason: `dir-unreadable ${errCode(e)}` };
|
|
531
1011
|
}
|
|
1012
|
+
return containedIn(real.root, real.dir) ? { ok: true } : { ok: false, reason: "outside-workspace" };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Post-open refusal unless the directory chain still passes and `fd` is the one-link regular
|
|
1016
|
+
* file `file` names (dev/ino), so a swap after the first check writes nothing. */
|
|
1017
|
+
function verifyOpenedLedger(cwd, file, fd) {
|
|
1018
|
+
const dirCheck = changeDirContained(cwd, path.dirname(file));
|
|
1019
|
+
if (!dirCheck.ok) return dirCheck;
|
|
1020
|
+
let opened, named;
|
|
1021
|
+
try {
|
|
1022
|
+
opened = fs.fstatSync(fd);
|
|
1023
|
+
named = fs.lstatSync(file);
|
|
1024
|
+
} catch (e) {
|
|
1025
|
+
return { ok: false, reason: `stat-failed ${errCode(e)}` };
|
|
1026
|
+
}
|
|
1027
|
+
if (!opened.isFile()) return { ok: false, reason: "not-regular" };
|
|
1028
|
+
if (opened.nlink > 1) return { ok: false, reason: "hard-link" };
|
|
1029
|
+
if (opened.dev !== named.dev || opened.ino !== named.ino) return { ok: false, reason: "swapped" };
|
|
1030
|
+
return { ok: true };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** Opens `file` for a contained append, never following a final symlink or blocking on a FIFO
|
|
1034
|
+
* (ELOOP, ENXIO); the caller closes `fd`. */
|
|
1035
|
+
function openContainedLedger(cwd, file) {
|
|
1036
|
+
const dirCheck = changeDirContained(cwd, path.dirname(file));
|
|
1037
|
+
if (!dirCheck.ok) return dirCheck;
|
|
1038
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK || 0);
|
|
1039
|
+
let fd;
|
|
1040
|
+
try {
|
|
1041
|
+
fd = fs.openSync(file, flags, 0o644);
|
|
1042
|
+
} catch (e) {
|
|
1043
|
+
return { ok: false, reason: `open-refused ${errCode(e)}` };
|
|
1044
|
+
}
|
|
1045
|
+
const verdict = verifyOpenedLedger(cwd, file, fd);
|
|
1046
|
+
if (verdict.ok) return { ok: true, fd };
|
|
1047
|
+
fs.closeSync(fd);
|
|
1048
|
+
return verdict;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** One stderr line per refusal; `reason` is a code, never a path. */
|
|
1052
|
+
function reportLedgerRefusal(reason) {
|
|
1053
|
+
process.stderr.write(`freeharness-cost-tracker: ledger append refused: ${reason}\n`);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** Appends one ledger line inside the SDD workspace, never creating the change directory or
|
|
1057
|
+
* throwing; the caller reports a refusal once per hook run.
|
|
1058
|
+
* @returns {{ok:true}|{ok:false, reason:string}}
|
|
1059
|
+
*/
|
|
1060
|
+
export function appendLedgerLine(file, line, cwd) {
|
|
1061
|
+
const opened = openContainedLedger(cwd, file);
|
|
1062
|
+
if (!opened.ok) return opened;
|
|
1063
|
+
try {
|
|
1064
|
+
fs.writeSync(opened.fd, `${JSON.stringify(line)}\n`);
|
|
1065
|
+
return { ok: true };
|
|
1066
|
+
} catch (e) {
|
|
1067
|
+
return { ok: false, reason: `write-failed ${errCode(e)}` };
|
|
1068
|
+
} finally {
|
|
1069
|
+
fs.closeSync(opened.fd);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/** `agent_id`s already in a ledger file: an empty set for a missing ledger or an uncontained
|
|
1074
|
+
* change directory (the append refuses the latter), null for an existing ledger that is a
|
|
1075
|
+
* symlink, non-regular or over its cap, so no caller mistakes its unknown ids for none. */
|
|
1076
|
+
function readLedgerAgentIds(file, cwd) {
|
|
1077
|
+
const ids = new Set();
|
|
1078
|
+
if (!changeDirContained(cwd, path.dirname(file)).ok) return ids;
|
|
1079
|
+
const { text, reason } = readContained(file, MAX_LEDGER_BYTES);
|
|
1080
|
+
if (text === null) return reason === "missing" ? ids : null;
|
|
532
1081
|
for (const raw of text.split("\n")) {
|
|
533
1082
|
if (!raw.trim()) continue;
|
|
534
1083
|
try {
|
|
@@ -540,33 +1089,45 @@ function readLedgerAgentIds(file) {
|
|
|
540
1089
|
return ids;
|
|
541
1090
|
}
|
|
542
1091
|
|
|
543
|
-
/** Backfills a `late: true` line for every subagent transcript
|
|
1092
|
+
/** Backfills a `late: true` line for every subagent transcript missing one (a crash or timeout can skip SubagentStop); failures push onto `snapshotWarnings`. The shell may sit below the project root, so the ledgers are found from the root above `cwd`. */
|
|
544
1093
|
export function backfillLedger(payload, cwd, prices, snapshotWarnings) {
|
|
545
1094
|
const transcriptPath = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
|
|
546
1095
|
if (!transcriptPath) return;
|
|
547
1096
|
const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
|
|
548
|
-
const
|
|
1097
|
+
const root = projectRootFor(cwd) ?? cwd;
|
|
1098
|
+
const recorded = recordedAgentIds(root);
|
|
549
1099
|
const done = new Set();
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
1100
|
+
// Target ledger -> whether its ids can be read, decided on the target path itself: a
|
|
1101
|
+
// `Change:` header may name the directory in another case, which a path comparison misses.
|
|
1102
|
+
const targetReadable = new Map();
|
|
1103
|
+
let reported = false;
|
|
1104
|
+
let skipReported = false;
|
|
1105
|
+
for (const { id, file } of subagentTranscripts(transcriptPath)) {
|
|
553
1106
|
const key = realPathOr(path.resolve(file));
|
|
554
|
-
if (done.has(key)) continue;
|
|
555
|
-
const result = ledgerLine({ transcript_path: transcriptPath, agent_id:
|
|
1107
|
+
if (done.has(key) || recorded.has(id)) continue;
|
|
1108
|
+
const result = ledgerLine({ transcript_path: transcriptPath, agent_id: id, session_id: sessionId, cwd }, root, prices, { late: true });
|
|
556
1109
|
if (!result.ok) continue;
|
|
557
|
-
if (
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
1110
|
+
if (!targetReadable.has(result.file)) targetReadable.set(result.file, readLedgerAgentIds(result.file, root) !== null);
|
|
1111
|
+
// Its recorded ids are unknown, so a backfill line could duplicate one already there.
|
|
1112
|
+
if (!targetReadable.get(result.file)) {
|
|
1113
|
+
if (!skipReported) snapshotWarnings.push("ledger backfill skipped: ledger unreadable");
|
|
1114
|
+
skipReported = true;
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
const line = result.line;
|
|
1118
|
+
const appended = appendLedgerLine(result.file, line, root);
|
|
1119
|
+
if (appended.ok) {
|
|
561
1120
|
recorded.add(line.agent_id);
|
|
562
1121
|
done.add(key);
|
|
563
|
-
}
|
|
564
|
-
snapshotWarnings.push(`ledger backfill
|
|
1122
|
+
} else {
|
|
1123
|
+
snapshotWarnings.push(`ledger backfill append refused: ${appended.reason}`);
|
|
1124
|
+
if (!reported) reportLedgerRefusal(appended.reason);
|
|
1125
|
+
reported = true;
|
|
565
1126
|
}
|
|
566
1127
|
}
|
|
567
1128
|
}
|
|
568
1129
|
|
|
569
|
-
/** Agent ids recorded in any ledger under the SDD workspace; the union across changes proves a transcript already has its line. */
|
|
1130
|
+
/** Agent ids recorded in any readable ledger under the SDD workspace; the union across changes proves a transcript already has its line. */
|
|
570
1131
|
function recordedAgentIds(cwd) {
|
|
571
1132
|
const ids = new Set();
|
|
572
1133
|
const root = path.join(cwd, ".freeharness", "sdd");
|
|
@@ -578,31 +1139,25 @@ function recordedAgentIds(cwd) {
|
|
|
578
1139
|
}
|
|
579
1140
|
for (const entry of entries) {
|
|
580
1141
|
if (!entry.isDirectory()) continue;
|
|
581
|
-
for (const id of readLedgerAgentIds(path.join(root, entry.name, "ledger.jsonl"))) ids.add(id);
|
|
1142
|
+
for (const id of readLedgerAgentIds(path.join(root, entry.name, "ledger.jsonl"), cwd) ?? []) ids.add(id);
|
|
582
1143
|
}
|
|
583
1144
|
return ids;
|
|
584
1145
|
}
|
|
585
1146
|
|
|
586
|
-
/**
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if (!sessionId) return counts;
|
|
1147
|
+
/** Calls `visit(changeDir, rec)` for the session's lines in every change's ledger, archived changes' included. */
|
|
1148
|
+
function eachSessionLedgerLine(cwd, sessionId, visit) {
|
|
1149
|
+
if (!sessionId) return;
|
|
590
1150
|
const root = path.join(cwd, ".freeharness", "sdd");
|
|
591
1151
|
let topEntries;
|
|
592
1152
|
try {
|
|
593
1153
|
topEntries = fs.readdirSync(root, { withFileTypes: true });
|
|
594
1154
|
} catch {
|
|
595
|
-
return
|
|
1155
|
+
return;
|
|
596
1156
|
}
|
|
597
|
-
const seenByRole = new Map();
|
|
598
1157
|
for (const top of topEntries) {
|
|
599
1158
|
if (!top.isDirectory()) continue;
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
text = fs.readFileSync(path.join(root, top.name, "ledger.jsonl"), "utf8");
|
|
603
|
-
} catch {
|
|
604
|
-
continue;
|
|
605
|
-
}
|
|
1159
|
+
const { text } = readContained(path.join(root, top.name, "ledger.jsonl"), MAX_LEDGER_BYTES);
|
|
1160
|
+
if (text === null) continue;
|
|
606
1161
|
for (const raw of text.split("\n")) {
|
|
607
1162
|
if (!raw.trim()) continue;
|
|
608
1163
|
let rec;
|
|
@@ -611,26 +1166,164 @@ export function dispatchesByRole(cwd, sessionId) {
|
|
|
611
1166
|
} catch {
|
|
612
1167
|
continue;
|
|
613
1168
|
}
|
|
614
|
-
if (
|
|
615
|
-
const role = typeof rec.role === "string" && rec.role ? rec.role : "other";
|
|
616
|
-
let seen = seenByRole.get(role);
|
|
617
|
-
if (!seen) {
|
|
618
|
-
seen = new Set();
|
|
619
|
-
seenByRole.set(role, seen);
|
|
620
|
-
}
|
|
621
|
-
if (seen.has(rec.agent_id)) continue;
|
|
622
|
-
seen.add(rec.agent_id);
|
|
623
|
-
counts[role] = (counts[role] || 0) + 1;
|
|
1169
|
+
if (rec && rec.session === sessionId) visit(top.name, rec);
|
|
624
1170
|
}
|
|
625
1171
|
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** Counts dispatches per role, one distinct `agent_id` per dispatch so a resumed worker's second line adds nothing. */
|
|
1175
|
+
export function dispatchesByRole(cwd, sessionId) {
|
|
1176
|
+
// Roles are ledger text; a `__proto__` role must stay a key, not a prototype assignment.
|
|
1177
|
+
const counts = Object.create(null);
|
|
1178
|
+
const seenByRole = new Map();
|
|
1179
|
+
eachSessionLedgerLine(cwd, sessionId, (_dir, rec) => {
|
|
1180
|
+
if (typeof rec.agent_id !== "string") return;
|
|
1181
|
+
const role = typeof rec.role === "string" && rec.role ? rec.role : "other";
|
|
1182
|
+
let seen = seenByRole.get(role);
|
|
1183
|
+
if (!seen) {
|
|
1184
|
+
seen = new Set();
|
|
1185
|
+
seenByRole.set(role, seen);
|
|
1186
|
+
}
|
|
1187
|
+
if (seen.has(rec.agent_id)) return;
|
|
1188
|
+
seen.add(rec.agent_id);
|
|
1189
|
+
counts[role] = (counts[role] || 0) + 1;
|
|
1190
|
+
});
|
|
626
1191
|
return counts;
|
|
627
1192
|
}
|
|
628
1193
|
|
|
629
|
-
/**
|
|
1194
|
+
/** Per change directory, the session's distinct dispatches and latest `ts`; a line without an `agent_id` still records a dispatch, so it counts as its own. */
|
|
1195
|
+
export function sessionLedger(root, sessionId) {
|
|
1196
|
+
const out = new Map();
|
|
1197
|
+
let anon = 0;
|
|
1198
|
+
eachSessionLedgerLine(root, sessionId, (dir, rec) => {
|
|
1199
|
+
let entry = out.get(dir);
|
|
1200
|
+
if (!entry) out.set(dir, (entry = { agents: new Set(), lastTs: null }));
|
|
1201
|
+
entry.agents.add(typeof rec.agent_id === "string" ? rec.agent_id : `\0anon-${anon++}`);
|
|
1202
|
+
if (typeof rec.ts === "string" && (entry.lastTs === null || rec.ts > entry.lastTs)) entry.lastTs = rec.ts;
|
|
1203
|
+
});
|
|
1204
|
+
return out;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const MAX_CHANGE_ID = 64;
|
|
1208
|
+
const MAX_SESSION_CHANGES = 8;
|
|
1209
|
+
const DATED_ARCHIVE_RE = /^\d{4}-\d{2}-\d{2}-(.+)$/;
|
|
1210
|
+
|
|
1211
|
+
/** Ledger directories and invocation arguments are untrusted text, so an id counts only as a real directory, never through a symlink, under openspec/changes/ or its archive. */
|
|
1212
|
+
function changeLookup(root) {
|
|
1213
|
+
let dated = null;
|
|
1214
|
+
return (id) => {
|
|
1215
|
+
if (typeof id !== "string" || id.length > MAX_CHANGE_ID || id === "archive" || !kebabSegment(id)) return false;
|
|
1216
|
+
if (realDirChain(root, ["openspec", "changes", id]) || realDirChain(root, ["openspec", "changes", "archive", id])) return true;
|
|
1217
|
+
if (dated === null) {
|
|
1218
|
+
dated = new Set();
|
|
1219
|
+
try {
|
|
1220
|
+
if (realDirChain(root, ["openspec", "changes", "archive"])) {
|
|
1221
|
+
for (const e of fs.readdirSync(path.join(root, "openspec", "changes", "archive"), { withFileTypes: true })) {
|
|
1222
|
+
const m = e.isDirectory() ? DATED_ARCHIVE_RE.exec(e.name) : null;
|
|
1223
|
+
if (m) dated.add(m[1]);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
} catch {
|
|
1227
|
+
// An unreadable archive names no change.
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return dated.has(id);
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const tsOf = (v) => (typeof v === "string" ? v : null);
|
|
1235
|
+
const tsOrder = (a, b) => (a === b ? 0 : a === null ? -1 : b === null ? 1 : a < b ? -1 : 1);
|
|
1236
|
+
|
|
1237
|
+
/** The session's change: a lead archives its change before it ends, so its own ledger lines decide first, then its last OpenSpec invocation argument, then the newest in-flight brief. */
|
|
1238
|
+
export function resolveSessionChange(root, signals, ledger) {
|
|
1239
|
+
const known = changeLookup(root);
|
|
1240
|
+
const ranked = [...(ledger instanceof Map ? ledger : [])]
|
|
1241
|
+
.filter(([dir, e]) => e && e.agents instanceof Set && known(dir))
|
|
1242
|
+
.sort(([da, a], [db, b]) => b.agents.size - a.agents.size || tsOrder(tsOf(b.lastTs), tsOf(a.lastTs)) || (da < db ? -1 : 1))
|
|
1243
|
+
.map(([dir]) => dir);
|
|
1244
|
+
const named = [];
|
|
1245
|
+
for (const s of Array.isArray(signals?.openspec) ? signals.openspec : []) {
|
|
1246
|
+
const token = typeof s?.arg === "string" ? s.arg.trim().split(/\s+/, 1)[0] : null;
|
|
1247
|
+
if (known(token)) named.push(token);
|
|
1248
|
+
}
|
|
1249
|
+
const change = ranked[0] ?? named[named.length - 1] ?? null;
|
|
1250
|
+
if (change === null) {
|
|
1251
|
+
// A brief header is free text, so it passes the same validity rule as the other signals.
|
|
1252
|
+
const fallback = briefTags(root).change;
|
|
1253
|
+
return known(fallback) ? { change: fallback, changes: [fallback] } : { change: null, changes: [] };
|
|
1254
|
+
}
|
|
1255
|
+
return { change, changes: [...new Set([change, ...ranked, ...named])].slice(0, MAX_SESSION_CHANGES) };
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** Task fields come from the resolved change's own brief, so a newer brief of another change never relabels the session. */
|
|
1259
|
+
function sessionTags(root, sessionId, signals) {
|
|
1260
|
+
const { change, changes } = resolveSessionChange(root, signals, sessionLedger(root, sessionId));
|
|
1261
|
+
const brief = change === null ? null : briefTags(root, change);
|
|
1262
|
+
return { change, changes, task: brief?.task ?? null, tier: brief?.tier ?? null, task_class: brief?.task_class ?? "untagged" };
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const MEM_DATA_DIR_KEYS = ["FREE_MEM_DATA_DIR", "CLAUDE_MEM_DATA_DIR"];
|
|
1266
|
+
const BOM = 0xfeff;
|
|
1267
|
+
|
|
1268
|
+
function expandMemHome(p, home) {
|
|
1269
|
+
if (p === "~") return home;
|
|
1270
|
+
return p.startsWith("~/") ? path.join(home, p.slice(2)) : p;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function isMemDirectory(p) {
|
|
1274
|
+
try {
|
|
1275
|
+
return fs.statSync(p).isDirectory();
|
|
1276
|
+
} catch {
|
|
1277
|
+
return false;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/** Keys as the engine reads them: BOM dropped, `env` first. */
|
|
1282
|
+
function readMemSettings(file) {
|
|
1283
|
+
try {
|
|
1284
|
+
const text = fs.readFileSync(file, "utf8");
|
|
1285
|
+
const raw = JSON.parse(text.charCodeAt(0) === BOM ? text.slice(1) : text);
|
|
1286
|
+
const s = raw && (raw.env ?? raw);
|
|
1287
|
+
return s && typeof s === "object" ? s : null;
|
|
1288
|
+
} catch {
|
|
1289
|
+
return null;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** The memory data directory from `env` and `home` alone (the engine's rule), so a test can pass fake values. */
|
|
1294
|
+
export function resolveMemoryDataDir(env, home) {
|
|
1295
|
+
for (const key of MEM_DATA_DIR_KEYS) {
|
|
1296
|
+
if (typeof env[key] === "string" && env[key]) return expandMemHome(env[key], home);
|
|
1297
|
+
}
|
|
1298
|
+
const freeDir = path.join(home, ".free-mem");
|
|
1299
|
+
const legacyDir = path.join(home, ".claude-mem");
|
|
1300
|
+
const defaults = [path.resolve(freeDir), path.resolve(legacyDir)];
|
|
1301
|
+
for (const dir of [freeDir, legacyDir]) {
|
|
1302
|
+
const s = readMemSettings(path.join(dir, "settings.json"));
|
|
1303
|
+
for (const key of MEM_DATA_DIR_KEYS) {
|
|
1304
|
+
const value = s ? s[key] : undefined;
|
|
1305
|
+
if (typeof value !== "string" || !value) continue;
|
|
1306
|
+
const target = expandMemHome(value, home);
|
|
1307
|
+
// A seeded default is not a redirect; honoring it would pin the directory choice.
|
|
1308
|
+
if (!defaults.includes(path.resolve(target))) return target;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
return isMemDirectory(freeDir) || !isMemDirectory(legacyDir) ? freeDir : legacyDir;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/** A resolver error falls back to the legacy default directory. */
|
|
630
1315
|
function memoryDataDir() {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1316
|
+
try {
|
|
1317
|
+
return resolveMemoryDataDir(process.env, os.homedir());
|
|
1318
|
+
} catch {
|
|
1319
|
+
return path.join(os.homedir(), ".claude-mem");
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/** `free-mem.db` when present, else the `claude-mem.db` an unmigrated engine still writes. */
|
|
1324
|
+
function memoryDatabase(dataDir) {
|
|
1325
|
+
const db = path.join(dataDir, "free-mem.db");
|
|
1326
|
+
return fs.existsSync(db) ? db : path.join(dataDir, "claude-mem.db");
|
|
634
1327
|
}
|
|
635
1328
|
|
|
636
1329
|
/** The `sqlite3` binary from an absolute PATH entry only, never a bare name handed to the child process's PATH search. */
|
|
@@ -653,15 +1346,23 @@ export function isClaudeModelId(model) {
|
|
|
653
1346
|
return /^(?:claude|\$TIER:|(?:haiku|sonnet|opus)(?:-|$))/.test(model);
|
|
654
1347
|
}
|
|
655
1348
|
|
|
656
|
-
/**
|
|
1349
|
+
/** A database the consolidation migration has not reached lacks these tables; its observation rows still count. */
|
|
1350
|
+
const MISSING_CONSOLIDATION_TABLE = /no such table: (?:main\.)?consolidation_(?:calls|jobs)\b/;
|
|
1351
|
+
|
|
1352
|
+
/** Observer spend for one session via a read-only `sqlite3` spawn: observation discovery tokens plus the session's consolidation provider calls. Failures and an unsafe id report as `{reason}`. Claude ids fold into subscription, others into `local`. */
|
|
657
1353
|
export function observerSink(sessionId, dataDir = memoryDataDir()) {
|
|
658
1354
|
if (!sessionId) return { reason: "no session id" };
|
|
659
|
-
|
|
1355
|
+
if (typeof sessionId !== "string" || !SAFE_SESSION_RE.test(sessionId)) return { reason: "unsafe session id" };
|
|
1356
|
+
const db = memoryDatabase(dataDir);
|
|
660
1357
|
if (!fs.existsSync(db)) return { reason: `database not found: ${db}` };
|
|
661
1358
|
const bin = resolveSqlite3();
|
|
662
1359
|
if (!bin) return { reason: "sqlite3 not found on an absolute PATH entry" };
|
|
663
|
-
const escaped =
|
|
664
|
-
|
|
1360
|
+
const escaped = sessionId.replace(/'/g, "''");
|
|
1361
|
+
// The consolidation statement runs last: sqlite3 prints each statement's rows before it prepares the next, so a missing table there leaves the observation rows intact.
|
|
1362
|
+
const sql = [
|
|
1363
|
+
`SELECT COALESCE(o.generated_by_model, s.observed_model), COALESCE(SUM(o.discovery_tokens),0) FROM observations o JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id WHERE s.content_session_id = '${escaped}' GROUP BY 1;`,
|
|
1364
|
+
`SELECT c.model, COALESCE(SUM(c.tokens),0) FROM consolidation_calls c JOIN consolidation_jobs j ON c.job_id = j.id JOIN sdk_sessions s ON j.memory_session_id = s.memory_session_id WHERE s.content_session_id = '${escaped}' GROUP BY 1;`,
|
|
1365
|
+
].join(" ");
|
|
665
1366
|
let r;
|
|
666
1367
|
try {
|
|
667
1368
|
r = spawnSync(bin, ["-readonly", db, sql], { encoding: "utf8", timeout: 2000 });
|
|
@@ -669,9 +1370,8 @@ export function observerSink(sessionId, dataDir = memoryDataDir()) {
|
|
|
669
1370
|
return { reason: e instanceof Error ? e.message : String(e) };
|
|
670
1371
|
}
|
|
671
1372
|
if (!r || r.error) return { reason: r && r.error ? r.error.message : "spawn failed" };
|
|
672
|
-
if (r.status !== 0) return { reason: `sqlite3 exited ${r.status}: ${(r.stderr || "").trim().slice(0, 200)}` };
|
|
673
|
-
const
|
|
674
|
-
const local = { tokens: 0, model: /** @type {string | null} */ (null), top: -1 };
|
|
1373
|
+
if (r.status !== 0 && !MISSING_CONSOLIDATION_TABLE.test(r.stderr || "")) return { reason: `sqlite3 exited ${r.status}: ${(r.stderr || "").trim().slice(0, 200)}` };
|
|
1374
|
+
const byModel = new Map();
|
|
675
1375
|
for (const raw of (r.stdout || "").split("\n")) {
|
|
676
1376
|
const line = raw.trim();
|
|
677
1377
|
if (!line) continue;
|
|
@@ -679,6 +1379,11 @@ export function observerSink(sessionId, dataDir = memoryDataDir()) {
|
|
|
679
1379
|
const tokens = Number.parseInt(tokensRaw, 10);
|
|
680
1380
|
if (!Number.isFinite(tokens)) continue;
|
|
681
1381
|
const model = modelRaw && modelRaw.trim() ? modelRaw.trim() : null;
|
|
1382
|
+
byModel.set(model, (byModel.get(model) || 0) + tokens);
|
|
1383
|
+
}
|
|
1384
|
+
const claude = { tokens: 0, model: /** @type {string | null} */ (null), top: -1 };
|
|
1385
|
+
const local = { tokens: 0, model: /** @type {string | null} */ (null), top: -1 };
|
|
1386
|
+
for (const [model, tokens] of byModel) {
|
|
682
1387
|
const bucket = model && isClaudeModelId(model) ? claude : local;
|
|
683
1388
|
bucket.tokens += tokens;
|
|
684
1389
|
if (tokens > bucket.top) {
|
|
@@ -692,12 +1397,8 @@ export function observerSink(sessionId, dataDir = memoryDataDir()) {
|
|
|
692
1397
|
/** One pass over the usage log: earliest `ts` and the largest `context_tokens` seen (context can fall after a compaction, so the peak isn't just the last snapshot). */
|
|
693
1398
|
function sessionState(file, sessionId) {
|
|
694
1399
|
if (!sessionId) return { started: null, contextMax: 0 };
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
text = fs.readFileSync(file, "utf8");
|
|
698
|
-
} catch {
|
|
699
|
-
return { started: null, contextMax: 0 };
|
|
700
|
-
}
|
|
1400
|
+
const { text } = readContained(file, MAX_USAGE_LOG_BYTES);
|
|
1401
|
+
if (text === null) return { started: null, contextMax: 0 };
|
|
701
1402
|
let started = null;
|
|
702
1403
|
let contextMax = 0;
|
|
703
1404
|
for (const raw of text.split("\n")) {
|
|
@@ -715,17 +1416,17 @@ function sessionState(file, sessionId) {
|
|
|
715
1416
|
return { started, contextMax };
|
|
716
1417
|
}
|
|
717
1418
|
|
|
718
|
-
/**
|
|
719
|
-
export function telemetryRecord(snapshot, tags, observer, warnings) {
|
|
1419
|
+
/** One telemetry record from the SessionEnd snapshot; `root` holds the SubagentStop ledgers. */
|
|
1420
|
+
export function telemetryRecord(snapshot, tags, observer, warnings, root = snapshot.cwd, signals = null) {
|
|
720
1421
|
const merged = Array.isArray(warnings) ? [...warnings] : [];
|
|
721
1422
|
const observerOk = observer && typeof observer === "object" && Number.isFinite(observer.tokens);
|
|
722
1423
|
if (!observerOk) merged.push(`observer sink unreadable: ${observer && observer.reason ? observer.reason : "unknown"}`);
|
|
723
|
-
const zero = { input: 0, output: 0, cache_read: 0, cache_write: 0, total: 0 };
|
|
1424
|
+
const zero = { input: 0, output: 0, cache_read: 0, cache_write: 0, cache_write_1h: 0, total: 0 };
|
|
724
1425
|
const byTool = snapshot.by_tool || {};
|
|
725
1426
|
const dispatchCalls = (name) => (byTool[name] && byTool[name].calls) || 0;
|
|
726
1427
|
const estUsdMain = Number(snapshot.est_usd) || 0;
|
|
727
1428
|
const estUsdSubagents = Number(snapshot.subagents_est_usd) || 0;
|
|
728
|
-
const dispatches = dispatchesByRole(typeof
|
|
1429
|
+
const dispatches = dispatchesByRole(typeof root === "string" ? root : "", typeof snapshot.session_id === "string" ? snapshot.session_id : "");
|
|
729
1430
|
return {
|
|
730
1431
|
ts: snapshot.ts,
|
|
731
1432
|
session_id: snapshot.session_id,
|
|
@@ -734,9 +1435,15 @@ export function telemetryRecord(snapshot, tags, observer, warnings) {
|
|
|
734
1435
|
ended: snapshot.ts,
|
|
735
1436
|
turns: snapshot.turns ?? 0,
|
|
736
1437
|
change: tags.change ?? null,
|
|
1438
|
+
changes: Array.isArray(tags.changes) ? tags.changes : tags.change ? [tags.change] : [],
|
|
737
1439
|
task: tags.task ?? null,
|
|
738
1440
|
tier: tags.tier ?? null,
|
|
739
1441
|
task_class: tags.task_class ?? "untagged",
|
|
1442
|
+
...classifySession(signals, dispatches),
|
|
1443
|
+
activity: snapshot.activity ?? "measured",
|
|
1444
|
+
end_reason: snapshot.end_reason ?? null,
|
|
1445
|
+
cc_version: ccVersionOf(snapshot.cc_version),
|
|
1446
|
+
by_model: snapshot.by_model || {},
|
|
740
1447
|
sinks: {
|
|
741
1448
|
main: snapshot.usage || zero,
|
|
742
1449
|
subagents: snapshot.subagents || zero,
|
|
@@ -751,6 +1458,7 @@ export function telemetryRecord(snapshot, tags, observer, warnings) {
|
|
|
751
1458
|
est_usd: round(estUsdMain + estUsdSubagents),
|
|
752
1459
|
est_usd_main: estUsdMain,
|
|
753
1460
|
est_usd_subagents: estUsdSubagents,
|
|
1461
|
+
...(snapshot.price_estimated === true ? { price_estimated: true } : {}),
|
|
754
1462
|
context_max: snapshot.context_max ?? snapshot.context_tokens ?? 0,
|
|
755
1463
|
context_window: snapshot.context_window ?? 200_000,
|
|
756
1464
|
context_window_source: snapshot.context_window_source ?? "model",
|
|
@@ -759,39 +1467,63 @@ export function telemetryRecord(snapshot, tags, observer, warnings) {
|
|
|
759
1467
|
};
|
|
760
1468
|
}
|
|
761
1469
|
|
|
762
|
-
/** Appends
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
1470
|
+
/** Appends `text` to `file` without following a symlink leaf or blocking on a FIFO; only a
|
|
1471
|
+
* regular file is written, since a cloned repository controls the telemetry path and the
|
|
1472
|
+
* environment controls the usage-log path.
|
|
1473
|
+
* @returns {{ok:true}|{ok:false, reason:string}} `reason` is a code, never a path.
|
|
1474
|
+
*/
|
|
1475
|
+
function appendContainedLine(file, text) {
|
|
1476
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK || 0);
|
|
1477
|
+
let fd;
|
|
767
1478
|
try {
|
|
768
|
-
|
|
769
|
-
} catch {
|
|
770
|
-
|
|
1479
|
+
fd = fs.openSync(file, flags, 0o644);
|
|
1480
|
+
} catch (e) {
|
|
1481
|
+
return { ok: false, reason: `open-refused ${errCode(e)}` };
|
|
771
1482
|
}
|
|
772
|
-
if (!exists) return;
|
|
773
|
-
const sessionId = typeof record.session_id === "string" ? record.session_id : "";
|
|
774
|
-
const observer = observerSink(sessionId);
|
|
775
|
-
const { started, contextMax: priorMax } = sessionState(logPath(), sessionId);
|
|
776
|
-
const contextMax = Math.max(priorMax, Number(record.context_tokens) || 0);
|
|
777
|
-
const tel = telemetryRecord({ ...record, started, context_max: contextMax }, tags, observer, Array.isArray(record.warnings) ? record.warnings : []);
|
|
778
1483
|
try {
|
|
779
|
-
fs.
|
|
1484
|
+
if (!fs.fstatSync(fd).isFile()) return { ok: false, reason: "not-regular" };
|
|
1485
|
+
fs.writeSync(fd, text);
|
|
1486
|
+
return { ok: true };
|
|
780
1487
|
} catch (e) {
|
|
781
|
-
|
|
782
|
-
|
|
1488
|
+
return { ok: false, reason: `write-failed ${errCode(e)}` };
|
|
1489
|
+
} finally {
|
|
1490
|
+
fs.closeSync(fd);
|
|
783
1491
|
}
|
|
784
1492
|
}
|
|
785
1493
|
|
|
786
|
-
/**
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1494
|
+
/** Appends one telemetry record when `.freeharness/` already exists as a real directory; never
|
|
1495
|
+
* creates it. A refused or failed write lands as a snapshot warning. */
|
|
1496
|
+
function writeTelemetryIfPossible(record, root) {
|
|
1497
|
+
const dir = path.join(root, ".freeharness");
|
|
1498
|
+
let stat;
|
|
790
1499
|
try {
|
|
791
|
-
|
|
1500
|
+
stat = fs.lstatSync(dir);
|
|
792
1501
|
} catch {
|
|
793
|
-
return
|
|
1502
|
+
return;
|
|
794
1503
|
}
|
|
1504
|
+
const warn = (reason) => {
|
|
1505
|
+
const msg = `telemetry write failed: ${reason}`;
|
|
1506
|
+
record.warnings = Array.isArray(record.warnings) ? [...record.warnings, msg] : [msg];
|
|
1507
|
+
};
|
|
1508
|
+
if (stat.isSymbolicLink()) return warn("symlinked-dir");
|
|
1509
|
+
if (!stat.isDirectory()) return;
|
|
1510
|
+
const sessionId = typeof record.session_id === "string" ? record.session_id : "";
|
|
1511
|
+
const observer = observerSink(sessionId);
|
|
1512
|
+
const { started, contextMax: priorMax } = sessionState(logPath(), sessionId);
|
|
1513
|
+
const contextMax = Math.max(priorMax, Number(record.context_tokens) || 0);
|
|
1514
|
+
const tags = sessionTags(root, sessionId, record.signals);
|
|
1515
|
+
const tel = telemetryRecord({ ...record, started, context_max: contextMax }, tags, observer, Array.isArray(record.warnings) ? record.warnings : [], root, record.signals);
|
|
1516
|
+
const appended = appendContainedLine(path.join(dir, "telemetry.jsonl"), `${JSON.stringify(tel)}\n`);
|
|
1517
|
+
if (!appended.ok) warn(appended.reason);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** Bytes the output pruner recovered: the sum of `{kind:"pruned"}` lines with this session id in
|
|
1521
|
+
* the cost log. A log over its cap counts 0 and pushes one warning onto `warnings` when given. */
|
|
1522
|
+
export function prunedBytesFor(sessionId, warnings) {
|
|
1523
|
+
if (!sessionId) return 0;
|
|
1524
|
+
const { text, reason } = readContained(logPath(), MAX_USAGE_LOG_BYTES);
|
|
1525
|
+
if (reason === "too-large" && Array.isArray(warnings)) warnings.push("usage log over cap");
|
|
1526
|
+
if (text === null) return 0;
|
|
795
1527
|
let total = 0;
|
|
796
1528
|
for (const raw of text.split("\n")) {
|
|
797
1529
|
if (!raw.includes('"pruned"') || !raw.includes(sessionId)) continue;
|
|
@@ -805,78 +1537,218 @@ export function prunedBytesFor(sessionId) {
|
|
|
805
1537
|
return total;
|
|
806
1538
|
}
|
|
807
1539
|
|
|
808
|
-
/**
|
|
809
|
-
|
|
1540
|
+
/** The context monitor's rule, so both hooks keep per-session state in one directory. */
|
|
1541
|
+
function stateDir() {
|
|
1542
|
+
return process.env.FREEHARNESS_STATE_DIR || path.join(os.homedir(), ".claude", "freeharness", "state");
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/** The id becomes a file name, so one that could leave the state dir runs uncached. */
|
|
1546
|
+
const SAFE_SESSION_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
1547
|
+
const CACHE_COUNTS = ["input", "output", "cache_read", "cache_write", "cache_write_1h"];
|
|
1548
|
+
|
|
1549
|
+
function subagentCachePath(sessionId) {
|
|
1550
|
+
return typeof sessionId === "string" && SAFE_SESSION_RE.test(sessionId) ? path.join(stateDir(), `${sessionId}.subagents.json`) : null;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function isRecordObject(v) {
|
|
1554
|
+
return Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function countOk(n) {
|
|
1558
|
+
return typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/** Rebuilds one entry field by field: the file sits in a user-writable dir and its totals are trusted unread. */
|
|
1562
|
+
function cacheEntry(raw) {
|
|
1563
|
+
if (!isRecordObject(raw) || !countOk(raw.size) || !countOk(raw.mtimeMs) || !countOk(raw.messages)) return null;
|
|
1564
|
+
if (typeof raw.key !== "string" || typeof raw.metaUnreadable !== "boolean" || !isRecordObject(raw.byModel)) return null;
|
|
1565
|
+
const byModel = Object.create(null);
|
|
1566
|
+
for (const [model, u] of Object.entries(raw.byModel)) {
|
|
1567
|
+
if (!isRecordObject(u) || !CACHE_COUNTS.every((k) => countOk(u[k]))) return null;
|
|
1568
|
+
byModel[model] = { input: u.input, output: u.output, cache_read: u.cache_read, cache_write: u.cache_write, cache_write_1h: u.cache_write_1h };
|
|
1569
|
+
}
|
|
1570
|
+
return { size: raw.size, mtimeMs: raw.mtimeMs, key: raw.key, metaUnreadable: raw.metaUnreadable, messages: raw.messages, byModel };
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/** `{v, session, files}` or null; one off-shape entry voids the whole file, since a partial trust would mix forged and real totals. */
|
|
1574
|
+
export function readSubagentCache(sessionId) {
|
|
1575
|
+
const file = subagentCachePath(sessionId);
|
|
1576
|
+
if (!file) return null;
|
|
1577
|
+
const { text } = readContained(file, MAX_SUBAGENT_CACHE_BYTES);
|
|
1578
|
+
if (text === null) return null;
|
|
1579
|
+
let parsed;
|
|
1580
|
+
try {
|
|
1581
|
+
parsed = JSON.parse(text);
|
|
1582
|
+
} catch {
|
|
1583
|
+
return null;
|
|
1584
|
+
}
|
|
1585
|
+
if (!isRecordObject(parsed) || parsed.v !== 1 || parsed.session !== sessionId || !isRecordObject(parsed.files)) return null;
|
|
1586
|
+
const files = Object.create(null);
|
|
1587
|
+
for (const [name, raw] of Object.entries(parsed.files)) {
|
|
1588
|
+
const entry = cacheEntry(raw);
|
|
1589
|
+
if (!entry) return null;
|
|
1590
|
+
files[name] = entry;
|
|
1591
|
+
}
|
|
1592
|
+
return { v: 1, session: sessionId, files };
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/** Temp file then rename: a reader never sees half a file and a symlink at the cache path is replaced, not followed. Failure is silent because the cache only saves time. */
|
|
1596
|
+
export function writeSubagentCache(sessionId, files) {
|
|
1597
|
+
const file = subagentCachePath(sessionId);
|
|
1598
|
+
if (!file) return;
|
|
1599
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
1600
|
+
let fd;
|
|
1601
|
+
try {
|
|
1602
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1603
|
+
fs.rmSync(tmp, { force: true });
|
|
1604
|
+
fd = fs.openSync(tmp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
1605
|
+
fs.writeFileSync(fd, JSON.stringify({ v: 1, session: sessionId, files }));
|
|
1606
|
+
fs.closeSync(fd);
|
|
1607
|
+
fd = undefined;
|
|
1608
|
+
fs.renameSync(tmp, file);
|
|
1609
|
+
} catch {
|
|
1610
|
+
try {
|
|
1611
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
1612
|
+
fs.rmSync(tmp, { force: true });
|
|
1613
|
+
} catch {
|
|
1614
|
+
// Nothing left to undo.
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/** One subagent transcript reduced to what the snapshot and the cache share, or null when unusable. */
|
|
1620
|
+
function readSubagentEntry(file, metaFile, legacyName, stat) {
|
|
1621
|
+
const sums = sumTranscript(file, MAX_SUBAGENT_FILE_BYTES);
|
|
1622
|
+
if (!sums.found) return { entry: null, tooLarge: sums.reason === "too-large" };
|
|
1623
|
+
let key = legacyName;
|
|
1624
|
+
let metaUnreadable = false;
|
|
1625
|
+
if (key === null) {
|
|
1626
|
+
// A missing meta is an ordinary gap, so only a present but unusable one counts.
|
|
1627
|
+
const { meta, reason } = loadAgentMeta(metaFile);
|
|
1628
|
+
metaUnreadable = reason === "unreadable";
|
|
1629
|
+
key = (meta.agentType && sanitizeTag(meta.agentType)) || "unknown";
|
|
1630
|
+
}
|
|
1631
|
+
let messages = 0;
|
|
1632
|
+
const byModel = Object.create(null);
|
|
1633
|
+
for (const [model, u] of Object.entries(sums.byModel)) {
|
|
1634
|
+
messages += u.messages;
|
|
1635
|
+
byModel[model] = { input: u.input, output: u.output, cache_read: u.cache_read, cache_write: u.cache_write, cache_write_1h: u.cache_write_1h };
|
|
1636
|
+
}
|
|
1637
|
+
const entry = { key, metaUnreadable, messages, byModel };
|
|
1638
|
+
return { entry: stat ? { size: stat.size, mtimeMs: stat.mtimeMs, ...entry } : entry, tooLarge: false };
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/**
|
|
1642
|
+
* Builds the usage line for one hook invocation. `tags` is a param so `main()` computes it once and reuses it.
|
|
1643
|
+
* `useCache` is the hook's alone: an in-process caller must not write session state.
|
|
1644
|
+
* @returns The record, or null for a main transcript over its cap: a zeroed snapshot would
|
|
1645
|
+
* become the session's last one, and cost aggregation keeps only the last.
|
|
1646
|
+
*/
|
|
1647
|
+
export function buildRecord(payload, tags, { useCache = false } = {}) {
|
|
810
1648
|
const pricesFile = pricesPath();
|
|
811
1649
|
const { prices, ok: pricesOk } = loadPrices(pricesFile);
|
|
812
1650
|
const transcript = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
|
|
813
1651
|
const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
814
1652
|
const sums = transcript
|
|
815
|
-
? sumTranscript(transcript)
|
|
816
|
-
: { byModel:
|
|
1653
|
+
? sumTranscript(transcript, MAX_MAIN_TRANSCRIPT_BYTES)
|
|
1654
|
+
: { byModel: Object.create(null), byTool: Object.create(null), turns: 0, lines: 0, contextTokens: 0, contextModel: "", toolOutputChars: 0, toolCalls: 0, found: false };
|
|
1655
|
+
if (sums.reason === "too-large") return null;
|
|
817
1656
|
const total = bucket();
|
|
818
|
-
|
|
1657
|
+
// Model names are transcript text, `by_agent` keys subagent meta `agentType` or a
|
|
1658
|
+
// legacy file name; both are prototype-free.
|
|
1659
|
+
const byModel = Object.create(null);
|
|
819
1660
|
let usd = 0;
|
|
1661
|
+
let priceEstimated = false;
|
|
820
1662
|
for (const [model, u] of Object.entries(sums.byModel)) {
|
|
821
|
-
const
|
|
1663
|
+
const match = priceMatch(prices, model);
|
|
1664
|
+
const est = estimate(u, match.row);
|
|
822
1665
|
usd += est;
|
|
823
|
-
|
|
1666
|
+
if (match.estimated) priceEstimated = true;
|
|
1667
|
+
byModel[model] = { ...u, est_usd: round(est), ...(match.estimated ? { price_estimated: true } : {}) };
|
|
824
1668
|
total.input += u.input;
|
|
825
1669
|
total.output += u.output;
|
|
826
1670
|
total.cache_read += u.cache_read;
|
|
827
1671
|
total.cache_write += u.cache_write;
|
|
1672
|
+
total.cache_write_1h += u.cache_write_1h;
|
|
828
1673
|
total.messages += u.messages;
|
|
829
1674
|
}
|
|
830
1675
|
|
|
831
1676
|
// Subagent transcripts stay in their own bucket; `usage` above is main-only. Repeated
|
|
832
|
-
// dispatches of one
|
|
833
|
-
//
|
|
1677
|
+
// dispatches of one key pool per-model sums, merged once. A current-shape file is keyed by its
|
|
1678
|
+
// meta agent type, a legacy one by its parsed name. A transcript over 64 MB or past the cap is
|
|
1679
|
+
// skipped with a warning. Stop fires every turn, so a file whose size and mtime match the
|
|
1680
|
+
// session cache is summed from it unread.
|
|
834
1681
|
const subagentFiles = transcript ? subagentTranscripts(transcript) : [];
|
|
835
|
-
const
|
|
1682
|
+
const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
|
|
1683
|
+
const cache = useCache ? readSubagentCache(sessionId) : null;
|
|
1684
|
+
const prior = cache ? cache.files : Object.create(null);
|
|
1685
|
+
const cacheFiles = Object.create(null);
|
|
1686
|
+
let reused = 0;
|
|
1687
|
+
let reread = false;
|
|
1688
|
+
const by_agent = Object.create(null);
|
|
836
1689
|
const subagentsTotal = bucket();
|
|
837
1690
|
let subagentsUsd = 0;
|
|
838
|
-
const perAgentModelSums =
|
|
839
|
-
const dispatchCounts =
|
|
1691
|
+
const perAgentModelSums = Object.create(null);
|
|
1692
|
+
const dispatchCounts = Object.create(null);
|
|
1693
|
+
const agentMessages = Object.create(null);
|
|
840
1694
|
let subagentsScanned = 0;
|
|
841
1695
|
let subagentsSkippedLarge = 0;
|
|
842
1696
|
let subagentsSkippedCap = 0;
|
|
843
|
-
|
|
1697
|
+
let metasUnreadable = 0;
|
|
1698
|
+
for (const { name: legacyName, file, metaFile } of subagentFiles) {
|
|
844
1699
|
if (subagentsScanned >= MAX_SUBAGENT_FILES) {
|
|
845
1700
|
subagentsSkippedCap += 1;
|
|
846
1701
|
continue;
|
|
847
1702
|
}
|
|
848
|
-
|
|
1703
|
+
const base = path.basename(file);
|
|
1704
|
+
let stat = null;
|
|
849
1705
|
try {
|
|
850
|
-
|
|
1706
|
+
stat = fs.lstatSync(file);
|
|
1707
|
+
if (!stat.isFile()) stat = null;
|
|
851
1708
|
} catch {
|
|
852
|
-
|
|
1709
|
+
// The read below reports the file as unusable.
|
|
853
1710
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
1711
|
+
let entry = prior[base];
|
|
1712
|
+
if (entry && stat && entry.size === stat.size && entry.mtimeMs === stat.mtimeMs) {
|
|
1713
|
+
reused += 1;
|
|
1714
|
+
} else {
|
|
1715
|
+
reread = true;
|
|
1716
|
+
const fresh = readSubagentEntry(file, metaFile, legacyName, stat);
|
|
1717
|
+
if (!fresh.entry) {
|
|
1718
|
+
if (fresh.tooLarge) subagentsSkippedLarge += 1;
|
|
1719
|
+
continue;
|
|
1720
|
+
}
|
|
1721
|
+
entry = fresh.entry;
|
|
857
1722
|
}
|
|
858
|
-
const subSums = sumTranscript(file);
|
|
859
|
-
if (!subSums.found) continue;
|
|
860
1723
|
subagentsScanned += 1;
|
|
1724
|
+
if (entry.size !== undefined) cacheFiles[base] = entry;
|
|
1725
|
+
if (entry.metaUnreadable) metasUnreadable += 1;
|
|
1726
|
+
const name = entry.key;
|
|
861
1727
|
dispatchCounts[name] = (dispatchCounts[name] || 0) + 1;
|
|
862
|
-
|
|
863
|
-
|
|
1728
|
+
agentMessages[name] = (agentMessages[name] || 0) + entry.messages;
|
|
1729
|
+
const acc = (perAgentModelSums[name] ??= Object.create(null));
|
|
1730
|
+
for (const [model, u] of Object.entries(entry.byModel)) {
|
|
864
1731
|
const b = (acc[model] ??= bucket());
|
|
865
1732
|
b.input += u.input;
|
|
866
1733
|
b.output += u.output;
|
|
867
1734
|
b.cache_read += u.cache_read;
|
|
868
1735
|
b.cache_write += u.cache_write;
|
|
869
|
-
b.
|
|
870
|
-
|
|
1736
|
+
b.cache_write_1h += u.cache_write_1h;
|
|
1737
|
+
const match = priceMatch(prices, model);
|
|
1738
|
+
if (match.estimated) priceEstimated = true;
|
|
1739
|
+
subagentsUsd += estimate(u, match.row);
|
|
871
1740
|
}
|
|
872
1741
|
}
|
|
1742
|
+
const cacheChanged = cache ? reread || reused !== Object.keys(prior).length : Object.keys(cacheFiles).length > 0;
|
|
1743
|
+
if (useCache && cacheChanged) writeSubagentCache(sessionId, cacheFiles);
|
|
873
1744
|
for (const [name, byModelForAgent] of Object.entries(perAgentModelSums)) {
|
|
874
1745
|
const merged = mergeAgentUsage(byModelForAgent);
|
|
875
|
-
by_agent[name] = { ...merged, dispatches: dispatchCounts[name] };
|
|
1746
|
+
by_agent[name] = { ...merged, messages: agentMessages[name], dispatches: dispatchCounts[name] };
|
|
876
1747
|
subagentsTotal.input += merged.usage.input;
|
|
877
1748
|
subagentsTotal.output += merged.usage.output;
|
|
878
1749
|
subagentsTotal.cache_read += merged.usage.cache_read;
|
|
879
1750
|
subagentsTotal.cache_write += merged.usage.cache_write;
|
|
1751
|
+
subagentsTotal.cache_write_1h += merged.usage.cache_write_1h;
|
|
880
1752
|
}
|
|
881
1753
|
|
|
882
1754
|
// The transcript's model id never carries a `[1m]` suffix, so one from settings.json is
|
|
@@ -902,15 +1774,28 @@ export function buildRecord(payload, tags) {
|
|
|
902
1774
|
if (!pricesOk) warnings.push(`price table not loaded: ${pricesFile}`);
|
|
903
1775
|
if (subagentsSkippedLarge > 0) warnings.push(`${subagentsSkippedLarge} subagent transcript(s) over 64 MB skipped`);
|
|
904
1776
|
if (subagentsSkippedCap > 0) warnings.push(`subagent transcript scan capped at ${MAX_SUBAGENT_FILES} files; ${subagentsSkippedCap} skipped`);
|
|
1777
|
+
if (metasUnreadable > 0) warnings.push(`subagent meta unreadable: ${metasUnreadable}`);
|
|
905
1778
|
if (context_window < 1_000_000 && sums.contextTokens > context_window) {
|
|
906
1779
|
context_window = 1_000_000;
|
|
907
1780
|
model_suffix = null;
|
|
908
1781
|
context_window_source = "inferred";
|
|
909
1782
|
warnings.push("context exceeded the resolved window; assumed 1m");
|
|
910
1783
|
}
|
|
1784
|
+
const eventName = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "Stop";
|
|
1785
|
+
// A session with tokens (main or subagent) is measured; no-model-calls gates on main-context
|
|
1786
|
+
// tokens, not the assistant message count, so a zero-usage <synthetic> line never counts as one.
|
|
1787
|
+
const subagentTokens = subagentsTotal.input + subagentsTotal.output + subagentsTotal.cache_read + subagentsTotal.cache_write;
|
|
1788
|
+
const mainTokens = total.input + total.output + total.cache_read + total.cache_write;
|
|
1789
|
+
let activity;
|
|
1790
|
+
if (!sums.found && subagentTokens === 0) activity = "no-transcript";
|
|
1791
|
+
else if (sums.found && mainTokens === 0 && subagentTokens === 0) activity = "no-model-calls";
|
|
1792
|
+
else activity = "measured";
|
|
1793
|
+
const reasonRaw = typeof payload.reason === "string" && payload.reason !== "" ? payload.reason : null;
|
|
1794
|
+
const sanitizedReason = eventName === "SessionEnd" && reasonRaw ? sanitizeTag(reasonRaw) : "";
|
|
1795
|
+
const end_reason = sanitizedReason || null;
|
|
911
1796
|
const record = {
|
|
912
1797
|
ts: new Date().toISOString(),
|
|
913
|
-
event:
|
|
1798
|
+
event: eventName,
|
|
914
1799
|
session_id: typeof payload.session_id === "string" ? payload.session_id : "",
|
|
915
1800
|
project: path.basename(cwd),
|
|
916
1801
|
cwd,
|
|
@@ -921,6 +1806,7 @@ export function buildRecord(payload, tags) {
|
|
|
921
1806
|
output: total.output,
|
|
922
1807
|
cache_read: total.cache_read,
|
|
923
1808
|
cache_write: total.cache_write,
|
|
1809
|
+
cache_write_1h: total.cache_write_1h,
|
|
924
1810
|
total: total.input + total.output + total.cache_read + total.cache_write,
|
|
925
1811
|
},
|
|
926
1812
|
by_model: byModel,
|
|
@@ -930,7 +1816,7 @@ export function buildRecord(payload, tags) {
|
|
|
930
1816
|
prices_file: pricesFile,
|
|
931
1817
|
cumulative: true,
|
|
932
1818
|
task_class: resolvedTags.task_class,
|
|
933
|
-
pruned_bytes: prunedBytesFor(typeof payload.session_id === "string" ? payload.session_id : ""),
|
|
1819
|
+
pruned_bytes: prunedBytesFor(typeof payload.session_id === "string" ? payload.session_id : "", warnings),
|
|
934
1820
|
context_tokens: sums.contextTokens,
|
|
935
1821
|
tool_calls: sums.toolCalls,
|
|
936
1822
|
tool_output_chars: sums.toolOutputChars,
|
|
@@ -941,14 +1827,21 @@ export function buildRecord(payload, tags) {
|
|
|
941
1827
|
output: subagentsTotal.output,
|
|
942
1828
|
cache_read: subagentsTotal.cache_read,
|
|
943
1829
|
cache_write: subagentsTotal.cache_write,
|
|
1830
|
+
cache_write_1h: subagentsTotal.cache_write_1h,
|
|
944
1831
|
total: subagentsTotal.input + subagentsTotal.output + subagentsTotal.cache_read + subagentsTotal.cache_write,
|
|
945
1832
|
},
|
|
946
1833
|
subagents_est_usd: round(subagentsUsd),
|
|
1834
|
+
...(priceEstimated ? { price_estimated: true } : {}),
|
|
947
1835
|
context_window,
|
|
948
1836
|
model_suffix,
|
|
949
1837
|
context_window_source,
|
|
1838
|
+
activity,
|
|
1839
|
+
end_reason,
|
|
1840
|
+
cc_version: sums.ccVersion ?? null,
|
|
950
1841
|
};
|
|
951
1842
|
if (warnings.length > 0) record.warnings = warnings;
|
|
1843
|
+
// Non-enumerable: telemetry reads the signals, JSON.stringify leaves them out of usage.jsonl.
|
|
1844
|
+
Object.defineProperty(record, "signals", { value: sums.signals ?? { commands: [], skills: [], openspec: [] }, enumerable: false });
|
|
952
1845
|
return record;
|
|
953
1846
|
}
|
|
954
1847
|
|
|
@@ -957,11 +1850,18 @@ function round(n) {
|
|
|
957
1850
|
return Math.round(n * 1e6) / 1e6;
|
|
958
1851
|
}
|
|
959
1852
|
|
|
960
|
-
/** Appends one record to the usage log, creating the directory on first use
|
|
1853
|
+
/** Appends one record to the usage log, creating the directory on first use; a refused mkdir
|
|
1854
|
+
* or append writes one code-only stderr line. */
|
|
961
1855
|
export function appendRecord(record) {
|
|
962
1856
|
const file = logPath();
|
|
963
|
-
|
|
964
|
-
|
|
1857
|
+
let appended;
|
|
1858
|
+
try {
|
|
1859
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1860
|
+
appended = appendContainedLine(file, `${JSON.stringify(record)}\n`);
|
|
1861
|
+
} catch (e) {
|
|
1862
|
+
appended = { ok: false, reason: `mkdir-failed ${errCode(e)}` };
|
|
1863
|
+
}
|
|
1864
|
+
if (!appended.ok) process.stderr.write(`freeharness-cost-tracker: usage log append refused: ${appended.reason}\n`);
|
|
965
1865
|
return file;
|
|
966
1866
|
}
|
|
967
1867
|
|
|
@@ -981,15 +1881,16 @@ function main() {
|
|
|
981
1881
|
const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
982
1882
|
if (payload.hook_event_name === "SubagentStop") {
|
|
983
1883
|
const { prices } = loadPrices(pricesPath());
|
|
984
|
-
|
|
1884
|
+
// With no project above it the cwd stands in as the root, so ledgerLine reports its own refusals.
|
|
1885
|
+
const root = projectRootFor(cwd) ?? cwd;
|
|
1886
|
+
const result = ledgerLine(payload, root, prices);
|
|
985
1887
|
if (result.ok) {
|
|
986
|
-
const recorded = readLedgerAgentIds(result.file);
|
|
987
|
-
const line = { ...result.line, resumed: recorded.has(result.line.agent_id) };
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
}
|
|
1888
|
+
const recorded = readLedgerAgentIds(result.file, root);
|
|
1889
|
+
const line = { ...result.line, resumed: recorded !== null && recorded.has(result.line.agent_id) };
|
|
1890
|
+
const appended = appendLedgerLine(result.file, line, root);
|
|
1891
|
+
// A refused append already reports once; the unknown-resumed line only qualifies a line that landed.
|
|
1892
|
+
if (!appended.ok) reportLedgerRefusal(appended.reason);
|
|
1893
|
+
else if (recorded === null) process.stderr.write("freeharness-cost-tracker: ledger ids unreadable; resumed unknown\n");
|
|
993
1894
|
} else if (result.reason === "unsafe-agent-id") {
|
|
994
1895
|
process.stderr.write("freeharness-cost-tracker: SubagentStop agent_id refused\n");
|
|
995
1896
|
} else if (result.reason === "missing-payload") {
|
|
@@ -999,20 +1900,30 @@ function main() {
|
|
|
999
1900
|
} else if (result.reason === "unsafe-change") {
|
|
1000
1901
|
process.stderr.write("freeharness-cost-tracker: SubagentStop brief Change header refused\n");
|
|
1001
1902
|
}
|
|
1002
|
-
// "no-brief": the
|
|
1903
|
+
// "no-brief": no in-flight brief declares the parsed task (a final review needs final.brief.md), or an unnamed dispatch finds no in-flight brief; silent.
|
|
1003
1904
|
return;
|
|
1004
1905
|
}
|
|
1005
|
-
const
|
|
1006
|
-
const
|
|
1906
|
+
const root = projectRootFor(cwd) ?? cwd;
|
|
1907
|
+
const tags = briefTags(root);
|
|
1908
|
+
const record = buildRecord(payload, tags, { useCache: true });
|
|
1007
1909
|
// Backfill runs between build and append, so a missing SubagentStop line still lands
|
|
1008
1910
|
// (late: true) in time for this run's own telemetry count.
|
|
1009
1911
|
const { prices } = loadPrices(pricesPath());
|
|
1010
|
-
const warnings = Array.isArray(record.warnings) ? record.warnings : [];
|
|
1912
|
+
const warnings = record && Array.isArray(record.warnings) ? record.warnings : [];
|
|
1011
1913
|
backfillLedger(payload, cwd, prices, warnings);
|
|
1914
|
+
if (record === null) {
|
|
1915
|
+
// No snapshot carries the backfill's warnings on this run, so stderr does; a refused
|
|
1916
|
+
// append is already there once.
|
|
1917
|
+
for (const warning of new Set(warnings)) {
|
|
1918
|
+
if (!warning.startsWith("ledger backfill append refused:")) process.stderr.write(`freeharness-cost-tracker: ${warning}\n`);
|
|
1919
|
+
}
|
|
1920
|
+
process.stderr.write(`freeharness-cost-tracker: main transcript over ${MAX_MAIN_TRANSCRIPT_BYTES / (1024 * 1024)} MB cap, no snapshot\n`);
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1012
1923
|
if (warnings.length > 0) record.warnings = warnings;
|
|
1013
1924
|
// The telemetry write is attempted before the snapshot is appended, so its failure lands
|
|
1014
1925
|
// as a warning on the appended snapshot itself.
|
|
1015
|
-
if (record.event === "SessionEnd") writeTelemetryIfPossible(record,
|
|
1926
|
+
if (record.event === "SessionEnd") writeTelemetryIfPossible(record, root);
|
|
1016
1927
|
appendRecord(record);
|
|
1017
1928
|
} catch (e) {
|
|
1018
1929
|
// Last resort: say why on stderr (Claude Code shows it in debug mode), never fail.
|