projectstore-codex 0.0.1 → 0.28.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 (185) hide show
  1. package/.codex-plugin/plugin.json +48 -0
  2. package/README.md +15 -7
  3. package/bin/projectstore-codex.mjs +88 -0
  4. package/hooks/hooks.json +59 -0
  5. package/node_modules/projectstore/.claude-plugin/marketplace.json +40 -0
  6. package/node_modules/projectstore/.claude-plugin/plugin.json +23 -0
  7. package/node_modules/projectstore/.mcp.json +14 -0
  8. package/node_modules/projectstore/AGENTS.md +26 -0
  9. package/node_modules/projectstore/LICENSE +21 -0
  10. package/node_modules/projectstore/README.md +284 -0
  11. package/node_modules/projectstore/agents/archaeologist.md +76 -0
  12. package/node_modules/projectstore/agents/clerk.md +93 -0
  13. package/node_modules/projectstore/agents/critic.md +94 -0
  14. package/node_modules/projectstore/agents/librarian.md +81 -0
  15. package/node_modules/projectstore/agents/planner.md +80 -0
  16. package/node_modules/projectstore/agents/reviewer.md +98 -0
  17. package/node_modules/projectstore/bin/projectstore.mjs +7 -0
  18. package/node_modules/projectstore/commands/adr.md +57 -0
  19. package/node_modules/projectstore/commands/agents.md +180 -0
  20. package/node_modules/projectstore/commands/bind.md +128 -0
  21. package/node_modules/projectstore/commands/codemap.md +50 -0
  22. package/node_modules/projectstore/commands/concept.md +17 -0
  23. package/node_modules/projectstore/commands/doctor.md +166 -0
  24. package/node_modules/projectstore/commands/epic.md +40 -0
  25. package/node_modules/projectstore/commands/graph.md +56 -0
  26. package/node_modules/projectstore/commands/kanban.md +40 -0
  27. package/node_modules/projectstore/commands/meeting.md +17 -0
  28. package/node_modules/projectstore/commands/reconcile.md +73 -0
  29. package/node_modules/projectstore/commands/research.md +17 -0
  30. package/node_modules/projectstore/commands/review.md +89 -0
  31. package/node_modules/projectstore/commands/runbook.md +17 -0
  32. package/node_modules/projectstore/commands/scaffold.md +23 -0
  33. package/node_modules/projectstore/commands/search.md +22 -0
  34. package/node_modules/projectstore/commands/spec.md +91 -0
  35. package/node_modules/projectstore/commands/status.md +27 -0
  36. package/node_modules/projectstore/commands/statusline.md +46 -0
  37. package/node_modules/projectstore/commands/story.md +113 -0
  38. package/node_modules/projectstore/docs/extending.md +172 -0
  39. package/node_modules/projectstore/docs/getting-started.md +133 -0
  40. package/node_modules/projectstore/docs/harnesses.md +163 -0
  41. package/node_modules/projectstore/docs/how-it-works.md +263 -0
  42. package/node_modules/projectstore/docs/images/loop-light.svg +94 -0
  43. package/node_modules/projectstore/docs/images/loop.svg +93 -0
  44. package/node_modules/projectstore/docs/images/statusline-hud.png +0 -0
  45. package/node_modules/projectstore/docs/images/team-light.svg +79 -0
  46. package/node_modules/projectstore/docs/images/team.svg +79 -0
  47. package/node_modules/projectstore/harnesses/claude-code.json +483 -0
  48. package/node_modules/projectstore/harnesses/codex.json +332 -0
  49. package/node_modules/projectstore/hooks/hooks.json +59 -0
  50. package/node_modules/projectstore/hooks/pre-compact.mjs +121 -0
  51. package/node_modules/projectstore/hooks/session-rules.mjs +63 -0
  52. package/node_modules/projectstore/hooks/session-start.mjs +301 -0
  53. package/node_modules/projectstore/hooks/session-stop.mjs +84 -0
  54. package/node_modules/projectstore/package.json +70 -0
  55. package/node_modules/projectstore/scaffold/checklists.json +88 -0
  56. package/node_modules/projectstore/scaffold/headings.json +171 -0
  57. package/node_modules/projectstore/scaffold/layouts/engineering.json +85 -0
  58. package/node_modules/projectstore/scripts/binding.mjs +165 -0
  59. package/node_modules/projectstore/scripts/build-adapters.mjs +264 -0
  60. package/node_modules/projectstore/scripts/cli.mjs +595 -0
  61. package/node_modules/projectstore/scripts/codemap.mjs +99 -0
  62. package/node_modules/projectstore/scripts/diff-refs.mjs +127 -0
  63. package/node_modules/projectstore/scripts/doctor.mjs +2127 -0
  64. package/node_modules/projectstore/scripts/draft.mjs +261 -0
  65. package/node_modules/projectstore/scripts/graph.mjs +219 -0
  66. package/node_modules/projectstore/scripts/harness.mjs +608 -0
  67. package/node_modules/projectstore/scripts/install-harness.mjs +1387 -0
  68. package/node_modules/projectstore/scripts/kanban.mjs +174 -0
  69. package/node_modules/projectstore/scripts/lib.mjs +3085 -0
  70. package/node_modules/projectstore/scripts/mcp.mjs +391 -0
  71. package/node_modules/projectstore/scripts/portable-registration.mjs +198 -0
  72. package/node_modules/projectstore/scripts/provenance.mjs +375 -0
  73. package/node_modules/projectstore/scripts/query.mjs +490 -0
  74. package/node_modules/projectstore/scripts/reconcile.mjs +422 -0
  75. package/node_modules/projectstore/scripts/statusline-launcher.mjs +141 -0
  76. package/node_modules/projectstore/scripts/statusline.mjs +253 -0
  77. package/node_modules/projectstore/scripts/story-section.mjs +209 -0
  78. package/node_modules/projectstore/scripts/surfaces.mjs +421 -0
  79. package/node_modules/projectstore/scripts/tokens.mjs +449 -0
  80. package/node_modules/projectstore/scripts/touch-session.mjs +336 -0
  81. package/node_modules/projectstore/scripts/version-guard.mjs +255 -0
  82. package/node_modules/projectstore/scripts/worktree.mjs +109 -0
  83. package/node_modules/projectstore/skills/projectstore-decision-detector/SKILL.md +40 -0
  84. package/node_modules/projectstore/skills/projectstore-peer-reviewer/SKILL.md +38 -0
  85. package/node_modules/projectstore/skills/projectstore-story-completion/SKILL.md +50 -0
  86. package/node_modules/projectstore/skills/projectstore-vault-communication/SKILL.md +96 -0
  87. package/node_modules/projectstore/templates/claude-md-block.md.tmpl +26 -0
  88. package/node_modules/projectstore/templates/de/adr.md.tmpl +67 -0
  89. package/node_modules/projectstore/templates/de/concept.md.tmpl +43 -0
  90. package/node_modules/projectstore/templates/de/epic.md.tmpl +59 -0
  91. package/node_modules/projectstore/templates/de/folder-readme.md.tmpl +14 -0
  92. package/node_modules/projectstore/templates/de/kanban.md.tmpl +36 -0
  93. package/node_modules/projectstore/templates/de/meeting.md.tmpl +38 -0
  94. package/node_modules/projectstore/templates/de/research.md.tmpl +47 -0
  95. package/node_modules/projectstore/templates/de/runbook.md.tmpl +53 -0
  96. package/node_modules/projectstore/templates/de/spec.md.tmpl +64 -0
  97. package/node_modules/projectstore/templates/de/story.md.tmpl +76 -0
  98. package/node_modules/projectstore/templates/de/strings.json +6 -0
  99. package/node_modules/projectstore/templates/en/adr.md.tmpl +67 -0
  100. package/node_modules/projectstore/templates/en/concept.md.tmpl +43 -0
  101. package/node_modules/projectstore/templates/en/epic.md.tmpl +59 -0
  102. package/node_modules/projectstore/templates/en/folder-readme.md.tmpl +14 -0
  103. package/node_modules/projectstore/templates/en/kanban.md.tmpl +36 -0
  104. package/node_modules/projectstore/templates/en/meeting.md.tmpl +38 -0
  105. package/node_modules/projectstore/templates/en/research.md.tmpl +47 -0
  106. package/node_modules/projectstore/templates/en/runbook.md.tmpl +53 -0
  107. package/node_modules/projectstore/templates/en/spec.md.tmpl +64 -0
  108. package/node_modules/projectstore/templates/en/story.md.tmpl +76 -0
  109. package/node_modules/projectstore/templates/en/strings.json +6 -0
  110. package/node_modules/projectstore/templates/es/adr.md.tmpl +67 -0
  111. package/node_modules/projectstore/templates/es/concept.md.tmpl +43 -0
  112. package/node_modules/projectstore/templates/es/epic.md.tmpl +59 -0
  113. package/node_modules/projectstore/templates/es/folder-readme.md.tmpl +14 -0
  114. package/node_modules/projectstore/templates/es/kanban.md.tmpl +36 -0
  115. package/node_modules/projectstore/templates/es/meeting.md.tmpl +38 -0
  116. package/node_modules/projectstore/templates/es/research.md.tmpl +47 -0
  117. package/node_modules/projectstore/templates/es/runbook.md.tmpl +53 -0
  118. package/node_modules/projectstore/templates/es/spec.md.tmpl +64 -0
  119. package/node_modules/projectstore/templates/es/story.md.tmpl +76 -0
  120. package/node_modules/projectstore/templates/es/strings.json +6 -0
  121. package/node_modules/projectstore/templates/fr/adr.md.tmpl +67 -0
  122. package/node_modules/projectstore/templates/fr/concept.md.tmpl +43 -0
  123. package/node_modules/projectstore/templates/fr/epic.md.tmpl +59 -0
  124. package/node_modules/projectstore/templates/fr/folder-readme.md.tmpl +14 -0
  125. package/node_modules/projectstore/templates/fr/kanban.md.tmpl +36 -0
  126. package/node_modules/projectstore/templates/fr/meeting.md.tmpl +38 -0
  127. package/node_modules/projectstore/templates/fr/research.md.tmpl +47 -0
  128. package/node_modules/projectstore/templates/fr/runbook.md.tmpl +53 -0
  129. package/node_modules/projectstore/templates/fr/spec.md.tmpl +64 -0
  130. package/node_modules/projectstore/templates/fr/story.md.tmpl +76 -0
  131. package/node_modules/projectstore/templates/fr/strings.json +6 -0
  132. package/node_modules/projectstore/templates/ru/adr.md.tmpl +67 -0
  133. package/node_modules/projectstore/templates/ru/concept.md.tmpl +43 -0
  134. package/node_modules/projectstore/templates/ru/epic.md.tmpl +59 -0
  135. package/node_modules/projectstore/templates/ru/folder-readme.md.tmpl +14 -0
  136. package/node_modules/projectstore/templates/ru/kanban.md.tmpl +36 -0
  137. package/node_modules/projectstore/templates/ru/meeting.md.tmpl +38 -0
  138. package/node_modules/projectstore/templates/ru/research.md.tmpl +47 -0
  139. package/node_modules/projectstore/templates/ru/runbook.md.tmpl +53 -0
  140. package/node_modules/projectstore/templates/ru/spec.md.tmpl +64 -0
  141. package/node_modules/projectstore/templates/ru/story.md.tmpl +76 -0
  142. package/node_modules/projectstore/templates/ru/strings.json +6 -0
  143. package/node_modules/projectstore/templates/zh/adr.md.tmpl +67 -0
  144. package/node_modules/projectstore/templates/zh/concept.md.tmpl +43 -0
  145. package/node_modules/projectstore/templates/zh/epic.md.tmpl +59 -0
  146. package/node_modules/projectstore/templates/zh/folder-readme.md.tmpl +14 -0
  147. package/node_modules/projectstore/templates/zh/kanban.md.tmpl +36 -0
  148. package/node_modules/projectstore/templates/zh/meeting.md.tmpl +38 -0
  149. package/node_modules/projectstore/templates/zh/research.md.tmpl +47 -0
  150. package/node_modules/projectstore/templates/zh/runbook.md.tmpl +53 -0
  151. package/node_modules/projectstore/templates/zh/spec.md.tmpl +64 -0
  152. package/node_modules/projectstore/templates/zh/story.md.tmpl +76 -0
  153. package/node_modules/projectstore/templates/zh/strings.json +6 -0
  154. package/package.json +36 -14
  155. package/plugin.json +53 -0
  156. package/skills/projectstore-adr/SKILL.md +76 -0
  157. package/skills/projectstore-agents/SKILL.md +50 -0
  158. package/skills/projectstore-archaeologist/SKILL.md +109 -0
  159. package/skills/projectstore-bind/SKILL.md +44 -0
  160. package/skills/projectstore-clerk/SKILL.md +126 -0
  161. package/skills/projectstore-codemap/SKILL.md +69 -0
  162. package/skills/projectstore-concept/SKILL.md +36 -0
  163. package/skills/projectstore-critic/SKILL.md +127 -0
  164. package/skills/projectstore-decision-detector/SKILL.md +59 -0
  165. package/skills/projectstore-doctor/SKILL.md +33 -0
  166. package/skills/projectstore-epic/SKILL.md +59 -0
  167. package/skills/projectstore-graph/SKILL.md +75 -0
  168. package/skills/projectstore-kanban/SKILL.md +60 -0
  169. package/skills/projectstore-librarian/SKILL.md +114 -0
  170. package/skills/projectstore-meeting/SKILL.md +36 -0
  171. package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
  172. package/skills/projectstore-planner/SKILL.md +113 -0
  173. package/skills/projectstore-reconcile/SKILL.md +92 -0
  174. package/skills/projectstore-research/SKILL.md +36 -0
  175. package/skills/projectstore-review/SKILL.md +108 -0
  176. package/skills/projectstore-reviewer/SKILL.md +131 -0
  177. package/skills/projectstore-runbook/SKILL.md +36 -0
  178. package/skills/projectstore-scaffold/SKILL.md +42 -0
  179. package/skills/projectstore-search/SKILL.md +41 -0
  180. package/skills/projectstore-spec/SKILL.md +110 -0
  181. package/skills/projectstore-status/SKILL.md +47 -0
  182. package/skills/projectstore-statusline/SKILL.md +29 -0
  183. package/skills/projectstore-story/SKILL.md +132 -0
  184. package/skills/projectstore-story-completion/SKILL.md +69 -0
  185. package/skills/projectstore-vault-communication/SKILL.md +115 -0
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env node
2
+ // projectstore — tokens.mjs
3
+ //
4
+ // Answers "how many tokens did we spend on keeping the vault, as opposed to
5
+ // writing code?" — read-only, from the Claude Code transcripts this project
6
+ // already produces. Nothing needs to be enabled ahead of time; it works on
7
+ // whatever history is still on disk.
8
+ //
9
+ // Two attribution sources, both written by Claude Code itself:
10
+ // 1. subagents — <session>/subagents/agent-*.meta.json carries `agentType`
11
+ // ("projectstore:critic", "projectstore:reviewer", …). Exact, per agent.
12
+ // 2. slash commands — main-thread records carry `attributionSkill`
13
+ // ("projectstore:research") and `attributionPlugin` ("projectstore").
14
+ // Anything else in the main thread is unattributed and reported as such.
15
+ //
16
+ // THREE COUNTING RULES, all load-bearing — get them wrong and the numbers lie:
17
+ // • Dedup by requestId. One API message is written as ONE JSONL RECORD PER
18
+ // CONTENT BLOCK (text + each tool_use). Summing records over-counts ~2.7×.
19
+ // • input/cache_* are identical across a requestId's records, but
20
+ // output_tokens is CUMULATIVE — the final value is in the last record.
21
+ // So: take input/cache from any one record, take max() of output.
22
+ // • Tool calls per turn = distinct tool_use block ids per requestId,
23
+ // collected ACROSS the request's records — counting per record yields a
24
+ // false "1 call/turn" for every turn (each record holds ≤1 tool_use).
25
+ //
26
+ // COST is computed at current API list prices (PRICING below) with the cache
27
+ // multipliers: read 0.1× input, 5m write 1.25×, 1h write 2× (the usage records
28
+ // carry the 5m/1h split). Under a subscription plan these dollars are a proxy
29
+ // for rate-limit consumption, not an invoice — the RATIOS hold either way.
30
+ // Historical price changes are not modeled; everything is priced at today's
31
+ // list. Unknown models are priced at 0 and reported.
32
+ //
33
+ // KNOWN BLIND SPOTS (deliberate, documented rather than silently skewed):
34
+ // • Vault edits made in the main thread without a /projectstore:* command
35
+ // land in the unattributed bucket — attribution only tags plugin commands.
36
+ // • The SessionStart vault injection is re-read as cache_read on EVERY
37
+ // request of a session, including pure-code ones. That is real vault cost
38
+ // smeared across the main thread; this script does not try to carve it out.
39
+ // • Transcripts are pruned on Claude Code's retention schedule
40
+ // (cleanupPeriodDays, 30 by default), so this is "what's left on disk",
41
+ // not all time. Persisting a per-session roll-up is a separate concern.
42
+ //
43
+ // Usage:
44
+ // node scripts/tokens.mjs [--runs] [--sessions] [--since YYYY-MM-DD]
45
+ // [--json] [--project <dir>]
46
+
47
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
48
+ import { join, basename, resolve } from "node:path";
49
+ import { fileURLToPath } from "node:url";
50
+ import { homedir } from "node:os";
51
+ import { projectRoot } from "./lib.mjs";
52
+
53
+ function die(msg) {
54
+ process.stderr.write(`projectstore/tokens: ${msg}\n`);
55
+ process.exit(1);
56
+ }
57
+
58
+ // Where a unit of work sits in the artifact lifecycle. Keyed by the bare agent
59
+ // or command name so plugin-prefixed and legacy (pre-v0.13) names both land.
60
+ const STAGE_OF = {
61
+ // writing the artifact
62
+ adr: "authoring", research: "authoring", epic: "authoring", story: "authoring",
63
+ spec: "authoring", concept: "authoring", meeting: "authoring", runbook: "authoring",
64
+ // checking the artifact — independent adversarial pass
65
+ critic: "critique", "projectstore-critic": "critique", review: "critique",
66
+ // planning the implementation, then checking the diff against the story
67
+ planner: "planning", "code-planner": "planning",
68
+ reviewer: "code-review", "code-reviewer": "code-review",
69
+ // keeping the vault itself coherent
70
+ librarian: "curation", archaeologist: "curation",
71
+ clerk: "upkeep",
72
+ doctor: "upkeep", kanban: "upkeep", reconcile: "upkeep", codemap: "upkeep",
73
+ status: "upkeep", statusline: "upkeep", search: "upkeep", bind: "upkeep",
74
+ scaffold: "upkeep", agents: "upkeep", tokens: "upkeep",
75
+ };
76
+ const STAGE_ORDER = ["authoring", "critique", "planning", "code-review", "curation", "upkeep", "other"];
77
+
78
+ // USD per MTok [input, output], API list prices as of 2026-08-04 (Fable 5.1 added 2026-09-02)
79
+ // (platform.claude.com pricing docs). Cache multipliers applied on top of
80
+ // the input rate.
81
+ export const PRICING = {
82
+ "claude-fable-5-1": [10, 50],
83
+ "claude-fable-5": [10, 50],
84
+ "claude-opus-5": [5, 25],
85
+ "claude-opus-4-8": [5, 25],
86
+ "claude-opus-4-7": [5, 25],
87
+ "claude-sonnet-5": [2, 10], // intro pricing through 2026-08-31; sticker is [3, 15]
88
+ "claude-sonnet-4-6": [3, 15],
89
+ "claude-haiku-4-5": [1, 5],
90
+ };
91
+
92
+ // Transcripts carry variant ids ("claude-haiku-4-5-20251001", "opus",
93
+ // "claude-opus-5[1m]") — normalize before the PRICING lookup so they don't
94
+ // silently price at $0.
95
+ const MODEL_ALIASES = { fable: "claude-fable-5-1", opus: "claude-opus-5", sonnet: "claude-sonnet-5", haiku: "claude-haiku-4-5" };
96
+
97
+ export function normModel(model) {
98
+ const m = model.replace(/\[[^\]]*\]$/, "").replace(/-\d{8}$/, "");
99
+ return MODEL_ALIASES[m] || m;
100
+ }
101
+
102
+ export function rowCost(row) {
103
+ const p = PRICING[normModel(row.model)];
104
+ if (!p) return 0;
105
+ const [inP, outP] = p;
106
+ return (
107
+ (row.input * inP + row.cw5m * 1.25 * inP + row.cw1h * 2 * inP + row.cacheRead * 0.1 * inP + row.output * outP) / 1e6
108
+ );
109
+ }
110
+
111
+ // Claude Code stores transcripts under ~/.claude/projects/<slug>, where the
112
+ // slug is the project path with every non-alphanumeric char replaced by "-".
113
+ function transcriptDir(dir) {
114
+ return join(homedir(), ".claude", "projects", dir.replace(/[^a-zA-Z0-9]/g, "-"));
115
+ }
116
+
117
+ function parseArgs(argv) {
118
+ const opts = { json: false, sessions: false, runs: false, since: null, project: null };
119
+ for (let i = 0; i < argv.length; i++) {
120
+ const a = argv[i];
121
+ if (a === "--json") opts.json = true;
122
+ else if (a === "--runs") opts.runs = true;
123
+ else if (a === "--sessions") opts.sessions = true;
124
+ else if (a === "--since") opts.since = argv[++i];
125
+ else if (a === "--project") { opts.project = argv[++i]; if (!opts.project) die("--project expects a directory"); }
126
+ else if (a === "--help" || a === "-h") opts.help = true;
127
+ else die(`unknown argument: ${a}`);
128
+ }
129
+ if (opts.since && !/^\d{4}-\d{2}-\d{2}$/.test(opts.since)) die("--since expects YYYY-MM-DD");
130
+ return opts;
131
+ }
132
+
133
+ // One row per API request. Called for every assistant record; the first record
134
+ // for a requestId establishes the bucket and the input/cache figures, later
135
+ // records only raise output. Keyed globally: a request can legitimately appear
136
+ // in two files (e.g. a resumed session), and collapsing it is what we want.
137
+ export function ingest(rows, rec, meta) {
138
+ const u = rec.message && rec.message.usage;
139
+ if (!u) return;
140
+ const id = rec.requestId;
141
+ let row = rows.get(id);
142
+ if (!row) {
143
+ const cw = u.cache_creation_input_tokens || 0;
144
+ const split = u.cache_creation || null;
145
+ row = {
146
+ ...meta,
147
+ model: (rec.message && rec.message.model) || "unknown",
148
+ ts: rec.timestamp || "",
149
+ input: u.input_tokens || 0,
150
+ cacheWrite: cw,
151
+ // no split present → price the aggregate at the cheaper 5m tier
152
+ cw5m: split ? split.ephemeral_5m_input_tokens || 0 : cw,
153
+ cw1h: split ? split.ephemeral_1h_input_tokens || 0 : 0,
154
+ cacheRead: u.cache_read_input_tokens || 0,
155
+ output: 0,
156
+ toolIds: new Set(),
157
+ };
158
+ rows.set(id, row);
159
+ }
160
+ row.output = Math.max(row.output, u.output_tokens || 0);
161
+ // one record per content block → union the ids across the request's records
162
+ for (const b of (rec.message && rec.message.content) || []) {
163
+ if (b && b.type === "tool_use" && b.id) row.toolIds.add(b.id);
164
+ }
165
+ }
166
+
167
+ // Strip the plugin prefix: "projectstore:critic" → "critic". Attribution and
168
+ // agentType both carry it; the lifecycle stage is keyed on the bare name.
169
+ const bare = (s) => (s.includes(":") ? s.slice(s.lastIndexOf(":") + 1) : s);
170
+
171
+ function stageOf(row) {
172
+ return (row.kind !== "main" && STAGE_OF[row.short]) || "other";
173
+ }
174
+
175
+ function eachRecord(file, fn) {
176
+ let raw;
177
+ try {
178
+ raw = readFileSync(file, "utf8");
179
+ } catch {
180
+ return; // a transcript can vanish mid-run; best-effort by design
181
+ }
182
+ for (const line of raw.split("\n")) {
183
+ // Cheap pre-filter: only assistant records carry requestId + usage.
184
+ if (!line || line.indexOf('"requestId"') === -1) continue;
185
+ let rec;
186
+ try {
187
+ rec = JSON.parse(line);
188
+ } catch {
189
+ continue;
190
+ }
191
+ if (rec.type === "assistant" && rec.requestId) fn(rec);
192
+ }
193
+ }
194
+
195
+ // Runs, not requests. For agents this is exact — one subagents/*.meta.json per
196
+ // spawn. For commands there is no start marker, so a run is counted as a
197
+ // contiguous stretch of one skill's requests inside a session (see below).
198
+ export function countCommandRuns(rows, calls) {
199
+ const bySession = new Map();
200
+ for (const row of rows.values()) {
201
+ if (row.kind !== "cmd") continue;
202
+ if (!bySession.has(row.session)) bySession.set(row.session, []);
203
+ bySession.get(row.session).push(row);
204
+ }
205
+ for (const list of bySession.values()) {
206
+ list.sort((a, b) => a.ts.localeCompare(b.ts));
207
+ let prev = null;
208
+ for (const row of list) {
209
+ if (row.bucket !== prev) {
210
+ const k = `${row.session}|${row.bucket}`;
211
+ calls.set(k, (calls.get(k) || 0) + 1);
212
+ }
213
+ prev = row.bucket;
214
+ }
215
+ }
216
+ }
217
+
218
+ function collect(dir, since) {
219
+ const rows = new Map();
220
+ // "<session>|<bucket>" → runs. Keyed per session so the session table sums
221
+ // only its own spawns; keying by bucket alone credited every session with
222
+ // the GLOBAL run count of each bucket it touched (~72% inflation).
223
+ const calls = new Map();
224
+ const runLabels = new Set();
225
+ const keep = (rec) => !since || !rec.timestamp || rec.timestamp.slice(0, 10) >= since;
226
+
227
+ // Subagents FIRST: their attribution is exact, so if the same request also
228
+ // shows up inline in a main transcript, the precise bucket wins the dedup.
229
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
230
+ if (!entry.isDirectory()) continue;
231
+ const subs = join(dir, entry.name, "subagents");
232
+ if (!existsSync(subs)) continue;
233
+ for (const f of readdirSync(subs)) {
234
+ if (!f.endsWith(".meta.json")) continue;
235
+ const jsonl = join(subs, f.replace(/\.meta\.json$/, ".jsonl"));
236
+ if (!existsSync(jsonl)) continue;
237
+ let meta = {};
238
+ try {
239
+ meta = JSON.parse(readFileSync(join(subs, f), "utf8"));
240
+ } catch {
241
+ /* broken meta file should not drop real usage */
242
+ }
243
+ const agentType = meta.agentType || "unknown";
244
+ const desc = meta.description || "";
245
+ const bucket = `agent: ${agentType}`;
246
+ let run = `${bare(agentType)}: ${desc || f.replace(/\.meta\.json$/, "")}`;
247
+ // two spawns can share a description — disambiguate instead of merging
248
+ while (runLabels.has(run)) run += " ²";
249
+ runLabels.add(run);
250
+ const callKey = `${entry.name}|${bucket}`; // per-session: the session table sums these
251
+ let counted = false;
252
+ eachRecord(jsonl, (rec) => {
253
+ if (!keep(rec)) return;
254
+ if (!counted) {
255
+ calls.set(callKey, (calls.get(callKey) || 0) + 1); // one meta file = one spawn
256
+ counted = true;
257
+ }
258
+ ingest(rows, rec, { bucket, kind: "agent", short: bare(agentType), session: entry.name, run });
259
+ });
260
+ }
261
+ }
262
+
263
+ for (const f of readdirSync(dir)) {
264
+ if (!f.endsWith(".jsonl")) continue;
265
+ const session = basename(f, ".jsonl");
266
+ eachRecord(join(dir, f), (rec) => {
267
+ if (!keep(rec)) return;
268
+ // Older Claude Code versions inlined subagent turns into the main
269
+ // transcript instead of a subagents/ file; mark them rather than
270
+ // letting them masquerade as main-thread work.
271
+ const bucket = rec.attributionSkill
272
+ ? `cmd: ${rec.attributionSkill}`
273
+ : rec.attributionPlugin
274
+ ? `cmd: ${rec.attributionPlugin}`
275
+ : rec.isSidechain
276
+ ? "agent: (inline, untyped)"
277
+ : "(main thread, unattributed)";
278
+ const kind = rec.attributionSkill || rec.attributionPlugin ? "cmd" : rec.isSidechain ? "agent" : "main";
279
+ const short = rec.attributionSkill ? bare(rec.attributionSkill) : "";
280
+ ingest(rows, rec, { bucket, kind, short, session, run: "" });
281
+ });
282
+ }
283
+ countCommandRuns(rows, calls);
284
+ return { rows, calls };
285
+ }
286
+
287
+ function isProjectstore(row) {
288
+ return row.kind !== "main" && /(^|[: ])projectstore/.test(row.bucket);
289
+ }
290
+
291
+ export function aggregate(rows, keyFn, calls) {
292
+ const out = new Map();
293
+ for (const row of rows.values()) {
294
+ const k = keyFn(row);
295
+ let g = out.get(k);
296
+ if (!g) {
297
+ g = { key: k, reqs: 0, input: 0, cacheWrite: 0, cacheRead: 0, output: 0, toolCalls: 0, toolTurns: 0, cost: 0, ps: isProjectstore(row), models: new Set(), buckets: new Set(), unpriced: new Set() };
298
+ out.set(k, g);
299
+ }
300
+ g.reqs++;
301
+ g.input += row.input;
302
+ g.cacheWrite += row.cacheWrite;
303
+ g.cacheRead += row.cacheRead;
304
+ g.output += row.output;
305
+ g.toolCalls += row.toolIds.size;
306
+ if (row.toolIds.size > 0) g.toolTurns++;
307
+ g.cost += rowCost(row);
308
+ if (!PRICING[normModel(row.model)]) g.unpriced.add(row.model);
309
+ g.models.add(row.model);
310
+ g.buckets.add(`${row.session}|${row.bucket}`);
311
+ }
312
+ for (const g of out.values()) {
313
+ g.runs = calls ? [...g.buckets].reduce((n, b) => n + (calls.get(b) || 0), 0) : 0;
314
+ }
315
+ return [...out.values()].sort((a, b) => b.cost - a.cost);
316
+ }
317
+
318
+ const n = (v) => v.toLocaleString("en-US");
319
+
320
+ // `t/turn` — avg distinct tool calls per tool-using turn (batching level; 1.0
321
+ // means fully sequential). `cost` — USD at current list prices; cache reads
322
+ // bill at 0.1× input, so token counts and dollars rank differently.
323
+ const usd = (v) => `$${v.toFixed(2)}`;
324
+
325
+ function table(groups, label, { runs = false } = {}) {
326
+ const w = Math.max(label.length, ...groups.map((g) => g.key.length));
327
+ const cols = (a, b, c, d, e, f, g2, h) =>
328
+ `${a.padEnd(w)} ${b.padStart(5)} ${c.padStart(6)} ${d.padStart(6)} ${e.padStart(13)} ${f.padStart(10)} ${g2.padStart(8)}${runs ? ` ${h.padStart(9)}` : ""}`;
329
+ const head = cols(label, "runs", "reqs", "t/turn", "cache_rd", "output", "cost", "cost/run");
330
+ const lines = [head, "─".repeat(head.length)];
331
+ for (const g of groups) {
332
+ lines.push(cols(
333
+ g.key, g.runs ? n(g.runs) : "—", n(g.reqs),
334
+ g.toolTurns ? (g.toolCalls / g.toolTurns).toFixed(2) : "—",
335
+ n(g.cacheRead), n(g.output), usd(g.cost),
336
+ g.runs ? usd(g.cost / g.runs) : "—",
337
+ ));
338
+ }
339
+ return lines.join("\n");
340
+ }
341
+
342
+ function totals(groups) {
343
+ const t = { reqs: 0, input: 0, cacheWrite: 0, cacheRead: 0, output: 0, cost: 0 };
344
+ for (const g of groups) {
345
+ t.reqs += g.reqs;
346
+ t.input += g.input;
347
+ t.cacheWrite += g.cacheWrite;
348
+ t.cacheRead += g.cacheRead;
349
+ t.output += g.output;
350
+ t.cost += g.cost;
351
+ }
352
+ return t;
353
+ }
354
+
355
+ function main() {
356
+ const opts = parseArgs(process.argv.slice(2));
357
+ if (opts.help) {
358
+ process.stdout.write(
359
+ "Usage: node scripts/tokens.mjs [--runs] [--sessions] [--since YYYY-MM-DD] [--json] [--project <dir>]\n",
360
+ );
361
+ return;
362
+ }
363
+
364
+ const dir = transcriptDir(opts.project || projectRoot());
365
+ if (!existsSync(dir)) die(`no transcripts for this project (looked in ${dir})`);
366
+
367
+ const { rows, calls } = collect(dir, opts.since);
368
+ if (rows.size === 0) die(opts.since ? `no requests since ${opts.since}` : "no assistant requests found");
369
+
370
+ const byBucket = aggregate(rows, (r) => r.bucket, calls);
371
+ const byStage = aggregate([...rows.values()].filter(isProjectstore), stageOf, calls)
372
+ .sort((a, b) => STAGE_ORDER.indexOf(a.key) - STAGE_ORDER.indexOf(b.key));
373
+ const ps = byBucket.filter((g) => g.ps);
374
+ const rest = byBucket.filter((g) => !g.ps);
375
+ const psT = totals(ps);
376
+ const restT = totals(rest);
377
+ const allT = totals(byBucket);
378
+
379
+ if (opts.json) {
380
+ const shape = (g) => ({
381
+ bucket: g.key,
382
+ runs: g.runs || null,
383
+ requests: g.reqs,
384
+ tool_calls: g.toolCalls,
385
+ tool_turns: g.toolTurns,
386
+ input: g.input,
387
+ cache_write: g.cacheWrite,
388
+ cache_read: g.cacheRead,
389
+ output: g.output,
390
+ cost_usd: +g.cost.toFixed(4),
391
+ models: [...g.models],
392
+ ...(g.unpriced.size ? { unpriced_models: [...g.unpriced] } : {}),
393
+ });
394
+ process.stdout.write(
395
+ JSON.stringify(
396
+ {
397
+ transcript_dir: dir,
398
+ since: opts.since,
399
+ sessions: new Set([...rows.values()].map((r) => r.session)).size,
400
+ pricing_usd_per_mtok: PRICING,
401
+ buckets: byBucket.map(shape),
402
+ stages: byStage.map(shape),
403
+ totals: Object.fromEntries(
404
+ Object.entries({ projectstore: psT, other: restT, all: allT }).map(([k, t]) => [k, {
405
+ requests: t.reqs, input: t.input, cache_write: t.cacheWrite,
406
+ cache_read: t.cacheRead, output: t.output, cost_usd: +t.cost.toFixed(4),
407
+ }]),
408
+ ),
409
+ ...(opts.runs ? { agent_runs: aggregate([...rows.values()].filter((r) => r.run), (r) => r.run).map(shape) } : {}),
410
+ ...(opts.sessions ? { by_session: aggregate(rows, (r) => r.session, calls).map(shape) } : {}),
411
+ },
412
+ null,
413
+ 2,
414
+ ) + "\n",
415
+ );
416
+ return;
417
+ }
418
+
419
+ const sessions = new Set([...rows.values()].map((r) => r.session)).size;
420
+ const out = [
421
+ `projectstore token usage — ${n(rows.size)} requests across ${sessions} session(s)${opts.since ? `, since ${opts.since}` : ""}`,
422
+ `source: ${dir}`,
423
+ "",
424
+ table(byBucket, "bucket", { runs: true }),
425
+ "",
426
+ "vault work by lifecycle stage:",
427
+ table(byStage, "stage", { runs: true }),
428
+ "",
429
+ `vault work ${n(psT.reqs).padStart(6)} reqs cache_rd ${n(psT.cacheRead)} out ${n(psT.output)} cost ${usd(psT.cost)}`,
430
+ `everything else${n(restT.reqs).padStart(5)} reqs cache_rd ${n(restT.cacheRead)} out ${n(restT.output)} cost ${usd(restT.cost)}`,
431
+ ];
432
+ const pct = (a, b) => (b ? `${((a / b) * 100).toFixed(1)}%` : "—");
433
+ out.push(
434
+ "",
435
+ `vault share: requests ${pct(psT.reqs, allT.reqs)} output ${pct(psT.output, allT.output)} cost ${pct(psT.cost, allT.cost)} of ${usd(allT.cost)}`,
436
+ );
437
+ const unpriced = new Set(byBucket.flatMap((g) => [...g.unpriced]));
438
+ if (unpriced.size) out.push(`⚠ unpriced models (cost counted as $0): ${[...unpriced].join(", ")}`);
439
+
440
+ if (opts.runs) {
441
+ out.push("", "per agent run:", table(aggregate([...rows.values()].filter((r) => r.run), (r) => r.run), "run"));
442
+ }
443
+ if (opts.sessions) {
444
+ out.push("", table(aggregate(rows, (r) => r.session, calls), "session", { runs: true }));
445
+ }
446
+ process.stdout.write(out.join("\n") + "\n");
447
+ }
448
+
449
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();