@caddis/cli 0.2.6 → 0.4.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.
- package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
- package/bundles/antigravity-plugin/agents/knowledge-transfer.md +17 -32
- package/bundles/antigravity-plugin/caddis_statusline.py +825 -0
- package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
- package/bundles/antigravity-plugin/guard_agy.py +60 -9
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +439 -0
- package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +825 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
- package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
- package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
- package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
- package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
- package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
- package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
- package/bundles/antigravity-plugin/scripts/claudster_config.py +121 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
- package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
- package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
- package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
- package/bundles/antigravity-plugin/scripts/new-machine.ps1 +170 -0
- package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
- package/bundles/antigravity-plugin/scripts/oss_model.py +174 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1432 -0
- package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
- package/bundles/antigravity-plugin/session_end_agy.py +52 -8
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +44 -0
- package/bundles/antigravity-plugin/skills/code-review/SKILL.md +11 -0
- package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +21 -0
- package/bundles/antigravity-plugin/skills/gate-review/SKILL.md +11 -0
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +36 -0
- package/bundles/antigravity-plugin/skills/security-review/SKILL.md +9 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
- package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
- package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +31 -0
- package/bundles/antigravity-plugin/warm_start_agy.py +117 -11
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
- package/bundles/codex/.codex/config.toml.example +36 -0
- package/bundles/codex/.codex/skills/coding/api-design/SKILL.md +249 -0
- package/bundles/codex/.codex/skills/coding/backend-development/SKILL.md +305 -0
- package/bundles/codex/.codex/skills/coding/code-review/SKILL.md +140 -0
- package/bundles/codex/.codex/skills/coding/codebase-audit/SKILL.md +204 -0
- package/bundles/codex/.codex/skills/coding/dashboard-perf-sweep/SKILL.md +132 -0
- package/bundles/codex/.codex/skills/coding/dashboard-perf-sweep/scripts/perf_sweep.py +249 -0
- package/bundles/codex/.codex/skills/coding/error-handling/SKILL.md +260 -0
- package/bundles/codex/.codex/skills/coding/fastapi-dev/SKILL.md +300 -0
- package/bundles/codex/.codex/skills/coding/javascript-typescript/SKILL.md +142 -0
- package/bundles/codex/.codex/skills/coding/python/SKILL.md +388 -0
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +486 -0
- package/bundles/codex/.codex/skills/coding/security-review/SKILL.md +205 -0
- package/bundles/codex/.codex/skills/coding/sql/SKILL.md +321 -0
- package/bundles/codex/.codex/skills/data/database-design/SKILL.md +177 -0
- package/bundles/codex/.codex/skills/data/db-diagram/SKILL.md +148 -0
- package/bundles/codex/.codex/skills/data/db-diagram/scripts/sql_to_graph.py +1212 -0
- package/bundles/codex/.codex/skills/devops/ci-cd-pipeline/SKILL.md +315 -0
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +195 -0
- package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +285 -0
- package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +217 -0
- package/bundles/codex/.codex/skills/devops/windows-deployment/SKILL.md +880 -0
- package/bundles/codex/.codex/skills/docs/code-documentation/SKILL.md +271 -0
- package/bundles/codex/.codex/skills/docs/explainer-doc/SKILL.md +97 -0
- package/bundles/codex/.codex/skills/docs/technical-writing/SKILL.md +286 -0
- package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +387 -0
- package/bundles/codex/.codex/skills/frontend/css-architecture/SKILL.md +305 -0
- package/bundles/codex/.codex/skills/frontend/css-architecture/references/RESPONSIVE-DESIGN.md +604 -0
- package/bundles/codex/.codex/skills/frontend/frontend-design/SKILL.md +193 -0
- package/bundles/codex/.codex/skills/frontend/frontend-design/references/my-tech-stack.md +127 -0
- package/bundles/codex/.codex/skills/frontend/mockup/SKILL.md +242 -0
- package/bundles/codex/.codex/skills/frontend/react-best-practices/SKILL.md +269 -0
- package/bundles/codex/.codex/skills/frontend/react-dev/README.md +404 -0
- package/bundles/codex/.codex/skills/frontend/react-dev/SKILL.md +459 -0
- package/bundles/codex/.codex/skills/frontend/react-dev/examples/generic-components.md +579 -0
- package/bundles/codex/.codex/skills/frontend/react-dev/examples/server-components.md +579 -0
- package/bundles/codex/.codex/skills/frontend/react-dev/references/event-handlers.md +574 -0
- package/bundles/codex/.codex/skills/frontend/react-dev/references/hooks.md +456 -0
- package/bundles/codex/.codex/skills/frontend/react-dev/references/react-19-patterns.md +638 -0
- package/bundles/codex/.codex/skills/frontend/react-dev/references/react-router.md +1002 -0
- package/bundles/codex/.codex/skills/frontend/react-dev/references/tanstack-router.md +587 -0
- package/bundles/codex/.codex/skills/frontend/ui-review/SKILL.md +176 -0
- package/bundles/codex/.codex/skills/frontend/ui-review/references/framework-fixes.md +471 -0
- package/bundles/codex/.codex/skills/frontend/ui-review/references/visual-checklist.md +236 -0
- package/bundles/codex/.codex/skills/frontend/warm-editorial-ui/DESIGN_TOKENS.md +487 -0
- package/bundles/codex/.codex/skills/frontend/warm-editorial-ui/IMPLEMENTATION_GUIDE.md +177 -0
- package/bundles/codex/.codex/skills/frontend/warm-editorial-ui/SKILL.md +732 -0
- package/bundles/codex/.codex/skills/media/draw-io/SKILL.md +162 -0
- package/bundles/codex/.codex/skills/media/draw-io/references/aws-icons.md +677 -0
- package/bundles/codex/.codex/skills/media/draw-io/references/layout-guidelines.md +142 -0
- package/bundles/codex/.codex/skills/media/draw-io/references/troubleshooting.md +118 -0
- package/bundles/codex/.codex/skills/media/draw-io/references/workflows.md +103 -0
- package/bundles/codex/.codex/skills/media/draw-io/scripts/convert-drawio-to-png.sh +25 -0
- package/bundles/codex/.codex/skills/media/draw-io/scripts/find_aws_icon.py +79 -0
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/SKILL.md +292 -0
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/references/ENHANCEMENTS.md +264 -0
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/references/MERMAID-SUMMARY.md +137 -0
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/references/advanced-features.md +556 -0
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/references/architecture-diagrams.md +192 -0
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/references/c4-diagrams.md +410 -0
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/references/class-diagrams.md +361 -0
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/references/erd-diagrams.md +510 -0
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/references/flowcharts.md +450 -0
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/references/sequence-diagrams.md +394 -0
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/references/troubleshooting.md +335 -0
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/references/workflows.md +418 -0
- package/bundles/codex/.codex/skills/media/particle-art/SKILL.md +243 -0
- package/bundles/codex/.codex/skills/media/particle-art/references/canvas-utils.ts +171 -0
- package/bundles/codex/.codex/skills/media/particle-art/references/dot-field.template.tsx +203 -0
- package/bundles/codex/.codex/skills/media/particle-art/references/flow-field.template.tsx +263 -0
- package/bundles/codex/.codex/skills/media/particle-art/references/node-shape.template.tsx +261 -0
- package/bundles/codex/.codex/skills/media/particle-art/references/shape-sampler.ts +281 -0
- package/bundles/codex/.codex/skills/media/particle-art/references/stipple-morph.template.tsx +167 -0
- package/bundles/codex/.codex/skills/media/particle-art/references/stipple.template.tsx +175 -0
- package/bundles/codex/.codex/skills/media/particle-art/references/trail-ghost.template.tsx +266 -0
- package/bundles/codex/.codex/skills/media/particle-art/references/usage-examples.md +320 -0
- package/bundles/codex/.codex/skills/testing/playwright/API_REFERENCE.md +653 -0
- package/bundles/codex/.codex/skills/testing/playwright/SKILL.md +454 -0
- package/bundles/codex/.codex/skills/testing/playwright/lib/helpers.js +441 -0
- package/bundles/codex/.codex/skills/testing/playwright/package.json +26 -0
- package/bundles/codex/.codex/skills/testing/playwright/run.js +228 -0
- package/bundles/codex/.codex/skills/testing/tdd-workflow/SKILL.md +188 -0
- package/bundles/codex/.codex/skills/testing/test-strategy/SKILL.md +155 -0
- package/bundles/codex/.codex/skills/testing/webapp-testing/LICENSE.txt +202 -0
- package/bundles/codex/.codex/skills/testing/webapp-testing/SKILL.md +97 -0
- package/bundles/codex/.codex/skills/testing/webapp-testing/examples/console_logging.py +35 -0
- package/bundles/codex/.codex/skills/testing/webapp-testing/examples/element_discovery.py +40 -0
- package/bundles/codex/.codex/skills/testing/webapp-testing/examples/static_html_automation.py +33 -0
- package/bundles/codex/.codex/skills/testing/webapp-testing/scripts/with_server.py +106 -0
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +227 -0
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +214 -0
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +233 -0
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +94 -0
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +118 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/SKILL.md +500 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +263 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +326 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +245 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/references/anti-patterns.md +571 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/references/before-after-examples.md +1114 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/references/best-practices-guide.md +513 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/references/common-workflows.md +692 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/references/prompt-patterns.md +547 -0
- package/bundles/codex/.codex/skills/workflow/brainstorming/SKILL.md +57 -0
- package/bundles/codex/.codex/skills/workflow/context-curator/SKILL.md +157 -0
- package/bundles/codex/.codex/skills/workflow/golden-plan/SKILL.md +586 -0
- package/bundles/codex/.codex/skills/workflow/preflight/SKILL.md +440 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/LICENSE.txt +202 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/SKILL.md +491 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/agents/analyzer.md +274 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/agents/comparator.md +202 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/agents/grader.md +223 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/assets/eval_review.html +146 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/eval-viewer/generate_review.py +471 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/eval-viewer/viewer.html +1325 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/references/schemas.md +430 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/scripts/__init__.py +0 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/scripts/generate_report.py +326 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/scripts/improve_description.py +247 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/scripts/package_skill.py +136 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/scripts/quick_validate.py +103 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/scripts/run_eval.py +310 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/scripts/run_loop.py +328 -0
- package/bundles/codex/.codex/skills/workflow/skill-creator/scripts/utils.py +47 -0
- package/bundles/codex/AGENTS.md +146 -0
- package/bundles/manifest.json +4 -3
- package/dist/claude-deepseek.js +544 -0
- package/dist/claude-glm.js +544 -0
- package/dist/claude-oss.js +544 -0
- package/dist/cli.js +542 -90
- package/package.json +5 -2
- package/bundles/antigravity-plugin/statusline-command-agy.sh +0 -91
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/ArsenalSC-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/ArsenalSC-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/BigShoulders-Bold.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/BigShoulders-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/BigShoulders-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Boldonse-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Boldonse-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/BricolageGrotesque-Bold.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/BricolageGrotesque-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/BricolageGrotesque-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/CrimsonPro-Bold.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/CrimsonPro-Italic.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/CrimsonPro-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/CrimsonPro-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/DMMono-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/DMMono-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/EricaOne-OFL.txt +0 -94
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/EricaOne-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/GeistMono-Bold.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/GeistMono-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/GeistMono-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Gloock-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Gloock-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/IBMPlexMono-Bold.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/IBMPlexMono-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/IBMPlexMono-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/IBMPlexSerif-Bold.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/IBMPlexSerif-BoldItalic.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/IBMPlexSerif-Italic.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/IBMPlexSerif-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/InstrumentSans-Bold.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/InstrumentSans-BoldItalic.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/InstrumentSans-Italic.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/InstrumentSans-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/InstrumentSans-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/InstrumentSerif-Italic.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/InstrumentSerif-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Italiana-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Italiana-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/JetBrainsMono-Bold.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/JetBrainsMono-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/JetBrainsMono-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Jura-Light.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Jura-Medium.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Jura-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/LibreBaskerville-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/LibreBaskerville-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Lora-Bold.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Lora-BoldItalic.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Lora-Italic.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Lora-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Lora-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/NationalPark-Bold.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/NationalPark-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/NationalPark-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/NothingYouCouldDo-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/NothingYouCouldDo-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Outfit-Bold.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Outfit-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Outfit-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/PixelifySans-Medium.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/PixelifySans-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/PoiretOne-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/PoiretOne-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/RedHatMono-Bold.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/RedHatMono-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/RedHatMono-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Silkscreen-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Silkscreen-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/SmoochSans-Medium.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/SmoochSans-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Tektur-Medium.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Tektur-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/Tektur-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/WorkSans-Bold.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/WorkSans-BoldItalic.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/WorkSans-Italic.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/WorkSans-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/WorkSans-Regular.ttf +0 -0
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/YoungSerif-OFL.txt +0 -93
- package/bundles/antigravity-plugin-extras/skills/canvas-design/canvas-fonts/YoungSerif-Regular.ttf +0 -0
|
@@ -0,0 +1,1323 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""caddis /usage-review: local usage analysis + harness self-tuning recommendations.
|
|
3
|
+
|
|
4
|
+
Reads:
|
|
5
|
+
<artifact-dir>/usage-log.jsonl per-session digest (Stop hook). Tries `.caddis/`, then the older
|
|
6
|
+
`.claude/usage-log.jsonl`.
|
|
7
|
+
~/.claude/projects/<slug>/ session transcripts (agent dispatches, context size)
|
|
8
|
+
claude-harness/agents/*.md agent model tiers (frontmatter)
|
|
9
|
+
|
|
10
|
+
Writes (into the repo's `.caddis/`):
|
|
11
|
+
<output-dir>/usage-review.html graph-first HTML dashboard (default: <artifact-dir>/reviews)
|
|
12
|
+
<artifact-dir>/.last-usage-review timestamp for cadence nudge (updated on each run)
|
|
13
|
+
|
|
14
|
+
Prints: markdown report to stdout.
|
|
15
|
+
|
|
16
|
+
CLI: usage_review.py [--days N] [--cwd PATH] [--no-html] [--output-dir DIR]
|
|
17
|
+
"""
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from collections import defaultdict
|
|
25
|
+
from datetime import datetime, timedelta, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
# Shared artifact-dir resolution (claude-harness/scripts/claudster_config.py). usage_review ships
|
|
29
|
+
# from scripts/ but the helper lives with the harness, so add it to the path; the inline fallback
|
|
30
|
+
# keeps a standalone copy of this script working.
|
|
31
|
+
try:
|
|
32
|
+
_HARNESS_SCRIPTS = str(Path(__file__).resolve().parent.parent / "claude-harness" / "scripts")
|
|
33
|
+
if _HARNESS_SCRIPTS not in sys.path:
|
|
34
|
+
sys.path.insert(0, _HARNESS_SCRIPTS)
|
|
35
|
+
from claudster_config import ARTIFACT_DIRS, artifact_root
|
|
36
|
+
except Exception: # pragma: no cover — defensive
|
|
37
|
+
ARTIFACT_DIRS = (".caddis",)
|
|
38
|
+
|
|
39
|
+
def artifact_root(root):
|
|
40
|
+
return Path(root) / ARTIFACT_DIRS[0]
|
|
41
|
+
|
|
42
|
+
_reconfig = getattr(sys.stdout, "reconfigure", None)
|
|
43
|
+
if _reconfig:
|
|
44
|
+
try:
|
|
45
|
+
_reconfig(encoding="utf-8")
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
# ── repo root ────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def _repo_root(start: str) -> str:
|
|
52
|
+
"""Git repo root for `start`, or `start` when not a git repo (best-effort).
|
|
53
|
+
|
|
54
|
+
The Stop hook writes the usage log to the repo root's artifact dir, so the
|
|
55
|
+
review anchors there too — otherwise running it from a subfolder reads an
|
|
56
|
+
empty/partial log. Non-git projects fall back to `start` unchanged.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
out = subprocess.run(
|
|
60
|
+
["git", "-C", start, "rev-parse", "--show-toplevel"],
|
|
61
|
+
capture_output=True, text=True, timeout=3,
|
|
62
|
+
)
|
|
63
|
+
root = out.stdout.strip()
|
|
64
|
+
if out.returncode == 0 and root:
|
|
65
|
+
return root
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
return start
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── model helpers ──────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
def _tier(model: str) -> str:
|
|
74
|
+
m = (model or "").lower()
|
|
75
|
+
if "fable" in m:
|
|
76
|
+
return "fable"
|
|
77
|
+
if "opus" in m:
|
|
78
|
+
return "opus"
|
|
79
|
+
if "sonnet" in m:
|
|
80
|
+
return "sonnet"
|
|
81
|
+
if "haiku" in m:
|
|
82
|
+
return "haiku"
|
|
83
|
+
return "sonnet"
|
|
84
|
+
|
|
85
|
+
TIER_COLORS = {
|
|
86
|
+
"fable": "#B06BC9",
|
|
87
|
+
"opus": "#C9736B",
|
|
88
|
+
"sonnet": "#E8A04C",
|
|
89
|
+
"haiku": "#8FA66B",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# ── project slug → transcript dir ─────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def _transcript_dir(cwd: str) -> Path | None:
|
|
95
|
+
"""Locate ~/.claude/projects/<slug>/ for a project path."""
|
|
96
|
+
p = cwd.replace("\\", "/").rstrip("/")
|
|
97
|
+
p = re.sub(r"^([a-zA-Z]):/", lambda m: m.group(1) + "--", p)
|
|
98
|
+
slug = p.replace("/", "-")
|
|
99
|
+
base = Path.home() / ".claude" / "projects"
|
|
100
|
+
for candidate in [slug, slug.lower()]:
|
|
101
|
+
d = base / candidate
|
|
102
|
+
if d.is_dir():
|
|
103
|
+
return d
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
# ── data loading ───────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
def _usage_log_path(cwd: str) -> str:
|
|
109
|
+
"""The usage log to read: `.caddis/`, then the older `.claude/`.
|
|
110
|
+
|
|
111
|
+
Returns the write-side path (under the dir the repo lives in) when none exists yet, so the
|
|
112
|
+
caller's `isfile` guard and the "no data" message both name the right place.
|
|
113
|
+
"""
|
|
114
|
+
for name in ARTIFACT_DIRS:
|
|
115
|
+
cand = os.path.join(cwd, name, "usage-log.jsonl")
|
|
116
|
+
if os.path.isfile(cand):
|
|
117
|
+
return cand
|
|
118
|
+
legacy = os.path.join(cwd, ".claude", "usage-log.jsonl")
|
|
119
|
+
if os.path.isfile(legacy):
|
|
120
|
+
return legacy
|
|
121
|
+
return os.path.join(str(artifact_root(cwd)), "usage-log.jsonl")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _load_log_window(cwd: str, start: datetime, end: datetime) -> list[dict]:
|
|
125
|
+
"""Return one record per session with ts = first entry, metrics = last entry."""
|
|
126
|
+
path = _usage_log_path(cwd)
|
|
127
|
+
if not os.path.isfile(path):
|
|
128
|
+
return []
|
|
129
|
+
first_ts: dict[str, datetime] = {}
|
|
130
|
+
last_rec: dict[str, dict] = {}
|
|
131
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
132
|
+
for line in fh:
|
|
133
|
+
line = line.strip()
|
|
134
|
+
if not line:
|
|
135
|
+
continue
|
|
136
|
+
try:
|
|
137
|
+
rec = json.loads(line)
|
|
138
|
+
raw_ts = rec.get("ts", "")
|
|
139
|
+
ts = datetime.fromisoformat(raw_ts)
|
|
140
|
+
if not ts.tzinfo:
|
|
141
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
142
|
+
sid = rec.get("session") or f"_unknown_{line[:12]}"
|
|
143
|
+
if sid not in first_ts:
|
|
144
|
+
first_ts[sid] = ts
|
|
145
|
+
last_rec[sid] = rec
|
|
146
|
+
except Exception:
|
|
147
|
+
continue
|
|
148
|
+
result = []
|
|
149
|
+
for sid, rec in last_rec.items():
|
|
150
|
+
ts = first_ts[sid]
|
|
151
|
+
if start <= ts < end:
|
|
152
|
+
r = dict(rec)
|
|
153
|
+
r["ts"] = ts.isoformat()
|
|
154
|
+
r["_session_id"] = sid
|
|
155
|
+
result.append(r)
|
|
156
|
+
return sorted(result, key=lambda r: r["ts"])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def load_usage_log(cwd: str, days: int) -> tuple[list[dict], list[dict]]:
|
|
160
|
+
"""Return (current_window, prev_window) session lists."""
|
|
161
|
+
now = datetime.now(timezone.utc)
|
|
162
|
+
curr_start = now - timedelta(days=days)
|
|
163
|
+
prev_start = now - timedelta(days=2 * days)
|
|
164
|
+
current = _load_log_window(cwd, curr_start, now)
|
|
165
|
+
previous = _load_log_window(cwd, prev_start, curr_start)
|
|
166
|
+
return current, previous
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _parse_transcript(path: str) -> dict:
|
|
170
|
+
"""Extract per-session metrics from a transcript JSONL file."""
|
|
171
|
+
peak_context = 0
|
|
172
|
+
agent_dispatches: dict[str, int] = defaultdict(int)
|
|
173
|
+
agent_models: dict[str, list[str]] = defaultdict(list)
|
|
174
|
+
skill_dispatches: dict[str, int] = defaultdict(int)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
178
|
+
for raw in fh:
|
|
179
|
+
raw = raw.strip()
|
|
180
|
+
if not raw:
|
|
181
|
+
continue
|
|
182
|
+
try:
|
|
183
|
+
ev = json.loads(raw)
|
|
184
|
+
except Exception:
|
|
185
|
+
continue
|
|
186
|
+
msg = ev.get("message") if isinstance(ev.get("message"), dict) else None
|
|
187
|
+
if not msg or msg.get("role") != "assistant":
|
|
188
|
+
continue
|
|
189
|
+
usage = msg.get("usage") or {}
|
|
190
|
+
if isinstance(usage, dict):
|
|
191
|
+
ctx = (int(usage.get("input_tokens") or 0)
|
|
192
|
+
+ int(usage.get("cache_read_input_tokens") or 0))
|
|
193
|
+
peak_context = max(peak_context, ctx)
|
|
194
|
+
content = msg.get("content") or []
|
|
195
|
+
if not isinstance(content, list):
|
|
196
|
+
continue
|
|
197
|
+
for item in content:
|
|
198
|
+
if not isinstance(item, dict) or item.get("type") != "tool_use":
|
|
199
|
+
continue
|
|
200
|
+
name = item.get("name", "")
|
|
201
|
+
inp = item.get("input") or {}
|
|
202
|
+
if name == "Agent":
|
|
203
|
+
atype = inp.get("subagent_type") or "unknown"
|
|
204
|
+
model = inp.get("model") or ""
|
|
205
|
+
agent_dispatches[atype] += 1
|
|
206
|
+
if model:
|
|
207
|
+
agent_models[atype].append(model)
|
|
208
|
+
elif name == "Skill":
|
|
209
|
+
skill = inp.get("skill") or "unknown"
|
|
210
|
+
skill_dispatches[skill] += 1
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
"peak_context": peak_context,
|
|
216
|
+
"agent_dispatches": dict(agent_dispatches),
|
|
217
|
+
"agent_models": {k: list(v) for k, v in agent_models.items()},
|
|
218
|
+
"skill_dispatches": dict(skill_dispatches),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def load_transcripts(cwd: str, session_ids: list[str]) -> dict[str, dict]:
|
|
223
|
+
tdir = _transcript_dir(cwd)
|
|
224
|
+
if not tdir:
|
|
225
|
+
return {}
|
|
226
|
+
result = {}
|
|
227
|
+
for sid in session_ids:
|
|
228
|
+
p = tdir / f"{sid}.jsonl"
|
|
229
|
+
if p.is_file():
|
|
230
|
+
result[sid] = _parse_transcript(str(p))
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def load_agent_config(cwd: str) -> list[dict]:
|
|
235
|
+
"""Parse frontmatter from agent definition files.
|
|
236
|
+
|
|
237
|
+
Checks paths in priority order:
|
|
238
|
+
1. $CLAUDE_PLUGIN_ROOT/agents/ — plugin install
|
|
239
|
+
2. <cwd>/.claude/agents/ — user's project agents
|
|
240
|
+
3. <cwd>/claude-harness/agents/ — caddis dev checkout
|
|
241
|
+
"""
|
|
242
|
+
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT")
|
|
243
|
+
candidates = []
|
|
244
|
+
if plugin_root:
|
|
245
|
+
candidates.append(os.path.join(plugin_root, "agents"))
|
|
246
|
+
candidates.append(os.path.join(cwd, ".claude", "agents"))
|
|
247
|
+
candidates.append(os.path.join(cwd, "claude-harness", "agents"))
|
|
248
|
+
|
|
249
|
+
agents_dir = next((c for c in candidates if os.path.isdir(c)), None)
|
|
250
|
+
if not agents_dir:
|
|
251
|
+
return []
|
|
252
|
+
agents = []
|
|
253
|
+
for fname in sorted(os.listdir(agents_dir)):
|
|
254
|
+
if not fname.endswith(".md"):
|
|
255
|
+
continue
|
|
256
|
+
path = os.path.join(agents_dir, fname)
|
|
257
|
+
try:
|
|
258
|
+
text = open(path, encoding="utf-8").read()
|
|
259
|
+
fm = re.search(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
|
|
260
|
+
if not fm:
|
|
261
|
+
continue
|
|
262
|
+
rec: dict[str, str] = {"_file": path}
|
|
263
|
+
for line in fm.group(1).splitlines():
|
|
264
|
+
if ":" in line:
|
|
265
|
+
k, _, v = line.partition(":")
|
|
266
|
+
rec[k.strip()] = v.strip()
|
|
267
|
+
if rec.get("name"):
|
|
268
|
+
agents.append(rec)
|
|
269
|
+
except Exception:
|
|
270
|
+
continue
|
|
271
|
+
return agents
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def load_command_roster(cwd: str) -> list[str]:
|
|
275
|
+
"""Bare command stems available in this install (e.g. ``implement``, ``handoff``).
|
|
276
|
+
|
|
277
|
+
Modeled on :func:`load_agent_config`: plugin install → project ``.claude`` → caddis dev
|
|
278
|
+
checkout. Each ``<name>.md`` is one command; the plugin prefix (``caddis:``) is added at
|
|
279
|
+
invocation time, so the roster holds bare stems and the never-fired comparison normalizes
|
|
280
|
+
fired names the same way (handles the caddis/claudster prefix divergence — Phase 5).
|
|
281
|
+
"""
|
|
282
|
+
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT")
|
|
283
|
+
candidates = []
|
|
284
|
+
if plugin_root:
|
|
285
|
+
candidates.append(os.path.join(plugin_root, "commands"))
|
|
286
|
+
candidates.append(os.path.join(cwd, ".claude", "commands"))
|
|
287
|
+
candidates.append(os.path.join(cwd, "claude-harness", "commands"))
|
|
288
|
+
roster: list[str] = []
|
|
289
|
+
for c in candidates:
|
|
290
|
+
if not os.path.isdir(c):
|
|
291
|
+
continue
|
|
292
|
+
try:
|
|
293
|
+
names = sorted(os.listdir(c))
|
|
294
|
+
except Exception:
|
|
295
|
+
continue
|
|
296
|
+
for fname in names:
|
|
297
|
+
if fname.endswith(".md"):
|
|
298
|
+
stem = fname[:-3]
|
|
299
|
+
if stem and stem not in roster:
|
|
300
|
+
roster.append(stem)
|
|
301
|
+
return roster
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _bare_cmd(name: str) -> str:
|
|
305
|
+
"""Strip a plugin namespace prefix (``caddis:implement`` / ``claudster:implement`` → ``implement``).
|
|
306
|
+
|
|
307
|
+
Command stems themselves contain no colon, so the segment after the last colon is the stem.
|
|
308
|
+
A bare name (no colon) is returned unchanged.
|
|
309
|
+
"""
|
|
310
|
+
return name.rsplit(":", 1)[-1].strip()
|
|
311
|
+
|
|
312
|
+
# ── metrics aggregation ────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
def compute_metrics(sessions: list[dict], transcripts: dict[str, dict]) -> dict:
|
|
315
|
+
if not sessions:
|
|
316
|
+
return {
|
|
317
|
+
"sessions": 0, "input": 0, "output": 0,
|
|
318
|
+
"cache_write": 0, "cache_read": 0, "est_cost_usd": 0.0,
|
|
319
|
+
"model_mix": {}, "per_day": {}, "agent_dispatches": {},
|
|
320
|
+
"agent_models": {}, "skill_dispatches": {}, "command_fires": {},
|
|
321
|
+
"peak_contexts": [],
|
|
322
|
+
}
|
|
323
|
+
model_mix: dict[str, int] = defaultdict(int)
|
|
324
|
+
per_day: dict[str, dict] = {}
|
|
325
|
+
tot_input = tot_output = tot_cw = tot_cr = 0
|
|
326
|
+
tot_cost = 0.0
|
|
327
|
+
agent_dispatches: dict[str, int] = defaultdict(int)
|
|
328
|
+
agent_models: dict[str, list[str]] = defaultdict(list)
|
|
329
|
+
skill_dispatches: dict[str, int] = defaultdict(int)
|
|
330
|
+
command_fires: dict[str, int] = defaultdict(int)
|
|
331
|
+
log_skill_fires: dict[str, int] = defaultdict(int)
|
|
332
|
+
peak_contexts: list[int] = []
|
|
333
|
+
|
|
334
|
+
for s in sessions:
|
|
335
|
+
day = s["ts"][:10]
|
|
336
|
+
if day not in per_day:
|
|
337
|
+
per_day[day] = {"sessions": 0, "input": 0, "output": 0,
|
|
338
|
+
"cache_write": 0, "cache_read": 0, "cost": 0.0}
|
|
339
|
+
inp = int(s.get("input") or 0)
|
|
340
|
+
out = int(s.get("output") or 0)
|
|
341
|
+
cw = int(s.get("cache_write") or 0)
|
|
342
|
+
cr = int(s.get("cache_read") or 0)
|
|
343
|
+
cost = float(s.get("est_cost_usd") or 0.0)
|
|
344
|
+
|
|
345
|
+
tot_input += inp; tot_output += out; tot_cw += cw; tot_cr += cr; tot_cost += cost
|
|
346
|
+
pd = per_day[day]
|
|
347
|
+
pd["sessions"] += 1; pd["input"] += inp; pd["output"] += out
|
|
348
|
+
pd["cache_write"] += cw; pd["cache_read"] += cr; pd["cost"] += cost
|
|
349
|
+
|
|
350
|
+
for model in (s.get("models") or []):
|
|
351
|
+
if model == "<synthetic>":
|
|
352
|
+
continue
|
|
353
|
+
model_mix[_tier(model)] += out
|
|
354
|
+
|
|
355
|
+
# Identity from the log (V15 / Phase 11): commands are recorded ONLY in the log
|
|
356
|
+
# (transcripts don't carry them), so the log is the source of truth; skills are
|
|
357
|
+
# unioned in for sessions whose transcript was deleted/compacted.
|
|
358
|
+
for cmd in (s.get("commands") or []):
|
|
359
|
+
command_fires[cmd] += 1
|
|
360
|
+
for sk in (s.get("skills") or []):
|
|
361
|
+
log_skill_fires[sk] += 1
|
|
362
|
+
|
|
363
|
+
sid = s.get("_session_id", "")
|
|
364
|
+
if sid in transcripts:
|
|
365
|
+
td = transcripts[sid]
|
|
366
|
+
ctx = td.get("peak_context", 0)
|
|
367
|
+
if ctx:
|
|
368
|
+
peak_contexts.append(ctx)
|
|
369
|
+
for atype, cnt in td.get("agent_dispatches", {}).items():
|
|
370
|
+
agent_dispatches[atype] += cnt
|
|
371
|
+
for skill, cnt in td.get("skill_dispatches", {}).items():
|
|
372
|
+
skill_dispatches[skill] += cnt
|
|
373
|
+
for atype, models in td.get("agent_models", {}).items():
|
|
374
|
+
agent_models[atype].extend(models)
|
|
375
|
+
|
|
376
|
+
# Log-derived skills fill gaps the surviving transcripts can't (deleted/compacted
|
|
377
|
+
# transcripts): a skill the log recorded but no transcript corroborates still counts.
|
|
378
|
+
for sk, cnt in log_skill_fires.items():
|
|
379
|
+
if sk not in skill_dispatches:
|
|
380
|
+
skill_dispatches[sk] = cnt
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
"sessions": len(sessions),
|
|
384
|
+
"input": tot_input, "output": tot_output,
|
|
385
|
+
"cache_write": tot_cw, "cache_read": tot_cr,
|
|
386
|
+
"est_cost_usd": round(tot_cost, 2),
|
|
387
|
+
"model_mix": dict(model_mix),
|
|
388
|
+
"per_day": per_day,
|
|
389
|
+
"agent_dispatches": dict(agent_dispatches),
|
|
390
|
+
"agent_models": {k: list(v) for k, v in agent_models.items()},
|
|
391
|
+
"skill_dispatches": dict(skill_dispatches),
|
|
392
|
+
"command_fires": dict(command_fires),
|
|
393
|
+
"peak_contexts": peak_contexts,
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
# ── rule engine ────────────────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
_CHEAP_AGENTS = {"preflight", "tester", "codebase-audit", "ui-design-reviewer", "data-engineer",
|
|
399
|
+
"sql-expert", "claude-md-curator", "debug"}
|
|
400
|
+
_HEAVY_AGENTS = {"anchor", "security-analyst"}
|
|
401
|
+
# Core skill identity is the caddis plugin's; the pre-rename "claudster:" prefix is also listed so
|
|
402
|
+
# legacy usage-log entries (recorded before the caddis rename) still categorize correctly.
|
|
403
|
+
_CORE_SKILL_NAMES = ("feature-plan", "handoff", "prd", "ship", "tdd", "ui-brief")
|
|
404
|
+
_CORE_SKILLS = {f"{p}:{n}" for p in ("caddis", "claudster") for n in _CORE_SKILL_NAMES}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def run_rules(metrics: dict, prev: dict, agents: list[dict],
|
|
408
|
+
cmd_roster: list[str] | None = None) -> list[dict]:
|
|
409
|
+
findings: list[dict] = []
|
|
410
|
+
mm = metrics.get("model_mix", {})
|
|
411
|
+
tot_out = metrics.get("output") or 1
|
|
412
|
+
sessions = metrics.get("sessions", 0)
|
|
413
|
+
prev_sess = prev.get("sessions", 0)
|
|
414
|
+
ctxs = metrics.get("peak_contexts", [])
|
|
415
|
+
a_disp = metrics.get("agent_dispatches", {})
|
|
416
|
+
s_disp = metrics.get("skill_dispatches", {})
|
|
417
|
+
|
|
418
|
+
# R0 — Rate-limit proxy ────────────────────────────────────────────────────
|
|
419
|
+
heavy_out = mm.get("opus", 0) + mm.get("fable", 0)
|
|
420
|
+
heavy_pct = heavy_out / tot_out * 100 if tot_out else 0
|
|
421
|
+
trend_note = ""
|
|
422
|
+
if prev_sess > 0:
|
|
423
|
+
delta = sessions - prev_sess
|
|
424
|
+
arrow = "↑" if delta > 0 else ("↓" if delta < 0 else "→")
|
|
425
|
+
trend_note = f" ({arrow} from {prev_sess} last window)"
|
|
426
|
+
findings.append({
|
|
427
|
+
"id": "R0", "severity": "info",
|
|
428
|
+
"title": "Your usage this window",
|
|
429
|
+
"finding": (
|
|
430
|
+
f"{sessions} session{'s' if sessions != 1 else ''}{trend_note}. "
|
|
431
|
+
f"{'All on' if heavy_pct >= 99 else f'{heavy_pct:.0f}% used'} Opus or Fable — "
|
|
432
|
+
"the models most likely to hit rate limits. "
|
|
433
|
+
"Actual rate-limit % (5h / weekly cap) is not stored locally."
|
|
434
|
+
),
|
|
435
|
+
"action": "Run `/usage` in Claude Code to see your real-time rate-limit status.",
|
|
436
|
+
"apply_target": None,
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
# R1 — Agent model tier mismatches ────────────────────────────────────────
|
|
440
|
+
issues: list[str] = []
|
|
441
|
+
apply_agents: list[dict] = []
|
|
442
|
+
for ag in agents:
|
|
443
|
+
name = ag.get("name", "")
|
|
444
|
+
model = ag.get("model", "inherit")
|
|
445
|
+
if model == "inherit" and name in _CHEAP_AGENTS:
|
|
446
|
+
issues.append(f"`{name}` (set to `inherit` — picks up whatever model the session is on)")
|
|
447
|
+
apply_agents.append({"file": ag["_file"], "name": name, "current": model, "suggested": "sonnet"})
|
|
448
|
+
elif model in ("sonnet", "haiku") and name in _HEAVY_AGENTS:
|
|
449
|
+
issues.append(f"`{name}` (set to `{model}` — too light for safety/security verification)")
|
|
450
|
+
apply_agents.append({"file": ag["_file"], "name": name, "current": model, "suggested": "opus"})
|
|
451
|
+
if issues:
|
|
452
|
+
n = len(issues)
|
|
453
|
+
findings.append({
|
|
454
|
+
"id": "R1", "severity": "medium",
|
|
455
|
+
"title": f"{n} agent{'s have' if n > 1 else ' has'} the wrong model set",
|
|
456
|
+
"finding": (
|
|
457
|
+
"These agents are not pinned to the right model tier, meaning they inherit "
|
|
458
|
+
"whatever model you happen to be on — usually Opus: "
|
|
459
|
+
+ "; ".join(issues) + "."
|
|
460
|
+
),
|
|
461
|
+
"action": "Pin each agent to the right tier. Quick agents (preflight, tester) → `sonnet`. "
|
|
462
|
+
"Safety-critical agents (anchor, security-analyst) → `opus`.",
|
|
463
|
+
"apply_target": {"type": "agent_frontmatter", "agents": apply_agents},
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
# R2 — Model mix ──────────────────────────────────────────────────────────
|
|
467
|
+
if heavy_pct > 70 and sessions >= 2:
|
|
468
|
+
findings.append({
|
|
469
|
+
"id": "R2", "severity": "medium",
|
|
470
|
+
"title": f"You're using the heaviest model for {heavy_pct:.0f}% of your work",
|
|
471
|
+
"finding": (
|
|
472
|
+
f"{'All of' if heavy_pct >= 99 else f'{heavy_pct:.0f}% of'} your output this window "
|
|
473
|
+
f"ran on Opus{'or Fable ' if mm.get('fable', 0) > 0 else ''}— the models most likely "
|
|
474
|
+
"to hit rate limits. For everyday tasks like coding, searching, and editing, Sonnet "
|
|
475
|
+
"delivers the same results and uses less of your rate-limit quota."
|
|
476
|
+
),
|
|
477
|
+
"action": "Switch to Sonnet for day-to-day work: type `/model` and pick Sonnet. "
|
|
478
|
+
"Keep Opus for planning, architecture decisions, and final reviews.",
|
|
479
|
+
"apply_target": None,
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
# R3 — Effort right-sizing (advisory) ─────────────────────────────────────
|
|
483
|
+
findings.append({
|
|
484
|
+
"id": "R3", "severity": "info",
|
|
485
|
+
"title": "Tip: save `max` effort for when it really matters",
|
|
486
|
+
"finding": (
|
|
487
|
+
"Effort level is not recorded in transcripts, so this is a general tip rather than a measured finding. "
|
|
488
|
+
"`max` effort burns rate-limit quota significantly faster than `high` — "
|
|
489
|
+
"reserve it for complex planning and architecture decisions. "
|
|
490
|
+
"For regular coding, `high` is sufficient; for searches and quick edits, `medium` is fine."
|
|
491
|
+
),
|
|
492
|
+
"action": "Before each task, ask: does this actually need `max`? For most coding tasks, `high` is the right call.",
|
|
493
|
+
"apply_target": None,
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
# R4 — Long context ───────────────────────────────────────────────────────
|
|
497
|
+
if ctxs:
|
|
498
|
+
high_ctxs = [c for c in ctxs if c > 150_000]
|
|
499
|
+
if high_ctxs:
|
|
500
|
+
pct = len(high_ctxs) / len(ctxs) * 100
|
|
501
|
+
sev = "medium" if pct > 50 else "low"
|
|
502
|
+
findings.append({
|
|
503
|
+
"id": "R4", "severity": sev,
|
|
504
|
+
"title": f"{'Most' if pct > 50 else 'Some'} of your sessions are running very long",
|
|
505
|
+
"finding": (
|
|
506
|
+
f"{len(high_ctxs)} of {len(ctxs)} session{'s' if len(ctxs) > 1 else ''} "
|
|
507
|
+
"peaked above 150k tokens of context. Long contexts make Claude slower, "
|
|
508
|
+
"consume more of your rate-limit, and can get truncated above 200k. "
|
|
509
|
+
"This usually means work is piling up without clearing out old context."
|
|
510
|
+
),
|
|
511
|
+
"action": "Use `/compact` mid-task to summarise context without losing the thread. "
|
|
512
|
+
"Use `/clear` when switching to a completely different task.",
|
|
513
|
+
"apply_target": None,
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
# R5 — Extras footprint ───────────────────────────────────────────────────
|
|
517
|
+
extras_fired = {s for s in s_disp if s.startswith(("caddis:", "claudster:")) and s not in _CORE_SKILLS}
|
|
518
|
+
total_skill_fires = sum(s_disp.values())
|
|
519
|
+
if total_skill_fires > 0 and not extras_fired and sessions >= 3:
|
|
520
|
+
findings.append({
|
|
521
|
+
"id": "R5", "severity": "low",
|
|
522
|
+
"title": "Extras plugin doesn't seem to be getting used",
|
|
523
|
+
"finding": (
|
|
524
|
+
f"You had {total_skill_fires} skill call{'s' if total_skill_fires > 1 else ''} "
|
|
525
|
+
"this window but none from the extras tier. "
|
|
526
|
+
"If caddis-extras is enabled, it adds context to every session even when unused."
|
|
527
|
+
),
|
|
528
|
+
"action": "Disable caddis-extras if you don't use those skills: remove it from the plugins list in `.claude/settings.json`.",
|
|
529
|
+
"apply_target": None,
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
# R6 — Per-agent heavy dispatch ───────────────────────────────────────────
|
|
533
|
+
if a_disp:
|
|
534
|
+
heavy: list[tuple[str, int, str]] = []
|
|
535
|
+
for atype, cnt in sorted(a_disp.items(), key=lambda x: -x[1]):
|
|
536
|
+
models = metrics.get("agent_models", {}).get(atype, [])
|
|
537
|
+
if models:
|
|
538
|
+
from collections import Counter
|
|
539
|
+
dominant = Counter(_tier(m) for m in models).most_common(1)[0][0]
|
|
540
|
+
if dominant in ("opus", "fable"):
|
|
541
|
+
heavy.append((atype, cnt, dominant))
|
|
542
|
+
if heavy:
|
|
543
|
+
names = ", ".join(f"`{t}`" for t, _, _ in heavy[:4])
|
|
544
|
+
n = len(heavy)
|
|
545
|
+
findings.append({
|
|
546
|
+
"id": "R6", "severity": "medium",
|
|
547
|
+
"title": f"{n} subagent{'s are' if n > 1 else ' is'} running on Opus unnecessarily",
|
|
548
|
+
"finding": (
|
|
549
|
+
f"These subagents dispatched on Opus this window: {names}. "
|
|
550
|
+
"Most subagents don't need Opus-level reasoning — Sonnet handles code review, "
|
|
551
|
+
"preflight checks, and testing just as well at a lower rate-limit cost."
|
|
552
|
+
),
|
|
553
|
+
"action": "Pin these agents to `model: sonnet` in their frontmatter files.",
|
|
554
|
+
"apply_target": {
|
|
555
|
+
"type": "agent_frontmatter",
|
|
556
|
+
"agents": [{"name": t, "suggested": "sonnet"} for t, _, _ in heavy],
|
|
557
|
+
},
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
# R7 — Dead weight ────────────────────────────────────────────────────────
|
|
561
|
+
if sessions >= 5 and a_disp:
|
|
562
|
+
all_names = {ag.get("name", "") for ag in agents} - {""}
|
|
563
|
+
fired = set(a_disp.keys())
|
|
564
|
+
never = all_names - fired
|
|
565
|
+
if len(never) >= 4 and len(never) / max(len(all_names), 1) > 0.5:
|
|
566
|
+
sample = ", ".join(f"`{n}`" for n in sorted(never)[:5])
|
|
567
|
+
n_never = len(never)
|
|
568
|
+
findings.append({
|
|
569
|
+
"id": "R7", "severity": "low",
|
|
570
|
+
"title": f"{n_never} agents were never dispatched this window",
|
|
571
|
+
"finding": (
|
|
572
|
+
f"These agents haven't been used at all: {sample}"
|
|
573
|
+
f"{'…' if n_never > 5 else ''}. "
|
|
574
|
+
"Unused agents don't cost much, but if they're consistently idle across "
|
|
575
|
+
"multiple weeks it may be worth moving them to extras."
|
|
576
|
+
),
|
|
577
|
+
"action": "If these agents aren't part of your workflow, consider moving them to the extras tier.",
|
|
578
|
+
"apply_target": None,
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
# R8 — Trend ──────────────────────────────────────────────────────────────
|
|
582
|
+
prev_out = prev.get("output", 0)
|
|
583
|
+
if prev_out > 0 and metrics.get("output", 0) > 0:
|
|
584
|
+
delta_pct = (metrics["output"] - prev_out) / prev_out * 100
|
|
585
|
+
if abs(delta_pct) >= 40:
|
|
586
|
+
direction = "up" if delta_pct > 0 else "down"
|
|
587
|
+
findings.append({
|
|
588
|
+
"id": "R8", "severity": "info",
|
|
589
|
+
"title": f"Your usage is {direction} {abs(delta_pct):.0f}% vs last window",
|
|
590
|
+
"finding": (
|
|
591
|
+
f"Output token volume went {direction} {abs(delta_pct):.0f}% "
|
|
592
|
+
f"({_fmt(prev_out)} → {_fmt(metrics['output'])}) compared to the previous window."
|
|
593
|
+
+ (" If it's trending up, check whether a new workflow or heavy agent is the cause."
|
|
594
|
+
if delta_pct > 0 else "")
|
|
595
|
+
),
|
|
596
|
+
"action": "Check which sessions or agents are driving the change." if delta_pct > 0 else "",
|
|
597
|
+
"apply_target": None,
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
# R9 — Never-fired commands (pruning evidence) ───────────────────────────────
|
|
601
|
+
# V15 / Phase 11: identity in the log now lets us name commands that ship but never
|
|
602
|
+
# fired in the window. This is the evidence Phase 12's prune runs on — a command the
|
|
603
|
+
# log never sees across enough sessions is a pruning candidate backed by data, not a guess.
|
|
604
|
+
cmd_fires = metrics.get("command_fires", {})
|
|
605
|
+
cmd_roster = cmd_roster or []
|
|
606
|
+
if sessions >= 3 and cmd_roster:
|
|
607
|
+
fired_bare = {_bare_cmd(n) for n in cmd_fires}
|
|
608
|
+
never = sorted(c for c in cmd_roster if c not in fired_bare)
|
|
609
|
+
if never:
|
|
610
|
+
shown = never[:8]
|
|
611
|
+
sample = ", ".join(f"`{n}`" for n in shown)
|
|
612
|
+
n_never = len(never)
|
|
613
|
+
findings.append({
|
|
614
|
+
"id": "R9", "severity": "low",
|
|
615
|
+
"title": f"{n_never} command{'s' if n_never != 1 else ''} never fired this window",
|
|
616
|
+
"finding": (
|
|
617
|
+
"These commands ship in the install but the log shows no invocation this "
|
|
618
|
+
f"window: {sample}{'…' if n_never > 8 else ''}. "
|
|
619
|
+
"Consistently-idle commands are pruning candidates — but confirm against "
|
|
620
|
+
"transcripts before removing, since a command may see use outside this repo."
|
|
621
|
+
),
|
|
622
|
+
"action": "Cross-check the 90-day transcript history before removing any command "
|
|
623
|
+
"(Phase 12). A command never seen anywhere is redundant; one used rarely "
|
|
624
|
+
"may still earn its place.",
|
|
625
|
+
"apply_target": None,
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
return findings
|
|
629
|
+
|
|
630
|
+
# ── format helpers ─────────────────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
def _fmt(n: int) -> str:
|
|
633
|
+
if n >= 1_000_000:
|
|
634
|
+
return f"{n / 1_000_000:.1f}M"
|
|
635
|
+
if n >= 1_000:
|
|
636
|
+
return f"{n / 1_000:.1f}k"
|
|
637
|
+
return str(n)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _pct(a: int, b: int) -> str:
|
|
641
|
+
return f"{a / b * 100:.0f}%" if b else "—"
|
|
642
|
+
|
|
643
|
+
# ── markdown renderer ──────────────────────────────────────────────────────────
|
|
644
|
+
|
|
645
|
+
def render_markdown(metrics: dict, findings: list[dict], days: int,
|
|
646
|
+
ws: str, we: str) -> str:
|
|
647
|
+
mm = metrics.get("model_mix", {})
|
|
648
|
+
tot_out = metrics.get("output") or 1
|
|
649
|
+
mix = " · ".join(
|
|
650
|
+
f"{t}: {_pct(v, tot_out)}"
|
|
651
|
+
for t, v in sorted(mm.items(), key=lambda x: -x[1])
|
|
652
|
+
) or "no model data"
|
|
653
|
+
|
|
654
|
+
lines = [
|
|
655
|
+
"# /usage-review",
|
|
656
|
+
f"**Window:** {days}d ({ws} – {we}) · **Sessions:** {metrics['sessions']} · {mix}",
|
|
657
|
+
"",
|
|
658
|
+
"| Metric | Value |",
|
|
659
|
+
"|---|---|",
|
|
660
|
+
f"| Output tokens | {_fmt(metrics['output'])} |",
|
|
661
|
+
f"| Input + cache read | {_fmt(metrics['input'] + metrics['cache_read'])} |",
|
|
662
|
+
f"| Cache efficiency | {_pct(metrics['cache_read'], metrics['input'] + metrics['cache_read'])} read from cache |",
|
|
663
|
+
f"| Est. cost equiv. | ${metrics['est_cost_usd']:.2f} (estimate; edit rates in session_end.py) |",
|
|
664
|
+
"",
|
|
665
|
+
"## Findings",
|
|
666
|
+
"",
|
|
667
|
+
]
|
|
668
|
+
|
|
669
|
+
sev_order = {"high": 0, "medium": 1, "low": 2, "info": 3}
|
|
670
|
+
icon = {"high": "🔴", "medium": "🟡", "low": "🔵", "info": "ℹ️"}
|
|
671
|
+
for f in sorted(findings, key=lambda x: sev_order.get(x["severity"], 4)):
|
|
672
|
+
lines.append(f"### {icon.get(f['severity'], '•')} [{f['id']}] {f['title']}")
|
|
673
|
+
lines.append("")
|
|
674
|
+
lines.append(f" {f['finding']}")
|
|
675
|
+
lines.append("")
|
|
676
|
+
if f.get("action"):
|
|
677
|
+
lines.append(f" **Action:** {f['action']}")
|
|
678
|
+
if f.get("apply_target"):
|
|
679
|
+
lines.append(f" **[apply]** — tell Claude: _apply finding {f['id']}_")
|
|
680
|
+
lines.append("")
|
|
681
|
+
|
|
682
|
+
if metrics.get("agent_dispatches"):
|
|
683
|
+
lines += ["## Agent dispatches this window", ""]
|
|
684
|
+
am = metrics.get("agent_models", {})
|
|
685
|
+
for atype, cnt in sorted(metrics["agent_dispatches"].items(), key=lambda x: -x[1]):
|
|
686
|
+
models = am.get(atype, [])
|
|
687
|
+
note = f" (on {', '.join(sorted(set(models)))})" if models else ""
|
|
688
|
+
lines.append(f"- `{atype}` × {cnt}{note}")
|
|
689
|
+
lines.append("")
|
|
690
|
+
|
|
691
|
+
if metrics.get("skill_dispatches"):
|
|
692
|
+
lines += ["## Skills fired this window", ""]
|
|
693
|
+
for skill, cnt in sorted(metrics["skill_dispatches"].items(), key=lambda x: -x[1]):
|
|
694
|
+
lines.append(f"- `{skill}` × {cnt}")
|
|
695
|
+
lines.append("")
|
|
696
|
+
|
|
697
|
+
lines += [
|
|
698
|
+
"---",
|
|
699
|
+
"HTML dashboard written alongside this report — see path printed by the script.",
|
|
700
|
+
]
|
|
701
|
+
return "\n".join(lines)
|
|
702
|
+
|
|
703
|
+
# ── SVG chart helpers ──────────────────────────────────────────────────────────
|
|
704
|
+
|
|
705
|
+
def _svg_empty(w: int, h: int, msg: str = "No data") -> str:
|
|
706
|
+
return (
|
|
707
|
+
f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg">'
|
|
708
|
+
f'<text x="{w//2}" y="{h//2}" text-anchor="middle" dominant-baseline="middle" '
|
|
709
|
+
f'fill="#6f6857" font-size="12" font-family="system-ui,sans-serif">{msg}</text>'
|
|
710
|
+
f'</svg>'
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _svg_timeline(data: dict[str, float], color: str = "#E8A04C",
|
|
715
|
+
w: int = 380, h: int = 120) -> str:
|
|
716
|
+
"""Single-series line + area chart keyed by YYYY-MM-DD."""
|
|
717
|
+
if not data or max(data.values(), default=0) == 0:
|
|
718
|
+
return _svg_empty(w, h)
|
|
719
|
+
days = sorted(data)
|
|
720
|
+
vals = [data[d] for d in days]
|
|
721
|
+
max_v = max(vals) or 1
|
|
722
|
+
pad_l, pad_r, pad_t, pad_b = 4, 4, 8, 20
|
|
723
|
+
aw = w - pad_l - pad_r
|
|
724
|
+
ah = h - pad_t - pad_b
|
|
725
|
+
n = max(len(vals) - 1, 1)
|
|
726
|
+
|
|
727
|
+
pts = []
|
|
728
|
+
for i, v in enumerate(vals):
|
|
729
|
+
x = pad_l + i / n * aw
|
|
730
|
+
y = pad_t + ah - v / max_v * ah
|
|
731
|
+
pts.append((x, y))
|
|
732
|
+
|
|
733
|
+
line_pts = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
|
|
734
|
+
area_pts = (
|
|
735
|
+
f"{pts[0][0]:.1f},{pad_t + ah:.1f} "
|
|
736
|
+
+ line_pts
|
|
737
|
+
+ f" {pts[-1][0]:.1f},{pad_t + ah:.1f}"
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
# x-axis labels (first + last date)
|
|
741
|
+
label_y = h - 4
|
|
742
|
+
labels = (
|
|
743
|
+
f'<text x="{pad_l}" y="{label_y}" font-size="9" fill="#6f6857">{days[0][5:]}</text>'
|
|
744
|
+
f'<text x="{w - pad_r}" y="{label_y}" font-size="9" fill="#6f6857" text-anchor="end">{days[-1][5:]}</text>'
|
|
745
|
+
)
|
|
746
|
+
return (
|
|
747
|
+
f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg"'
|
|
748
|
+
f' font-family="system-ui,sans-serif" style="cursor:crosshair">'
|
|
749
|
+
f'<polygon points="{area_pts}" fill="{color}" fill-opacity="0.12"/>'
|
|
750
|
+
f'<polyline points="{line_pts}" fill="none" stroke="{color}"'
|
|
751
|
+
f' stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>'
|
|
752
|
+
+ "".join(
|
|
753
|
+
f'<circle cx="{x:.1f}" cy="{y:.1f}" r="2.5" fill="{color}">'
|
|
754
|
+
f'<title>{days[i][5:]}: {vals[i]:.0f}</title>'
|
|
755
|
+
f'</circle>'
|
|
756
|
+
for i, (x, y) in enumerate(pts)
|
|
757
|
+
)
|
|
758
|
+
+ labels
|
|
759
|
+
+ "</svg>"
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _svg_stacked_bar(per_day: dict[str, dict], w: int = 380, h: int = 130) -> str:
|
|
764
|
+
"""Stacked bar: output (amber) + cache_read (blue) per day."""
|
|
765
|
+
if not per_day:
|
|
766
|
+
return _svg_empty(w, h)
|
|
767
|
+
days = sorted(per_day)
|
|
768
|
+
out_vals = [per_day[d].get("output", 0) for d in days]
|
|
769
|
+
cr_vals = [per_day[d].get("cache_read", 0) for d in days]
|
|
770
|
+
max_v = max((a + b for a, b in zip(out_vals, cr_vals)), default=0) or 1
|
|
771
|
+
pad_l, pad_r, pad_t, pad_b = 4, 4, 8, 20
|
|
772
|
+
aw = w - pad_l - pad_r
|
|
773
|
+
ah = h - pad_t - pad_b
|
|
774
|
+
bar_w = max(aw / max(len(days), 1) - 3, 4)
|
|
775
|
+
n = max(len(days) - 1, 1)
|
|
776
|
+
parts = []
|
|
777
|
+
for i, day in enumerate(days):
|
|
778
|
+
x = pad_l + i / max(len(days) - 1, 1) * aw - bar_w / 2
|
|
779
|
+
x = max(pad_l, min(x, w - pad_r - bar_w))
|
|
780
|
+
out_h = out_vals[i] / max_v * ah
|
|
781
|
+
cr_h = cr_vals[i] / max_v * ah
|
|
782
|
+
# cache_read bar (bottom)
|
|
783
|
+
cr_y = pad_t + ah - cr_h
|
|
784
|
+
parts.append(
|
|
785
|
+
f'<rect x="{x:.1f}" y="{cr_y:.1f}" width="{bar_w:.1f}" height="{cr_h:.1f}"'
|
|
786
|
+
f' rx="2" fill="#6B9BC9" fill-opacity="0.7">'
|
|
787
|
+
f'<title>{day[5:]}: cache read {_fmt(int(cr_vals[i]))}</title>'
|
|
788
|
+
f'</rect>'
|
|
789
|
+
)
|
|
790
|
+
# output bar (stacked on top)
|
|
791
|
+
out_y = pad_t + ah - cr_h - out_h
|
|
792
|
+
parts.append(
|
|
793
|
+
f'<rect x="{x:.1f}" y="{out_y:.1f}" width="{bar_w:.1f}" height="{out_h:.1f}"'
|
|
794
|
+
f' rx="2" fill="#E8A04C" fill-opacity="0.85">'
|
|
795
|
+
f'<title>{day[5:]}: output {_fmt(int(out_vals[i]))}</title>'
|
|
796
|
+
f'</rect>'
|
|
797
|
+
)
|
|
798
|
+
# x labels
|
|
799
|
+
label_y = h - 4
|
|
800
|
+
parts.append(f'<text x="{pad_l}" y="{label_y}" font-size="9" fill="#6f6857">{days[0][5:]}</text>')
|
|
801
|
+
if len(days) > 1:
|
|
802
|
+
parts.append(f'<text x="{w - pad_r}" y="{label_y}" font-size="9" fill="#6f6857" text-anchor="end">{days[-1][5:]}</text>')
|
|
803
|
+
# legend
|
|
804
|
+
parts.append(f'<rect x="{w-80}" y="4" width="8" height="8" rx="1" fill="#E8A04C"/>')
|
|
805
|
+
parts.append(f'<text x="{w-70}" y="11" font-size="9" fill="#9a9282">output</text>')
|
|
806
|
+
parts.append(f'<rect x="{w-80}" y="16" width="8" height="8" rx="1" fill="#6B9BC9"/>')
|
|
807
|
+
parts.append(f'<text x="{w-70}" y="23" font-size="9" fill="#9a9282">cache</text>')
|
|
808
|
+
return (
|
|
809
|
+
f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg"'
|
|
810
|
+
f' font-family="system-ui,sans-serif">'
|
|
811
|
+
+ "".join(parts)
|
|
812
|
+
+ "</svg>"
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _svg_donut(slices: list[tuple[str, float, str]], size: int = 160) -> str:
|
|
817
|
+
"""Donut chart. slices = [(label, value, color)]."""
|
|
818
|
+
total = sum(v for _, v, _ in slices)
|
|
819
|
+
if not total:
|
|
820
|
+
return _svg_empty(size, size, "No data")
|
|
821
|
+
cx = cy = size / 2
|
|
822
|
+
r = size / 2 - 22
|
|
823
|
+
sw = 20
|
|
824
|
+
circ = 2 * 3.14159265 * r
|
|
825
|
+
parts = []
|
|
826
|
+
offset = 0.0
|
|
827
|
+
for label, value, color in slices:
|
|
828
|
+
pct = value / total
|
|
829
|
+
dash = pct * circ
|
|
830
|
+
parts.append(
|
|
831
|
+
f'<circle cx="{cx}" cy="{cy}" r="{r:.1f}" fill="none" stroke="{color}"'
|
|
832
|
+
f' stroke-width="{sw}" stroke-dasharray="{dash:.2f} {circ:.2f}"'
|
|
833
|
+
f' stroke-dashoffset="{-offset:.2f}"'
|
|
834
|
+
f' transform="rotate(-90 {cx} {cy})" style="cursor:crosshair">'
|
|
835
|
+
f'<title>{label}: {pct*100:.1f}%</title>'
|
|
836
|
+
f'</circle>'
|
|
837
|
+
)
|
|
838
|
+
offset += dash
|
|
839
|
+
# center label
|
|
840
|
+
parts.append(
|
|
841
|
+
f'<text x="{cx}" y="{cy - 5}" text-anchor="middle" dominant-baseline="middle"'
|
|
842
|
+
f' font-size="11" font-weight="600" fill="#F5EFE0" font-family="system-ui,sans-serif">'
|
|
843
|
+
+ slices[0][0][:8] + "</text>"
|
|
844
|
+
)
|
|
845
|
+
parts.append(
|
|
846
|
+
f'<text x="{cx}" y="{cy + 10}" text-anchor="middle" dominant-baseline="middle"'
|
|
847
|
+
f' font-size="10" fill="#9a9282" font-family="system-ui,sans-serif">'
|
|
848
|
+
f"{slices[0][1] / total * 100:.0f}%</text>"
|
|
849
|
+
)
|
|
850
|
+
return (
|
|
851
|
+
f'<svg viewBox="0 0 {size} {size}" xmlns="http://www.w3.org/2000/svg">'
|
|
852
|
+
+ "".join(parts)
|
|
853
|
+
+ "</svg>"
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _svg_histogram(values: list[int], bins: int = 6,
|
|
858
|
+
w: int = 320, h: int = 120) -> str:
|
|
859
|
+
"""Histogram of context sizes (in tokens)."""
|
|
860
|
+
if not values:
|
|
861
|
+
return _svg_empty(w, h)
|
|
862
|
+
max_v = max(values)
|
|
863
|
+
bin_size = max(max_v // bins, 1)
|
|
864
|
+
counts = [0] * bins
|
|
865
|
+
for v in values:
|
|
866
|
+
idx = min(int(v / bin_size), bins - 1)
|
|
867
|
+
counts[idx] += 1
|
|
868
|
+
max_count = max(counts) or 1
|
|
869
|
+
pad_l, pad_r, pad_t, pad_b = 4, 4, 8, 24
|
|
870
|
+
aw = w - pad_l - pad_r
|
|
871
|
+
ah = h - pad_t - pad_b
|
|
872
|
+
bar_w = aw / bins - 3
|
|
873
|
+
parts = []
|
|
874
|
+
for i, cnt in enumerate(counts):
|
|
875
|
+
x = pad_l + i * (aw / bins)
|
|
876
|
+
bar_h = cnt / max_count * ah
|
|
877
|
+
y = pad_t + ah - bar_h
|
|
878
|
+
color = "#C9736B" if (i + 1) * bin_size > 150_000 else "#E8A04C"
|
|
879
|
+
lo_k = i * bin_size // 1000
|
|
880
|
+
hi_k = (i + 1) * bin_size // 1000
|
|
881
|
+
parts.append(
|
|
882
|
+
f'<rect x="{x:.1f}" y="{y:.1f}" width="{bar_w:.1f}" height="{bar_h:.1f}"'
|
|
883
|
+
f' rx="2" fill="{color}" fill-opacity="0.8" style="cursor:crosshair">'
|
|
884
|
+
f'<title>{lo_k}k–{hi_k}k tokens: {cnt} session{"s" if cnt != 1 else ""}</title>'
|
|
885
|
+
f'</rect>'
|
|
886
|
+
)
|
|
887
|
+
label = f"{lo_k}k"
|
|
888
|
+
parts.append(
|
|
889
|
+
f'<text x="{x + bar_w/2:.1f}" y="{h - 6}" text-anchor="middle"'
|
|
890
|
+
f' font-size="8" fill="#6f6857">{label}</text>'
|
|
891
|
+
)
|
|
892
|
+
# 150k threshold line
|
|
893
|
+
if max_v > 150_000:
|
|
894
|
+
tx = pad_l + (150_000 / max(max_v, 1)) * aw
|
|
895
|
+
parts.append(
|
|
896
|
+
f'<line x1="{tx:.1f}" y1="{pad_t}" x2="{tx:.1f}" y2="{pad_t + ah}"'
|
|
897
|
+
f' stroke="#C9736B" stroke-width="1" stroke-dasharray="3,2"/>'
|
|
898
|
+
)
|
|
899
|
+
parts.append(
|
|
900
|
+
f'<text x="{tx + 2:.1f}" y="{pad_t + 8}" font-size="8" fill="#C9736B">150k</text>'
|
|
901
|
+
)
|
|
902
|
+
return (
|
|
903
|
+
f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg"'
|
|
904
|
+
f' font-family="system-ui,sans-serif">'
|
|
905
|
+
+ "".join(parts)
|
|
906
|
+
+ "</svg>"
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _svg_bar_h(items: list[tuple[str, int, str]], w: int = 320, h: int = 160) -> str:
|
|
911
|
+
"""Horizontal bar chart. items = [(label, value, color)]."""
|
|
912
|
+
if not items:
|
|
913
|
+
return _svg_empty(w, h)
|
|
914
|
+
max_v = max(v for _, v, _ in items) or 1
|
|
915
|
+
n = len(items)
|
|
916
|
+
row_h = h / n
|
|
917
|
+
bar_h = min(row_h * 0.55, 22)
|
|
918
|
+
lw = 110
|
|
919
|
+
bw = w - lw - 36
|
|
920
|
+
parts = []
|
|
921
|
+
for i, (label, value, color) in enumerate(items):
|
|
922
|
+
y_mid = i * row_h + row_h / 2
|
|
923
|
+
parts.append(
|
|
924
|
+
f'<text x="{lw - 6}" y="{y_mid + 4:.1f}" text-anchor="end"'
|
|
925
|
+
f' font-size="11" fill="#9a9282">{label[:18]}</text>'
|
|
926
|
+
)
|
|
927
|
+
bar_w = value / max_v * bw
|
|
928
|
+
y_bar = y_mid - bar_h / 2
|
|
929
|
+
parts.append(
|
|
930
|
+
f'<rect x="{lw}" y="{y_bar:.1f}" width="{bar_w:.1f}" height="{bar_h:.1f}"'
|
|
931
|
+
f' rx="3" fill="{color}" fill-opacity="0.8" style="cursor:crosshair">'
|
|
932
|
+
f'<title>{label}: {value} dispatch{"es" if value != 1 else ""}</title>'
|
|
933
|
+
f'</rect>'
|
|
934
|
+
)
|
|
935
|
+
parts.append(
|
|
936
|
+
f'<text x="{lw + bar_w + 4:.1f}" y="{y_mid + 4:.1f}"'
|
|
937
|
+
f' font-size="10" fill="#6f6857">{value}</text>'
|
|
938
|
+
)
|
|
939
|
+
return (
|
|
940
|
+
f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg"'
|
|
941
|
+
f' font-family="system-ui,sans-serif">'
|
|
942
|
+
+ "".join(parts)
|
|
943
|
+
+ "</svg>"
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
# ── HTML renderer ──────────────────────────────────────────────────────────────
|
|
947
|
+
|
|
948
|
+
_CSS = """
|
|
949
|
+
:root {
|
|
950
|
+
--bg:#16140f;--bg-2:#1c1a14;--surface:#242018;--surface-2:#2c281f;
|
|
951
|
+
--line:#3a352a;--ink-1:#F5EFE0;--ink-2:#C9C2B2;--ink-3:#9a9282;--ink-4:#6f6857;
|
|
952
|
+
--amber:#E8A04C;--amber-soft:#f0c089;--sage:#8FA66B;--rose:#C9736B;--blue:#6B9BC9;
|
|
953
|
+
--radius:14px;--shadow:0 1px 2px rgba(0,0,0,.4),0 12px 32px -12px rgba(0,0,0,.6);
|
|
954
|
+
}
|
|
955
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
956
|
+
body{background:var(--bg);color:var(--ink-1);
|
|
957
|
+
font-family:'Plus Jakarta Sans',system-ui,-apple-system,'Segoe UI',sans-serif;
|
|
958
|
+
font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased}
|
|
959
|
+
h1,h2,h3.display-font{font-family:'Space Grotesk','Segoe UI',system-ui,sans-serif;letter-spacing:-.02em}
|
|
960
|
+
.page{max-width:1040px;margin:0 auto;padding:48px 24px 80px}
|
|
961
|
+
h1{font-size:38px;font-weight:700;letter-spacing:-.05em}
|
|
962
|
+
.subtitle{color:var(--ink-3);font-size:14px;margin-top:6px}
|
|
963
|
+
.brand-strip{margin-bottom:28px;padding-bottom:20px;border-bottom:1px solid var(--line)}
|
|
964
|
+
/* running hamster logo */
|
|
965
|
+
.hamster-logo{display:flex;align-items:center;gap:20px}
|
|
966
|
+
.h-run{position:relative;height:6em;width:9ch;
|
|
967
|
+
font-family:'JetBrains Mono',ui-monospace,'Cascadia Code',Consolas,monospace;
|
|
968
|
+
color:var(--amber);font-size:11px;line-height:1.5}
|
|
969
|
+
.rf{position:absolute;top:0;left:0;margin:0;white-space:pre}
|
|
970
|
+
.rf1{animation:rf-a .9s linear infinite 0s}
|
|
971
|
+
.rf2{animation:rf-a .9s linear infinite -.6s}
|
|
972
|
+
.rf3{animation:rf-a .9s linear infinite -.3s}
|
|
973
|
+
@keyframes rf-a{0%,32.9%{opacity:1}33%,100%{opacity:0}}
|
|
974
|
+
.h-brand{display:flex;flex-direction:column;gap:5px;padding-left:4px}
|
|
975
|
+
.brand-name{font-family:'Space Grotesk',system-ui,sans-serif;font-size:13px;font-weight:700;
|
|
976
|
+
letter-spacing:.08em;text-transform:uppercase;color:var(--amber)}
|
|
977
|
+
.brand-tag{font-size:12px;color:var(--ink-4)}
|
|
978
|
+
.summary-bar{display:inline-flex;align-items:center;gap:10px;margin-top:16px;
|
|
979
|
+
padding:8px 16px;background:var(--surface);border:1px solid var(--line);
|
|
980
|
+
border-radius:999px;font-size:13px;font-weight:600;color:var(--ink-2)}
|
|
981
|
+
.summary-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
|
982
|
+
.summary-dot.ok{background:var(--sage)}
|
|
983
|
+
.summary-dot.warn{background:var(--amber)}
|
|
984
|
+
.summary-dot.bad{background:var(--rose)}
|
|
985
|
+
.section-label{font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;
|
|
986
|
+
color:var(--ink-4);margin:40px 0 14px;display:flex;align-items:center;gap:10px}
|
|
987
|
+
.section-label::after{content:"";flex:1;height:1px;background:var(--line)}
|
|
988
|
+
.section-label.warn::before{content:"";width:6px;height:6px;border-radius:50%;
|
|
989
|
+
background:var(--amber);flex-shrink:0}
|
|
990
|
+
.section-label.info-lbl::before{content:"";width:6px;height:6px;border-radius:50%;
|
|
991
|
+
background:var(--ink-4);flex-shrink:0}
|
|
992
|
+
.pills{display:flex;gap:12px;flex-wrap:wrap;margin:20px 0 0}
|
|
993
|
+
.pill{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);
|
|
994
|
+
padding:14px 20px;min-width:118px;box-shadow:var(--shadow)}
|
|
995
|
+
.pill .n{font-size:24px;font-weight:700;letter-spacing:-.03em;color:var(--amber);line-height:1;
|
|
996
|
+
font-family:'Space Grotesk',system-ui,sans-serif}
|
|
997
|
+
.pill .l{font-size:10px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;
|
|
998
|
+
color:var(--ink-4);margin-top:7px}
|
|
999
|
+
.charts-label{font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;
|
|
1000
|
+
color:var(--ink-4);margin:40px 0 14px;display:flex;align-items:center;gap:10px}
|
|
1001
|
+
.charts-label::after{content:"";flex:1;height:1px;background:var(--line)}
|
|
1002
|
+
.charts{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}
|
|
1003
|
+
.chart-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);
|
|
1004
|
+
padding:20px;box-shadow:var(--shadow)}
|
|
1005
|
+
.chart-card h3{font-size:11px;font-weight:700;color:var(--ink-4);letter-spacing:.07em;
|
|
1006
|
+
text-transform:uppercase;margin-bottom:2px;font-family:'Space Grotesk',system-ui,sans-serif}
|
|
1007
|
+
.chart-card svg{width:100%;height:auto;display:block;margin-top:10px}
|
|
1008
|
+
.findings{display:flex;flex-direction:column;gap:10px}
|
|
1009
|
+
.finding{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);
|
|
1010
|
+
padding:20px 22px;box-shadow:var(--shadow)}
|
|
1011
|
+
.finding.medium{border-left:3px solid var(--amber)}
|
|
1012
|
+
.finding.high{border-left:3px solid var(--rose)}
|
|
1013
|
+
.finding.low{border-left:3px solid var(--blue)}
|
|
1014
|
+
.finding.info{border-left:3px solid var(--line)}
|
|
1015
|
+
.finding-meta{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
|
1016
|
+
.finding-badge{font-size:10px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;
|
|
1017
|
+
background:var(--bg-2);border:1px solid var(--line);border-radius:5px;
|
|
1018
|
+
padding:2px 7px;color:var(--ink-4);font-family:'JetBrains Mono',monospace}
|
|
1019
|
+
.finding-sev{font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase}
|
|
1020
|
+
.finding-sev.medium{color:var(--amber)}
|
|
1021
|
+
.finding-sev.high{color:var(--rose)}
|
|
1022
|
+
.finding-sev.low{color:var(--blue)}
|
|
1023
|
+
.finding-sev.info{color:var(--ink-4)}
|
|
1024
|
+
.finding-title{font-size:16px;font-weight:600;color:var(--ink-1);margin-bottom:10px;
|
|
1025
|
+
letter-spacing:-.01em;line-height:1.3;font-family:'Space Grotesk',system-ui,sans-serif}
|
|
1026
|
+
.finding-body{font-size:14px;color:var(--ink-2);line-height:1.65}
|
|
1027
|
+
.fix-block{margin-top:14px;padding:12px 16px;background:rgba(232,160,76,.07);
|
|
1028
|
+
border:1px solid rgba(232,160,76,.2);border-radius:10px;
|
|
1029
|
+
font-size:13px;color:var(--amber-soft);line-height:1.6}
|
|
1030
|
+
.fix-label{font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;
|
|
1031
|
+
color:var(--amber);margin-bottom:5px}
|
|
1032
|
+
.apply-tag{display:inline-block;background:rgba(232,160,76,.12);
|
|
1033
|
+
border:1px solid rgba(232,160,76,.3);border-radius:6px;padding:2px 8px;
|
|
1034
|
+
font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;
|
|
1035
|
+
color:var(--amber-soft);margin-left:8px;vertical-align:middle}
|
|
1036
|
+
.legend{display:flex;gap:14px;flex-wrap:wrap;margin-top:10px}
|
|
1037
|
+
.legend-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--ink-3)}
|
|
1038
|
+
.legend-dot{width:8px;height:8px;border-radius:2px;flex-shrink:0}
|
|
1039
|
+
code{font-family:'JetBrains Mono',ui-monospace,'Cascadia Code',Consolas,monospace;
|
|
1040
|
+
background:var(--bg-2);padding:1px 5px;border-radius:4px;font-size:.85em;color:var(--amber-soft)}
|
|
1041
|
+
footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--line);
|
|
1042
|
+
font-size:12px;color:var(--ink-4)}
|
|
1043
|
+
"""
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _pill(value: str, label: str) -> str:
|
|
1047
|
+
return (
|
|
1048
|
+
f'<div class="pill"><div class="n">{value}</div>'
|
|
1049
|
+
f'<div class="l">{label}</div></div>'
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def _backtick_to_code(text: str) -> str:
|
|
1054
|
+
"""Replace `foo` with <code>foo</code> for HTML display."""
|
|
1055
|
+
return re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _card_html(f: dict) -> str:
|
|
1059
|
+
sev = f["severity"]
|
|
1060
|
+
sev_labels = {"high": "Needs fixing", "medium": "Worth fixing", "low": "Minor", "info": "Good to know"}
|
|
1061
|
+
apply_tag = '<span class="apply-tag">apply</span>' if f.get("apply_target") else ""
|
|
1062
|
+
body = _backtick_to_code(f["finding"])
|
|
1063
|
+
fix_html = ""
|
|
1064
|
+
if f.get("action"):
|
|
1065
|
+
fix_html = (
|
|
1066
|
+
f'<div class="fix-block">'
|
|
1067
|
+
f'<div class="fix-label">What to do</div>'
|
|
1068
|
+
+ _backtick_to_code(f["action"])
|
|
1069
|
+
+ "</div>"
|
|
1070
|
+
)
|
|
1071
|
+
return (
|
|
1072
|
+
f'<div class="finding {sev}">'
|
|
1073
|
+
f'<div class="finding-meta">'
|
|
1074
|
+
f'<span class="finding-badge">{f["id"]}</span>'
|
|
1075
|
+
f'<span class="finding-sev {sev}">{sev_labels.get(sev, sev)}</span>'
|
|
1076
|
+
f'</div>'
|
|
1077
|
+
f'<div class="finding-title">{f["title"]}{apply_tag}</div>'
|
|
1078
|
+
f'<div class="finding-body">{body}</div>'
|
|
1079
|
+
+ fix_html
|
|
1080
|
+
+ "</div>"
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def render_html(metrics: dict, findings: list[dict], days: int,
|
|
1085
|
+
ws: str, we: str, generated: str,
|
|
1086
|
+
log_path: str = ".caddis/usage-log.jsonl") -> str:
|
|
1087
|
+
# log_path is shown in the footer so the dashboard names the dir this repo actually uses.
|
|
1088
|
+
mm = metrics.get("model_mix", {})
|
|
1089
|
+
tot_out = metrics.get("output") or 1
|
|
1090
|
+
sessions = metrics.get("sessions", 0)
|
|
1091
|
+
per_day = metrics.get("per_day", {})
|
|
1092
|
+
|
|
1093
|
+
# Summary bar
|
|
1094
|
+
action_count = sum(1 for f in findings if f["severity"] in ("high", "medium"))
|
|
1095
|
+
info_count = len(findings) - action_count
|
|
1096
|
+
if action_count == 0:
|
|
1097
|
+
dot_cls = "ok"
|
|
1098
|
+
summary_text = f"Nothing needs immediate attention · {info_count} tip{'s' if info_count != 1 else ''}"
|
|
1099
|
+
elif action_count <= 2:
|
|
1100
|
+
dot_cls = "warn"
|
|
1101
|
+
summary_text = (
|
|
1102
|
+
f"{action_count} thing{'s' if action_count != 1 else ''} "
|
|
1103
|
+
f"need{'s' if action_count == 1 else ''} attention "
|
|
1104
|
+
f"· {info_count} tip{'s' if info_count != 1 else ''}"
|
|
1105
|
+
)
|
|
1106
|
+
else:
|
|
1107
|
+
dot_cls = "bad"
|
|
1108
|
+
summary_text = f"{action_count} things need attention · {info_count} tips"
|
|
1109
|
+
|
|
1110
|
+
# Pills
|
|
1111
|
+
mix_top = max(mm.items(), key=lambda x: x[1], default=("—", 0))
|
|
1112
|
+
pills = "".join([
|
|
1113
|
+
_pill(str(sessions), "sessions"),
|
|
1114
|
+
_pill(_fmt(metrics["output"]), "output tokens"),
|
|
1115
|
+
_pill(_pct(metrics["cache_read"], metrics["input"] + metrics["cache_read"]), "cache efficiency"),
|
|
1116
|
+
_pill(f"{mix_top[0]}: {_pct(mix_top[1], tot_out)}", "top model"),
|
|
1117
|
+
])
|
|
1118
|
+
|
|
1119
|
+
# Charts
|
|
1120
|
+
per_day_cost = {d: v["cost"] for d, v in per_day.items()}
|
|
1121
|
+
per_day_sessions = {d: v["sessions"] for d, v in per_day.items()}
|
|
1122
|
+
mix_slices = [
|
|
1123
|
+
(t, mm.get(t, 0), TIER_COLORS.get(t, "#9a9282"))
|
|
1124
|
+
for t in ["fable", "opus", "sonnet", "haiku"]
|
|
1125
|
+
if mm.get(t, 0) > 0
|
|
1126
|
+
]
|
|
1127
|
+
a_disp = metrics.get("agent_dispatches", {})
|
|
1128
|
+
agent_items = [
|
|
1129
|
+
(t, cnt, TIER_COLORS.get(
|
|
1130
|
+
_tier((metrics.get("agent_models", {}).get(t) or ["sonnet"])[0]),
|
|
1131
|
+
"#E8A04C"
|
|
1132
|
+
))
|
|
1133
|
+
for t, cnt in sorted(a_disp.items(), key=lambda x: -x[1])[:8]
|
|
1134
|
+
]
|
|
1135
|
+
|
|
1136
|
+
charts_html = (
|
|
1137
|
+
f'<div class="chart-card"><h3>Sessions</h3>'
|
|
1138
|
+
+ _svg_timeline(per_day_sessions, "#8FA66B") + "</div>"
|
|
1139
|
+
+ f'<div class="chart-card"><h3>Est. API cost equiv. per day</h3>'
|
|
1140
|
+
+ _svg_timeline(per_day_cost, "#E8A04C") + "</div>"
|
|
1141
|
+
+ f'<div class="chart-card"><h3>Token volume per day</h3>'
|
|
1142
|
+
+ _svg_stacked_bar(per_day)
|
|
1143
|
+
+ '<div class="legend">'
|
|
1144
|
+
+ '<div class="legend-item"><div class="legend-dot" style="background:#E8A04C"></div>output</div>'
|
|
1145
|
+
+ '<div class="legend-item"><div class="legend-dot" style="background:#6B9BC9"></div>cache read</div>'
|
|
1146
|
+
+ "</div></div>"
|
|
1147
|
+
+ '<div class="chart-card" style="display:flex;flex-direction:column;align-items:center">'
|
|
1148
|
+
+ '<h3 style="width:100%">Model mix (by output tokens)</h3>'
|
|
1149
|
+
+ f'<div style="width:160px;margin:4px auto 0">{_svg_donut(mix_slices)}</div>'
|
|
1150
|
+
+ '<div class="legend" style="justify-content:center;margin-top:8px">'
|
|
1151
|
+
+ "".join(
|
|
1152
|
+
f'<div class="legend-item"><div class="legend-dot" style="background:{c}"></div>{t}</div>'
|
|
1153
|
+
for t, _, c in mix_slices
|
|
1154
|
+
)
|
|
1155
|
+
+ "</div></div>"
|
|
1156
|
+
)
|
|
1157
|
+
if metrics.get("peak_contexts"):
|
|
1158
|
+
charts_html += (
|
|
1159
|
+
'<div class="chart-card"><h3>Context size per session</h3>'
|
|
1160
|
+
+ _svg_histogram(metrics["peak_contexts"])
|
|
1161
|
+
+ '<div class="legend"><div class="legend-item">'
|
|
1162
|
+
+ '<div class="legend-dot" style="background:#C9736B"></div>above 150k (heavy)</div></div></div>'
|
|
1163
|
+
)
|
|
1164
|
+
if a_disp:
|
|
1165
|
+
charts_html += (
|
|
1166
|
+
'<div class="chart-card"><h3>Agent usage</h3>'
|
|
1167
|
+
+ _svg_bar_h(agent_items, h=max(60, len(agent_items) * 30))
|
|
1168
|
+
+ "</div>"
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
# Findings — split into "needs attention" and "good to know"
|
|
1172
|
+
sev_order = {"high": 0, "medium": 1, "low": 2, "info": 3}
|
|
1173
|
+
sorted_f = sorted(findings, key=lambda x: sev_order.get(x["severity"], 4))
|
|
1174
|
+
action_cards = [_card_html(f) for f in sorted_f if f["severity"] in ("high", "medium")]
|
|
1175
|
+
advisory_cards = [_card_html(f) for f in sorted_f if f["severity"] in ("low", "info")]
|
|
1176
|
+
|
|
1177
|
+
action_section = ""
|
|
1178
|
+
if action_cards:
|
|
1179
|
+
action_section = (
|
|
1180
|
+
'<div class="section-label warn">Needs attention</div>'
|
|
1181
|
+
+ '<div class="findings">' + "\n".join(action_cards) + "</div>"
|
|
1182
|
+
)
|
|
1183
|
+
advisory_section = ""
|
|
1184
|
+
if advisory_cards:
|
|
1185
|
+
advisory_section = (
|
|
1186
|
+
'<div class="section-label info-lbl">Good to know</div>'
|
|
1187
|
+
+ '<div class="findings">' + "\n".join(advisory_cards) + "</div>"
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
return f"""<!DOCTYPE html>
|
|
1191
|
+
<html lang="en">
|
|
1192
|
+
<head>
|
|
1193
|
+
<meta charset="UTF-8">
|
|
1194
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
1195
|
+
<title>caddis · usage review</title>
|
|
1196
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
1197
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
1198
|
+
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
1199
|
+
<style>{_CSS}</style>
|
|
1200
|
+
</head>
|
|
1201
|
+
<body>
|
|
1202
|
+
<div class="page">
|
|
1203
|
+
<div class="brand-strip">
|
|
1204
|
+
<div class="hamster-logo">
|
|
1205
|
+
<div class="h-run">
|
|
1206
|
+
<pre class="rf rf1"> (\\(\\
|
|
1207
|
+
( ˘ω˘ )
|
|
1208
|
+
(っ っ)
|
|
1209
|
+
╱ ╲</pre>
|
|
1210
|
+
<pre class="rf rf2"> (\\(\\
|
|
1211
|
+
( ˘ω˘ )
|
|
1212
|
+
(っ っ)
|
|
1213
|
+
│ │</pre>
|
|
1214
|
+
<pre class="rf rf3"> (\\(\\
|
|
1215
|
+
( ˘ω˘ )
|
|
1216
|
+
(っ っ)
|
|
1217
|
+
╲ ╱</pre>
|
|
1218
|
+
</div>
|
|
1219
|
+
<div class="h-brand">
|
|
1220
|
+
<span class="brand-name">◆ caddis</span>
|
|
1221
|
+
<span class="brand-tag">usage review</span>
|
|
1222
|
+
</div>
|
|
1223
|
+
</div>
|
|
1224
|
+
</div>
|
|
1225
|
+
|
|
1226
|
+
<div>
|
|
1227
|
+
<h1>/usage-review</h1>
|
|
1228
|
+
<div class="subtitle">Last {days} days · {ws} – {we} · {sessions} session{"s" if sessions != 1 else ""}</div>
|
|
1229
|
+
<div class="summary-bar">
|
|
1230
|
+
<div class="summary-dot {dot_cls}"></div>
|
|
1231
|
+
<span>{summary_text}</span>
|
|
1232
|
+
</div>
|
|
1233
|
+
</div>
|
|
1234
|
+
|
|
1235
|
+
<div class="pills">{pills}</div>
|
|
1236
|
+
|
|
1237
|
+
<div class="charts-label">Usage charts</div>
|
|
1238
|
+
<div class="charts">{charts_html}</div>
|
|
1239
|
+
|
|
1240
|
+
{action_section}
|
|
1241
|
+
{advisory_section}
|
|
1242
|
+
|
|
1243
|
+
<footer>Generated {generated} · <code>{log_path}</code> + session transcripts · Estimates only — all data stays local. · <a href="https://github.com/saajunaid/caddis-plugin" style="color:var(--ink-4)">caddis</a></footer>
|
|
1244
|
+
</div>
|
|
1245
|
+
</body>
|
|
1246
|
+
</html>"""
|
|
1247
|
+
|
|
1248
|
+
# ── main ───────────────────────────────────────────────────────────────────────
|
|
1249
|
+
|
|
1250
|
+
def main() -> None:
|
|
1251
|
+
ap = argparse.ArgumentParser(
|
|
1252
|
+
description="caddis /usage-review: usage analysis + harness recommendations"
|
|
1253
|
+
)
|
|
1254
|
+
ap.add_argument("--days", type=int, default=7, help="Analysis window in days (default 7)")
|
|
1255
|
+
ap.add_argument("--cwd", default=os.getcwd(), help="Project root (default: cwd)")
|
|
1256
|
+
ap.add_argument("--no-html", action="store_true", help="Skip HTML dashboard generation")
|
|
1257
|
+
ap.add_argument(
|
|
1258
|
+
"--output-dir", default=None,
|
|
1259
|
+
help="Directory for HTML output relative to cwd. Defaults to <artifact-dir>/reviews.",
|
|
1260
|
+
)
|
|
1261
|
+
args = ap.parse_args()
|
|
1262
|
+
|
|
1263
|
+
cwd = _repo_root(os.path.abspath(args.cwd))
|
|
1264
|
+
days = args.days
|
|
1265
|
+
now = datetime.now(timezone.utc)
|
|
1266
|
+
ws = (now - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
1267
|
+
we = now.strftime("%Y-%m-%d")
|
|
1268
|
+
|
|
1269
|
+
# Resolve output directory: the HTML dashboard lives under <artifact-dir>/reviews — the dir
|
|
1270
|
+
# this repo actually uses, so an unmigrated repo doesn't sprout a second one.
|
|
1271
|
+
if args.output_dir is not None:
|
|
1272
|
+
out_subdir = args.output_dir
|
|
1273
|
+
else:
|
|
1274
|
+
out_subdir = os.path.join(artifact_root(cwd).name, "reviews")
|
|
1275
|
+
|
|
1276
|
+
# Load data
|
|
1277
|
+
current_sessions, prev_sessions = load_usage_log(cwd, days)
|
|
1278
|
+
|
|
1279
|
+
if not current_sessions:
|
|
1280
|
+
print(
|
|
1281
|
+
f"# /usage-review\n\n"
|
|
1282
|
+
f"No sessions found in the last {days} days.\n"
|
|
1283
|
+
f"Run a few sessions to build up data, then re-run `/usage-review`.\n\n"
|
|
1284
|
+
f"(Data file: {_usage_log_path(cwd)})"
|
|
1285
|
+
)
|
|
1286
|
+
return
|
|
1287
|
+
|
|
1288
|
+
session_ids = [s.get("_session_id", "") for s in current_sessions if s.get("_session_id")]
|
|
1289
|
+
transcripts = load_transcripts(cwd, session_ids)
|
|
1290
|
+
metrics = compute_metrics(current_sessions, transcripts)
|
|
1291
|
+
prev_metrics = compute_metrics(prev_sessions, {})
|
|
1292
|
+
agents = load_agent_config(cwd)
|
|
1293
|
+
cmd_roster = load_command_roster(cwd)
|
|
1294
|
+
findings = run_rules(metrics, prev_metrics, agents, cmd_roster=cmd_roster)
|
|
1295
|
+
|
|
1296
|
+
# Markdown to stdout
|
|
1297
|
+
print(render_markdown(metrics, findings, days, ws, we))
|
|
1298
|
+
|
|
1299
|
+
# HTML dashboard
|
|
1300
|
+
if not args.no_html:
|
|
1301
|
+
generated = now.strftime("%Y-%m-%d %H:%M UTC")
|
|
1302
|
+
html = render_html(metrics, findings, days, ws, we, generated,
|
|
1303
|
+
log_path=os.path.relpath(_usage_log_path(cwd), cwd).replace(os.sep, "/"))
|
|
1304
|
+
out_dir = os.path.join(cwd, out_subdir)
|
|
1305
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
1306
|
+
html_path = os.path.join(out_dir, "usage-review.html")
|
|
1307
|
+
with open(html_path, "w", encoding="utf-8") as fh:
|
|
1308
|
+
fh.write(html)
|
|
1309
|
+
print(f"\n→ HTML dashboard: {html_path}")
|
|
1310
|
+
|
|
1311
|
+
# Update last-review timestamp — in the repo's artifact dir so inject_relay.py finds it
|
|
1312
|
+
caddis_dir = str(artifact_root(cwd))
|
|
1313
|
+
os.makedirs(caddis_dir, exist_ok=True)
|
|
1314
|
+
stamp_path = os.path.join(caddis_dir, ".last-usage-review")
|
|
1315
|
+
try:
|
|
1316
|
+
with open(stamp_path, "w", encoding="utf-8") as fh:
|
|
1317
|
+
fh.write(now.isoformat(timespec="seconds"))
|
|
1318
|
+
except Exception:
|
|
1319
|
+
pass
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
if __name__ == "__main__":
|
|
1323
|
+
main()
|