jekyll-theme-zer0 1.28.0 → 1.30.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 (191) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1143 -13
  3. data/_data/README.md +2 -0
  4. data/_data/ai.yml +5 -3
  5. data/_data/ai_pricing.yml +36 -0
  6. data/_data/backlog.yml +507 -2
  7. data/_data/consumers.yml +157 -9
  8. data/_data/features.yml +303 -20
  9. data/_data/feedback_types.yml +17 -12
  10. data/_data/i18n/fr.yml +12 -7
  11. data/_data/i18n/manifest.yml +39 -12
  12. data/_data/ingredient_densities.yml +122 -0
  13. data/_data/landing.yml +5 -2
  14. data/_data/navigation/main.yml +16 -0
  15. data/_data/navigation/quickstart.yml +4 -0
  16. data/_data/recipe_courses.yml +64 -0
  17. data/_data/site_builder.yml +874 -0
  18. data/_data/theme-manifest.yml +160 -124
  19. data/_data/ui-text.yml +26 -0
  20. data/_includes/README.md +26 -2
  21. data/_includes/analytics/posthog.html +2 -2
  22. data/_includes/components/admin-links.html +2 -2
  23. data/_includes/components/admin-tabs.html +2 -2
  24. data/_includes/components/ai-chat.html +14 -11
  25. data/_includes/components/analytics-dashboard.html +8 -8
  26. data/_includes/components/author-bio.html +1 -1
  27. data/_includes/components/author-card.html +10 -2
  28. data/_includes/components/author-eeat.html +4 -4
  29. data/_includes/components/background-customizer.html +10 -10
  30. data/_includes/components/background-image.html +114 -0
  31. data/_includes/components/background-settings.html +28 -15
  32. data/_includes/components/collection-manager.html +5 -5
  33. data/_includes/components/component-showcase.html +13 -13
  34. data/_includes/components/config-editor.html +12 -12
  35. data/_includes/components/config-viewer.html +8 -8
  36. data/_includes/components/cookie-consent.html +15 -15
  37. data/_includes/components/cta-button.html +7 -2
  38. data/_includes/components/dev-shortcuts.html +7 -7
  39. data/_includes/components/env-dashboard.html +8 -8
  40. data/_includes/components/env-switcher.html +9 -9
  41. data/_includes/components/feature-card.html +2 -2
  42. data/_includes/components/halfmoon.html +2 -2
  43. data/_includes/components/info-section.html +42 -37
  44. data/_includes/components/js-cdn.html +15 -15
  45. data/_includes/components/language-toggle.html +168 -21
  46. data/_includes/components/mermaid.html +72 -435
  47. data/_includes/components/nanobar.html +5 -5
  48. data/_includes/components/nav-editor.html +2 -2
  49. data/_includes/components/nav-export.html +2 -2
  50. data/_includes/components/nav-overview.html +2 -2
  51. data/_includes/components/page-feedback.html +45 -30
  52. data/_includes/components/page-views-init.html +55 -0
  53. data/_includes/components/page-views.html +33 -0
  54. data/_includes/components/post-card.html +22 -22
  55. data/_includes/components/post-type-badge.html +2 -2
  56. data/_includes/components/powered-by.html +2 -2
  57. data/_includes/components/preview-image.html +6 -0
  58. data/_includes/components/quick-index.html +2 -2
  59. data/_includes/components/recipe-card.html +67 -0
  60. data/_includes/components/recipe-duration.html +50 -0
  61. data/_includes/components/recipe-grams.html +58 -0
  62. data/_includes/components/recipe-index.html +96 -0
  63. data/_includes/components/recipe-ingredients.html +90 -0
  64. data/_includes/components/recipe-meta.html +96 -0
  65. data/_includes/components/recipe-nutrition.html +57 -0
  66. data/_includes/components/recipe-qty.html +73 -0
  67. data/_includes/components/recipe-ratio.html +151 -0
  68. data/_includes/components/recipe-scaler.html +73 -0
  69. data/_includes/components/recipe-steps.html +86 -0
  70. data/_includes/components/recipe-temp.html +45 -0
  71. data/_includes/components/search-modal.html +29 -4
  72. data/_includes/components/searchbar.html +2 -2
  73. data/_includes/components/shortcuts-modal.html +3 -0
  74. data/_includes/components/svg-background.html +2 -2
  75. data/_includes/components/theme-customizer.html +2 -2
  76. data/_includes/components/theme-info.html +14 -7
  77. data/_includes/components/theme-preview-gallery.html +22 -22
  78. data/_includes/content/giscus.html +2 -2
  79. data/_includes/content/intro.html +8 -8
  80. data/_includes/content/jsonld-faq.html +2 -2
  81. data/_includes/content/jsonld-software.html +24 -5
  82. data/_includes/content/seo.html +4 -4
  83. data/_includes/content/sitemap.html +27 -27
  84. data/_includes/content/toc.html +183 -183
  85. data/_includes/core/branding.html +6 -6
  86. data/_includes/core/console-capture.html +32 -74
  87. data/_includes/core/favicon.html +49 -7
  88. data/_includes/core/footer-fabs.html +17 -3
  89. data/_includes/core/footer.html +49 -34
  90. data/_includes/core/head.html +110 -86
  91. data/_includes/core/header.html +76 -54
  92. data/_includes/docs/bootstrap-docs.html +8 -8
  93. data/_includes/landing/landing-install-cards.html +2 -2
  94. data/_includes/landing/landing-quick-links.html +1 -1
  95. data/_includes/navigation/admin-nav.html +2 -2
  96. data/_includes/navigation/nav-tree.html +8 -8
  97. data/_includes/navigation/navbar.html +12 -12
  98. data/_includes/navigation/section-sidebar.html +109 -27
  99. data/_includes/navigation/sidebar-config.html +36 -2
  100. data/_includes/navigation/sidebar-left.html +17 -16
  101. data/_includes/navigation/sidebar-right.html +8 -7
  102. data/_includes/obsidian/full-graph.html +2 -2
  103. data/_includes/setup/claude-session.html +72 -0
  104. data/_includes/setup/prereq-checklist.html +90 -0
  105. data/_includes/setup/wizard.html +924 -222
  106. data/_includes/stats/stats-categories.html +8 -8
  107. data/_includes/stats/stats-header.html +14 -14
  108. data/_includes/stats/stats-metrics.html +14 -14
  109. data/_includes/stats/stats-no-data.html +12 -12
  110. data/_includes/stats/stats-overview.html +6 -6
  111. data/_includes/stats/stats-tags.html +8 -8
  112. data/_layouts/404.html +38 -24
  113. data/_layouts/README.md +2 -0
  114. data/_layouts/admin.html +24 -24
  115. data/_layouts/article.html +43 -33
  116. data/_layouts/author.html +20 -20
  117. data/_layouts/authors.html +2 -2
  118. data/_layouts/book-abc.html +12 -12
  119. data/_layouts/book-story.html +15 -15
  120. data/_layouts/book.html +12 -12
  121. data/_layouts/collection.html +33 -33
  122. data/_layouts/cookbook.html +88 -0
  123. data/_layouts/default.html +31 -28
  124. data/_layouts/home.html +23 -23
  125. data/_layouts/index.html +10 -10
  126. data/_layouts/landing.html +17 -17
  127. data/_layouts/news.html +44 -44
  128. data/_layouts/note.html +38 -38
  129. data/_layouts/notebook.html +34 -34
  130. data/_layouts/recipe.html +274 -0
  131. data/_layouts/root.html +92 -55
  132. data/_layouts/section.html +62 -33
  133. data/_layouts/setup.html +3 -3
  134. data/_layouts/sitemap-collection.html +49 -49
  135. data/_layouts/stats.html +40 -40
  136. data/_layouts/tag.html +12 -12
  137. data/_layouts/welcome.html +21 -21
  138. data/_sass/components/_callout.scss +1 -1
  139. data/_sass/components/_footer.scss +37 -1
  140. data/_sass/components/_mermaid.scss +375 -0
  141. data/_sass/components/_page-views.scss +36 -0
  142. data/_sass/components/_recipe.scss +506 -0
  143. data/_sass/components/_setup-wizard.scss +764 -0
  144. data/_sass/components/_ui-enhancements.scss +6 -6
  145. data/_sass/core/_navbar.scss +261 -46
  146. data/_sass/layouts/_landing.scss +2 -2
  147. data/_sass/layouts/_navbar-extras.scss +14 -4
  148. data/_sass/tokens/_color.scss +6 -0
  149. data/_sass/tokens/_index.scss +2 -0
  150. data/_sass/tokens/_radius.scss +21 -0
  151. data/_sass/tokens/_typography.scss +4 -0
  152. data/_sass/utilities/_focus.scss +14 -0
  153. data/assets/css/main.scss +4 -0
  154. data/assets/js/ai-chat.js +47 -5
  155. data/assets/js/fleet-feedback-capture.js +124 -0
  156. data/assets/js/fleet-feedback.js +853 -0
  157. data/assets/js/mermaid-diagrams.js +1267 -0
  158. data/assets/js/modules/navigation/config.js +9 -6
  159. data/assets/js/modules/navigation/navbar.js +55 -0
  160. data/assets/js/modules/navigation/scroll-spy.js +315 -80
  161. data/assets/js/modules/theme/appearance.js +8 -2
  162. data/assets/js/obsidian-wiki-links.js +8 -3
  163. data/assets/js/page-feedback.js +125 -192
  164. data/assets/js/page-views.js +372 -0
  165. data/assets/js/recipe-scaler.js +501 -0
  166. data/assets/js/search-modal.js +36 -0
  167. data/assets/js/setup-wizard.js +2279 -226
  168. data/assets/js/site-builder.js +1834 -0
  169. data/assets/js/ui-enhancements.js +11 -3
  170. data/scripts/README.md +44 -0
  171. data/scripts/ai/README.md +38 -0
  172. data/scripts/ai/api_call.rb +124 -0
  173. data/scripts/ai/usage.rb +314 -0
  174. data/scripts/ai/usage_report.rb +225 -0
  175. data/scripts/bin/audit-consumer +39 -7
  176. data/scripts/bin/giscus-discussions +213 -14
  177. data/scripts/bin/manifest +35 -12
  178. data/scripts/ci/agent_review_result.py +164 -0
  179. data/scripts/ci/test_agent_review_result.py +172 -0
  180. data/scripts/ci/test_visual_evidence_autogen.py +341 -0
  181. data/scripts/ci/visual_evidence_autogen.py +1060 -0
  182. data/scripts/content-review.rb +20 -1
  183. data/scripts/design-system-check.rb +170 -0
  184. data/scripts/lib/audit.sh +42 -2
  185. data/scripts/lint-liquid-raw.rb +137 -0
  186. data/scripts/test/integration/mermaid +22 -8
  187. data/scripts/test/lib/run_tests.sh +3 -1
  188. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  189. data/scripts/test/lib/test_visual_evidence_autogen.sh +24 -0
  190. data/scripts/translate.rb +94 -16
  191. metadata +48 -2
