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.
Files changed (194) hide show
  1. package/README.md +8 -7
  2. package/canonical/HARVEST.yaml +25 -14
  3. package/canonical/PROVENANCE.yaml +1 -1
  4. package/canonical/agents/README.md +77 -36
  5. package/canonical/agents/agentic-architect.md +2 -1
  6. package/canonical/agents/database-reviewer.md +6 -8
  7. package/canonical/agents/explorer-ai-engineer-analyst.md +2 -6
  8. package/canonical/agents/implementer.md +30 -0
  9. package/canonical/agents/pr-reviewer.md +9 -11
  10. package/canonical/agents/python-reviewer.md +7 -9
  11. package/canonical/agents/report-formatter.md +7 -3
  12. package/canonical/agents/security-reviewer.md +7 -8
  13. package/canonical/agents/test-writer.md +5 -2
  14. package/canonical/hooks/cost-prices.json +11 -9
  15. package/canonical/hooks/freeharness-config-guard.mjs +8072 -87
  16. package/canonical/hooks/freeharness-context-monitor.mjs +88 -54
  17. package/canonical/hooks/freeharness-converge.mjs +78 -23
  18. package/canonical/hooks/freeharness-cost-tracker.mjs +1129 -218
  19. package/canonical/hooks/freeharness-memory-guard.mjs +20 -23
  20. package/canonical/hooks/freeharness-precompact.mjs +217 -87
  21. package/canonical/hooks/freeharness-quota-tap.mjs +173 -0
  22. package/canonical/hooks/freeharness-reload-context.mjs +197 -0
  23. package/canonical/hooks/freeharness-repeat-tool.mjs +1 -1
  24. package/canonical/hooks/freeharness-sdd-context.mjs +970 -0
  25. package/canonical/hooks/freeharness-session-start +297 -59
  26. package/canonical/hooks/hooks.claude-code.json +1 -1
  27. package/canonical/hooks/hooks.config-guard.claude-code.json +3 -2
  28. package/canonical/hooks/hooks.context-monitor.claude-code.json +1 -1
  29. package/canonical/hooks/hooks.converge.claude-code.json +1 -1
  30. package/canonical/hooks/hooks.memory-guard.claude-code.json +3 -2
  31. package/canonical/hooks/hooks.reload-context.claude-code.json +15 -0
  32. package/canonical/openspec/schema.spec-driven.yaml +29 -8
  33. package/canonical/openspec/templates/follow-ups.md +3 -2
  34. package/canonical/openspec/templates/proposal.md +1 -1
  35. package/canonical/openspec/templates/tasks.md +8 -0
  36. package/canonical/rules/CLAUDE.md +5 -4
  37. package/canonical/rules/routing.md +10 -9
  38. package/canonical/skills/agent-evaluation/SKILL.md +17 -6
  39. package/canonical/skills/agent-evaluation/evals/cases.yaml +38 -23
  40. package/canonical/skills/agentic-architecture/SKILL.md +4 -0
  41. package/canonical/skills/agentic-architecture/evals/cases.yaml +27 -0
  42. package/canonical/skills/code-review/SKILL.md +9 -2
  43. package/canonical/skills/code-review/evals/cases.yaml +1118 -27
  44. package/canonical/skills/git-workflow/SKILL.md +2 -0
  45. package/canonical/skills/local-code/SKILL.md +2 -2
  46. package/canonical/skills/local-code/evals/cases.yaml +1 -1
  47. package/canonical/skills/report-formatting/SKILL.md +10 -2
  48. package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +3 -3
  49. package/canonical/skills/report-formatting/reference/HELPERS.md +10 -5
  50. package/canonical/skills/report-formatting/reference/RELEASE.md +1 -2
  51. package/canonical/skills/report-formatting/reference/TEMPLATES.md +3 -2
  52. package/canonical/skills/report-formatting/reference/authoring.md +25 -37
  53. package/canonical/skills/report-formatting/scripts/build-architecture.js +1 -1
  54. package/canonical/skills/report-formatting/scripts/build-md-claro.js +5 -8
  55. package/canonical/skills/report-formatting/scripts/check-env.js +0 -4
  56. package/canonical/skills/report-formatting/scripts/docmodel.js +3 -11
  57. package/canonical/skills/report-formatting/scripts/example-build.js +8 -10
  58. package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +8 -10
  59. package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +8 -10
  60. package/canonical/skills/report-formatting/scripts/theme-claro.js +39 -10
  61. package/canonical/skills/subagent-driven-development/SKILL.md +35 -28
  62. package/canonical/skills/subagent-driven-development/evals/cases.yaml +3496 -19
  63. package/canonical/skills/subagent-driven-development/reference/brief.md +185 -35
  64. package/canonical/skills/subagent-driven-development/reference/handoff.md +82 -2
  65. package/canonical/skills/subagent-driven-development/reference/loop.md +57 -0
  66. package/canonical/skills/subagent-driven-development/reference/trees.md +40 -0
  67. package/canonical/skills/subagent-driven-development/reference/workspace.md +31 -0
  68. package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +4771 -326
  69. package/dist/adapters/harness/claude-code.d.ts +19 -3
  70. package/dist/adapters/harness/claude-code.js +204 -39
  71. package/dist/adapters/harness/opencode.js +16 -2
  72. package/dist/adapters/harness/plugin.js +28 -17
  73. package/dist/adapters/memory/doctor.d.ts +174 -29
  74. package/dist/adapters/memory/doctor.js +758 -134
  75. package/dist/adapters/memory/migrate.d.ts +95 -0
  76. package/dist/adapters/memory/migrate.js +497 -0
  77. package/dist/adapters/memory/paths.d.ts +22 -0
  78. package/dist/adapters/memory/paths.js +85 -0
  79. package/dist/adapters/memory/vendored.d.ts +56 -29
  80. package/dist/adapters/memory/vendored.js +191 -154
  81. package/dist/adapters/sync/diff.js +4 -1
  82. package/dist/cli/cleanup-prompt.d.ts +35 -0
  83. package/dist/cli/cleanup-prompt.js +68 -0
  84. package/dist/cli/cleanup.d.ts +22 -24
  85. package/dist/cli/cleanup.js +182 -68
  86. package/dist/cli/cost-quota.d.ts +158 -0
  87. package/dist/cli/cost-quota.js +645 -0
  88. package/dist/cli/cost.d.ts +408 -46
  89. package/dist/cli/cost.js +1731 -57
  90. package/dist/cli/doctor.d.ts +52 -13
  91. package/dist/cli/doctor.js +193 -44
  92. package/dist/cli/hard-cleanup.d.ts +93 -0
  93. package/dist/cli/hard-cleanup.js +552 -0
  94. package/dist/cli/install-facts.d.ts +28 -0
  95. package/dist/cli/install-facts.js +66 -0
  96. package/dist/cli/json-key-edit.d.ts +18 -0
  97. package/dist/cli/json-key-edit.js +118 -0
  98. package/dist/cli/memory-import.d.ts +74 -0
  99. package/dist/cli/memory-import.js +469 -0
  100. package/dist/cli/plugin-validate.d.ts +12 -0
  101. package/dist/cli/plugin-validate.js +43 -0
  102. package/dist/cli/uninstall.d.ts +1 -1
  103. package/dist/cli/uninstall.js +2 -2
  104. package/dist/cli/upstream-launchers.d.ts +49 -0
  105. package/dist/cli/upstream-launchers.js +287 -0
  106. package/dist/index.js +247 -70
  107. package/dist/infra/fsutil.d.ts +31 -3
  108. package/dist/infra/fsutil.js +69 -6
  109. package/dist/infra/manifest.d.ts +2 -1
  110. package/dist/infra/manifest.js +3 -2
  111. package/dist/infra/prices.d.ts +18 -0
  112. package/dist/infra/prices.js +64 -0
  113. package/node_modules/@freeharness/core/dist/tiers.d.ts +10 -1
  114. package/node_modules/@freeharness/core/dist/tiers.js +24 -2
  115. package/node_modules/@freeharness/core/package.json +1 -1
  116. package/node_modules/@freeharness/sdd/PROVENANCE.yaml +279 -2
  117. package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +34 -8
  118. package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +7 -6
  119. package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +72 -64
  120. package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +48 -16
  121. package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +168 -70
  122. package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +36 -6
  123. package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +110 -49
  124. package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +1 -1
  125. package/node_modules/@freeharness/sdd/dist/src/shim/list.js +57 -14
  126. package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.d.ts +7 -0
  127. package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.js +118 -0
  128. package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +3 -1
  129. package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.d.ts +12 -0
  130. package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.js +94 -0
  131. package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +17 -0
  132. package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +182 -1
  133. package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +2 -2
  134. package/node_modules/@freeharness/sdd/dist/src/shim/status.js +34 -5
  135. package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +9 -0
  136. package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +49 -41
  137. package/node_modules/@freeharness/sdd/dist/src/shim/warnings.d.ts +21 -0
  138. package/node_modules/@freeharness/sdd/dist/src/shim/warnings.js +66 -0
  139. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.d.ts +4 -0
  140. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.js +17 -0
  141. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +95 -22
  142. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +6 -0
  143. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +14 -6
  144. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +1 -1
  145. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +1 -1
  146. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +3 -3
  147. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +24 -4
  148. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +4 -7
  149. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +13 -1
  150. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +21 -6
  151. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +80 -19
  152. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +42 -22
  153. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.d.ts +32 -0
  154. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.js +39 -0
  155. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +16 -9
  156. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +36 -1
  157. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +65 -30
  158. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +1 -1
  159. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +1 -1
  160. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +8 -13
  161. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +79 -102
  162. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +4 -9
  163. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +50 -10
  164. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +165 -37
  165. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +24 -3
  166. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +55 -8
  167. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +4 -1
  168. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +19 -9
  169. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +10 -0
  170. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +115 -0
  171. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +3 -3
  172. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +20 -20
  173. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +6 -0
  174. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +75 -14
  175. package/node_modules/@freeharness/sdd/package.json +1 -1
  176. package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +47 -7
  177. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +25 -7
  178. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +50 -11
  179. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +48 -10
  180. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +27 -7
  181. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +25 -12
  182. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +17 -5
  183. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +12 -1
  184. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +47 -33
  185. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +17 -5
  186. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +29 -1
  187. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +31 -13
  188. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +75 -27
  189. package/package.json +6 -4
  190. package/provenance/canonical.PROVENANCE.yaml +1 -1
  191. package/provenance/memory.PROVENANCE.yaml +458 -6
  192. package/provenance/sdd.PROVENANCE.yaml +279 -2
  193. package/canonical/hooks/freeharness-auto-memory.mjs +0 -158
  194. 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 = 200;
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
- /** Price row for `model`: exact key, else the longest prefix key, else `default`. */
50
- function priceFor(prices, model) {
51
- if (prices[model]) return prices[model];
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 ? prices[best] : prices.default;
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
- return per(u.input, row.input) + per(u.output, row.output) + per(u.cache_read, row.cache_read) + per(u.cache_write, row.cache_write);
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
- /** 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. */
72
- function sumTranscript(file) {
73
- const byModel = {};
74
- const byTool = {};
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 text;
82
- try {
83
- text = fs.readFileSync(file, "utf8");
84
- } catch {
85
- return { byModel, byTool, turns, lines, contextTokens, contextModel, toolOutputChars, toolCalls, found: false };
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
- const msg = rec && typeof rec === "object" ? rec.message : undefined;
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 (msg && Array.isArray(msg.content)) {
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 += Number(usage.cache_creation_input_tokens) || 0;
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 row = priceFor(prices, base || raw);
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
- /** Subagent transcripts at `<dirname>/<session-id>/subagents/agent-<name>-<hash>.jsonl`; empty when the directory is missing. */
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
- const m = /^agent-(.+)-[^-]+\.jsonl$/.exec(entry);
210
- if (!m) continue;
211
- out.push({ name: m[1], file: path.join(subDir, entry) });
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.name.localeCompare(b.name));
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
- /** Tags a session from the newest `*.brief.md`: `Change:`, `Task:`, `Tier:`, `Class:`, sanitized. No brief yields every field null and `task_class: "untagged"`. */
247
- export function briefTags(cwd) {
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
- walk(root);
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
- text = fs.readFileSync(newest, "utf8");
538
+ topEntries = fs.readdirSync(root, { withFileTypes: true });
283
539
  } catch {
284
- return empty;
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 subagent's dispatch name and agent type; the model always comes from the transcript, never this file's alias (`sonnet`, `opus`). */
345
- export function readAgentMeta(file) {
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(fs.readFileSync(file, "utf8"));
707
+ parsed = JSON.parse(text);
349
708
  } catch {
350
- return { name: null, agentType: null };
709
+ return { meta: empty, reason: "unreadable" };
351
710
  }
352
- const name = parsed && typeof parsed.name === "string" && parsed.name ? parsed.name : null;
353
- const agentType = parsed && typeof parsed.agentType === "string" && parsed.agentType ? parsed.agentType : null;
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 `agent-<candidate>-<hash>.jsonl` when no meta names it; kept only if it, or with one leading `a` stripped, matches the dispatch pattern. */
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
- const m = /^agent-(.+)-[^-]+\.jsonl$/.exec(base);
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/`, with change directory and mtime; empty when the root is absent. */
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
- /** Ledger file for a dispatch task: the change whose brief declares the id, newest when several match; else the newest brief's own change. */
439
- export function ledgerPathFor(cwd, task, tags) {
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
- let text;
445
- try {
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
- if (best) return path.join(root, best.change, "ledger.jsonl");
899
+ return best ? path.join(root, best.change, "ledger.jsonl") : null;
455
900
  }
456
- if (tags && tags.change && safeChangeSegment(root, tags.change)) return path.join(root, tags.change, "ledger.jsonl");
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
- /** Builds one dispatch ledger line, or a skip reason when the payload, transcript or brief is missing. Model is the transcript's dominant model, never the meta alias. */
461
- export function ledgerLine(payload, cwd, prices) {
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
- let size;
470
- try {
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 est = estimate(merged.usage, priceFor(prices, merged.model));
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
- const name = meta.name || fallbackDispatchName(path.basename(file));
485
- const agentType = meta.agentType ?? (typeof payload.agent_type === "string" && payload.agent_type ? payload.agent_type : null);
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 ledgerFile = ledgerPathFor(cwd, parsed.task, tags);
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
- late: false,
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
- /** Appends one ledger line; never creates the change directory (`ledgerPathFor` returns a path only once a brief lives there). */
519
- export function appendLedgerLine(file, line) {
520
- fs.appendFileSync(file, `${JSON.stringify(line)}\n`, "utf8");
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
- /** `agent_id`s already in a ledger file; unreadable or malformed lines contribute nothing rather than failing. */
524
- function readLedgerAgentIds(file) {
525
- const ids = new Set();
526
- let text;
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
- text = fs.readFileSync(file, "utf8");
529
- } catch {
530
- return ids;
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 whose `agent_id` never landed one (a crash or timeout can skip SubagentStop); failures push onto `snapshotWarnings`. */
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 recorded = recordedAgentIds(cwd);
1097
+ const root = projectRootFor(cwd) ?? cwd;
1098
+ const recorded = recordedAgentIds(root);
549
1099
  const done = new Set();
550
- for (const { file } of subagentTranscripts(transcriptPath)) {
551
- const m = /^agent-(.+)\.jsonl$/.exec(path.basename(file));
552
- if (!m) continue;
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: m[1], session_id: sessionId, cwd }, cwd, prices);
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 (recorded.has(result.line.agent_id)) continue;
558
- const line = { ...result.line, late: true };
559
- try {
560
- appendLedgerLine(result.file, line);
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
- } catch (e) {
564
- snapshotWarnings.push(`ledger backfill write failed: ${e instanceof Error ? e.message : String(e)}`);
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
- /** Counts dispatches per role, one distinct `agent_id` per dispatch so a resumed worker's second line adds nothing. */
587
- export function dispatchesByRole(cwd, sessionId) {
588
- const counts = {};
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 counts;
1155
+ return;
596
1156
  }
597
- const seenByRole = new Map();
598
1157
  for (const top of topEntries) {
599
1158
  if (!top.isDirectory()) continue;
600
- let text;
601
- try {
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 (!rec || rec.session !== sessionId || typeof rec.agent_id !== "string") continue;
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
- /** Memory database directory: `CLAUDE_MEM_DATA_DIR` when set, else `~/.claude-mem`. */
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
- const env = process.env.CLAUDE_MEM_DATA_DIR;
632
- if (env) return env.startsWith("~/") ? path.join(os.homedir(), env.slice(2)) : env;
633
- return path.join(os.homedir(), ".claude-mem");
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
- /** Observer discovery-token spend via a read-only `sqlite3` spawn; failures report as `{reason}`. Claude ids fold into subscription, others into `local`. */
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
- const db = path.join(dataDir, "claude-mem.db");
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 = String(sessionId).replace(/'/g, "''");
664
- const sql = `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;`;
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 claude = { tokens: 0, model: /** @type {string | null} */ (null), top: -1 };
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
- let text;
696
- try {
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
- /** Builds one telemetry record from the SessionEnd snapshot, brief tags and observer sink. An observer failure adds its reason to the warnings. */
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 snapshot.cwd === "string" ? snapshot.cwd : "", typeof snapshot.session_id === "string" ? snapshot.session_id : "");
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 one telemetry record when `.freeharness/` already exists; never creates it. A write failure lands as a snapshot warning. */
763
- function writeTelemetryIfPossible(record, payload, tags) {
764
- const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
765
- const dir = path.join(cwd, ".freeharness");
766
- let exists = false;
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
- exists = fs.statSync(dir).isDirectory();
769
- } catch {
770
- exists = false;
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.appendFileSync(path.join(dir, "telemetry.jsonl"), `${JSON.stringify(tel)}\n`, "utf8");
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
- const msg = `telemetry write failed: ${e instanceof Error ? e.message : String(e)}`;
782
- record.warnings = Array.isArray(record.warnings) ? [...record.warnings, msg] : [msg];
1488
+ return { ok: false, reason: `write-failed ${errCode(e)}` };
1489
+ } finally {
1490
+ fs.closeSync(fd);
783
1491
  }
784
1492
  }
785
1493
 
786
- /** Bytes the output pruner recovered: the sum of `{kind:"pruned"}` lines with this session id in the cost log. */
787
- export function prunedBytesFor(sessionId) {
788
- if (!sessionId) return 0;
789
- let text;
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
- text = fs.readFileSync(logPath(), "utf8");
1500
+ stat = fs.lstatSync(dir);
792
1501
  } catch {
793
- return 0;
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
- /** Builds the usage line for one hook invocation. `tags` is a param so `main()` computes it once and reuses it. */
809
- export function buildRecord(payload, tags) {
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: {}, byTool: {}, turns: 0, lines: 0, contextTokens: 0, contextModel: "", toolOutputChars: 0, toolCalls: 0, found: false };
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
- const byModel = {};
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 est = estimate(u, priceFor(prices, model));
1663
+ const match = priceMatch(prices, model);
1664
+ const est = estimate(u, match.row);
822
1665
  usd += est;
823
- byModel[model] = { ...u, est_usd: round(est) };
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 agent name pool per-model sums, merged once. A transcript over 64 MB or
833
- // past the 200th file is skipped with a warning: an unbounded scan would block the hook.
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 by_agent = {};
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
- for (const { name, file } of subagentFiles) {
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
- let size;
1703
+ const base = path.basename(file);
1704
+ let stat = null;
849
1705
  try {
850
- size = fs.statSync(file).size;
1706
+ stat = fs.lstatSync(file);
1707
+ if (!stat.isFile()) stat = null;
851
1708
  } catch {
852
- continue;
1709
+ // The read below reports the file as unusable.
853
1710
  }
854
- if (size > MAX_SUBAGENT_FILE_BYTES) {
855
- subagentsSkippedLarge += 1;
856
- continue;
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
- const acc = (perAgentModelSums[name] ??= {});
863
- for (const [model, u] of Object.entries(subSums.byModel)) {
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.messages += u.messages;
870
- subagentsUsd += estimate(u, priceFor(prices, model));
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: typeof payload.hook_event_name === "string" ? payload.hook_event_name : "Stop",
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
- fs.mkdirSync(path.dirname(file), { recursive: true });
964
- fs.appendFileSync(file, `${JSON.stringify(record)}\n`, "utf8");
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
- const result = ledgerLine(payload, cwd, prices);
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
- try {
989
- appendLedgerLine(result.file, line);
990
- } catch (e) {
991
- process.stderr.write(`freeharness-cost-tracker: ledger write failed: ${e instanceof Error ? e.message : String(e)}\n`);
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 project has no active brief to attribute the dispatch to; silent.
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 tags = briefTags(cwd);
1006
- const record = buildRecord(payload, tags);
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, payload, tags);
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.