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
@@ -0,0 +1,645 @@
1
+ // `freeharness cost quota`: no official per-token quota weight exists, so the window percentages
2
+ // the quota tap records are fitted against the tokens the transcripts recorded between two
3
+ // readings. Reads the readings and transcripts under the user's ~/.claude; writes only
4
+ // quota-calibration.json, and only with --write. index.ts prints.
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { familyOf } from "./cost.js";
9
+ export const QUOTA_WINDOWS = ["five_hour", "seven_day"];
10
+ /** Same order as cost.ts's family list, so weights print in the routing report's order. */
11
+ const FAMILY_ORDER = ["opus", "sonnet", "haiku", "fable", "other"];
12
+ export const DEFAULT_QUOTA_DAYS = 14;
13
+ const DAY_MS = 24 * 3600 * 1000;
14
+ /** The tap rotates past 2 MiB; anything far larger is not its file. */
15
+ const MAX_READINGS_BYTES = 16 * 1024 * 1024;
16
+ /** The cost tracker's per-file transcript bounds (main and subagent), so both skip the same files. */
17
+ const MAX_MAIN_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
18
+ const MAX_SUBAGENT_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
19
+ const MAX_CALIBRATION_BYTES = 1024 * 1024;
20
+ const MAX_MODEL_CHARS = 128;
21
+ const UNSAFE_CHARS = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u;
22
+ /** Fewer rows than this per fitted weight leaves a least-squares fit too loose to trust. */
23
+ const INTERVALS_PER_FEATURE = 3;
24
+ const NOTE = "note: usage on the same account outside Claude Code (claude.ai, other machines) is invisible to these transcripts and inflates the weights";
25
+ export function quotaDir(home = os.homedir()) {
26
+ return path.join(home, ".claude", "freeharness");
27
+ }
28
+ export function quotaCalibrationPath(home = os.homedir()) {
29
+ return path.join(quotaDir(home), "quota-calibration.json");
30
+ }
31
+ /** Strips float noise so a re-run over the same inputs prints and stores the same numbers. */
32
+ function clean(x) {
33
+ const r = Math.round(x * 1e9) / 1e9;
34
+ return r === 0 ? 0 : r;
35
+ }
36
+ function own(obj, key) {
37
+ if (obj === null || typeof obj !== "object" || Array.isArray(obj))
38
+ return undefined;
39
+ return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
40
+ }
41
+ function isPlainObject(v) {
42
+ return v !== null && typeof v === "object" && !Array.isArray(v);
43
+ }
44
+ /**
45
+ * A file's text through one descriptor that never follows a symlink leaf or blocks on a FIFO.
46
+ * @returns The text, or the reason it was not read.
47
+ */
48
+ function readBounded(file, maxBytes) {
49
+ let fd;
50
+ try {
51
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK || 0));
52
+ }
53
+ catch (e) {
54
+ const code = e.code;
55
+ return { text: null, reason: code === "ENOENT" ? "absent" : code === "ELOOP" ? "irregular" : "unreadable" };
56
+ }
57
+ try {
58
+ const st = fs.fstatSync(fd);
59
+ if (!st.isFile())
60
+ return { text: null, reason: "irregular" };
61
+ if (st.size > maxBytes)
62
+ return { text: null, reason: "oversize" };
63
+ const buf = Buffer.allocUnsafe(st.size);
64
+ let total = 0;
65
+ while (total < buf.length) {
66
+ const n = fs.readSync(fd, buf, total, buf.length - total, total);
67
+ if (n === 0)
68
+ break;
69
+ total += n;
70
+ }
71
+ return { text: buf.toString("utf8", 0, total), reason: null };
72
+ }
73
+ catch {
74
+ return { text: null, reason: "unreadable" };
75
+ }
76
+ finally {
77
+ fs.closeSync(fd);
78
+ }
79
+ }
80
+ /** The tap's window rule, so a line the tap would never write counts as malformed. */
81
+ function windowOf(value) {
82
+ if (value === null)
83
+ return null;
84
+ const pct = own(value, "used_percentage");
85
+ const resets = own(value, "resets_at");
86
+ if (typeof pct !== "number" || !Number.isFinite(pct) || pct < 0 || pct > 100)
87
+ return undefined;
88
+ if (typeof resets !== "number" || !Number.isSafeInteger(resets) || resets <= 0)
89
+ return undefined;
90
+ return { used_percentage: pct, resets_at: resets };
91
+ }
92
+ /** A readings line in the sink schema, else null (malformed). */
93
+ export function parseReading(raw) {
94
+ let rec;
95
+ try {
96
+ rec = JSON.parse(raw);
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ if (!isPlainObject(rec))
102
+ return null;
103
+ const ts = own(rec, "ts");
104
+ const time = typeof ts === "string" ? Date.parse(ts) : NaN;
105
+ if (!Number.isFinite(time))
106
+ return null;
107
+ const five = windowOf(own(rec, "five_hour") ?? null);
108
+ const seven = windowOf(own(rec, "seven_day") ?? null);
109
+ if (five === undefined || seven === undefined || (five === null && seven === null))
110
+ return null;
111
+ const m = own(rec, "model");
112
+ const model = typeof m === "string" && m.length > 0 && m.length <= MAX_MODEL_CHARS && !UNSAFE_CHARS.test(m) ? m : null;
113
+ return { ts: new Date(time).toISOString(), time, model, five_hour: five, seven_day: seven };
114
+ }
115
+ /** Readings from the rotation then the live file, within the last `days`, in time order. */
116
+ export function readReadings(home, days, now) {
117
+ const dir = quotaDir(home);
118
+ const since = now.getTime() - days * DAY_MS;
119
+ const report = { files: [], lines: 0, malformed: 0, out_of_range: 0, skipped: [] };
120
+ const readings = [];
121
+ for (const name of ["quota-readings.jsonl.1", "quota-readings.jsonl"]) {
122
+ const file = path.join(dir, name);
123
+ const { text, reason } = readBounded(file, MAX_READINGS_BYTES);
124
+ if (text === null) {
125
+ if (reason !== "absent")
126
+ report.skipped.push(`${name} (${reason === "oversize" ? "over the size bound" : reason === "irregular" ? "a symlink or not a regular file" : "unreadable"})`);
127
+ continue;
128
+ }
129
+ report.files.push(file);
130
+ for (const raw of text.split("\n")) {
131
+ if (!raw.trim())
132
+ continue;
133
+ const r = parseReading(raw);
134
+ if (r === null)
135
+ report.malformed += 1;
136
+ else if (r.time < since)
137
+ report.out_of_range += 1;
138
+ else {
139
+ report.lines += 1;
140
+ readings.push(r);
141
+ }
142
+ }
143
+ }
144
+ // Stable: equal times keep file order, the order the tap appended them in.
145
+ readings.sort((a, b) => a.time - b.time);
146
+ return { readings, report };
147
+ }
148
+ /**
149
+ * Rising intervals per window. A reading repeating the window's previous percentage and reset is
150
+ * no new reading for that window (the other window changed), so the interval starts at the first
151
+ * reading of the value and keeps the tokens spent while the meter had not yet moved.
152
+ */
153
+ export function formIntervals(readings) {
154
+ const out = { five_hour: [], seven_day: [] };
155
+ for (const window of QUOTA_WINDOWS) {
156
+ const seen = new Set();
157
+ let prev = null;
158
+ for (const r of readings) {
159
+ const cur = r[window];
160
+ if (cur === null)
161
+ continue;
162
+ const p = prev ? prev[window] : null;
163
+ if (prev && p && p.resets_at === cur.resets_at && p.used_percentage === cur.used_percentage)
164
+ continue;
165
+ if (prev && p && p.resets_at === cur.resets_at && cur.used_percentage > p.used_percentage) {
166
+ const key = `${window}|${cur.resets_at}|${prev.time}`;
167
+ if (!seen.has(key)) {
168
+ seen.add(key);
169
+ out[window].push({
170
+ window,
171
+ resets_at: cur.resets_at,
172
+ delta: clean(cur.used_percentage - p.used_percentage),
173
+ start: { ts: prev.ts, model: prev.model, used_percentage: p.used_percentage },
174
+ end: { ts: r.ts, model: r.model, used_percentage: cur.used_percentage },
175
+ });
176
+ }
177
+ }
178
+ prev = r;
179
+ }
180
+ }
181
+ return out;
182
+ }
183
+ function count(v) {
184
+ const n = Number(v);
185
+ return Number.isFinite(n) && n > 0 ? n : 0;
186
+ }
187
+ /** Main transcripts and their `subagents/agent-*.jsonl`, the layout the cost tracker reads; symlinks are never followed. */
188
+ function transcriptFiles(home) {
189
+ const root = path.join(home, ".claude", "projects");
190
+ const out = [];
191
+ const dirents = (dir) => {
192
+ try {
193
+ return fs.readdirSync(dir, { withFileTypes: true });
194
+ }
195
+ catch {
196
+ return [];
197
+ }
198
+ };
199
+ for (const project of dirents(root)) {
200
+ if (!project.isDirectory())
201
+ continue;
202
+ const pdir = path.join(root, project.name);
203
+ for (const e of dirents(pdir)) {
204
+ if (e.isFile() && e.name.endsWith(".jsonl"))
205
+ out.push({ file: path.join(pdir, e.name), max: MAX_MAIN_TRANSCRIPT_BYTES });
206
+ if (!e.isDirectory())
207
+ continue;
208
+ for (const s of dirents(path.join(pdir, e.name, "subagents"))) {
209
+ if (s.isFile() && /^agent-.+\.jsonl$/.test(s.name))
210
+ out.push({ file: path.join(pdir, e.name, "subagents", s.name), max: MAX_SUBAGENT_TRANSCRIPT_BYTES });
211
+ }
212
+ }
213
+ }
214
+ return out.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
215
+ }
216
+ /**
217
+ * Assistant messages with usage, one per `message.id` (else per file and line); the last line of
218
+ * an id wins, as in the cost tracker, since a streamed message repeats its usage on every line.
219
+ * A file last written before `since` cannot hold a message inside any interval and is not read.
220
+ */
221
+ export function readMessages(home, since) {
222
+ const report = { files: 0, oversize: 0, skipped: 0, messages: 0 };
223
+ const byId = new Map();
224
+ for (const { file, max } of transcriptFiles(home)) {
225
+ let st;
226
+ try {
227
+ st = fs.lstatSync(file);
228
+ }
229
+ catch {
230
+ continue;
231
+ }
232
+ if (!st.isFile()) {
233
+ report.skipped += 1;
234
+ continue;
235
+ }
236
+ if (st.size > max) {
237
+ report.oversize += 1;
238
+ continue;
239
+ }
240
+ if (st.mtimeMs < since)
241
+ continue;
242
+ const { text, reason } = readBounded(file, max);
243
+ if (text === null) {
244
+ if (reason === "oversize")
245
+ report.oversize += 1;
246
+ else
247
+ report.skipped += 1;
248
+ continue;
249
+ }
250
+ report.files += 1;
251
+ const lines = text.split("\n");
252
+ for (let i = 0; i < lines.length; i++) {
253
+ const raw = lines[i];
254
+ if (!raw.includes('"assistant"') || !raw.includes('"usage"'))
255
+ continue;
256
+ let rec;
257
+ try {
258
+ rec = JSON.parse(raw);
259
+ }
260
+ catch {
261
+ continue;
262
+ }
263
+ if (own(rec, "type") !== "assistant")
264
+ continue;
265
+ const msg = own(rec, "message");
266
+ const usage = own(msg, "usage");
267
+ if (!isPlainObject(usage))
268
+ continue;
269
+ const ts = own(rec, "timestamp");
270
+ const time = typeof ts === "string" ? Date.parse(ts) : NaN;
271
+ if (!Number.isFinite(time))
272
+ continue;
273
+ const id = own(msg, "id");
274
+ const key = typeof id === "string" && id ? `id:${id}` : `line:${file}:${i + 1}`;
275
+ const model = own(msg, "model");
276
+ // cache_creation_input_tokens already holds the one-hour writes; the one-hour field counts
277
+ // only when it is the larger, so a line missing the total still counts its writes once.
278
+ const cacheWrite = Math.max(count(usage.cache_creation_input_tokens), count(own(usage.cache_creation, "ephemeral_1h_input_tokens")));
279
+ byId.set(key, {
280
+ time,
281
+ family: familyOf(typeof model === "string" ? model : "unknown"),
282
+ cache_read: count(usage.cache_read_input_tokens),
283
+ fresh: count(usage.input_tokens) + cacheWrite + count(usage.output_tokens),
284
+ });
285
+ }
286
+ }
287
+ const messages = [...byId.values()].sort((a, b) => a.time - b.time);
288
+ report.messages = messages.length;
289
+ return { messages, report };
290
+ }
291
+ /** First index whose time is above `t`, so (start, end] maps to [upper(start), upper(end)). */
292
+ function upper(messages, t) {
293
+ let lo = 0;
294
+ let hi = messages.length;
295
+ while (lo < hi) {
296
+ const mid = (lo + hi) >>> 1;
297
+ if (messages[mid].time <= t)
298
+ lo = mid + 1;
299
+ else
300
+ hi = mid;
301
+ }
302
+ return lo;
303
+ }
304
+ function ordered(groups) {
305
+ const out = {};
306
+ for (const f of FAMILY_ORDER) {
307
+ const g = groups.get(f);
308
+ if (g)
309
+ out[f] = g;
310
+ }
311
+ return out;
312
+ }
313
+ /** Tokens per family in (start, end]; a family with no tokens in the interval is absent. */
314
+ function sumInterval(messages, start, end) {
315
+ const groups = new Map();
316
+ for (let i = upper(messages, start), stop = upper(messages, end); i < stop; i++) {
317
+ const m = messages[i];
318
+ if (m.cache_read === 0 && m.fresh === 0)
319
+ continue;
320
+ const g = groups.get(m.family) ?? { cache_read: 0, fresh: 0 };
321
+ g.cache_read += m.cache_read;
322
+ g.fresh += m.fresh;
323
+ groups.set(m.family, g);
324
+ }
325
+ return ordered(groups);
326
+ }
327
+ /**
328
+ * Solves the least-squares problem on the given columns through the normal equations; a column
329
+ * dependent on the earlier ones gets 0, which Lawson-Hanson then treats as inactive.
330
+ */
331
+ function solvePassive(A, b, cols) {
332
+ const k = cols.length;
333
+ const G = cols.map((p) => cols.map((q) => A.reduce((s, row) => s + row[p] * row[q], 0)));
334
+ const y = cols.map((p) => A.reduce((s, row, i) => s + row[p] * b[i], 0));
335
+ const scale = Math.max(1e-300, ...G.map((row, i) => Math.abs(row[i])));
336
+ const x = new Array(k).fill(0);
337
+ const pivotRow = new Array(k).fill(-1);
338
+ const used = new Array(k).fill(false);
339
+ for (let c = 0; c < k; c++) {
340
+ let best = -1;
341
+ for (let r = 0; r < k; r++)
342
+ if (!used[r] && (best < 0 || Math.abs(G[r][c]) > Math.abs(G[best][c])))
343
+ best = r;
344
+ if (best < 0 || Math.abs(G[best][c]) <= 1e-12 * scale)
345
+ continue;
346
+ used[best] = true;
347
+ pivotRow[c] = best;
348
+ for (let r = 0; r < k; r++) {
349
+ if (r === best || G[r][c] === 0)
350
+ continue;
351
+ const f = G[r][c] / G[best][c];
352
+ for (let j = 0; j < k; j++)
353
+ G[r][j] -= f * G[best][j];
354
+ y[r] -= f * y[best];
355
+ }
356
+ }
357
+ for (let c = 0; c < k; c++)
358
+ if (pivotRow[c] >= 0)
359
+ x[c] = y[pivotRow[c]] / G[pivotRow[c]][c];
360
+ return x;
361
+ }
362
+ /** Non-negative least squares, Lawson-Hanson (1974): min ||Ax - b|| subject to x >= 0. */
363
+ export function nnls(A, b) {
364
+ const n = A[0]?.length ?? 0;
365
+ const x = new Array(n).fill(0);
366
+ const passive = new Set();
367
+ const gradient = () => {
368
+ const r = b.map((bi, i) => bi - A[i].reduce((s, a, j) => s + a * x[j], 0));
369
+ return Array.from({ length: n }, (_, j) => A.reduce((s, row, i) => s + row[j] * r[i], 0));
370
+ };
371
+ const tol = 1e-9 * Math.max(1, ...gradient().map(Math.abs));
372
+ for (let outer = 0; outer < 3 * n + 3; outer++) {
373
+ const w = gradient();
374
+ let j = -1;
375
+ for (let c = 0; c < n; c++)
376
+ if (!passive.has(c) && w[c] > tol && (j < 0 || w[c] > w[j]))
377
+ j = c;
378
+ if (j < 0)
379
+ break;
380
+ passive.add(j);
381
+ for (let inner = 0; inner < 3 * n + 3; inner++) {
382
+ const cols = [...passive].sort((p, q) => p - q);
383
+ const sol = solvePassive(A, b, cols);
384
+ const s = new Array(n).fill(0);
385
+ cols.forEach((c, i) => (s[c] = sol[i]));
386
+ if (cols.every((c) => s[c] > 0)) {
387
+ for (let c = 0; c < n; c++)
388
+ x[c] = s[c];
389
+ break;
390
+ }
391
+ // Step from x toward s only as far as the first passive weight reaching zero.
392
+ let alpha = 1;
393
+ for (const c of cols)
394
+ if (s[c] <= 0)
395
+ alpha = Math.min(alpha, x[c] - s[c] > 0 ? x[c] / (x[c] - s[c]) : 0);
396
+ for (let c = 0; c < n; c++)
397
+ x[c] += alpha * (s[c] - x[c]);
398
+ for (const c of cols) {
399
+ if (x[c] <= 1e-15 || s[c] === 0) {
400
+ x[c] = 0;
401
+ passive.delete(c);
402
+ }
403
+ }
404
+ if (passive.size === 0)
405
+ break;
406
+ }
407
+ }
408
+ return x.map((v) => (v > 0 ? v : 0));
409
+ }
410
+ function median(values) {
411
+ if (values.length === 0)
412
+ return 0;
413
+ const s = [...values].sort((a, b) => a - b);
414
+ const mid = s.length >> 1;
415
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
416
+ }
417
+ /**
418
+ * Fits percent per million tokens per family and group to the intervals' percentage rises.
419
+ * @param intervals Rows of (delta percent, tokens per family); tokens are raw counts.
420
+ * @returns The weights of every family with tokens, the interval count and the median absolute
421
+ * residual in percentage points; or, below three intervals per feature, the count needed (a
422
+ * window without tokens needs at least one family's two features).
423
+ */
424
+ export function fitWeights(intervals) {
425
+ const families = FAMILY_ORDER.filter((f) => intervals.some((iv) => (iv.tokens[f]?.cache_read ?? 0) > 0 || (iv.tokens[f]?.fresh ?? 0) > 0));
426
+ const features = families.length * 2;
427
+ if (features === 0)
428
+ return { needed: 2 * INTERVALS_PER_FEATURE, features: 0 };
429
+ const needed = INTERVALS_PER_FEATURE * features;
430
+ if (intervals.length < needed)
431
+ return { needed, features };
432
+ const A = intervals.map((iv) => families.flatMap((f) => [(iv.tokens[f]?.cache_read ?? 0) / 1e6, (iv.tokens[f]?.fresh ?? 0) / 1e6]));
433
+ const b = intervals.map((iv) => iv.delta);
434
+ const x = nnls(A, b);
435
+ const residuals = A.map((row, i) => Math.abs(b[i] - row.reduce((s, a, j) => s + a * x[j], 0)));
436
+ const weights = {};
437
+ families.forEach((f, i) => (weights[f] = { cache_read: clean(x[2 * i]), fresh: clean(x[2 * i + 1]) }));
438
+ return { weights, residual: clean(median(residuals)), intervals: intervals.length };
439
+ }
440
+ function perPct(tokens, delta) {
441
+ const out = {};
442
+ for (const f of FAMILY_ORDER) {
443
+ const g = tokens[f];
444
+ if (g)
445
+ out[f] = { cache_read: clean(g.cache_read / delta), fresh: clean(g.fresh / delta) };
446
+ }
447
+ return out;
448
+ }
449
+ /** Reads, forms intervals, sums tokens and fits each window; writes nothing. */
450
+ export function quotaReport(home, days, now = new Date()) {
451
+ const { readings, report: readingsReport } = readReadings(home, days, now);
452
+ const formed = formIntervals(readings);
453
+ const all = [...formed.five_hour, ...formed.seven_day];
454
+ const earliest = all.length ? Math.min(...all.map((iv) => Date.parse(iv.start.ts))) : Infinity;
455
+ const { messages, report: transcripts } = all.length ? readMessages(home, earliest) : { messages: [], report: { files: 0, oversize: 0, skipped: 0, messages: 0 } };
456
+ const windows = {};
457
+ for (const w of QUOTA_WINDOWS) {
458
+ const intervals = formed[w].map((iv) => {
459
+ const tokens = sumInterval(messages, Date.parse(iv.start.ts), Date.parse(iv.end.ts));
460
+ return { ...iv, tokens, tokens_per_pct: perPct(tokens, iv.delta) };
461
+ });
462
+ const fit = fitWeights(intervals);
463
+ windows[w] =
464
+ "weights" in fit
465
+ ? { calibrated: true, interval_count: intervals.length, features: Object.keys(fit.weights).length * 2, needed: INTERVALS_PER_FEATURE * Object.keys(fit.weights).length * 2, weights: fit.weights, residual: fit.residual, intervals }
466
+ : { calibrated: false, interval_count: intervals.length, features: fit.features, needed: fit.needed, intervals };
467
+ }
468
+ return { days, readings: readingsReport, transcripts, windows, note: NOTE, written: null };
469
+ }
470
+ /** At most four significant digits, always with a decimal point, so 2 prints as 2.0. */
471
+ function fmtNum(x) {
472
+ const s = String(Number(x.toPrecision(4)));
473
+ return /[.e]/.test(s) ? s : `${s}.0`;
474
+ }
475
+ function fmtCount(n) {
476
+ if (n >= 1e6)
477
+ return `${(n / 1e6).toFixed(2)}M`;
478
+ if (n >= 1e3)
479
+ return `${(n / 1e3).toFixed(1)}k`;
480
+ return String(Math.round(n));
481
+ }
482
+ export function formatQuota(report) {
483
+ const lines = [`quota calibration over the last ${report.days} days`];
484
+ for (const w of QUOTA_WINDOWS) {
485
+ const win = report.windows[w];
486
+ if (win.calibrated && win.weights) {
487
+ lines.push(`${w}: calibrated from ${win.interval_count} intervals, median absolute residual ${fmtNum(win.residual ?? 0)} percentage points`);
488
+ for (const [f, g] of Object.entries(win.weights))
489
+ lines.push(` ${f}: cache-read ${fmtNum(g.cache_read)}, fresh ${fmtNum(g.fresh)} percent per million tokens`);
490
+ continue;
491
+ }
492
+ const noTokens = win.features === 0 && win.interval_count >= win.needed;
493
+ lines.push(`${w}: not yet calibrated (${win.interval_count} intervals, ${noTokens ? "none with transcript tokens" : `${win.needed} needed`})`);
494
+ if (win.intervals.length)
495
+ lines.push(" tokens per percent of each interval:");
496
+ for (const iv of win.intervals) {
497
+ const parts = Object.entries(iv.tokens_per_pct).map(([f, g]) => `${f} cache-read ${fmtCount(g.cache_read)}, fresh ${fmtCount(g.fresh)}`);
498
+ lines.push(` ${iv.start.ts} -> ${iv.end.ts} +${fmtNum(iv.delta)} pct: ${parts.length ? `${parts.join("; ")} per percent` : "no transcript tokens"}`);
499
+ }
500
+ }
501
+ lines.push(report.note);
502
+ const r = report.readings;
503
+ lines.push(`readings: ${r.lines} lines in range, ${r.malformed} malformed skipped${r.out_of_range ? `, ${r.out_of_range} older than ${report.days} days` : ""}`);
504
+ for (const s of r.skipped)
505
+ lines.push(`readings: skipped ${s}`);
506
+ const t = report.transcripts;
507
+ lines.push(`transcripts: ${t.files} files read, ${t.oversize} over the size bound skipped${t.skipped ? `, ${t.skipped} not regular files skipped` : ""}`);
508
+ return lines;
509
+ }
510
+ /** The calibration file's content: calibrated windows only, so a window without a fit never overwrites one. */
511
+ export function calibrationOf(report, now) {
512
+ const windows = {};
513
+ for (const w of QUOTA_WINDOWS) {
514
+ const win = report.windows[w];
515
+ if (win.calibrated && win.weights)
516
+ windows[w] = { weights: win.weights, intervals: win.interval_count, residual: win.residual ?? 0 };
517
+ }
518
+ return { written: now.toISOString(), days: report.days, windows };
519
+ }
520
+ /**
521
+ * Replaces the calibration file whole: a temporary file in the same directory, fsync, rename, so
522
+ * the routing report never reads a half-written file; an existing symlink or non-regular target
523
+ * is refused rather than replaced.
524
+ */
525
+ export function writeCalibration(file, calibration) {
526
+ const dir = path.dirname(file);
527
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
528
+ let existing = null;
529
+ try {
530
+ existing = fs.lstatSync(file);
531
+ }
532
+ catch (e) {
533
+ if (e.code !== "ENOENT")
534
+ throw e;
535
+ }
536
+ if (existing && !existing.isFile())
537
+ throw new Error(`refusing to replace ${file}: a symlink or not a regular file`);
538
+ const tmp = path.join(dir, `.quota-calibration.json.${process.pid}.${Date.now()}.tmp`);
539
+ const fd = fs.openSync(tmp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
540
+ try {
541
+ try {
542
+ fs.writeSync(fd, `${JSON.stringify(calibration, null, 2)}\n`);
543
+ fs.fsyncSync(fd);
544
+ }
545
+ finally {
546
+ fs.closeSync(fd);
547
+ }
548
+ fs.renameSync(tmp, file);
549
+ }
550
+ catch (e) {
551
+ fs.rmSync(tmp, { force: true });
552
+ throw e;
553
+ }
554
+ }
555
+ function validWeights(v) {
556
+ if (!isPlainObject(v))
557
+ return "weights is not an object";
558
+ for (const [f, g] of Object.entries(v)) {
559
+ if (!FAMILY_ORDER.includes(f))
560
+ return `unknown family ${JSON.stringify(f).slice(0, 40)}`;
561
+ for (const k of ["cache_read", "fresh"]) {
562
+ const n = own(g, k);
563
+ if (typeof n !== "number" || !Number.isFinite(n) || n < 0)
564
+ return `${f}.${k} is not a finite number 0 or greater`;
565
+ }
566
+ }
567
+ return null;
568
+ }
569
+ /** Why a parsed calibration fails the data contract, or null when it holds. */
570
+ function calibrationProblem(v) {
571
+ if (!isPlainObject(v))
572
+ return "not a JSON object";
573
+ if (typeof v.written !== "string" || !Number.isFinite(Date.parse(v.written)))
574
+ return "written is not a timestamp";
575
+ if (typeof v.days !== "number" || !Number.isSafeInteger(v.days) || v.days <= 0)
576
+ return "days is not a positive integer";
577
+ if (!isPlainObject(v.windows))
578
+ return "windows is not an object";
579
+ for (const [w, win] of Object.entries(v.windows)) {
580
+ if (!QUOTA_WINDOWS.includes(w))
581
+ return `unknown window ${JSON.stringify(w).slice(0, 40)}`;
582
+ if (!isPlainObject(win))
583
+ return `${w} is not an object`;
584
+ const weights = validWeights(win.weights);
585
+ if (weights)
586
+ return `${w}: ${weights}`;
587
+ if (typeof win.intervals !== "number" || !Number.isSafeInteger(win.intervals) || win.intervals <= 0)
588
+ return `${w}: intervals is not a positive integer`;
589
+ if (typeof win.residual !== "number" || !Number.isFinite(win.residual) || win.residual < 0)
590
+ return `${w}: residual is not a finite number 0 or greater`;
591
+ }
592
+ return null;
593
+ }
594
+ /**
595
+ * Reads the calibration for the routing report's window estimate.
596
+ * @returns The shape-checked calibration; null without a warning when the file is absent; null
597
+ * with one `quota calibration ignored: <reason>` line when it is unreadable or off-contract.
598
+ */
599
+ export function readCalibration(file = quotaCalibrationPath()) {
600
+ const { text, reason } = readBounded(file, MAX_CALIBRATION_BYTES);
601
+ if (text === null) {
602
+ if (reason === "absent")
603
+ return { calibration: null, warning: null };
604
+ return { calibration: null, warning: `quota calibration ignored: ${reason === "oversize" ? "over the size bound" : reason === "irregular" ? "a symlink or not a regular file" : "unreadable"}` };
605
+ }
606
+ let parsed;
607
+ try {
608
+ parsed = JSON.parse(text);
609
+ }
610
+ catch {
611
+ return { calibration: null, warning: "quota calibration ignored: not valid JSON" };
612
+ }
613
+ const problem = calibrationProblem(parsed);
614
+ if (problem)
615
+ return { calibration: null, warning: `quota calibration ignored: ${problem.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ")}` };
616
+ return { calibration: parsed, warning: null };
617
+ }
618
+ /**
619
+ * Runs the calibration and, with `write`, stores the calibrated windows.
620
+ * @returns The lines to print: the text report, or one JSON document with `--json`.
621
+ */
622
+ export function costQuota(opts = {}) {
623
+ const home = opts.home ?? os.homedir();
624
+ const now = opts.now ?? new Date();
625
+ const report = quotaReport(home, opts.days ?? DEFAULT_QUOTA_DAYS, now);
626
+ let writeLine = null;
627
+ if (opts.write) {
628
+ const calibration = calibrationOf(report, now);
629
+ const file = quotaCalibrationPath(home);
630
+ if (Object.keys(calibration.windows).length === 0) {
631
+ writeLine = `nothing written: no window is calibrated (${file} left as it was)`;
632
+ }
633
+ else {
634
+ writeCalibration(file, calibration);
635
+ report.written = file;
636
+ writeLine = `OK wrote ${file}`;
637
+ }
638
+ }
639
+ if (opts.json)
640
+ return [JSON.stringify(report, null, 2)];
641
+ const lines = formatQuota(report);
642
+ if (writeLine)
643
+ lines.push(writeLine);
644
+ return lines;
645
+ }