@attalabs/vinaya 0.22.0 → 0.23.0

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 (188) hide show
  1. package/README.md +13 -3
  2. package/aeg-root/contracts/archivist-tranche-archivist.md +2 -2
  3. package/aeg-root/contracts/brief-developer.md +1 -0
  4. package/aeg-root/contracts/developer-reviewer.md +3 -3
  5. package/aeg-root/contracts/reviewer-archivist.md +3 -3
  6. package/aeg-root/enforcement.md +26 -20
  7. package/aeg-root/process.md +8 -4
  8. package/aeg-root/roles/archivist.md +2 -2
  9. package/aeg-root/roles/brief-author.md +9 -3
  10. package/aeg-root/roles/developer.md +63 -31
  11. package/aeg-root/roles/planner.md +1 -1
  12. package/aeg-root/roles/principal.md +14 -0
  13. package/aeg-root/roles/reviewer.md +32 -15
  14. package/aeg-root/roles/security.md +15 -5
  15. package/aeg-root/skills/aeg/SKILL.md +9 -5
  16. package/aeg-root/skills/aeg-roles/SKILL.md +2 -2
  17. package/aeg-root/skills/brief-authoring/SKILL.md +39 -27
  18. package/aeg-root/state-machine.md +9 -11
  19. package/aeg-root/templates/brief-template.md +6 -6
  20. package/aeg-root/templates/pr-report-template.md +10 -7
  21. package/aeg-root/tranche-model.md +6 -2
  22. package/dist/checks/bin/check-body-bare-digits.js +552 -139
  23. package/dist/checks/bin/check-branch-topology.js +603 -144
  24. package/dist/checks/bin/check-brief-shape.js +593 -142
  25. package/dist/checks/bin/check-changeset-coverage.js +935 -161
  26. package/dist/checks/bin/check-closes-n.js +603 -144
  27. package/dist/checks/bin/check-coherence.js +614 -147
  28. package/dist/checks/bin/check-dead-branch-push.js +552 -139
  29. package/dist/checks/bin/check-dispatch-readiness.js +612 -145
  30. package/dist/checks/bin/check-doc-coverage-push.js +689 -218
  31. package/dist/checks/bin/check-doc-coverage.js +689 -218
  32. package/dist/checks/bin/check-doctrine-no-procedures.js +4794 -0
  33. package/dist/checks/bin/check-doctrine-portability.js +684 -146
  34. package/dist/checks/bin/check-evidence-fresh.js +994 -174
  35. package/dist/checks/bin/check-exec-bits.js +4931 -0
  36. package/dist/checks/bin/check-first-push-dispatch.js +603 -144
  37. package/dist/checks/bin/check-issue-assignment.js +603 -144
  38. package/dist/checks/bin/check-main-branch-refusal.js +552 -139
  39. package/dist/checks/bin/check-no-disk-state.js +552 -139
  40. package/dist/checks/bin/check-pr-report-density.js +4461 -0
  41. package/dist/checks/bin/check-quoted-command.js +621 -155
  42. package/dist/checks/bin/check-reader-resolvable-prose.js +617 -154
  43. package/dist/checks/bin/check-registry-gates.js +595 -144
  44. package/dist/checks/bin/check-retired-vocabulary.js +617 -154
  45. package/dist/checks/bin/check-review-gate.js +670 -141
  46. package/dist/checks/bin/check-single-plan-pr.js +552 -139
  47. package/dist/checks/bin/check-test-plan.js +553 -140
  48. package/dist/checks/bin/check-token-collection-wired.js +552 -139
  49. package/dist/checks/bin/check-token-report.js +552 -139
  50. package/dist/checks/bin/check-workspace-escape.js +985 -145
  51. package/dist/index.js +8500 -5518
  52. package/package.json +1 -1
  53. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/actions.ts +173 -0
  54. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/anchored-region.ts +113 -0
  55. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/archive-task.ts +206 -0
  56. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/baseline-capture.ts +65 -0
  57. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/blast-radius-domains.ts +192 -0
  58. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/branch-topology-gate.ts +85 -0
  59. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-validation.ts +511 -0
  60. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/claude-code-transcript.ts +460 -0
  61. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +787 -0
  62. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-audit.ts +55 -0
  63. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-guard.ts +77 -0
  64. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-section7.ts +66 -0
  65. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-tranche.ts +94 -0
  66. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/diagram-model.ts +372 -0
  67. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/direct-main-push.ts +39 -0
  68. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +224 -0
  69. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doc-owners.ts +314 -0
  70. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/build-doc-nav.ts +38 -0
  71. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/docs-coherence.ts +136 -0
  72. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/index.ts +29 -0
  73. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/legacy-anchors.ts +48 -0
  74. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/nav-helpers.ts +17 -0
  75. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/node-route.ts +134 -0
  76. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/parse-doc.ts +43 -0
  77. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/published-prose.ts +306 -0
  78. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/surfaced-manifest.ts +89 -0
  79. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/types.ts +33 -0
  80. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +170 -0
  81. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-source.ts +23 -0
  82. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/ensure-label.ts +28 -0
  83. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/file-classify.ts +51 -0
  84. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/first-push-dispatch-gate.ts +80 -0
  85. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +168 -0
  86. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +301 -0
  87. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-assignment.ts +102 -0
  88. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-validation.ts +746 -0
  89. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/leftover-detection.ts +68 -0
  90. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/local-anchor-coverage.ts +245 -0
  91. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/main-branch-refusal.ts +64 -0
  92. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/manifest-validity.ts +81 -0
  93. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/markdown-table.ts +76 -0
  94. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/metering-io-guard.ts +119 -0
  95. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/milestone-validation.ts +240 -0
  96. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/no-disk-state.ts +50 -0
  97. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-ledger.ts +77 -0
  98. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-registry.ts +105 -0
  99. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +165 -0
  100. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-tranche.ts +183 -0
  101. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-tier.ts +81 -0
  102. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +125 -0
  103. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +275 -0
  104. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/reader-resolvable-prose.ts +320 -0
  105. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-checks.ts +255 -0
  106. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-parse.ts +164 -0
  107. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-scaffold.ts +216 -0
  108. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/report-tokens.ts +261 -0
  109. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/retired-vocabulary.ts +163 -0
  110. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +269 -0
  111. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/single-plan-pr.ts +73 -0
  112. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-machine-model.ts +269 -0
  113. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-source.ts +14 -0
  114. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/status-block.ts +8 -0
  115. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/sum-ledger.ts +26 -0
  116. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/symbol-collisions.ts +78 -0
  117. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +104 -0
  118. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-section.ts +65 -0
  119. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/types.ts +174 -0
  120. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +140 -0
  121. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/vocabulary-citation.ts +114 -0
  122. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/waiver-label.ts +52 -0
  123. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/workspace-escape.ts +172 -0
  124. package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
  125. package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
  126. package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
  127. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
  128. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
  129. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  130. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  131. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  132. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  133. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  134. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
  135. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  136. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  137. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js.nft.json +1 -1
  138. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
  139. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
  140. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
  141. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
  142. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
  143. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
  144. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
  145. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
  146. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
  147. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
  148. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
  149. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
  150. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
  151. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
  152. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
  153. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
  154. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
  155. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
  156. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
  157. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
  158. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
  159. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
  160. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
  161. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
  162. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/[root-of-the-server]__02a-3g8._.js +1 -1
  163. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/1q96_modules_@clerk_nextjs_dist_esm_app-router_client_keyless-creator-reader_0lom2js.js +1 -1
  164. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0053k9k._.js +1 -1
  165. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0112h-k._.js +1 -1
  166. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0o771t1._.js +1 -1
  167. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
  168. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
  169. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__069-_41._.js → [root-of-the-server]__1mcitst._.js} +1 -1
  170. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
  171. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
  172. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
  173. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
  174. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +1 -1
  175. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
  176. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
  177. package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
  178. package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
  179. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
  180. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
  181. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{37hnluhzj9bei.js → 0jer_drkl1e4r.js} +4 -4
  182. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{267o-tsrnuwnv.js → 20x-t0a0v129z.js} +1 -1
  183. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hufpuody6vjv.js → 2ou_81ahqw31e.js} +1 -1
  184. package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
  185. /package/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/{aeg-core → vinaya/studio-standalone/_node_modules/@attalabs/aeg-core}/bin/verify-coherence.ts +0 -0
  186. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_buildManifest.js +0 -0
  187. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_clientMiddlewareManifest.js +0 -0
  188. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_ssgManifest.js +0 -0
