freeharness 0.4.1

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 (375) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +108 -0
  3. package/README.md +134 -0
  4. package/canonical/HARVEST.yaml +858 -0
  5. package/canonical/PROVENANCE.yaml +740 -0
  6. package/canonical/agents/README.md +95 -0
  7. package/canonical/agents/agentic-architect.md +18 -0
  8. package/canonical/agents/database-reviewer.md +41 -0
  9. package/canonical/agents/explorer-ai-engineer-analyst.md +65 -0
  10. package/canonical/agents/pr-reviewer.md +96 -0
  11. package/canonical/agents/python-reviewer.md +55 -0
  12. package/canonical/agents/report-formatter.md +19 -0
  13. package/canonical/agents/security-reviewer.md +53 -0
  14. package/canonical/agents/test-writer.md +34 -0
  15. package/canonical/commands/fh-context.md +37 -0
  16. package/canonical/evals/promptfoo.redteam.yaml +108 -0
  17. package/canonical/hooks/cost-prices.json +14 -0
  18. package/canonical/hooks/freeharness-auto-memory.mjs +158 -0
  19. package/canonical/hooks/freeharness-config-guard.mjs +162 -0
  20. package/canonical/hooks/freeharness-context-monitor.mjs +198 -0
  21. package/canonical/hooks/freeharness-converge.mjs +227 -0
  22. package/canonical/hooks/freeharness-cost-tracker.mjs +1027 -0
  23. package/canonical/hooks/freeharness-memory-guard.mjs +57 -0
  24. package/canonical/hooks/freeharness-output-pruner.mjs +333 -0
  25. package/canonical/hooks/freeharness-precompact.mjs +323 -0
  26. package/canonical/hooks/freeharness-repeat-tool.mjs +208 -0
  27. package/canonical/hooks/freeharness-security-guidance.mjs +164 -0
  28. package/canonical/hooks/freeharness-session-start +583 -0
  29. package/canonical/hooks/hooks.auto-memory.claude-code.json +16 -0
  30. package/canonical/hooks/hooks.claude-code.json +17 -0
  31. package/canonical/hooks/hooks.config-guard.claude-code.json +16 -0
  32. package/canonical/hooks/hooks.context-monitor.claude-code.json +15 -0
  33. package/canonical/hooks/hooks.converge.claude-code.json +15 -0
  34. package/canonical/hooks/hooks.cost-tracker.claude-code.json +37 -0
  35. package/canonical/hooks/hooks.memory-guard.claude-code.json +16 -0
  36. package/canonical/hooks/hooks.output-pruner.claude-code.json +16 -0
  37. package/canonical/hooks/hooks.precompact.claude-code.json +15 -0
  38. package/canonical/hooks/hooks.repeat-tool.claude-code.json +25 -0
  39. package/canonical/hooks/hooks.security-guidance.claude-code.json +16 -0
  40. package/canonical/mcp/servers.json +20 -0
  41. package/canonical/openspec/config.base.yaml +89 -0
  42. package/canonical/openspec/schema.spec-driven.yaml +277 -0
  43. package/canonical/openspec/templates/data-contract.md +36 -0
  44. package/canonical/openspec/templates/design.md +84 -0
  45. package/canonical/openspec/templates/eval-plan.md +33 -0
  46. package/canonical/openspec/templates/follow-ups.md +2 -0
  47. package/canonical/openspec/templates/proposal.md +45 -0
  48. package/canonical/openspec/templates/spec.md +42 -0
  49. package/canonical/openspec/templates/tasks.md +38 -0
  50. package/canonical/profiles/local-llamacpp.LOCAL-MODELS.md +47 -0
  51. package/canonical/profiles/local-llamacpp.json +27 -0
  52. package/canonical/profiles/local-ollama.json +20 -0
  53. package/canonical/profiles/mcp.json +91 -0
  54. package/canonical/rules/CLAUDE.md +78 -0
  55. package/canonical/rules/project-CLAUDE.template.md +62 -0
  56. package/canonical/rules/routing.md +20 -0
  57. package/canonical/rules/safety.md +41 -0
  58. package/canonical/settings/settings.cache-hygiene.claude-code.json +5 -0
  59. package/canonical/skills/agent-evaluation/SKILL.md +102 -0
  60. package/canonical/skills/agent-evaluation/assets/evaluation_report_template.md +206 -0
  61. package/canonical/skills/agent-evaluation/evals/cases.yaml +106 -0
  62. package/canonical/skills/agent-evaluation/references/dataset-preparation.md +351 -0
  63. package/canonical/skills/agent-evaluation/references/scorers-constraints.md +165 -0
  64. package/canonical/skills/agent-evaluation/references/scorers.md +431 -0
  65. package/canonical/skills/agent-evaluation/references/setup-guide.md +252 -0
  66. package/canonical/skills/agent-evaluation/references/tracing-integration.md +164 -0
  67. package/canonical/skills/agent-evaluation/references/troubleshooting.md +514 -0
  68. package/canonical/skills/agent-evaluation/scripts/analyze_results.py +462 -0
  69. package/canonical/skills/agent-evaluation/scripts/create_dataset_template.py +382 -0
  70. package/canonical/skills/agent-evaluation/scripts/list_datasets.py +273 -0
  71. package/canonical/skills/agent-evaluation/scripts/run_evaluation_template.py +309 -0
  72. package/canonical/skills/agent-evaluation/scripts/setup_mlflow.py +353 -0
  73. package/canonical/skills/agent-evaluation/scripts/utils/__init__.py +27 -0
  74. package/canonical/skills/agent-evaluation/scripts/utils/env_validation.py +123 -0
  75. package/canonical/skills/agent-evaluation/scripts/utils/tracing_utils.py +108 -0
  76. package/canonical/skills/agent-evaluation/scripts/validate_agent_tracing.py +148 -0
  77. package/canonical/skills/agent-evaluation/scripts/validate_auth.py +240 -0
  78. package/canonical/skills/agent-evaluation/scripts/validate_environment.py +185 -0
  79. package/canonical/skills/agent-evaluation/scripts/validate_tracing_runtime.py +318 -0
  80. package/canonical/skills/agentic-architecture/SKILL.md +41 -0
  81. package/canonical/skills/agentic-architecture/evals/cases.yaml +94 -0
  82. package/canonical/skills/agentic-architecture/reference/context-engineering.md +75 -0
  83. package/canonical/skills/agentic-architecture/reference/patterns.md +76 -0
  84. package/canonical/skills/agentic-architecture/reference/reliability-observability.md +94 -0
  85. package/canonical/skills/agentic-architecture/reference/review-framework.md +59 -0
  86. package/canonical/skills/code-review/SKILL.md +52 -0
  87. package/canonical/skills/code-review/evals/cases.yaml +197 -0
  88. package/canonical/skills/git-workflow/SKILL.md +41 -0
  89. package/canonical/skills/git-workflow/evals/cases.yaml +92 -0
  90. package/canonical/skills/local-code/SKILL.md +24 -0
  91. package/canonical/skills/local-code/evals/cases.yaml +85 -0
  92. package/canonical/skills/local-code/scripts/query_llama.py +68 -0
  93. package/canonical/skills/report-formatting/SKILL.md +62 -0
  94. package/canonical/skills/report-formatting/evals/cases.yaml +83 -0
  95. package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +135 -0
  96. package/canonical/skills/report-formatting/reference/HELPERS.md +120 -0
  97. package/canonical/skills/report-formatting/reference/PPTX.md +136 -0
  98. package/canonical/skills/report-formatting/reference/RELEASE.md +99 -0
  99. package/canonical/skills/report-formatting/reference/TEMPLATES.md +48 -0
  100. package/canonical/skills/report-formatting/reference/authoring.md +66 -0
  101. package/canonical/skills/report-formatting/scripts/architecture-template.md +271 -0
  102. package/canonical/skills/report-formatting/scripts/assets/claro-logo.png +0 -0
  103. package/canonical/skills/report-formatting/scripts/assets/claro-wordmark.svg +1 -0
  104. package/canonical/skills/report-formatting/scripts/build-architecture.js +57 -0
  105. package/canonical/skills/report-formatting/scripts/build-md-claro.js +224 -0
  106. package/canonical/skills/report-formatting/scripts/build-release.js +51 -0
  107. package/canonical/skills/report-formatting/scripts/check-env.js +58 -0
  108. package/canonical/skills/report-formatting/scripts/docmodel.js +283 -0
  109. package/canonical/skills/report-formatting/scripts/example-build.js +119 -0
  110. package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +195 -0
  111. package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +114 -0
  112. package/canonical/skills/report-formatting/scripts/package.json +17 -0
  113. package/canonical/skills/report-formatting/scripts/pptx/example_build_StatusReport_pptx.py +223 -0
  114. package/canonical/skills/report-formatting/scripts/pptx/example_build_WeeklyStatus_pptx.py +245 -0
  115. package/canonical/skills/report-formatting/scripts/pptx/pptx_office.py +144 -0
  116. package/canonical/skills/report-formatting/scripts/pptx/requirements.txt +7 -0
  117. package/canonical/skills/report-formatting/scripts/pptx/status_report_blocks.py +193 -0
  118. package/canonical/skills/report-formatting/scripts/pptx/theme_claro_pptx.py +473 -0
  119. package/canonical/skills/report-formatting/scripts/release-template.md +80 -0
  120. package/canonical/skills/report-formatting/scripts/render-mermaid.js +105 -0
  121. package/canonical/skills/report-formatting/scripts/theme-claro.js +403 -0
  122. package/canonical/skills/subagent-driven-development/SKILL.md +60 -0
  123. package/canonical/skills/subagent-driven-development/evals/cases.yaml +208 -0
  124. package/canonical/skills/subagent-driven-development/reference/brief.md +91 -0
  125. package/canonical/skills/subagent-driven-development/reference/handoff.md +118 -0
  126. package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +850 -0
  127. package/canonical/skills/systematic-debugging/SKILL.md +44 -0
  128. package/canonical/skills/systematic-debugging/evals/cases.yaml +90 -0
  129. package/canonical/skills/test-driven-development/SKILL.md +42 -0
  130. package/canonical/skills/test-driven-development/evals/cases.yaml +86 -0
  131. package/canonical/skills/test-driven-development/reference/writing-good-tests.md +52 -0
  132. package/canonical/skills/verification-before-completion/SKILL.md +53 -0
  133. package/canonical/skills/verification-before-completion/evals/cases.yaml +107 -0
  134. package/dist/adapters/content/language-lint.d.ts +46 -0
  135. package/dist/adapters/content/language-lint.js +191 -0
  136. package/dist/adapters/harness/claude-code.d.ts +57 -0
  137. package/dist/adapters/harness/claude-code.js +385 -0
  138. package/dist/adapters/harness/index.d.ts +3 -0
  139. package/dist/adapters/harness/index.js +17 -0
  140. package/dist/adapters/harness/opencode.d.ts +7 -0
  141. package/dist/adapters/harness/opencode.js +166 -0
  142. package/dist/adapters/harness/plugin.d.ts +22 -0
  143. package/dist/adapters/harness/plugin.js +257 -0
  144. package/dist/adapters/memory/doctor.d.ts +480 -0
  145. package/dist/adapters/memory/doctor.js +1217 -0
  146. package/dist/adapters/memory/vendored.d.ts +150 -0
  147. package/dist/adapters/memory/vendored.js +487 -0
  148. package/dist/adapters/package/node.d.ts +9 -0
  149. package/dist/adapters/package/node.js +20 -0
  150. package/dist/adapters/prompt/readline.d.ts +19 -0
  151. package/dist/adapters/prompt/readline.js +53 -0
  152. package/dist/adapters/sync/diff.d.ts +43 -0
  153. package/dist/adapters/sync/diff.js +380 -0
  154. package/dist/cli/cleanup.d.ts +86 -0
  155. package/dist/cli/cleanup.js +382 -0
  156. package/dist/cli/cost.d.ts +304 -0
  157. package/dist/cli/cost.js +501 -0
  158. package/dist/cli/doctor.d.ts +205 -0
  159. package/dist/cli/doctor.js +654 -0
  160. package/dist/cli/executor.d.ts +5 -0
  161. package/dist/cli/executor.js +48 -0
  162. package/dist/cli/plugin-validate.d.ts +37 -0
  163. package/dist/cli/plugin-validate.js +256 -0
  164. package/dist/cli/prefix.d.ts +68 -0
  165. package/dist/cli/prefix.js +218 -0
  166. package/dist/cli/profile.d.ts +170 -0
  167. package/dist/cli/profile.js +285 -0
  168. package/dist/cli/runner.d.ts +10 -0
  169. package/dist/cli/runner.js +27 -0
  170. package/dist/cli/uninstall.d.ts +65 -0
  171. package/dist/cli/uninstall.js +147 -0
  172. package/dist/index.d.ts +8 -0
  173. package/dist/index.js +989 -0
  174. package/dist/infra/dogfood.d.ts +31 -0
  175. package/dist/infra/dogfood.js +51 -0
  176. package/dist/infra/fsutil.d.ts +40 -0
  177. package/dist/infra/fsutil.js +143 -0
  178. package/dist/infra/manifest.d.ts +76 -0
  179. package/dist/infra/manifest.js +92 -0
  180. package/dist/infra/paths.d.ts +19 -0
  181. package/dist/infra/paths.js +74 -0
  182. package/dist/infra/routing.d.ts +4 -0
  183. package/dist/infra/routing.js +7 -0
  184. package/node_modules/@freeharness/core/dist/context.d.ts +30 -0
  185. package/node_modules/@freeharness/core/dist/context.js +1 -0
  186. package/node_modules/@freeharness/core/dist/index.d.ts +5 -0
  187. package/node_modules/@freeharness/core/dist/index.js +6 -0
  188. package/node_modules/@freeharness/core/dist/planner.d.ts +13 -0
  189. package/node_modules/@freeharness/core/dist/planner.js +18 -0
  190. package/node_modules/@freeharness/core/dist/ports.d.ts +90 -0
  191. package/node_modules/@freeharness/core/dist/ports.js +1 -0
  192. package/node_modules/@freeharness/core/dist/registry.d.ts +41 -0
  193. package/node_modules/@freeharness/core/dist/registry.js +124 -0
  194. package/node_modules/@freeharness/core/dist/tiers.d.ts +30 -0
  195. package/node_modules/@freeharness/core/dist/tiers.js +99 -0
  196. package/node_modules/@freeharness/core/package.json +34 -0
  197. package/node_modules/@freeharness/sdd/PROVENANCE.yaml +175 -0
  198. package/node_modules/@freeharness/sdd/dist/src/bin/openspec.d.ts +2 -0
  199. package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +166 -0
  200. package/node_modules/@freeharness/sdd/dist/src/index.d.ts +14 -0
  201. package/node_modules/@freeharness/sdd/dist/src/index.js +11 -0
  202. package/node_modules/@freeharness/sdd/dist/src/init.d.ts +39 -0
  203. package/node_modules/@freeharness/sdd/dist/src/init.js +190 -0
  204. package/node_modules/@freeharness/sdd/dist/src/root-resolver.d.ts +8 -0
  205. package/node_modules/@freeharness/sdd/dist/src/root-resolver.js +32 -0
  206. package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +81 -0
  207. package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +483 -0
  208. package/node_modules/@freeharness/sdd/dist/src/shim/context.d.ts +4 -0
  209. package/node_modules/@freeharness/sdd/dist/src/shim/context.js +48 -0
  210. package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +57 -0
  211. package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +279 -0
  212. package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +67 -0
  213. package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +460 -0
  214. package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +5 -0
  215. package/node_modules/@freeharness/sdd/dist/src/shim/list.js +95 -0
  216. package/node_modules/@freeharness/sdd/dist/src/shim/new-change.d.ts +6 -0
  217. package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +150 -0
  218. package/node_modules/@freeharness/sdd/dist/src/shim/schemas.d.ts +4 -0
  219. package/node_modules/@freeharness/sdd/dist/src/shim/schemas.js +23 -0
  220. package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +56 -0
  221. package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +140 -0
  222. package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +8 -0
  223. package/node_modules/@freeharness/sdd/dist/src/shim/status.js +85 -0
  224. package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +6 -0
  225. package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +229 -0
  226. package/node_modules/@freeharness/sdd/dist/src/validate-change.d.ts +40 -0
  227. package/node_modules/@freeharness/sdd/dist/src/validate-change.js +114 -0
  228. package/node_modules/@freeharness/sdd/dist/src/validate.d.ts +24 -0
  229. package/node_modules/@freeharness/sdd/dist/src/validate.js +72 -0
  230. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.d.ts +130 -0
  231. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +981 -0
  232. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-metadata.d.ts +46 -0
  233. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-metadata.js +128 -0
  234. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +26 -0
  235. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +32 -0
  236. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/file-system.d.ts +50 -0
  237. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/file-system.js +394 -0
  238. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/global-config.d.ts +15 -0
  239. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/global-config.js +49 -0
  240. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/graph.d.ts +71 -0
  241. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/graph.js +164 -0
  242. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +11 -0
  243. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +21 -0
  244. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/instruction-loader.d.ts +102 -0
  245. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/instruction-loader.js +230 -0
  246. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +18 -0
  247. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +138 -0
  248. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/resolver.d.ts +82 -0
  249. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/resolver.js +333 -0
  250. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/schema.d.ts +12 -0
  251. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/schema.js +110 -0
  252. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/state.d.ts +11 -0
  253. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/state.js +32 -0
  254. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.d.ts +33 -0
  255. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +149 -0
  256. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.d.ts +39 -0
  257. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +49 -0
  258. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/claude.d.ts +12 -0
  259. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/claude.js +31 -0
  260. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/index.d.ts +10 -0
  261. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/index.js +10 -0
  262. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/opencode.d.ts +12 -0
  263. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/opencode.js +26 -0
  264. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/generator.d.ts +28 -0
  265. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/generator.js +41 -0
  266. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/index.d.ts +23 -0
  267. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/index.js +25 -0
  268. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/invocation.d.ts +72 -0
  269. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/invocation.js +76 -0
  270. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/registry.d.ts +39 -0
  271. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/registry.js +53 -0
  272. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/types.d.ts +62 -0
  273. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/types.js +7 -0
  274. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/yaml.d.ts +35 -0
  275. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/yaml.js +47 -0
  276. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-references.d.ts +33 -0
  277. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-references.js +55 -0
  278. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/shared/allowed-tools.d.ts +11 -0
  279. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/shared/allowed-tools.js +11 -0
  280. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/shared/skill-generation.d.ts +48 -0
  281. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/shared/skill-generation.js +99 -0
  282. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/skill-templates.d.ts +19 -0
  283. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/skill-templates.js +18 -0
  284. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/types.d.ts +18 -0
  285. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/types.js +4 -0
  286. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.d.ts +19 -0
  287. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +207 -0
  288. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.d.ts +12 -0
  289. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +409 -0
  290. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/bulk-archive-change.d.ts +9 -0
  291. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/bulk-archive-change.js +673 -0
  292. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/continue-change.d.ts +9 -0
  293. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/continue-change.js +230 -0
  294. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.d.ts +12 -0
  295. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +550 -0
  296. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/feedback.d.ts +8 -0
  297. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/feedback.js +107 -0
  298. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/ff-change.d.ts +12 -0
  299. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/ff-change.js +228 -0
  300. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/new-change.d.ts +9 -0
  301. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/new-change.js +147 -0
  302. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/onboard.d.ts +9 -0
  303. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/onboard.js +571 -0
  304. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.d.ts +12 -0
  305. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +322 -0
  306. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/store-selection.d.ts +7 -0
  307. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/store-selection.js +7 -0
  308. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.d.ts +9 -0
  309. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +518 -0
  310. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.d.ts +9 -0
  311. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +176 -0
  312. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/verify-change.d.ts +9 -0
  313. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/verify-change.js +344 -0
  314. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +39 -0
  315. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +54 -0
  316. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/delta-resolution.d.ts +35 -0
  317. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/delta-resolution.js +171 -0
  318. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +27 -0
  319. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +210 -0
  320. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/code-fence.d.ts +14 -0
  321. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/code-fence.js +48 -0
  322. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.d.ts +22 -0
  323. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +186 -0
  324. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +65 -0
  325. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +409 -0
  326. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +55 -0
  327. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +98 -0
  328. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.d.ts +8 -0
  329. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +77 -0
  330. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.d.ts +44 -0
  331. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +116 -0
  332. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/base.schema.d.ts +12 -0
  333. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/base.schema.js +17 -0
  334. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/change.schema.d.ts +72 -0
  335. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/change.schema.js +30 -0
  336. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/index.d.ts +3 -0
  337. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/index.js +3 -0
  338. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/spec.schema.d.ts +17 -0
  339. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/spec.schema.js +14 -0
  340. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +39 -0
  341. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +114 -0
  342. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +45 -0
  343. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +105 -0
  344. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-numbering.d.ts +15 -0
  345. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-numbering.js +56 -0
  346. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/types.d.ts +17 -0
  347. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/types.js +1 -0
  348. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +94 -0
  349. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +756 -0
  350. package/node_modules/@freeharness/sdd/package.json +57 -0
  351. package/node_modules/@freeharness/sdd/vendor/openspec/LICENSE.upstream +22 -0
  352. package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +232 -0
  353. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/README.md +19 -0
  354. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +187 -0
  355. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +181 -0
  356. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +338 -0
  357. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +117 -0
  358. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +342 -0
  359. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +116 -0
  360. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +76 -0
  361. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +560 -0
  362. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +162 -0
  363. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +261 -0
  364. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +90 -0
  365. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +174 -0
  366. package/package.json +70 -0
  367. package/provenance/canonical.PROVENANCE.yaml +740 -0
  368. package/provenance/claude-mem.LICENSE.upstream +202 -0
  369. package/provenance/memory.PROVENANCE.yaml +399 -0
  370. package/provenance/sdd.PROVENANCE.yaml +175 -0
  371. package/vendor-licenses/caveman.LICENSE +27 -0
  372. package/vendor-licenses/deepseek-harness.LICENSE +21 -0
  373. package/vendor-licenses/ecc.LICENSE +21 -0
  374. package/vendor-licenses/pi.LICENSE +21 -0
  375. package/vendor-licenses/superpowers.LICENSE +21 -0