@@ -101,12 +101,20 @@
101
101
  }
102
102
 
103
103
  /**
104
- * Add active state to navigation links on scroll
104
+ * Add active state to in-page navigation links on scroll.
105
+ *
106
+ * Deliberately excludes `#TableOfContents` links (and the section-page
107
+ * sidebar): those are owned by assets/js/modules/navigation/scroll-spy.js.
108
+ * This observer used to claim every `a[href^="#"]` on the page and clear
109
+ * `.active` from all of them, which fought the TOC scroll spy and made the
110
+ * active heading flicker between entries.
105
111
  */
106
112
  function initScrollSpy() {
107
113
  const sections = document.querySelectorAll('section[id], [id^="get-started"], [id^="features"]');
108
- const navLinks = document.querySelectorAll('a[href^="#"]');
109
-
114
+ const navLinks = Array.from(document.querySelectorAll('a[href^="#"]')).filter(
115
+ link => !link.closest('#TableOfContents, .bd-toc, .sidebar-nav')
116
+ );
117
+
110
118
  if (!sections.length || !navLinks.length) return;
111
119
 
112
120
  const observerOptions = {
data/scripts/README.md CHANGED
@@ -25,6 +25,15 @@ scripts/
25
25
  │ ├── pixelate_images.py # Pure-stdlib pixelate/quantize engine
26
26
  │ ├── install-preview-generator # Preview generator installer
27
27
  │ └── validate_preview_urls.py # Preview URL validator
28
+ ├── ai/ # Consumer companions to the hub's `ai-runner` kit (the runner itself is referenced, not stored)
29
+ │ ├── api_call.rb # Single-shot Claude API fallback (stdlib only)
30
+ │ ├── usage.rb # Optional metering: one JSONL record per call
31
+ │ ├── usage_report.rb # Publishes metering (summary, artifact, PR comment)
32
+ │ └── README.md # What stays here and why
33
+ ├── ci/ # Helpers called directly by workflow steps
34
+ │ ├── classify_changes.py # Change classifier (issue-pr-auto-merge)
35
+ │ ├── agent_review_result.py # Did the Claude content review actually run?
36
+ │ └── test_agent_review_result.py # …its tests (CI-run via test/lib bridge)
28
37
  ├── utils/ # Utility scripts
29
38
  │ ├── analyze-commits # Commit analyzer for version bumps
30
39
  │ ├── fix-markdown # Markdown formatting fixer
@@ -171,6 +180,41 @@ Validate preview image URLs in frontmatter.
171
180
  python3 scripts/features/validate_preview_urls.py [--verbose] [--suggestions]
172
181
  ```
173
182
 
183
+ ### AI Runner (scripts/ai/)
184
+
185
+ Every model call a workflow makes goes through `uses: bamr87/bamr87/.github/actions/claude-run@main` — the fleet's shared **`ai-runner` kit**, versioned once in the [bamr87/bamr87 hub](https://github.com/bamr87/bamr87) and consumed by reference. The runner (`run.sh`) and the composite action are no longer stored in this repo; the hub's kit README ([`templates/ai-runner/README.md`](https://github.com/bamr87/bamr87/blob/main/templates/ai-runner/README.md)) is the contract. In short:
186
+
187
+ - Model: `--model` input > `AI_MODEL` > `_data/ai.yml` `model:` > the fleet default.
188
+ - Auth from the env, OAuth first: `CLAUDE_CODE_OAUTH_TOKEN` (preferred; strips `ANTHROPIC_API_KEY` from the CLI's env) or `ANTHROPIC_API_KEY` (also the only credential the API fallback can use).
189
+ - Exit codes: `0` when the call ran or nothing was ever attempted (no auth — the documented no-op); `1` when the call was attempted and failed with no usable fallback, with the reason raised as a `::error::` annotation.
190
+
191
+ This directory holds the **consumer-owned companions** the hub runner probes for in the checkout and uses when present: `usage.rb` metering (prices from `_data/ai_pricing.yml`), `usage_report.rb` publishing, `api_call.rb` fallback — plus `tools/unwrap-prose.py` as the post-run prose normalizer. See [`scripts/ai/README.md`](ai/README.md). The exit-code contract test lives in the hub (`templates/ai-runner/tests/contract.sh`) and runs there on every runner change.
192
+
193
+ ### CI Helpers (scripts/ci/)
194
+
195
+ #### `agent_review_result.py`
196
+
197
+ Decides whether the Claude tier of `ai-content-review.yml` **actually produced a review**, and fails the job when it did not.
198
+
199
+ Before this existed, the agent step captured the Claude CLI's exit status, echoed it, and then posted whatever landed on stdout as the review. When the OAuth credential was revoked, `Failed to authenticate. API Error: 401 OAuth access token has been revoked.` became the "review" and every job still reported success (issue #418) — for days, across several PRs, with nothing on the checks list to say the editorial review had stopped happening.
200
+
201
+ Note that a non-zero exit code would not have caught it: the CLI reported the auth failure on stdout. Three conditions are classified — a non-zero exit, no output at all, and a short output that leads with a known CLI failure signature. Any of them writes an explicit failure notice for the sticky comment, emits a `::error::` annotation, and exits 1.
202
+
203
+ ```bash
204
+ python3 scripts/ci/agent_review_result.py \
205
+ --status "$status" --stdout /tmp/agent-review.md \
206
+ --stderr /tmp/agent-err.log --out /tmp/agent-review-final.md
207
+ # exit 0 = the review is real · 1 = the tier failed · 2 = bad invocation
208
+
209
+ python3 scripts/ci/test_agent_review_result.py # tests, incl. the verbatim #418 output
210
+ ```
211
+
212
+ The tests run in CI through `scripts/test/lib/test_agent_review_result.sh`, which `run_tests.sh` sources and `./scripts/bin/test` executes on every PR.
213
+
214
+ #### `classify_changes.py`
215
+
216
+ Classifies a PR's changed files for `issue-pr-auto-merge.yml` (`--content-only`).
217
+
174
218
  ### Content Validation
175
219
 
176
220
  #### `lint-pages`
@@ -0,0 +1,38 @@
1
+ # scripts/ai — companions to the fleet's `ai-runner` kit
2
+
3
+ Every model call a workflow here makes goes through `uses: bamr87/bamr87/.github/actions/claude-run@main` — the fleet's one AI step, versioned once in the [bamr87/bamr87 hub](https://github.com/bamr87/bamr87) and consumed **by reference**. This repo no longer carries the runner (`scripts/ai/run.sh`) or the composite action (`.github/actions/claude-run/`); it receives every fix on its next run with nothing to copy forward. The kit's contract, environment, and exit codes are documented in the hub: [`templates/ai-runner/README.md`](https://github.com/bamr87/bamr87/blob/main/templates/ai-runner/README.md).
4
+
5
+ What stays here are the **consumer-owned companions** the hub runner probes for in `$GITHUB_WORKSPACE` and uses when present:
6
+
7
+ | File | Role | Required |
8
+ | --- | --- | --- |
9
+ | `usage.rb` | Metering: one JSONL record per call (tokens, API-equivalent cost, model, status, CI context) into `$AI_USAGE_DIR/records.jsonl` (default `$RUNNER_TEMP/ai-usage`, outside the checkout). Prices come from `_data/ai_pricing.yml`. | optional |
10
+ | `usage_report.rb` | End-of-job publisher: step summary, `ai-usage-*` artifact, sticky PR comment (marker `<!-- lh-ai-usage -->`). Run by the hub action's `always()` post-step. | optional |
11
+ | `api_call.rb` | The single-shot Messages API fallback the runner uses when Claude Code is missing or fails. Stdlib only, self-contained. | optional |
12
+ | `../../_data/ai.yml` | `model:` (and `max_tokens` for the API fallback). The runner resolves `--model` > `AI_MODEL` > this file > the fleet default. | recommended |
13
+ | `../../tools/unwrap-prose.py` | Post-run one-paragraph-per-line normalizer for markdown the agent changed. `SCHEMA.md`/`CHANGELOG.md` are always skipped; further exclusions go in a repo-root `.prose-excludes`, one extended regex per line. | optional |
14
+
15
+ Without a companion the runner degrades honestly: no `usage.rb` means nothing is recorded (an inline emitter still treats `is_error` as a failure); no `api_call.rb` means a failed primary call exits `1` with "no scripts/ai/api_call.rb|py to fall back to".
16
+
17
+ ## Contract, in one line
18
+
19
+ Auth from the job env, OAuth first (`CLAUDE_CODE_OAUTH_TOKEN`, then `ANTHROPIC_API_KEY`); canonical `AI_*` environment with no repo prefix (`AI_MODEL`, `AI_FORCE_API`, `AI_MAX_TURNS`, `AI_USAGE_DIR`, `AI_ROLE`, `AI_REPO_ROOT`); exit `0` when the call ran or nothing was attempted (no auth — the documented no-op), exit `1` when a call was attempted and failed with no usable fallback, with the reason raised as a `::error::` annotation. The exit-code contract is pinned by the hub's `templates/ai-runner/tests/contract.sh` (stubbed `claude`, no network, no credentials) and run there on every change to the runner — not vendored here.
20
+
21
+ ## Calling it
22
+
23
+ ```yaml
24
+ - uses: bamr87/bamr87/.github/actions/claude-run@main
25
+ env:
26
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
27
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # optional fallback
28
+ with:
29
+ agent: issue-triager # .claude/agents/<name>.md
30
+ prompt: "..."
31
+ tools: "Read,Grep,Glob,Bash(gh:*)"
32
+ ```
33
+
34
+ Inputs: `prompt`, `agent`, `tools`, `mcp`, `system`, `out`, `model`, `max-turns`. Callers in this repo: `issue-autopilot.yml` (triage, verify, resolve) and `visual-evidence-autogen.yml`. Where a lane is the standard shape (gate on a `*_ENABLED` variable, run one agent, open one PR), the hub also offers the reusable `bamr87/bamr87/.github/workflows/ai-lane.yml@main`; the matrix-fed autopilot is deliberately not that shape.
35
+
36
+ ## Metering companions are kit files too
37
+
38
+ `usage.rb`, `usage_report.rb`, and `api_call.rb` are the same files lifehacker.dev runs. Fix them there first and copy forward (`shasum` parity is the check); never fork them here.
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env ruby
2
+ # =============================================================================
3
+ # api_call.rb — the Claude API fallback (single-shot Messages call)
4
+ # -----------------------------------------------------------------------------
5
+ # Used by scripts/ai/run.sh when the Claude Code CLI is unavailable or fails.
6
+ # Calls POST /v1/messages over Ruby's stdlib (net/http + json) — NO gem — so it
7
+ # runs on a bare runner just like the rest of scripts/. Reads ANTHROPIC_API_KEY
8
+ # and _data/ai.yml. Prints the model's text to stdout.
9
+ #
10
+ # Auth note: this path is ANTHROPIC_API_KEY-only by design. A Claude Code OAuth
11
+ # token (CLAUDE_CODE_OAUTH_TOKEN) authenticates the `claude` CLI, NOT the raw
12
+ # Messages API, so it cannot be used here. run.sh selects the credential and only
13
+ # reaches this fallback when an API key is present; with only an OAuth token the
14
+ # primary Claude Code path runs and this fallback is intentionally unavailable.
15
+ #
16
+ # This is a degraded path: a single message in, the final text out — not the
17
+ # full agent. It covers the text-generation/analysis steps (a review comment, a
18
+ # draft, a classification); steps that need multi-file edits should run under
19
+ # Claude Code (the primary), which run.sh tries first.
20
+ #
21
+ # ruby scripts/ai/api_call.rb --prompt "..." [--system "..."]
22
+ # echo "..." | ruby scripts/ai/api_call.rb
23
+ # =============================================================================
24
+ require 'net/http'
25
+ require 'uri'
26
+ require 'json'
27
+ require 'yaml'
28
+
29
+ # kit: ai-runner — stdlib only and self-contained (no repo-local library), so the
30
+ # same file runs in every repo that carries scripts/ai/run.sh.
31
+ ROOT = File.expand_path('../..', __dir__)
32
+ cfg = begin
33
+ raw = File.read(File.join(ROOT, '_data', 'ai.yml'), encoding: 'UTF-8')
34
+ (YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(raw) : YAML.load(raw)) || {}
35
+ rescue StandardError
36
+ {}
37
+ end
38
+ MODEL = ENV['AI_MODEL'] || cfg['fallback_model'] || cfg['model'] || 'claude-opus-4-8'
39
+ MAXTOK = (ENV['AI_MAX_TOKENS'] || cfg['max_tokens'] || 8000).to_i
40
+ VERSION = cfg['api_version'] || '2023-06-01'
41
+ BASE = cfg['api_base'] || 'https://api.anthropic.com'
42
+
43
+ key = ENV['ANTHROPIC_API_KEY'].to_s
44
+ abort '[api_call] ANTHROPIC_API_KEY not set — cannot use the Claude API fallback' if key.empty?
45
+
46
+ # --- parse args: --prompt/-p, --system, else stdin -------------------------
47
+ args = ARGV.dup
48
+ system_prompt = nil
49
+ if (i = args.index('--system'))
50
+ system_prompt = args[i + 1]
51
+ args.delete_at(i + 1); args.delete_at(i)
52
+ end
53
+ prompt =
54
+ if (i = (args.index('--prompt') || args.index('-p'))) then args[i + 1]
55
+ elsif !args.empty? then args.join(' ')
56
+ else $stdin.read
57
+ end
58
+ abort '[api_call] empty prompt' if prompt.to_s.strip.empty?
59
+
60
+ body = { 'model' => MODEL, 'max_tokens' => MAXTOK,
61
+ 'messages' => [{ 'role' => 'user', 'content' => prompt }] }
62
+ body['system'] = system_prompt if system_prompt
63
+
64
+ # Dry run: print the request shape without calling the API (for tests).
65
+ if ENV['AI_DRY_RUN'] == '1'
66
+ puts JSON.pretty_generate('endpoint' => "#{BASE}/v1/messages", 'model' => MODEL,
67
+ 'max_tokens' => MAXTOK, 'anthropic_version' => VERSION,
68
+ 'has_system' => !system_prompt.nil?)
69
+ exit 0
70
+ end
71
+
72
+ uri = URI.join(BASE, '/v1/messages')
73
+ http = Net::HTTP.new(uri.host, uri.port)
74
+ http.use_ssl = (uri.scheme == 'https')
75
+ http.read_timeout = 120
76
+ http.open_timeout = 20
77
+
78
+ attempt = 0
79
+ loop do
80
+ attempt += 1
81
+ req = Net::HTTP::Post.new(uri)
82
+ req['x-api-key'] = key
83
+ req['anthropic-version'] = VERSION
84
+ req['content-type'] = 'application/json'
85
+ req.body = JSON.generate(body)
86
+
87
+ res = (http.request(req) rescue nil)
88
+ code = res ? res.code.to_i : 0
89
+
90
+ if code == 200
91
+ data = JSON.parse(res.body)
92
+ # Meter the call (tokens were billed either way — refusals included). The
93
+ # record is an ESTIMATE from _data/ai_pricing.yml: the raw API reports
94
+ # usage but not dollars. Best-effort — a metering bug must never break
95
+ # the actual AI step.
96
+ begin
97
+ require_relative 'usage'
98
+ AIUsage.append(AIUsage.from_api_response(data, agent: ENV['AI_ROLE'].to_s))
99
+ rescue StandardError => e
100
+ warn "[api_call] usage record failed (non-fatal): #{e.class}: #{e.message}"
101
+ end
102
+ if data['stop_reason'] == 'refusal'
103
+ warn '[api_call] request refused by safety classifiers'
104
+ exit 2
105
+ end
106
+ text = (data['content'] || []).select { |b| b['type'] == 'text' }.map { |b| b['text'] }.join
107
+ warn "[api_call] Claude API fallback ok (model=#{data['model']}, stop=#{data['stop_reason']})"
108
+ puts text
109
+ exit 0
110
+ elsif code == 401 || code == 403
111
+ # Auth failure is not transient — don't retry, and say so plainly so a
112
+ # misconfigured/expired/insufficient ANTHROPIC_API_KEY is diagnosable instead
113
+ # of hiding behind the generic HTTP error below.
114
+ warn "[api_call] authentication failed (HTTP #{code}) — the ANTHROPIC_API_KEY is missing, invalid, expired, or lacks access. #{res && res.body.to_s[0, 200]}"
115
+ exit 1
116
+ elsif (code.zero? || [429, 500, 502, 503, 529].include?(code)) && attempt < 4
117
+ warn "[api_call] transient (HTTP #{code}), retry #{attempt}/3"
118
+ sleep([2**attempt, 30].min)
119
+ next
120
+ else
121
+ warn "[api_call] HTTP #{code}: #{res && res.body.to_s[0, 300]}"
122
+ exit 1
123
+ end
124
+ end
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env ruby
2
+ # =============================================================================
3
+ # usage.rb — capture ONE normalized usage record per AI invocation
4
+ # -----------------------------------------------------------------------------
5
+ # The metering half of the universal AI runner. Every model call in the repo
6
+ # already flows through scripts/ai/run.sh (Claude Code) or scripts/ai/api_call.rb
7
+ # (API fallback) — this library turns each call's raw usage payload into one
8
+ # JSONL record so tokens and cost are never invisible. Records accumulate in
9
+ # AI_USAGE_DIR (default: $RUNNER_TEMP/ai-usage — OUTSIDE the checkout, so
10
+ # agents never see a dirty tree), and scripts/ai/usage_report.rb publishes them
11
+ # (step summary + artifact + PR comment) at the end of the job.
12
+ #
13
+ # Cost semantics: Claude Code reports its own total_cost_usd — recorded verbatim
14
+ # (cost_source: "reported"). That figure is what the tokens would bill at API
15
+ # list prices; under subscription OAuth the marginal dollar cost is $0, so every
16
+ # surface labels it "API-equivalent". Paths that report tokens but no dollars
17
+ # (the raw API fallback) get cost_source: "estimated" from _data/ai_pricing.yml.
18
+ #
19
+ # ruby scripts/ai/usage.rb ingest-claude <result.json> [--agent X] [--rc N] [--emit-result]
20
+ # ruby scripts/ai/usage.rb ingest-execution-log <log.json> [--agent X]
21
+ #
22
+ # ingest-claude exits non-zero when the file is not a Claude result JSON, so
23
+ # run.sh can treat "unparseable output" like a failed run (fallback engages).
24
+ # Stdlib only — no gems — so it runs on a bare runner before `bundle install`.
25
+ # =============================================================================
26
+ require 'json'
27
+ require 'yaml'
28
+ require 'time'
29
+ require 'digest'
30
+ require 'fileutils'
31
+
32
+ module AIUsage
33
+ ROOT = File.expand_path('../..', __dir__)
34
+
35
+ module_function
36
+
37
+ def dir
38
+ ENV['AI_USAGE_DIR'].to_s.empty? ? File.join(ENV['RUNNER_TEMP'] || ENV['TMPDIR'] || '/tmp', 'ai-usage') : ENV['AI_USAGE_DIR']
39
+ end
40
+
41
+ def records_path
42
+ File.join(dir, 'records.jsonl')
43
+ end
44
+
45
+ # --- pricing (estimates only — reported costs are always preferred) --------
46
+ def pricing
47
+ @pricing ||= begin
48
+ raw = YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(File.read(File.join(ROOT, '_data', 'ai_pricing.yml'))) : YAML.load(File.read(File.join(ROOT, '_data', 'ai_pricing.yml')))
49
+ raw.is_a?(Hash) ? raw : {}
50
+ rescue StandardError
51
+ {}
52
+ end
53
+ end
54
+
55
+ def price_for(model)
56
+ models = pricing['models'] || {}
57
+ key = models.keys.reject { |k| k == 'default' }
58
+ .select { |k| model.to_s.start_with?(k) }
59
+ .max_by(&:length)
60
+ models[key] || models['default'] || { 'input' => 5.0, 'output' => 25.0 }
61
+ end
62
+
63
+ # tokens: {'input'=>, 'output'=>, 'cache_read'=>, 'cache_5m'=>, 'cache_1h'=>}
64
+ # Reproduces the CLI's own arithmetic (validated to the cent, 2026-07-14).
65
+ def estimate_cost(model, tokens)
66
+ p = price_for(model)
67
+ m = pricing['multipliers'] || {}
68
+ inp = p['input'].to_f / 1_000_000
69
+ out = p['output'].to_f / 1_000_000
70
+ tokens['input'].to_i * inp +
71
+ tokens['output'].to_i * out +
72
+ tokens['cache_read'].to_i * inp * (m['cache_read'] || 0.1).to_f +
73
+ tokens['cache_5m'].to_i * inp * (m['cache_write_5m'] || 1.25).to_f +
74
+ tokens['cache_1h'].to_i * inp * (m['cache_write_1h'] || 2.0).to_f
75
+ end
76
+
77
+ # --- shared context ---------------------------------------------------------
78
+ def auth_mode
79
+ return 'oauth' unless ENV['CLAUDE_CODE_OAUTH_TOKEN'].to_s.empty?
80
+ return 'api_key' unless ENV['ANTHROPIC_API_KEY'].to_s.empty?
81
+ 'none'
82
+ end
83
+
84
+ # One safe, bounded line out of a model-supplied error string: credentials
85
+ # masked, whitespace collapsed, length capped. These records ship as build
86
+ # artifacts and land in the public ledger, so nothing verbatim goes in.
87
+ MAX_ERROR_CHARS = 500
88
+ def scrub(text)
89
+ text.to_s
90
+ .gsub(/sk-ant-[A-Za-z0-9_-]{8,}/, 'sk-ant-***')
91
+ .gsub(/\s+/, ' ')
92
+ .strip[0, MAX_ERROR_CHARS]
93
+ .to_s
94
+ end
95
+
96
+ def ci_context
97
+ {
98
+ 'repo' => ENV['GITHUB_REPOSITORY'].to_s,
99
+ 'workflow' => ENV['GITHUB_WORKFLOW'].to_s,
100
+ 'job' => ENV['GITHUB_JOB'].to_s,
101
+ 'run_id' => ENV['GITHUB_RUN_ID'].to_s,
102
+ 'run_attempt' => ENV['GITHUB_RUN_ATTEMPT'].to_s,
103
+ 'event' => ENV['GITHUB_EVENT_NAME'].to_s,
104
+ 'ref' => ENV['GITHUB_REF_NAME'].to_s,
105
+ 'sha' => ENV['GITHUB_SHA'].to_s
106
+ }
107
+ end
108
+
109
+ def base_record(source:, agent:, ts: nil)
110
+ {
111
+ 'id' => nil, # set by the ingester (stable per payload)
112
+ 'ts' => (ts || Time.now.utc).iso8601,
113
+ 'source' => source, # claude-code | api-fallback | claude-code-action
114
+ 'status' => 'success',
115
+ 'agent' => agent.to_s,
116
+ 'model' => '',
117
+ 'auth' => auth_mode,
118
+ 'tokens' => { 'input' => 0, 'output' => 0, 'cache_read' => 0, 'cache_creation' => 0 },
119
+ 'model_usage' => {},
120
+ 'cost_usd' => 0.0,
121
+ 'cost_source' => 'reported',
122
+ 'duration_ms' => nil,
123
+ 'num_turns' => nil,
124
+ 'session_id' => nil,
125
+ 'pr' => nil, # attributed later by usage_report.rb
126
+ 'pr_source' => nil # 'event' (ran on the PR) | 'created' (run opened it)
127
+ }.merge(ci_context)
128
+ end
129
+
130
+ def stable_id(payload_anchor)
131
+ Digest::SHA256.hexdigest("#{ENV['GITHUB_RUN_ID']}|#{ENV['GITHUB_JOB']}|#{payload_anchor}")[0, 16]
132
+ end
133
+
134
+ # --- ingesters ---------------------------------------------------------------
135
+ # A `claude -p --output-format json` result object (the probed 2026 schema:
136
+ # total_cost_usd, usage{...}, modelUsage{<id>=>{...costUSD}}, num_turns, uuid).
137
+ def from_claude_result(res, agent: '', exit_code: 0, source: 'claude-code')
138
+ rec = base_record(source: source, agent: agent)
139
+ rec['id'] = stable_id(res['uuid'] || res['session_id'] || Digest::SHA256.hexdigest(res.to_s))
140
+ rec['status'] = (res['is_error'] || exit_code.to_i != 0) ? 'error' : 'success'
141
+ rec['duration_ms'] = res['duration_ms']
142
+ rec['num_turns'] = res['num_turns']
143
+ rec['session_id'] = res['session_id']
144
+ # WHY it failed, not just that it did. A rejected call reports zero tokens
145
+ # and an empty model, so without this the record is indistinguishable from
146
+ # any other dead run and the caller's evidence is already gone.
147
+ if rec['status'] == 'error'
148
+ rec['error'] = {
149
+ 'subtype' => res['subtype'].to_s,
150
+ 'exit_code' => exit_code.to_i,
151
+ 'message' => scrub(res['result'] || res['error'])
152
+ }
153
+ end
154
+
155
+ usage = res['usage'] || {}
156
+ rec['tokens'] = {
157
+ 'input' => usage['input_tokens'].to_i,
158
+ 'output' => usage['output_tokens'].to_i,
159
+ 'cache_read' => usage['cache_read_input_tokens'].to_i,
160
+ 'cache_creation' => usage['cache_creation_input_tokens'].to_i
161
+ }
162
+
163
+ mu = res['modelUsage'] || {}
164
+ mu.each do |model, u|
165
+ rec['model_usage'][model] = {
166
+ 'input' => u['inputTokens'].to_i,
167
+ 'output' => u['outputTokens'].to_i,
168
+ 'cache_read' => u['cacheReadInputTokens'].to_i,
169
+ 'cache_creation' => u['cacheCreationInputTokens'].to_i,
170
+ 'cost_usd' => u['costUSD'].to_f
171
+ }
172
+ end
173
+ # Primary model = the one that did the most output work (subagents may add others).
174
+ rec['model'] = mu.max_by { |_, u| u['outputTokens'].to_i }&.first || ''
175
+
176
+ if res['total_cost_usd']
177
+ rec['cost_usd'] = res['total_cost_usd'].to_f
178
+ rec['cost_source'] = 'reported'
179
+ else
180
+ cache = usage['cache_creation'] || {}
181
+ rec['cost_usd'] = estimate_cost(rec['model'], rec['tokens'].merge(
182
+ 'cache_5m' => cache['ephemeral_5m_input_tokens'].to_i,
183
+ 'cache_1h' => cache['ephemeral_1h_input_tokens'].to_i
184
+ ))
185
+ rec['cost_source'] = 'estimated'
186
+ end
187
+ rec
188
+ end
189
+
190
+ # A raw Messages API response (api_call.rb fallback). No cost field — estimate.
191
+ def from_api_response(data, agent: '')
192
+ rec = base_record(source: 'api-fallback', agent: agent)
193
+ rec['id'] = stable_id(data['id'] || Digest::SHA256.hexdigest(data.to_s))
194
+ rec['model'] = data['model'].to_s
195
+ rec['status'] = data['stop_reason'] == 'refusal' ? 'error' : 'success'
196
+ usage = data['usage'] || {}
197
+ cache = usage['cache_creation'] || {}
198
+ rec['tokens'] = {
199
+ 'input' => usage['input_tokens'].to_i,
200
+ 'output' => usage['output_tokens'].to_i,
201
+ 'cache_read' => usage['cache_read_input_tokens'].to_i,
202
+ 'cache_creation' => usage['cache_creation_input_tokens'].to_i
203
+ }
204
+ rec['cost_usd'] = estimate_cost(rec['model'], rec['tokens'].merge(
205
+ 'cache_5m' => cache['ephemeral_5m_input_tokens'] ? cache['ephemeral_5m_input_tokens'].to_i : usage['cache_creation_input_tokens'].to_i,
206
+ 'cache_1h' => cache['ephemeral_1h_input_tokens'].to_i
207
+ ))
208
+ rec['cost_source'] = 'estimated'
209
+ rec['model_usage'][rec['model']] = rec['tokens'].merge('cost_usd' => rec['cost_usd'])
210
+ rec
211
+ end
212
+
213
+ # A claude-code-action execution log: a JSON array (or NDJSON) of stream
214
+ # events. Prefer the final `result` event (same shape as ingest-claude);
215
+ # fall back to summing per-message assistant usage when the log has none.
216
+ def from_execution_log(text, agent: '')
217
+ events =
218
+ begin
219
+ parsed = JSON.parse(text)
220
+ parsed.is_a?(Array) ? parsed : [parsed]
221
+ rescue JSON::ParserError
222
+ text.split("\n").map { |l| JSON.parse(l) rescue nil }.compact
223
+ end
224
+ result = events.reverse.find { |e| e.is_a?(Hash) && e['type'] == 'result' }
225
+ return from_claude_result(result, agent: agent, source: 'claude-code-action') if result
226
+
227
+ rec = base_record(source: 'claude-code-action', agent: agent)
228
+ rec['id'] = stable_id(Digest::SHA256.hexdigest(text))
229
+ turns = 0
230
+ events.each do |e|
231
+ next unless e.is_a?(Hash) && e['type'] == 'assistant'
232
+ msg = e['message'] || {}
233
+ usage = msg['usage'] || {}
234
+ next if usage.empty?
235
+ turns += 1
236
+ model = msg['model'].to_s
237
+ mu = rec['model_usage'][model] ||= { 'input' => 0, 'output' => 0, 'cache_read' => 0, 'cache_creation' => 0, 'cost_usd' => 0.0 }
238
+ mu['input'] += usage['input_tokens'].to_i
239
+ mu['output'] += usage['output_tokens'].to_i
240
+ mu['cache_read'] += usage['cache_read_input_tokens'].to_i
241
+ mu['cache_creation'] += usage['cache_creation_input_tokens'].to_i
242
+ %w[input output cache_read cache_creation].each { |k| rec['tokens'][k] += mu_key(usage, k) }
243
+ end
244
+ rec['num_turns'] = turns
245
+ rec['model'] = rec['model_usage'].max_by { |_, u| u['output'].to_i }&.first || ''
246
+ rec['model_usage'].each do |model, u|
247
+ u['cost_usd'] = estimate_cost(model, u.merge('cache_5m' => u['cache_creation'], 'cache_1h' => 0))
248
+ rec['cost_usd'] += u['cost_usd']
249
+ end
250
+ rec['cost_source'] = 'estimated'
251
+ rec
252
+ end
253
+
254
+ def mu_key(usage, key)
255
+ { 'input' => usage['input_tokens'], 'output' => usage['output_tokens'],
256
+ 'cache_read' => usage['cache_read_input_tokens'],
257
+ 'cache_creation' => usage['cache_creation_input_tokens'] }[key].to_i
258
+ end
259
+
260
+ # --- sink ---------------------------------------------------------------------
261
+ def append(rec, to: nil)
262
+ path = to || records_path
263
+ FileUtils.mkdir_p(File.dirname(path))
264
+ File.open(path, 'a') { |io| io.puts(JSON.generate(rec)) }
265
+ rec
266
+ end
267
+ end
268
+
269
+ # --- CLI -------------------------------------------------------------------------
270
+ if __FILE__ == $PROGRAM_NAME
271
+ cmd = ARGV.shift
272
+ agent = ''
273
+ rc = 0
274
+ emit = false
275
+ file = nil
276
+ args = ARGV.dup
277
+ until args.empty?
278
+ case (a = args.shift)
279
+ when '--agent' then agent = args.shift.to_s
280
+ when '--rc' then rc = args.shift.to_i
281
+ when '--emit-result' then emit = true
282
+ else file ||= a
283
+ end
284
+ end
285
+
286
+ case cmd
287
+ when 'ingest-claude'
288
+ abort '[usage] no input file' unless file && File.exist?(file)
289
+ res = begin
290
+ JSON.parse(File.read(file, encoding: 'UTF-8'))
291
+ rescue JSON::ParserError, Errno::ENOENT
292
+ warn '[usage] not a Claude result JSON — nothing recorded.'
293
+ exit 65
294
+ end
295
+ unless res.is_a?(Hash) && (res.key?('usage') || res.key?('total_cost_usd'))
296
+ warn '[usage] JSON has no usage payload — nothing recorded.'
297
+ exit 65
298
+ end
299
+ rec = AIUsage.append(AIUsage.from_claude_result(res, agent: agent, exit_code: rc))
300
+ warn "[usage] recorded #{rec['id']}: #{rec['model']} $#{format('%.4f', rec['cost_usd'])} (#{rec['tokens']['output']} out tok, #{rec['status']})"
301
+ warn "[usage] failure: #{[rec['error']['subtype'], rec['error']['message']].reject { |v| v.to_s.empty? }.join(' — ')}" if rec['error']
302
+ # Emit the result text only on success — on error the caller falls back to
303
+ # the API path, and emitting here would double the output it produces.
304
+ print res['result'].to_s if emit && rec['status'] == 'success'
305
+ exit(rec['status'] == 'success' ? 0 : 1)
306
+ when 'ingest-execution-log'
307
+ abort '[usage] no input file' unless file && File.exist?(file)
308
+ rec = AIUsage.append(AIUsage.from_execution_log(File.read(file, encoding: 'UTF-8'), agent: agent))
309
+ warn "[usage] recorded #{rec['id']}: #{rec['model']} $#{format('%.4f', rec['cost_usd'])} (#{rec['source']})"
310
+ else
311
+ abort "usage: usage.rb ingest-claude <result.json> [--agent X] [--rc N] [--emit-result]\n" \
312
+ " usage.rb ingest-execution-log <log.json> [--agent X]"
313
+ end
314
+ end