@@ -0,0 +1,460 @@
1
+ /**
2
+ * claude-code-transcript — the Claude Code **collection adapter** for the
3
+ * token-report obligation (`aeg-root/tranche-model.md` §12, layer 2).
4
+ *
5
+ * This file is host-coupled on purpose: it is the only `src/` module whose
6
+ * LOGIC parses a host's own data format. Others name host paths as
7
+ * classification patterns — `file-classify.ts` matches `.claude/skills/` and
8
+ * `CLAUDE.md` — which is a different and weaker kind of coupling.
9
+ * It knows one harness's session-transcript format: JSONL, one object per
10
+ * line, assistant turns carrying a `message.usage` object whose fields are
11
+ * named `input_tokens` / `output_tokens` / `cache_creation_input_tokens` /
12
+ * `cache_read_input_tokens`. None of that is doctrine — it is one vendor's
13
+ * on-disk shape, and an adopter on another harness replaces this module
14
+ * wholesale rather than configuring it.
15
+ *
16
+ * **The seam is `TranscriptSummary`, not the file tree.** An adapter's entire
17
+ * contract is to produce that shape (four integers plus a model id) from
18
+ * whatever its own host exposes. Everything downstream of it — the
19
+ * `formatTokensLine` renderer in `report-tokens.ts`, the `Tokens: …` grammar
20
+ * in `parse-token-report.ts` — is portable and shared by every host. The
21
+ * `bin/` vs `src/` split does NOT mark this boundary and never did:
22
+ * `bin/report-tokens.ts` is equally host-coupled (it knows how Claude Code
23
+ * points a session at its own transcript), while `src/parse-token-report.ts`
24
+ * is fully portable. Splitting this module out of `report-tokens.ts` is what
25
+ * makes the boundary legible in the tree rather than only in prose — the
26
+ * package ships inside the public tarball, where a reader has no other way to
27
+ * tell which half they may reuse.
28
+ *
29
+ * Pure — no `fs`, no `process.env`, per this package's purity charter. The
30
+ * CLI shim reads the file; this takes its text.
31
+ */
32
+
33
+ import { createHash } from 'node:crypto'
34
+ import type { TranscriptSummary, UsageComponents } from './report-tokens'
35
+
36
+ /**
37
+ * Sum usage across every unique assistant message in a session transcript
38
+ * (JSONL — one object per line). A single API turn is frequently split
39
+ * across several JSONL entries (a thinking block, a tool_use block, a text
40
+ * block, …) that each carry an identical copy of that turn's `usage` object
41
+ * under the same `message.id` — confirmed against a live transcript, where
42
+ * 59 assistant-typed lines held only 22 unique message ids. Summing every
43
+ * line naively over-counts by as much as 3x. Dedup by `message.id` first; a
44
+ * line missing an id or a usage object is skipped, not guessed at.
45
+ */
46
+ export function summarizeTranscript(jsonl: string): TranscriptSummary {
47
+ const seen = new Set<string>()
48
+ const components: UsageComponents = {
49
+ inputTokens: 0,
50
+ outputTokens: 0,
51
+ cacheCreationInputTokens: 0,
52
+ cacheReadInputTokens: 0
53
+ }
54
+ let model: string | null = null
55
+ let messageCount = 0
56
+
57
+ for (const rawLine of jsonl.split(/\r?\n/)) {
58
+ const line = rawLine.trim()
59
+ if (!line) continue
60
+
61
+ let entry: unknown
62
+ try {
63
+ entry = JSON.parse(line)
64
+ } catch {
65
+ continue
66
+ }
67
+ if (!entry || typeof entry !== 'object') continue
68
+ const obj = entry as Record<string, unknown>
69
+ if (obj.type !== 'assistant') continue
70
+
71
+ const message = obj.message as Record<string, unknown> | undefined
72
+ const id = message?.id
73
+ const usage = message?.usage as Record<string, unknown> | undefined
74
+ if (typeof id !== 'string' || !id || !usage || seen.has(id)) continue
75
+ seen.add(id)
76
+ messageCount++
77
+
78
+ components.inputTokens += numberOr(usage.input_tokens, 0)
79
+ components.outputTokens += numberOr(usage.output_tokens, 0)
80
+ components.cacheCreationInputTokens += numberOr(usage.cache_creation_input_tokens, 0)
81
+ components.cacheReadInputTokens += numberOr(usage.cache_read_input_tokens, 0)
82
+
83
+ const messageModel = message?.model
84
+ if (typeof messageModel === 'string' && messageModel) model = messageModel
85
+ }
86
+
87
+ return { components, model, messageCount }
88
+ }
89
+
90
+ function numberOr(value: unknown, fallback: number): number {
91
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
92
+ }
93
+
94
+ /**
95
+ * `resolveMeteringCapability`'s I/O — injected, never imported directly, per
96
+ * this package's purity charter (`fs`/`process.env` stay out of `src/`). The
97
+ * real caller (`apps/cli`'s `tokens`/`doctor` commands) supplies
98
+ * `existsSync`/`readFileSync`/`process.env`/`process.cwd()`; tests supply
99
+ * fakes.
100
+ */
101
+ export type MeteringCapabilityDeps = {
102
+ env: Record<string, string | undefined>
103
+ cwd: string
104
+ exists: (path: string) => boolean
105
+ readFile: (path: string) => string
106
+ }
107
+
108
+ export type MeteringIncapableReason =
109
+ | 'no-transcript-resolved'
110
+ | 'pointer-unusable'
111
+ | 'transcript-unreadable'
112
+ | 'transcript-empty'
113
+
114
+ export type MeteringCapability =
115
+ | { capable: true; transcriptPath: string; summary: TranscriptSummary }
116
+ | { capable: false; reason: MeteringIncapableReason; detail: string }
117
+
118
+ /**
119
+ * Collapses every run of non-alphanumeric characters to a single `-`. Kept
120
+ * (unchanged) as the READABLE prefix of a pointer key and as the sole
121
+ * derivation for a pre-migration (legacy) pointer filename — see
122
+ * `legacyTranscriptPointerPath`. On its own it is not collision-resistant:
123
+ * `/a/b` and `/a-b` both collapse to `-a-b` (`#315`).
124
+ */
125
+ function sanitizeKey(value: string): string {
126
+ return value.replace(/[^A-Za-z0-9]+/g, '-')
127
+ }
128
+
129
+ /**
130
+ * Collision-resistant (`#315`). `sanitizeKey` alone collapses distinct paths
131
+ * that differ only in which non-alphanumeric characters they use — a full
132
+ * SHA-256 digest of the UNCOLLAPSED, UNTRUNCATED original `value` restores
133
+ * distinctness: two different `value`s can share a `sanitizeKey` prefix, but
134
+ * cannot share this digest without an actual SHA-256 collision, which is
135
+ * cryptographically negligible regardless of how many paths ever run through
136
+ * this on one machine. The digest is not truncated — truncating to, say, 64
137
+ * bits would trade that guarantee for a birthday-bound one; the two extra
138
+ * lines of filename length buy a strictly stronger property, and a pointer
139
+ * filename is never displayed to a person who needs it short. `sanitizeKey`'s
140
+ * output is kept as a prefix purely so the filename still hints at which
141
+ * project it belongs to when a human is looking at a `/tmp` listing — the
142
+ * digest suffix, not the prefix, is what the uniqueness guarantee rests on.
143
+ */
144
+ function collisionResistantKey(value: string): string {
145
+ const digest = createHash('sha256').update(value).digest('hex')
146
+ return `${sanitizeKey(value)}-${digest}`
147
+ }
148
+
149
+ /**
150
+ * Mirrors `bin/report-tokens.ts`'s own pointer-file convention (a
151
+ * `track-transcript.sh` Stop hook, keyed by `CLAUDE_PROJECT_DIR`) so a repo
152
+ * that already wires that hook gets probe support for free. Deliberately
153
+ * duplicated rather than imported from `bin/`: `bin/` is I/O-shim code that
154
+ * imports `src/`, never the reverse, and is not part of this package's
155
+ * published `exports` map — the whole reason this probe exists is to work
156
+ * where that path is unreachable.
157
+ *
158
+ * This is the PRIMARY (post-migration, `#315`) pointer path — collision-
159
+ * resistant. `resolvePointer` also consults `legacyTranscriptPointerPath`
160
+ * as a fallback, so a pointer the shipped Stop hook already wrote under the
161
+ * old, collision-prone name is still found rather than orphaned.
162
+ */
163
+ function transcriptPointerPath(projectDir: string, tmpDir: string): string {
164
+ return `${tmpDir}/claude-transcript-${collisionResistantKey(projectDir)}.txt`
165
+ }
166
+
167
+ /**
168
+ * The pre-`#315` pointer filename — `sanitizeKey` alone, collision-prone.
169
+ * Never the primary read or write target going forward; consulted only when
170
+ * `transcriptPointerPath` is absent, so a pointer a not-yet-upgraded
171
+ * `track-transcript.sh` (or one written before this fix shipped) already has
172
+ * on disk is still readable. Removing this fallback would orphan every such
173
+ * pointer the moment this fix ships, which `#315`'s Acceptance forbids.
174
+ */
175
+ function legacyTranscriptPointerPath(projectDir: string, tmpDir: string): string {
176
+ return `${tmpDir}/claude-transcript-${sanitizeKey(projectDir)}.txt`
177
+ }
178
+
179
+ /**
180
+ * Resolves a transcript path to probe — `explicitTranscriptPath` (the
181
+ * caller's own `--transcript`) wins outright, exactly like
182
+ * `bin/report-tokens.ts`'s `--transcript`. Otherwise consults the Stop-hook
183
+ * pointer file, applying the same `CLAUDE_CODE_SESSION_ID` staleness
184
+ * cross-check that adapter uses — a stale pointer left by a previous session
185
+ * sharing this worktree must not be silently trusted.
186
+ *
187
+ * Returns an error string rather than throwing: this is the
188
+ * "nothing to even try" case (Stop-and-escalate's "no transcript exists"),
189
+ * kept distinct in `resolveMeteringCapability` from a resolved-but-unreadable
190
+ * path.
191
+ */
192
+ type PointerResolution =
193
+ | { path: string; corroborated: boolean }
194
+ /**
195
+ * `corroborated` — we read the pointer's session id and it is ours.
196
+ * `oursByLocation` — we could NOT read an id, but the file sits at this
197
+ * project's own pointer path and is owned by this user. Two different
198
+ * grounds, deliberately not one field: an earlier revision set
199
+ * `corroborated: Boolean(currentSessionId)` on branches where the id was
200
+ * never read, which asserted a match that had not been established and put
201
+ * three shipped docs at odds with the code.
202
+ */
203
+ | { error: string; pointerExisted: boolean; corroborated?: boolean; oursByLocation?: boolean }
204
+
205
+ function resolvePointer(explicitTranscriptPath: string | undefined, deps: MeteringCapabilityDeps): PointerResolution {
206
+ // A caller-named transcript is self-corroborating: they told us which file
207
+ // is theirs, so there is no session to cross-check it against.
208
+ if (explicitTranscriptPath) return { path: explicitTranscriptPath, corroborated: true }
209
+
210
+ const projectDir = deps.env.CLAUDE_PROJECT_DIR ?? deps.cwd
211
+ const tmpDir = deps.env.TMPDIR ?? '/tmp'
212
+ const primaryPointerPath = transcriptPointerPath(projectDir, tmpDir)
213
+ const legacyPointerPath = legacyTranscriptPointerPath(projectDir, tmpDir)
214
+
215
+ // Corroboration = we can tell this pointer belongs to THIS session. Without
216
+ // `CLAUDE_CODE_SESSION_ID` there is nothing to cross-check against, so a
217
+ // pointer left by an earlier session is indistinguishable from our own. A
218
+ // plain human terminal is exactly that case, and gating its commits on
219
+ // another session's leftovers is the false positive `#272` names as the
220
+ // expensive failure mode.
221
+ const currentSessionId = deps.env.CLAUDE_CODE_SESSION_ID
222
+
223
+ // `#315` migration: the primary (collision-resistant) path wins when both
224
+ // exist — it is what any Stop hook upgraded past this fix writes. The
225
+ // legacy path is consulted ONLY when the primary is absent, so a pointer
226
+ // written before this fix shipped (or by a not-yet-upgraded hook script)
227
+ // is still found rather than orphaned.
228
+ const pointerPath = deps.exists(primaryPointerPath)
229
+ ? primaryPointerPath
230
+ : deps.exists(legacyPointerPath)
231
+ ? legacyPointerPath
232
+ : undefined
233
+
234
+ if (!pointerPath) {
235
+ return {
236
+ pointerExisted: false,
237
+ corroborated: false,
238
+ error:
239
+ `No transcript pointer at ${primaryPointerPath} (nor its pre-migration name ${legacyPointerPath}) ` +
240
+ 'and no --transcript given. Either this repo installs no track-transcript.sh Stop hook (lacking one ' +
241
+ 'is not a defect — name the transcript directly instead), or no session has completed a turn yet.'
242
+ }
243
+ }
244
+
245
+ let contents: string
246
+ try {
247
+ // Trailing newline only — NEVER `.trim()`. The shipped Stop hook writes
248
+ // `(hook.session_id || "") + "\t" + transcript_path`, so a Stop payload
249
+ // carrying no `session_id` produces a pointer beginning with a TAB.
250
+ // `.trim()` ate that leading tab, `split('\t')` then found no separator,
251
+ // and a pointer naming a present, readable, summarizable transcript was
252
+ // classified malformed — refusing every commit on a host that meters
253
+ // perfectly. Proven end to end against the real hook body.
254
+ contents = deps.readFile(pointerPath).replace(/\r?\n+$/, '')
255
+ } catch (err) {
256
+ return {
257
+ pointerExisted: true,
258
+ // NOT a corroboration claim: an unreadable pointer's session id is never
259
+ // read, so nothing here can show whose it is. The refusal rests on a
260
+ // different and sufficient ground — this is OUR project's pointer path,
261
+ // holding a file WE own, which we cannot use. That is broken wiring
262
+ // whoever wrote it, and it is repaired by removing the file.
263
+ oursByLocation: true,
264
+ error:
265
+ `Transcript pointer at ${pointerPath} could not be read: ${(err as Error).message}. ` +
266
+ 'Remove that file to clear this — the Stop hook rewrites it on the next turn.'
267
+ }
268
+ }
269
+
270
+ const [pointerSessionId, transcriptPath] = contents.split('\t')
271
+ if (!transcriptPath) {
272
+ return {
273
+ pointerExisted: true,
274
+ // Same ground as the unreadable branch, and NOT a corroboration claim: a
275
+ // malformed pointer carries no parseable session id, so whose it is
276
+ // cannot be established. Ours by location and ownership, and unusable.
277
+ oursByLocation: true,
278
+ // Bounded: this echoes an on-disk file's contents into check output,
279
+ // which in the hook path reaches commit output and CI logs. The guard
280
+ // upstream proves the file is owned by this user, so it is not
281
+ // attacker-controlled — but an unbounded echo is still wrong.
282
+ error:
283
+ `Transcript pointer file ${pointerPath} is malformed: "${contents.slice(0, 120)}${contents.length > 120 ? '…' : ''}". ` +
284
+ 'Remove that file to clear this — the Stop hook rewrites it on the next turn.'
285
+ }
286
+ }
287
+
288
+ if (currentSessionId && pointerSessionId && currentSessionId !== pointerSessionId) {
289
+ // `corroborated: false`, and the distinction is the whole point: the ids
290
+ // DISAGREE, so this pointer is provably NOT this session's. Two reviewers
291
+ // reached opposite conclusions here and the second is right. Treating a
292
+ // stale pointer as a wiring defect refuses a correctly wired host: a second
293
+ // session in the same project directory sees the first session's pointer
294
+ // until its own Stop hook fires, which by construction is only after its
295
+ // first turn completes — so its very first commit is blocked, and the
296
+ // remedy the message names (`--transcript`) is a flag `vinaya check` does
297
+ // not accept, leaving no action that clears the refusal.
298
+ //
299
+ // It also makes `corroborated` mean one thing rather than two. Everywhere
300
+ // else it answers "can we show this pointer is ours"; setting it true here
301
+ // made it mean "we could tell, and it wasn't", which is what put the three
302
+ // shipped docs at odds with the code.
303
+ return {
304
+ pointerExisted: true,
305
+ corroborated: false,
306
+ error:
307
+ `Transcript pointer at ${pointerPath} is stale: written for session ${pointerSessionId}, ` +
308
+ `but this session is ${currentSessionId}. Name your own transcript with --transcript instead of ` +
309
+ "reporting another session's figures as yours."
310
+ }
311
+ }
312
+
313
+ return { path: transcriptPath, corroborated: Boolean(currentSessionId && pointerSessionId) }
314
+ }
315
+
316
+ /**
317
+ * Probes whether this host can currently produce real token figures —
318
+ * capable/incapable, never declared by host identity (`vinaya doctor`'s
319
+ * whole reason for calling this rather than checking `process.env` itself).
320
+ * Distinguishes the two failure classes the Stop-and-escalate condition
321
+ * names: `no-transcript-resolved` (nothing to even try — no `--transcript`,
322
+ * no pointer file) is a different fact from `transcript-unreadable` (a path
323
+ * was resolved but the file can't be read) or `transcript-empty` (the file
324
+ * reads but summarizes to zero assistant messages — empty, unparseable, or
325
+ * not yet flushed). Conflating any of these into one "incapable" bit would
326
+ * reproduce the false "host has no usage" claim this tranche removes.
327
+ *
328
+ * `explicitTranscriptPath`, when given, wins outright over pointer-file
329
+ * discovery — the caller's own `--transcript` is always better evidence than
330
+ * any inference this function could make.
331
+ */
332
+ export function resolveMeteringCapability(
333
+ deps: MeteringCapabilityDeps,
334
+ explicitTranscriptPath?: string
335
+ ): MeteringCapability {
336
+ const resolved = resolvePointer(explicitTranscriptPath, deps)
337
+ if ('error' in resolved) {
338
+ // A pointer that EXISTS but cannot be used is a wiring defect, not an
339
+ // absence of wiring — but only when we can corroborate it is ours.
340
+ // Uncorroborated, it is indistinguishable from another session's leftover
341
+ // and degrades to the sanctioned operator-metered case.
342
+ // Refuse when the pointer is ours on EITHER ground: its id matches, or it
343
+ // is a file we own at our own pointer path that we cannot use. Pass when
344
+ // it is provably another session's (the stale branch sets neither).
345
+ const ours = resolved.pointerExisted && (resolved.corroborated || resolved.oursByLocation)
346
+ if (ours) return { capable: false, reason: 'pointer-unusable', detail: resolved.error }
347
+ // Degraded, so the detail is rewritten to match. An earlier revision
348
+ // degraded only the reason on this path, leaving a `no-transcript-resolved`
349
+ // verdict whose detail described a pointer that HAD resolved — the
350
+ // contradictory pair that reaches `vinaya doctor` and `pr report`'s token
351
+ // cell. The original condition is kept, marked as diagnostic.
352
+ return {
353
+ capable: false,
354
+ reason: 'no-transcript-resolved',
355
+ detail: resolved.pointerExisted
356
+ ? `A transcript pointer exists but belongs to another session, so this session has no wiring of its own to use. (Underlying condition, for diagnosis only: ${resolved.error})`
357
+ : resolved.error
358
+ }
359
+ }
360
+
361
+ // Downstream transcript failures gate only on a corroborated pointer, for the
362
+ // same reason: a stale pointer naming a since-pruned transcript must not
363
+ // refuse a human's commit.
364
+ // Returns the reason AND a detail consistent with it. An earlier revision
365
+ // degraded only the reason and kept the original detail, so an incapable
366
+ // verdict could read `no-transcript-resolved` while its detail said a
367
+ // transcript HAD been resolved and was unreadable. That pair leaks past this
368
+ // check into `vinaya doctor` and into `pr report`'s token cell, which is the
369
+ // false-provenance class this whole tranche exists to remove.
370
+ const downstream = (
371
+ r: 'transcript-unreadable' | 'transcript-empty',
372
+ detail: string
373
+ ): { reason: MeteringIncapableReason; detail: string } =>
374
+ resolved.corroborated
375
+ ? { reason: r, detail }
376
+ : {
377
+ reason: 'no-transcript-resolved',
378
+ detail: `A transcript pointer was found but could not be shown to belong to this session, so it is not treated as this session's wiring. (Underlying condition, for diagnosis only: ${detail})`
379
+ }
380
+
381
+ if (!deps.exists(resolved.path)) {
382
+ return {
383
+ capable: false,
384
+ // "not readable as a regular file owned by this user" rather than "does
385
+ // not exist": the shipped check's `exists` dep is `lstat`-hardened, so a
386
+ // symlinked, foreign-owned, or non-regular transcript reports false here
387
+ // for a path that does exist. Saying "does not exist" of such a file is
388
+ // both wrong and unactionable.
389
+ ...downstream(
390
+ 'transcript-unreadable',
391
+ `Resolved transcript path ${resolved.path} is not readable as a regular file owned by this user.`
392
+ )
393
+ }
394
+ }
395
+
396
+ let jsonl: string
397
+ try {
398
+ jsonl = deps.readFile(resolved.path)
399
+ } catch (err) {
400
+ return {
401
+ capable: false,
402
+ ...downstream(
403
+ 'transcript-unreadable',
404
+ `Transcript at ${resolved.path} could not be read: ${(err as Error).message}`
405
+ )
406
+ }
407
+ }
408
+
409
+ const summary = summarizeTranscript(jsonl)
410
+ if (summary.messageCount === 0) {
411
+ return {
412
+ capable: false,
413
+ ...downstream(
414
+ 'transcript-empty',
415
+ `Transcript at ${resolved.path} yielded zero assistant messages with usage data — ` +
416
+ "it's empty, unparseable, or not yet flushed to disk."
417
+ )
418
+ }
419
+ }
420
+
421
+ return { capable: true, transcriptPath: resolved.path, summary }
422
+ }
423
+
424
+ /**
425
+ * The one fact `token-collection-wired` (task 5, #272) gates a commit on,
426
+ * factored out here — contract-agnostic (a plain boolean, no `CheckError`
427
+ * shape) — so both the shipped `apps/cli` check and this repo's own
428
+ * self-hosting `bin/check-token-collection-wired.ts` gate consume the SAME
429
+ * predicate, never two copies (the `isNewDiskStateFile` precedent).
430
+ *
431
+ * `no-transcript-resolved` means this session has no corroborated wiring to
432
+ * try — no pointer file at all, or a pointer it cannot show is its own. That
433
+ * is the sanctioned operator-metered case, never a defect.
434
+ *
435
+ * Every other reason means a pointer BOTH existed AND was corroborated as this
436
+ * session's, and reaching the figures still failed: `pointer-unusable` (the
437
+ * pointer itself is unreadable, malformed, or stale) or `transcript-unreadable`
438
+ * / `transcript-empty` (the path it named could not be read or held nothing).
439
+ * Those are the wiring defect this predicate flags.
440
+ *
441
+ * The corroboration condition is load-bearing in BOTH directions, and an
442
+ * earlier revision got both wrong. Without it, a pointer left in a shared
443
+ * `TMPDIR` by an unrelated session refuses a plain human's commit (a false
444
+ * positive `#272` names as the expensive failure mode); and folding every
445
+ * pointer failure into `no-transcript-resolved` let an unreadable, malformed,
446
+ * or stale pointer pass silently — the exact wired-but-unreachable state this
447
+ * check exists to refuse.
448
+ */
449
+ /*
450
+ * Returns a plain boolean, deliberately NOT a type predicate. As a predicate it
451
+ * was unsound: `false` also covers `{capable: false, reason:
452
+ * 'no-transcript-resolved'}`, so the negative branch narrowed to `capable:
453
+ * true` and `cap.summary` compiled clean while throwing at runtime. That is a
454
+ * false capability guarantee handed to every adopter of a published package,
455
+ * and no caller needs the narrowing badly enough to be worth it.
456
+ */
457
+ export function isTokenCollectionWiringBroken(capability: MeteringCapability): boolean {
458
+ if (capability.capable) return false
459
+ return capability.reason !== 'no-transcript-resolved'
460
+ }