@@ -0,0 +1,1027 @@
1
+ // FreeHarness cost tracker (Claude Code Stop / SessionEnd / SubagentStop hook).
2
+ // Sums transcript token usage, prices it with cost-prices.json, and appends a cumulative
3
+ // usage.jsonl snapshot on Stop/SessionEnd (last snapshot per session wins, so firing on every
4
+ // Stop never double counts) plus a per-project telemetry.jsonl record on SessionEnd. On
5
+ // SubagentStop it appends one line per finished subagent to a change's ledger.jsonl instead.
6
+ // Never blocks: exit 0 on every path. Snapshot fields are additive-only once shipped.
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ /** Per-session subagent scan bounds (never read an unbounded directory). */
16
+ const MAX_SUBAGENT_FILE_BYTES = 64 * 1024 * 1024;
17
+ const MAX_SUBAGENT_FILES = 200;
18
+
19
+ /** Usage log path: FREEHARNESS_COST_LOG override, else ~/.claude/freeharness/cost/usage.jsonl. */
20
+ function logPath() {
21
+ return process.env.FREEHARNESS_COST_LOG || path.join(os.homedir(), ".claude", "freeharness", "cost", "usage.jsonl");
22
+ }
23
+
24
+ /** Price table path: FREEHARNESS_COST_PRICES override, else cost-prices.json next to this script. */
25
+ function pricesPath() {
26
+ return process.env.FREEHARNESS_COST_PRICES || path.join(HERE, "cost-prices.json");
27
+ }
28
+
29
+ /** All of stdin as text; empty when stdin is closed or not a pipe. */
30
+ function readStdin() {
31
+ try {
32
+ return fs.readFileSync(0, "utf8");
33
+ } catch {
34
+ return "";
35
+ }
36
+ }
37
+
38
+ /** Price table from `file`; missing or malformed yields an empty table (estimate degrades to 0 USD, never fails). */
39
+ function loadPrices(file) {
40
+ try {
41
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
42
+ const prices = parsed && typeof parsed.prices === "object" && parsed.prices ? parsed.prices : {};
43
+ return { prices, ok: Object.keys(prices).length > 0 };
44
+ } catch {
45
+ return { prices: {}, ok: false };
46
+ }
47
+ }
48
+
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];
52
+ let best;
53
+ for (const key of Object.keys(prices)) {
54
+ if (key !== "default" && model.startsWith(key) && (!best || key.length > best.length)) best = key;
55
+ }
56
+ return best ? prices[best] : prices.default;
57
+ }
58
+
59
+ /** Estimated USD for one bucket at `row`'s per-MTok prices (0 when no row). */
60
+ function estimate(u, row) {
61
+ 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);
64
+ }
65
+
66
+ /** @returns {{input:number,output:number,cache_read:number,cache_write:number,messages:number}} Zeroed bucket. */
67
+ function bucket() {
68
+ return { input: 0, output: 0, cache_read: 0, cache_write: 0, messages: 0 };
69
+ }
70
+
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 = {};
75
+ let turns = 0;
76
+ let lines = 0;
77
+ let contextTokens = 0;
78
+ let contextModel = "";
79
+ let toolOutputChars = 0;
80
+ 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
+ }
87
+ const seen = new Map(); // message.id -> { model, usage }
88
+ const toolNames = new Map(); // tool_use id -> name
89
+ let anon = 0;
90
+ for (const raw of text.split("\n")) {
91
+ if (!raw.trim()) continue;
92
+ lines += 1;
93
+ let rec;
94
+ try {
95
+ rec = JSON.parse(raw);
96
+ } catch {
97
+ continue;
98
+ }
99
+ const msg = rec && typeof rec === "object" ? rec.message : undefined;
100
+ if (rec.type === "user" && msg) {
101
+ const content = msg.content;
102
+ const isToolResult = Array.isArray(content) && content.length > 0 && content.every((c) => c && c.type === "tool_result");
103
+ if (!isToolResult) {
104
+ turns += 1;
105
+ continue;
106
+ }
107
+ // Tool output size, attributed to the tool that produced it (chars; ~4 chars per token).
108
+ for (const c of content) {
109
+ const chars = resultChars(c.content);
110
+ toolOutputChars += chars;
111
+ const name = toolNames.get(c.tool_use_id) || "unknown";
112
+ const t = (byTool[name] ??= { calls: 0, output_chars: 0 });
113
+ t.output_chars += chars;
114
+ }
115
+ continue;
116
+ }
117
+ if (msg && Array.isArray(msg.content)) {
118
+ for (const c of msg.content) {
119
+ if (c && c.type === "tool_use" && typeof c.name === "string") {
120
+ toolCalls += 1;
121
+ if (typeof c.id === "string") toolNames.set(c.id, c.name);
122
+ const t = (byTool[c.name] ??= { calls: 0, output_chars: 0 });
123
+ t.calls += 1;
124
+ }
125
+ }
126
+ }
127
+ if (!msg || !msg.usage || typeof msg.usage !== "object") continue;
128
+ const id = typeof msg.id === "string" && msg.id ? msg.id : `anon-${anon++}`;
129
+ const model = typeof msg.model === "string" && msg.model ? msg.model : "unknown";
130
+ seen.set(id, { model, usage: msg.usage });
131
+ // Prompt size of the latest assistant message = current context; a zero-usage trailing
132
+ // <synthetic> message never resets an already-observed context size.
133
+ const promptSize = (Number(msg.usage.input_tokens) || 0) + (Number(msg.usage.cache_read_input_tokens) || 0) + (Number(msg.usage.cache_creation_input_tokens) || 0);
134
+ if (promptSize > 0) {
135
+ contextTokens = promptSize;
136
+ contextModel = model;
137
+ }
138
+ }
139
+ for (const { model, usage } of seen.values()) {
140
+ const b = (byModel[model] ??= bucket());
141
+ b.input += Number(usage.input_tokens) || 0;
142
+ b.output += Number(usage.output_tokens) || 0;
143
+ b.cache_read += Number(usage.cache_read_input_tokens) || 0;
144
+ b.cache_write += Number(usage.cache_creation_input_tokens) || 0;
145
+ b.messages += 1;
146
+ }
147
+ return { byModel, byTool, turns, lines, contextTokens, contextModel, toolOutputChars, toolCalls, found: true };
148
+ }
149
+
150
+ /** Character count of a tool_result content. */
151
+ function resultChars(content) {
152
+ if (typeof content === "string") return content.length;
153
+ if (!Array.isArray(content)) return 0;
154
+ let n = 0;
155
+ for (const b of content) if (b && typeof b.text === "string") n += b.text.length;
156
+ return n;
157
+ }
158
+
159
+ /** The `[suffix]` tag trailing a model id (e.g. `claude-fable-5-1[1m]` -> `"1m"`), or null. */
160
+ function modelSuffix(model) {
161
+ const m = /\[([^\]]+)\]$/.exec(typeof model === "string" ? model : "");
162
+ return m ? m[1] : null;
163
+ }
164
+
165
+ /** Token window: an explicit `[1m]` suffix means 1,000,000, else the price row's `window`, defaulting to 200,000. */
166
+ export function windowFor(model, prices) {
167
+ const raw = typeof model === "string" ? model : "";
168
+ if (/\[1m\]$/i.test(raw)) return 1_000_000;
169
+ const base = raw.replace(/\[[^\]]*\]$/, "");
170
+ const row = priceFor(prices, base || raw);
171
+ const w = row && Number.isInteger(row.window) && row.window > 0 ? row.window : null;
172
+ return w ?? 200_000;
173
+ }
174
+
175
+ /** User's Claude Code config directory: `CLAUDE_CONFIG_DIR` override, else `~/.claude`. */
176
+ function userConfigDir() {
177
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
178
+ }
179
+
180
+ /** settings.json's `model` field, or null when absent, unreadable or unset. */
181
+ function settingsModel(file) {
182
+ try {
183
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
184
+ return parsed && typeof parsed.model === "string" && parsed.model ? parsed.model : null;
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ /** Configured model id: project `.claude/settings.json` `model`, else the user's. The transcript's own id is always bare; a suffix is visible only here. */
191
+ function configuredModel(cwd) {
192
+ return settingsModel(path.join(cwd, ".claude", "settings.json")) ?? settingsModel(path.join(userConfigDir(), "settings.json"));
193
+ }
194
+
195
+ /** Subagent transcripts at `<dirname>/<session-id>/subagents/agent-<name>-<hash>.jsonl`; empty when the directory is missing. */
196
+ export function subagentTranscripts(transcriptPath) {
197
+ if (!transcriptPath) return [];
198
+ const dir = path.dirname(transcriptPath);
199
+ const sessionId = path.basename(transcriptPath, path.extname(transcriptPath));
200
+ const subDir = path.join(dir, sessionId, "subagents");
201
+ let entries;
202
+ try {
203
+ entries = fs.readdirSync(subDir);
204
+ } catch {
205
+ return [];
206
+ }
207
+ const out = [];
208
+ 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) });
212
+ }
213
+ return out.sort((a, b) => a.name.localeCompare(b.name));
214
+ }
215
+
216
+ /** Merges per-model sums into one `by_agent` bucket, picking the model with the most tokens (ties break alphabetically). */
217
+ function mergeAgentUsage(byModel) {
218
+ const total = bucket();
219
+ let model = "unknown";
220
+ let bestTotal = -1;
221
+ for (const key of Object.keys(byModel).sort()) {
222
+ const u = byModel[key];
223
+ total.input += u.input;
224
+ total.output += u.output;
225
+ total.cache_read += u.cache_read;
226
+ total.cache_write += u.cache_write;
227
+ total.messages += u.messages;
228
+ const t = u.input + u.output + u.cache_read + u.cache_write;
229
+ if (t > bestTotal) {
230
+ bestTotal = t;
231
+ model = key;
232
+ }
233
+ }
234
+ return {
235
+ 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 },
237
+ messages: total.messages,
238
+ };
239
+ }
240
+
241
+ /** Sanitizes a brief-header tag: trims, keeps only `[\w.\-/ ,()]`, caps at 64 chars. The brief is untrusted input to the telemetry tail. */
242
+ function sanitizeTag(raw) {
243
+ return String(raw).trim().replace(/[^\w.\-/ ,()]/g, "").slice(0, 64).trim();
244
+ }
245
+
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) {
248
+ const root = path.join(cwd, ".freeharness", "sdd");
249
+ let newest = null;
250
+ let newestMtime = -Infinity;
251
+ const walk = (dir) => {
252
+ let entries;
253
+ try {
254
+ entries = fs.readdirSync(dir, { withFileTypes: true });
255
+ } catch {
256
+ return;
257
+ }
258
+ for (const entry of entries) {
259
+ const full = path.join(dir, entry.name);
260
+ if (entry.isDirectory()) {
261
+ walk(full);
262
+ continue;
263
+ }
264
+ if (!entry.isFile() || !entry.name.endsWith(".brief.md")) continue;
265
+ let stat;
266
+ try {
267
+ stat = fs.statSync(full);
268
+ } catch {
269
+ continue;
270
+ }
271
+ if (stat.mtimeMs > newestMtime || (stat.mtimeMs === newestMtime && (!newest || full > newest))) {
272
+ newestMtime = stat.mtimeMs;
273
+ newest = full;
274
+ }
275
+ }
276
+ };
277
+ walk(root);
278
+ const empty = { change: null, task: null, tier: null, task_class: "untagged" };
279
+ if (!newest) return empty;
280
+ let text;
281
+ try {
282
+ text = fs.readFileSync(newest, "utf8");
283
+ } catch {
284
+ return empty;
285
+ }
286
+ const field = (key) => {
287
+ const m = new RegExp(`^${key}:\\s*(.+)$`, "m").exec(text);
288
+ return m ? sanitizeTag(m[1]) : null;
289
+ };
290
+ const cls = field("Class");
291
+ return { change: field("Change"), task: field("Task"), tier: field("Tier"), task_class: cls || "untagged" };
292
+ }
293
+
294
+ /** `<role>-<task>[-r<round>][-<slug>]` dispatch name pattern; the slug distinguishes parallel reviewers of one round independent of the round number. */
295
+ const DISPATCH_RE = /^(impl|review|fix)-(\d+(?:_\d+)*b?|final)(?:-r(\d+))?(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?$/;
296
+
297
+ /** Safe SubagentStop `agent_id` shape: no `/`, so it can never introduce a path segment. */
298
+ const AGENT_ID_RE = /^[A-Za-z0-9_.-]+$/;
299
+
300
+ /** Whether `target` sits strictly inside `dir`: false for `dir` itself or any escape. */
301
+ function containedIn(dir, target) {
302
+ const rel = path.relative(dir, target);
303
+ return Boolean(rel) && !path.isAbsolute(rel) && rel.split(path.sep)[0] !== "..";
304
+ }
305
+
306
+ /** The real path of `p`, or `p` itself when it cannot be resolved. */
307
+ function realPathOr(p) {
308
+ try {
309
+ return fs.realpathSync(p);
310
+ } catch {
311
+ return p;
312
+ }
313
+ }
314
+
315
+ /** 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
+ export function subagentTranscriptFor(transcriptPath, agentId, payload) {
317
+ if (!transcriptPath || !agentId || !AGENT_ID_RE.test(agentId)) return null;
318
+ const dir = path.dirname(transcriptPath);
319
+ const sessionId = path.basename(transcriptPath, path.extname(transcriptPath));
320
+ const subagentsDir = path.resolve(path.join(dir, sessionId, "subagents"));
321
+ const override = payload && typeof payload.agent_transcript_path === "string" && payload.agent_transcript_path ? payload.agent_transcript_path : null;
322
+ const candidate = override
323
+ ? path.isAbsolute(override)
324
+ ? path.resolve(override)
325
+ : path.resolve(subagentsDir, override)
326
+ : path.join(subagentsDir, `agent-${agentId}.jsonl`);
327
+ if (!containedIn(subagentsDir, candidate)) return null;
328
+ let real;
329
+ try {
330
+ real = fs.realpathSync(candidate);
331
+ } catch {
332
+ // Not on disk: the lexical fence above is the whole answer, and the caller reports the miss.
333
+ return candidate;
334
+ }
335
+ return containedIn(realPathOr(subagentsDir), real) ? candidate : null;
336
+ }
337
+
338
+ /** The `.meta.json` path next to a subagent transcript: the same path with its extension replaced. */
339
+ function agentMetaPathFor(transcriptFile) {
340
+ const ext = path.extname(transcriptFile);
341
+ return `${ext ? transcriptFile.slice(0, -ext.length) : transcriptFile}.meta.json`;
342
+ }
343
+
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) {
346
+ let parsed;
347
+ try {
348
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
349
+ } catch {
350
+ return { name: null, agentType: null };
351
+ }
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 };
355
+ }
356
+
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. */
358
+ function fallbackDispatchName(base) {
359
+ const m = /^agent-(.+)-[^-]+\.jsonl$/.exec(base);
360
+ if (!m) return null;
361
+ const candidate = m[1];
362
+ if (DISPATCH_RE.test(candidate)) return candidate;
363
+ if (candidate.startsWith("a") && DISPATCH_RE.test(candidate.slice(1))) return candidate.slice(1);
364
+ return null;
365
+ }
366
+
367
+ /** Parses a dispatch name into role, task and round; underscores become dots (`impl-1_3b` -> `1.3b`). A non-matching name yields every field null. */
368
+ export function parseDispatchName(name) {
369
+ const empty = { role: null, task: null, round: null };
370
+ if (typeof name !== "string" || !name) return empty;
371
+ const m = DISPATCH_RE.exec(name);
372
+ if (!m) return empty;
373
+ return { role: m[1], task: m[2] === "final" ? "final" : m[2].replace(/_/g, "."), round: m[3] ? Number(m[3]) : null };
374
+ }
375
+
376
+ /** Every `*.brief.md` under `.freeharness/sdd/`, with change directory and mtime; empty when the root is absent. */
377
+ function changeBriefs(root) {
378
+ const out = [];
379
+ let topEntries;
380
+ try {
381
+ topEntries = fs.readdirSync(root, { withFileTypes: true });
382
+ } catch {
383
+ return out;
384
+ }
385
+ for (const top of topEntries) {
386
+ if (!top.isDirectory()) continue;
387
+ const change = top.name;
388
+ const walk = (dir) => {
389
+ let entries;
390
+ try {
391
+ entries = fs.readdirSync(dir, { withFileTypes: true });
392
+ } catch {
393
+ return;
394
+ }
395
+ for (const entry of entries) {
396
+ const full = path.join(dir, entry.name);
397
+ if (entry.isDirectory()) {
398
+ walk(full);
399
+ continue;
400
+ }
401
+ if (!entry.isFile() || !entry.name.endsWith(".brief.md")) continue;
402
+ let stat;
403
+ try {
404
+ stat = fs.statSync(full);
405
+ } catch {
406
+ continue;
407
+ }
408
+ out.push({ file: full, mtimeMs: stat.mtimeMs, change });
409
+ }
410
+ };
411
+ walk(path.join(root, change));
412
+ }
413
+ return out;
414
+ }
415
+
416
+ /** An id token shaped like a dispatch task: digits and dots, an optional trailing batch `b`, or `final`. */
417
+ const TASK_ID_RE = /^(?:\d+(?:\.\d+)*b?|final)$/;
418
+
419
+ /** Whether a `Task:` header declares the task: exact match, a batch id token, or a `b` task matching the batch's first token (else `2.10` wrongly matches `2.1`). */
420
+ function matchesTaskHeader(headerValue, task) {
421
+ const raw = headerValue.trim();
422
+ if (raw === task) return true;
423
+ const ids = raw
424
+ .split(/[,\s]+/)
425
+ .map((s) => s.trim())
426
+ .filter((s) => TASK_ID_RE.test(s));
427
+ if (ids.includes(task)) return true;
428
+ if (task.endsWith("b") && ids[0] === task.slice(0, -1)) return true;
429
+ return false;
430
+ }
431
+
432
+ /** Whether a `Change:` header names one contained directory (`sanitizeTag` keeps `.` and `/`, so `..` could otherwise escape it). */
433
+ export function safeChangeSegment(root, change) {
434
+ if (!change || change.includes("/") || change.includes("\\") || change.includes("..")) return false;
435
+ return containedIn(root, path.resolve(root, change));
436
+ }
437
+
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) {
440
+ const root = path.join(cwd, ".freeharness", "sdd");
441
+ if (task) {
442
+ 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
+ }
450
+ const m = /^Task:\s*(.+)$/m.exec(text);
451
+ if (!m || !matchesTaskHeader(m[1], task)) continue;
452
+ if (!best || b.mtimeMs > best.mtimeMs || (b.mtimeMs === best.mtimeMs && b.file > best.file)) best = b;
453
+ }
454
+ if (best) return path.join(root, best.change, "ledger.jsonl");
455
+ }
456
+ if (tags && tags.change && safeChangeSegment(root, tags.change)) return path.join(root, tags.change, "ledger.jsonl");
457
+ return null;
458
+ }
459
+
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) {
462
+ const transcriptPath = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
463
+ const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
464
+ if (!agentId) return { ok: false, reason: "missing-payload" };
465
+ if (!AGENT_ID_RE.test(agentId)) return { ok: false, reason: "unsafe-agent-id" };
466
+ const file = subagentTranscriptFor(transcriptPath, agentId, payload);
467
+ if (!file) return { ok: false, reason: "missing-payload" };
468
+
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 };
479
+
480
+ const merged = mergeAgentUsage(sums.byModel);
481
+ const est = estimate(merged.usage, priceFor(prices, merged.model));
482
+
483
+ 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);
486
+ const parsed = parseDispatchName(name);
487
+
488
+ const tags = briefTags(cwd);
489
+ const ledgerFile = ledgerPathFor(cwd, parsed.task, tags);
490
+ if (!ledgerFile) {
491
+ const root = path.join(cwd, ".freeharness", "sdd");
492
+ const unsafe = Boolean(tags.change) && !safeChangeSegment(root, tags.change);
493
+ return { ok: false, reason: unsafe ? "unsafe-change" : "no-brief" };
494
+ }
495
+
496
+ const line = {
497
+ ts: new Date().toISOString(),
498
+ session: typeof payload.session_id === "string" ? payload.session_id : "",
499
+ agent_id: agentId,
500
+ name: name ?? null,
501
+ agent_type: agentType,
502
+ role: parsed.role,
503
+ task: parsed.task,
504
+ round: parsed.round,
505
+ model: merged.model,
506
+ input: merged.usage.input,
507
+ cache_read: merged.usage.cache_read,
508
+ cache_write: merged.usage.cache_write,
509
+ output: merged.usage.output,
510
+ est_usd: round(est),
511
+ late: false,
512
+ transcript: file,
513
+ resumed: false,
514
+ };
515
+ return { ok: true, file: ledgerFile, line };
516
+ }
517
+
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");
521
+ }
522
+
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;
527
+ try {
528
+ text = fs.readFileSync(file, "utf8");
529
+ } catch {
530
+ return ids;
531
+ }
532
+ for (const raw of text.split("\n")) {
533
+ if (!raw.trim()) continue;
534
+ try {
535
+ const rec = JSON.parse(raw);
536
+ if (rec && typeof rec.agent_id === "string") ids.add(rec.agent_id);
537
+ } catch {
538
+ }
539
+ }
540
+ return ids;
541
+ }
542
+
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`. */
544
+ export function backfillLedger(payload, cwd, prices, snapshotWarnings) {
545
+ const transcriptPath = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
546
+ if (!transcriptPath) return;
547
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
548
+ const recorded = recordedAgentIds(cwd);
549
+ const done = new Set();
550
+ for (const { file } of subagentTranscripts(transcriptPath)) {
551
+ const m = /^agent-(.+)\.jsonl$/.exec(path.basename(file));
552
+ if (!m) continue;
553
+ 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);
556
+ 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);
561
+ recorded.add(line.agent_id);
562
+ done.add(key);
563
+ } catch (e) {
564
+ snapshotWarnings.push(`ledger backfill write failed: ${e instanceof Error ? e.message : String(e)}`);
565
+ }
566
+ }
567
+ }
568
+
569
+ /** Agent ids recorded in any ledger under the SDD workspace; the union across changes proves a transcript already has its line. */
570
+ function recordedAgentIds(cwd) {
571
+ const ids = new Set();
572
+ const root = path.join(cwd, ".freeharness", "sdd");
573
+ let entries;
574
+ try {
575
+ entries = fs.readdirSync(root, { withFileTypes: true });
576
+ } catch {
577
+ return ids;
578
+ }
579
+ for (const entry of entries) {
580
+ if (!entry.isDirectory()) continue;
581
+ for (const id of readLedgerAgentIds(path.join(root, entry.name, "ledger.jsonl"))) ids.add(id);
582
+ }
583
+ return ids;
584
+ }
585
+
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;
590
+ const root = path.join(cwd, ".freeharness", "sdd");
591
+ let topEntries;
592
+ try {
593
+ topEntries = fs.readdirSync(root, { withFileTypes: true });
594
+ } catch {
595
+ return counts;
596
+ }
597
+ const seenByRole = new Map();
598
+ for (const top of topEntries) {
599
+ 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
+ }
606
+ for (const raw of text.split("\n")) {
607
+ if (!raw.trim()) continue;
608
+ let rec;
609
+ try {
610
+ rec = JSON.parse(raw);
611
+ } catch {
612
+ continue;
613
+ }
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;
624
+ }
625
+ }
626
+ return counts;
627
+ }
628
+
629
+ /** Memory database directory: `CLAUDE_MEM_DATA_DIR` when set, else `~/.claude-mem`. */
630
+ 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");
634
+ }
635
+
636
+ /** The `sqlite3` binary from an absolute PATH entry only, never a bare name handed to the child process's PATH search. */
637
+ function resolveSqlite3() {
638
+ const bin = process.platform === "win32" ? "sqlite3.exe" : "sqlite3";
639
+ for (const dir of String(process.env.PATH || "").split(path.delimiter)) {
640
+ if (!dir || !path.isAbsolute(dir)) continue;
641
+ const candidate = path.join(dir, bin);
642
+ try {
643
+ if (fs.statSync(candidate).isFile()) return candidate;
644
+ } catch {
645
+ // not in this PATH entry
646
+ }
647
+ }
648
+ return null;
649
+ }
650
+
651
+ /** Model ids naming metered Claude spend: `claude-*`, a bare tier alias, `$TIER:` (else `haiku` alone would file as free spend). */
652
+ export function isClaudeModelId(model) {
653
+ return /^(?:claude|\$TIER:|(?:haiku|sonnet|opus)(?:-|$))/.test(model);
654
+ }
655
+
656
+ /** Observer discovery-token spend via a read-only `sqlite3` spawn; failures report as `{reason}`. Claude ids fold into subscription, others into `local`. */
657
+ export function observerSink(sessionId, dataDir = memoryDataDir()) {
658
+ if (!sessionId) return { reason: "no session id" };
659
+ const db = path.join(dataDir, "claude-mem.db");
660
+ if (!fs.existsSync(db)) return { reason: `database not found: ${db}` };
661
+ const bin = resolveSqlite3();
662
+ 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;`;
665
+ let r;
666
+ try {
667
+ r = spawnSync(bin, ["-readonly", db, sql], { encoding: "utf8", timeout: 2000 });
668
+ } catch (e) {
669
+ return { reason: e instanceof Error ? e.message : String(e) };
670
+ }
671
+ 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 };
675
+ for (const raw of (r.stdout || "").split("\n")) {
676
+ const line = raw.trim();
677
+ if (!line) continue;
678
+ const [modelRaw, tokensRaw] = line.split("|");
679
+ const tokens = Number.parseInt(tokensRaw, 10);
680
+ if (!Number.isFinite(tokens)) continue;
681
+ const model = modelRaw && modelRaw.trim() ? modelRaw.trim() : null;
682
+ const bucket = model && isClaudeModelId(model) ? claude : local;
683
+ bucket.tokens += tokens;
684
+ if (tokens > bucket.top) {
685
+ bucket.top = tokens;
686
+ bucket.model = model;
687
+ }
688
+ }
689
+ return { tokens: claude.tokens, model: claude.model, local: { tokens: local.tokens, model: local.model } };
690
+ }
691
+
692
+ /** 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
+ function sessionState(file, sessionId) {
694
+ 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
+ }
701
+ let started = null;
702
+ let contextMax = 0;
703
+ for (const raw of text.split("\n")) {
704
+ if (!raw.trim() || !raw.includes(sessionId)) continue;
705
+ try {
706
+ const rec = JSON.parse(raw);
707
+ if (!rec || rec.session_id !== sessionId) continue;
708
+ if (typeof rec.ts === "string" && (!started || rec.ts < started)) started = rec.ts;
709
+ const c = Number(rec.context_tokens) || 0;
710
+ if (c > contextMax) contextMax = c;
711
+ } catch {
712
+ // Malformed line: same tolerance as readUsage on the reader side.
713
+ }
714
+ }
715
+ return { started, contextMax };
716
+ }
717
+
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) {
720
+ const merged = Array.isArray(warnings) ? [...warnings] : [];
721
+ const observerOk = observer && typeof observer === "object" && Number.isFinite(observer.tokens);
722
+ 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 };
724
+ const byTool = snapshot.by_tool || {};
725
+ const dispatchCalls = (name) => (byTool[name] && byTool[name].calls) || 0;
726
+ const estUsdMain = Number(snapshot.est_usd) || 0;
727
+ 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 : "");
729
+ return {
730
+ ts: snapshot.ts,
731
+ session_id: snapshot.session_id,
732
+ project: snapshot.project,
733
+ started: snapshot.started ?? null,
734
+ ended: snapshot.ts,
735
+ turns: snapshot.turns ?? 0,
736
+ change: tags.change ?? null,
737
+ task: tags.task ?? null,
738
+ tier: tags.tier ?? null,
739
+ task_class: tags.task_class ?? "untagged",
740
+ sinks: {
741
+ main: snapshot.usage || zero,
742
+ subagents: snapshot.subagents || zero,
743
+ by_agent: snapshot.by_agent || {},
744
+ observer: observerOk ? { tokens: observer.tokens, model: observer.model ?? null } : null,
745
+ observer_local: observerOk ? { tokens: observer.local?.tokens ?? 0, model: observer.local?.model ?? null } : null,
746
+ tool_output_chars: snapshot.tool_output_chars ?? 0,
747
+ pruned_bytes: snapshot.pruned_bytes ?? 0,
748
+ // The subagent-dispatch tool is named "Agent" or "Task" depending on the client build.
749
+ agent_calls: dispatchCalls("Agent") + dispatchCalls("Task"),
750
+ },
751
+ est_usd: round(estUsdMain + estUsdSubagents),
752
+ est_usd_main: estUsdMain,
753
+ est_usd_subagents: estUsdSubagents,
754
+ context_max: snapshot.context_max ?? snapshot.context_tokens ?? 0,
755
+ context_window: snapshot.context_window ?? 200_000,
756
+ context_window_source: snapshot.context_window_source ?? "model",
757
+ ...(Object.keys(dispatches).length > 0 ? { dispatches_by_role: dispatches } : {}),
758
+ warnings: merged,
759
+ };
760
+ }
761
+
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;
767
+ try {
768
+ exists = fs.statSync(dir).isDirectory();
769
+ } catch {
770
+ exists = false;
771
+ }
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
+ try {
779
+ fs.appendFileSync(path.join(dir, "telemetry.jsonl"), `${JSON.stringify(tel)}\n`, "utf8");
780
+ } 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];
783
+ }
784
+ }
785
+
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;
790
+ try {
791
+ text = fs.readFileSync(logPath(), "utf8");
792
+ } catch {
793
+ return 0;
794
+ }
795
+ let total = 0;
796
+ for (const raw of text.split("\n")) {
797
+ if (!raw.includes('"pruned"') || !raw.includes(sessionId)) continue;
798
+ try {
799
+ const rec = JSON.parse(raw);
800
+ if (rec && rec.kind === "pruned" && rec.session_id === sessionId) total += Number(rec.bytes) || 0;
801
+ } catch {
802
+ // skip
803
+ }
804
+ }
805
+ return total;
806
+ }
807
+
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) {
810
+ const pricesFile = pricesPath();
811
+ const { prices, ok: pricesOk } = loadPrices(pricesFile);
812
+ const transcript = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
813
+ const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
814
+ const sums = transcript
815
+ ? sumTranscript(transcript)
816
+ : { byModel: {}, byTool: {}, turns: 0, lines: 0, contextTokens: 0, contextModel: "", toolOutputChars: 0, toolCalls: 0, found: false };
817
+ const total = bucket();
818
+ const byModel = {};
819
+ let usd = 0;
820
+ for (const [model, u] of Object.entries(sums.byModel)) {
821
+ const est = estimate(u, priceFor(prices, model));
822
+ usd += est;
823
+ byModel[model] = { ...u, est_usd: round(est) };
824
+ total.input += u.input;
825
+ total.output += u.output;
826
+ total.cache_read += u.cache_read;
827
+ total.cache_write += u.cache_write;
828
+ total.messages += u.messages;
829
+ }
830
+
831
+ // 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.
834
+ const subagentFiles = transcript ? subagentTranscripts(transcript) : [];
835
+ const by_agent = {};
836
+ const subagentsTotal = bucket();
837
+ let subagentsUsd = 0;
838
+ const perAgentModelSums = {};
839
+ const dispatchCounts = {};
840
+ let subagentsScanned = 0;
841
+ let subagentsSkippedLarge = 0;
842
+ let subagentsSkippedCap = 0;
843
+ for (const { name, file } of subagentFiles) {
844
+ if (subagentsScanned >= MAX_SUBAGENT_FILES) {
845
+ subagentsSkippedCap += 1;
846
+ continue;
847
+ }
848
+ let size;
849
+ try {
850
+ size = fs.statSync(file).size;
851
+ } catch {
852
+ continue;
853
+ }
854
+ if (size > MAX_SUBAGENT_FILE_BYTES) {
855
+ subagentsSkippedLarge += 1;
856
+ continue;
857
+ }
858
+ const subSums = sumTranscript(file);
859
+ if (!subSums.found) continue;
860
+ subagentsScanned += 1;
861
+ dispatchCounts[name] = (dispatchCounts[name] || 0) + 1;
862
+ const acc = (perAgentModelSums[name] ??= {});
863
+ for (const [model, u] of Object.entries(subSums.byModel)) {
864
+ const b = (acc[model] ??= bucket());
865
+ b.input += u.input;
866
+ b.output += u.output;
867
+ b.cache_read += u.cache_read;
868
+ b.cache_write += u.cache_write;
869
+ b.messages += u.messages;
870
+ subagentsUsd += estimate(u, priceFor(prices, model));
871
+ }
872
+ }
873
+ for (const [name, byModelForAgent] of Object.entries(perAgentModelSums)) {
874
+ const merged = mergeAgentUsage(byModelForAgent);
875
+ by_agent[name] = { ...merged, dispatches: dispatchCounts[name] };
876
+ subagentsTotal.input += merged.usage.input;
877
+ subagentsTotal.output += merged.usage.output;
878
+ subagentsTotal.cache_read += merged.usage.cache_read;
879
+ subagentsTotal.cache_write += merged.usage.cache_write;
880
+ }
881
+
882
+ // The transcript's model id never carries a `[1m]` suffix, so one from settings.json is
883
+ // folded on before resolving the window. A context past the resolved window raises it to
884
+ // 1,000,000 rather than silently clipping it; `context_window_source` names the origin.
885
+ const contextModel = sums.contextModel || Object.keys(sums.byModel).sort()[0] || "unknown";
886
+ const configured = configuredModel(cwd);
887
+ const configuredSuffix = configured ? modelSuffix(configured) : null;
888
+ let windowModel;
889
+ if (modelSuffix(contextModel) || !configuredSuffix) {
890
+ windowModel = contextModel;
891
+ } else {
892
+ windowModel = `${contextModel}[${configuredSuffix}]`;
893
+ }
894
+ let context_window = windowFor(windowModel, prices);
895
+ let model_suffix = modelSuffix(windowModel);
896
+ let context_window_source = modelSuffix(contextModel) ? "model" : configuredSuffix ? "settings" : "model";
897
+ const resolvedTags = tags || briefTags(cwd);
898
+
899
+ const warnings = [];
900
+ if (!transcript) warnings.push("no transcript_path in payload");
901
+ else if (!sums.found) warnings.push(`transcript not readable: ${transcript}`);
902
+ if (!pricesOk) warnings.push(`price table not loaded: ${pricesFile}`);
903
+ if (subagentsSkippedLarge > 0) warnings.push(`${subagentsSkippedLarge} subagent transcript(s) over 64 MB skipped`);
904
+ if (subagentsSkippedCap > 0) warnings.push(`subagent transcript scan capped at ${MAX_SUBAGENT_FILES} files; ${subagentsSkippedCap} skipped`);
905
+ if (context_window < 1_000_000 && sums.contextTokens > context_window) {
906
+ context_window = 1_000_000;
907
+ model_suffix = null;
908
+ context_window_source = "inferred";
909
+ warnings.push("context exceeded the resolved window; assumed 1m");
910
+ }
911
+ const record = {
912
+ ts: new Date().toISOString(),
913
+ event: typeof payload.hook_event_name === "string" ? payload.hook_event_name : "Stop",
914
+ session_id: typeof payload.session_id === "string" ? payload.session_id : "",
915
+ project: path.basename(cwd),
916
+ cwd,
917
+ transcript,
918
+ models: Object.keys(byModel).sort(),
919
+ usage: {
920
+ input: total.input,
921
+ output: total.output,
922
+ cache_read: total.cache_read,
923
+ cache_write: total.cache_write,
924
+ total: total.input + total.output + total.cache_read + total.cache_write,
925
+ },
926
+ by_model: byModel,
927
+ turns: sums.turns,
928
+ assistant_messages: total.messages,
929
+ est_usd: round(usd),
930
+ prices_file: pricesFile,
931
+ cumulative: true,
932
+ task_class: resolvedTags.task_class,
933
+ pruned_bytes: prunedBytesFor(typeof payload.session_id === "string" ? payload.session_id : ""),
934
+ context_tokens: sums.contextTokens,
935
+ tool_calls: sums.toolCalls,
936
+ tool_output_chars: sums.toolOutputChars,
937
+ by_tool: sums.byTool,
938
+ by_agent,
939
+ subagents: {
940
+ input: subagentsTotal.input,
941
+ output: subagentsTotal.output,
942
+ cache_read: subagentsTotal.cache_read,
943
+ cache_write: subagentsTotal.cache_write,
944
+ total: subagentsTotal.input + subagentsTotal.output + subagentsTotal.cache_read + subagentsTotal.cache_write,
945
+ },
946
+ subagents_est_usd: round(subagentsUsd),
947
+ context_window,
948
+ model_suffix,
949
+ context_window_source,
950
+ };
951
+ if (warnings.length > 0) record.warnings = warnings;
952
+ return record;
953
+ }
954
+
955
+ /** Rounds to 6 decimals (micro-dollars are enough for per-session estimates). */
956
+ function round(n) {
957
+ return Math.round(n * 1e6) / 1e6;
958
+ }
959
+
960
+ /** Appends one record to the usage log, creating the directory on first use. */
961
+ export function appendRecord(record) {
962
+ const file = logPath();
963
+ fs.mkdirSync(path.dirname(file), { recursive: true });
964
+ fs.appendFileSync(file, `${JSON.stringify(record)}\n`, "utf8");
965
+ return file;
966
+ }
967
+
968
+ /** Entry point: parse stdin, build, append; never throws out. */
969
+ function main() {
970
+ let payload = {};
971
+ try {
972
+ const raw = readStdin();
973
+ if (raw.trim()) {
974
+ const parsed = JSON.parse(raw);
975
+ if (parsed && typeof parsed === "object") payload = parsed;
976
+ }
977
+ } catch {
978
+ payload = {};
979
+ }
980
+ try {
981
+ const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
982
+ if (payload.hook_event_name === "SubagentStop") {
983
+ const { prices } = loadPrices(pricesPath());
984
+ const result = ledgerLine(payload, cwd, prices);
985
+ 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
+ }
993
+ } else if (result.reason === "unsafe-agent-id") {
994
+ process.stderr.write("freeharness-cost-tracker: SubagentStop agent_id refused\n");
995
+ } else if (result.reason === "missing-payload") {
996
+ process.stderr.write("freeharness-cost-tracker: SubagentStop payload missing transcript_path or agent_id\n");
997
+ } else if (result.reason === "no-transcript" || result.reason === "too-large") {
998
+ process.stderr.write(`freeharness-cost-tracker: subagent transcript not usable: ${result.file}\n`);
999
+ } else if (result.reason === "unsafe-change") {
1000
+ process.stderr.write("freeharness-cost-tracker: SubagentStop brief Change header refused\n");
1001
+ }
1002
+ // "no-brief": the project has no active brief to attribute the dispatch to; silent.
1003
+ return;
1004
+ }
1005
+ const tags = briefTags(cwd);
1006
+ const record = buildRecord(payload, tags);
1007
+ // Backfill runs between build and append, so a missing SubagentStop line still lands
1008
+ // (late: true) in time for this run's own telemetry count.
1009
+ const { prices } = loadPrices(pricesPath());
1010
+ const warnings = Array.isArray(record.warnings) ? record.warnings : [];
1011
+ backfillLedger(payload, cwd, prices, warnings);
1012
+ if (warnings.length > 0) record.warnings = warnings;
1013
+ // The telemetry write is attempted before the snapshot is appended, so its failure lands
1014
+ // as a warning on the appended snapshot itself.
1015
+ if (record.event === "SessionEnd") writeTelemetryIfPossible(record, payload, tags);
1016
+ appendRecord(record);
1017
+ } catch (e) {
1018
+ // Last resort: say why on stderr (Claude Code shows it in debug mode), never fail.
1019
+ process.stderr.write(`freeharness-cost-tracker: ${e instanceof Error ? e.message : String(e)}\n`);
1020
+ }
1021
+ }
1022
+
1023
+ // Run only when executed directly (importable from tests without side effects).
1024
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
1025
+ main();
1026
+ process.exit(0);
1027
+ }