scar-cli 0.23.0__tar.gz → 0.24.0__tar.gz

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 (162) hide show
  1. scar_cli-0.24.0/.release-please-manifest.json +3 -0
  2. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0005-history-rewrite-orphans-commit-evidence.landmine.md +5 -0
  3. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0006-yaml-pattern-anchor-over-escaping.landmine.md +2 -1
  4. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0008-rich-output-nontty-must-bypass-rich.landmine.md +2 -2
  5. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0011-lint-compile-check-lets-redos-anchors-hang-hotpath.landmine.md +2 -2
  6. scar_cli-0.24.0/.scars/candidates/host-detection-must-not-read-the-process-home.md +40 -0
  7. scar_cli-0.24.0/.scars/candidates/path-anchor-breadth-drives-the-review-due-threshold.md +37 -0
  8. {scar_cli-0.23.0 → scar_cli-0.24.0}/AGENTS.md +5 -2
  9. {scar_cli-0.23.0 → scar_cli-0.24.0}/CHANGELOG.md +13 -0
  10. {scar_cli-0.23.0 → scar_cli-0.24.0}/PKG-INFO +3 -1
  11. {scar_cli-0.23.0 → scar_cli-0.24.0}/README.md +2 -0
  12. {scar_cli-0.23.0 → scar_cli-0.24.0}/SCAR-FORMAT.md +1 -1
  13. {scar_cli-0.23.0 → scar_cli-0.24.0}/SPEC.md +2 -0
  14. {scar_cli-0.23.0 → scar_cli-0.24.0}/plugin/plugin.json +1 -1
  15. {scar_cli-0.23.0 → scar_cli-0.24.0}/pyproject.toml +1 -1
  16. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/cli.py +67 -40
  17. scar_cli-0.24.0/src/scar/firing_log.py +71 -0
  18. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/hooks.py +8 -10
  19. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/hosts.py +27 -16
  20. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/installer.py +51 -13
  21. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/lint.py +6 -2
  22. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_cli.py +127 -1
  23. scar_cli-0.24.0/tests/test_firing_log.py +113 -0
  24. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_hosts.py +48 -3
  25. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_installer.py +126 -3
  26. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_lint.py +26 -0
  27. {scar_cli-0.23.0 → scar_cli-0.24.0}/uv.lock +1 -1
  28. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/docs/agents.md +27 -0
  29. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/static/llms.txt +1 -1
  30. scar_cli-0.23.0/.release-please-manifest.json +0 -3
  31. {scar_cli-0.23.0 → scar_cli-0.24.0}/.claude-plugin/marketplace.json +0 -0
  32. {scar_cli-0.23.0 → scar_cli-0.24.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  33. {scar_cli-0.23.0 → scar_cli-0.24.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  34. {scar_cli-0.23.0 → scar_cli-0.24.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  35. {scar_cli-0.23.0 → scar_cli-0.24.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  36. {scar_cli-0.23.0 → scar_cli-0.24.0}/.github/workflows/ci.yml +0 -0
  37. {scar_cli-0.23.0 → scar_cli-0.24.0}/.github/workflows/docs.yml +0 -0
  38. {scar_cli-0.23.0 → scar_cli-0.24.0}/.github/workflows/pr-validation.yml +0 -0
  39. {scar_cli-0.23.0 → scar_cli-0.24.0}/.github/workflows/release.yml +0 -0
  40. {scar_cli-0.23.0 → scar_cli-0.24.0}/.gitignore +0 -0
  41. {scar_cli-0.23.0 → scar_cli-0.24.0}/.pre-commit-config.yaml +0 -0
  42. {scar_cli-0.23.0 → scar_cli-0.24.0}/.pre-commit-hooks.yaml +0 -0
  43. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
  44. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
  45. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +0 -0
  46. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +0 -0
  47. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0007-release-please-config-change-skips-open-pr.landmine.md +0 -0
  48. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0009-anchor-survival-harness-measures-symbol-not-path-anchors.deadend.md +0 -0
  49. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0010-git-returncode-128-vs-empty-tracked-set.landmine.md +0 -0
  50. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0012-firing-log-readers-need-blanket-guards.landmine.md +0 -0
  51. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0013-docusaurus-future-v4-requires-faster-dep.landmine.md +0 -0
  52. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0014-inject-flag-dest-collides-with-subcommand-dispatch.landmine.md +0 -0
  53. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0015-zero-checkruns-means-approval-gate-not-workflow-bug.deadend.md +0 -0
  54. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0016-hook-ownership-must-be-kind-scoped.landmine.md +0 -0
  55. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0017-codex-pretool-context-can-replay-apply-patch.landmine.md +0 -0
  56. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0018-cli-subparsers-register-through-_add-wrapper.landmine.md +0 -0
  57. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0019-token-in-scar-body-cannot-prove-hook-delivery.deadend.md +0 -0
  58. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0020-one-directional-health-check-leaves-flattering-direction-unwatched.landmine.md +0 -0
  59. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0021-posttool-fix-in-hooks-does-not-reach-codex-or-cascade.landmine.md +0 -0
  60. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/0022-codex-hook-delivery-needs-user-file-and-trust.landmine.md +0 -0
  61. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/README.md +0 -0
  62. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/candidates/cascade-block-exit-collides-with-argparse.md +0 -0
  63. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/candidates/one-plugin-hook-manifest-two-hosts-two-root-variables.md +0 -0
  64. {scar_cli-0.23.0 → scar_cli-0.24.0}/.scars/template.md +0 -0
  65. {scar_cli-0.23.0 → scar_cli-0.24.0}/CONTRIBUTING.md +0 -0
  66. {scar_cli-0.23.0 → scar_cli-0.24.0}/IDEA.md +0 -0
  67. {scar_cli-0.23.0 → scar_cli-0.24.0}/LICENSE +0 -0
  68. {scar_cli-0.23.0 → scar_cli-0.24.0}/ROADMAP.md +0 -0
  69. {scar_cli-0.23.0 → scar_cli-0.24.0}/STRESS-TEST.md +0 -0
  70. {scar_cli-0.23.0 → scar_cli-0.24.0}/action.yml +0 -0
  71. {scar_cli-0.23.0 → scar_cli-0.24.0}/codecov.yml +0 -0
  72. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/anchor-survival/PROTOCOL.md +0 -0
  73. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/anchor-survival/RESULTS.md +0 -0
  74. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/anchor-survival/long_replay.py +0 -0
  75. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/anchor-survival/replay.py +0 -0
  76. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/anchor-survival/replay_shipped.py +0 -0
  77. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/anchor-survival/test_replay_shipped.py +0 -0
  78. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/auto-authorship/FINDINGS.md +0 -0
  79. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/auto-authorship/PROTOCOL.md +0 -0
  80. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/fence-honor/.gitignore +0 -0
  81. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/fence-honor/PROTOCOL.md +0 -0
  82. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/fence-honor/RESULTS.md +0 -0
  83. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
  84. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
  85. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
  86. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/fence-honor/fixture/README.md +0 -0
  87. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/fence-honor/fixture/payments/retry.py +0 -0
  88. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/fence-honor/fixture/reports/export.py +0 -0
  89. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/fence-honor/fixture/services/sessions.py +0 -0
  90. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/fence-honor/grade.py +0 -0
  91. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/harvest/PROTOCOL.md +0 -0
  92. {scar_cli-0.23.0 → scar_cli-0.24.0}/experiments/harvest/harvest.py +0 -0
  93. {scar_cli-0.23.0 → scar_cli-0.24.0}/hook/scar-hooks.py +0 -0
  94. {scar_cli-0.23.0 → scar_cli-0.24.0}/plugin/hooks/run.sh +0 -0
  95. {scar_cli-0.23.0 → scar_cli-0.24.0}/plugin/skills/scar-authoring/SKILL.md +0 -0
  96. {scar_cli-0.23.0 → scar_cli-0.24.0}/plugin/skills/scar-authoring/assets/template.md +0 -0
  97. {scar_cli-0.23.0 → scar_cli-0.24.0}/release-please-config.json +0 -0
  98. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/__init__.py +0 -0
  99. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/agent.py +0 -0
  100. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/cascade.py +0 -0
  101. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/codex.py +0 -0
  102. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/draftcheck.py +0 -0
  103. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/evidence.py +0 -0
  104. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/gc.py +0 -0
  105. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/harvest.py +0 -0
  106. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/match.py +0 -0
  107. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/mcp.py +0 -0
  108. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/model.py +0 -0
  109. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/orphan.py +0 -0
  110. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/output.py +0 -0
  111. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/reanchor.py +0 -0
  112. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/renames.py +0 -0
  113. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/render.py +0 -0
  114. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/review.py +0 -0
  115. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/skills/scar-authoring/SKILL.md +0 -0
  116. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/skills/scar-authoring/assets/template.md +0 -0
  117. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/store.py +0 -0
  118. {scar_cli-0.23.0 → scar_cli-0.24.0}/src/scar/symbols.py +0 -0
  119. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/conftest.py +0 -0
  120. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_action_yml.py +0 -0
  121. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_anchor_kind.py +0 -0
  122. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_block_capability.py +0 -0
  123. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_cascade.py +0 -0
  124. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_codex.py +0 -0
  125. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_context_size.py +0 -0
  126. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_docs.py +0 -0
  127. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_draftcheck.py +0 -0
  128. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_evidence.py +0 -0
  129. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_gc.py +0 -0
  130. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_git_hook.py +0 -0
  131. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_harvest.py +0 -0
  132. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_hooks.py +0 -0
  133. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_json_contract.py +0 -0
  134. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_lifecycle.py +0 -0
  135. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_match.py +0 -0
  136. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_mcp.py +0 -0
  137. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_model.py +0 -0
  138. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_orphan.py +0 -0
  139. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_output.py +0 -0
  140. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_plugin.py +0 -0
  141. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_precommit_hooks.py +0 -0
  142. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_reanchor.py +0 -0
  143. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_renames.py +0 -0
  144. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_render.py +0 -0
  145. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_review.py +0 -0
  146. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_skill.py +0 -0
  147. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_store.py +0 -0
  148. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_symbols.py +0 -0
  149. {scar_cli-0.23.0 → scar_cli-0.24.0}/tests/test_verdict_observability.py +0 -0
  150. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/.gitignore +0 -0
  151. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/docs/concepts.md +0 -0
  152. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/docs/index.md +0 -0
  153. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/docs/methodology.md +0 -0
  154. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/docs/quickstart.md +0 -0
  155. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/docusaurus.config.ts +0 -0
  156. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/package-lock.json +0 -0
  157. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/package.json +0 -0
  158. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/scripts/sync-changelog.mjs +0 -0
  159. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/sidebars.ts +0 -0
  160. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/src/css/custom.css +0 -0
  161. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/static/img/favicon.svg +0 -0
  162. {scar_cli-0.23.0 → scar_cli-0.24.0}/website/tsconfig.json +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.24.0"
3
+ }
@@ -8,8 +8,13 @@ created: 2026-06-11
8
8
  authors: ["claude-code", "Kibukx"]
9
9
  anchors:
10
10
  - path: .scars/
11
+ - command: "git push.*--force"
12
+ - command: "git rebase"
13
+ - command: "git filter-repo|git filter-branch"
14
+ - command: "git commit.*--amend"
11
15
  violation: "commit: [0-9a-f]{7,40}"
12
16
  evidence:
17
+ - note: "2026-09-05 anchors: path .scars/ KEPT on purpose. The violation tripwire commit: [0-9a-f]{7,40} only evaluates on files an anchor matched, so dropping it to cut firings would silently disarm the scar. Command anchors added instead, so the history-rewrite half fires on the act rather than on any scar edit"
13
18
  - note: v0.1.0 public release (2026-06-11): fresh-start force-push orphaned 3 commit SHAs cited by scars 0001 and 0002; SHAs still resolve on GitHub by URL but fail in any fresh clone, and GitHub may GC them eventually
14
19
  expires:
15
20
  condition: "evidence schema gains a resolvable form (full URL or archived diff) or lint warns on bare SHAs at promotion"
@@ -9,8 +9,9 @@ authors: ["claude-code", "kibukx"]
9
9
  anchors:
10
10
  - path: src/scar/orphan.py
11
11
  - path: src/scar/match.py
12
- - path: .scars/
12
+ - pattern: "\n\s*-\s*pattern:"
13
13
  evidence:
14
+ - note: "2026-09-05 anchors: path .scars/ replaced by a pattern matching a scar frontmatter pattern: entry. The directory anchor fired on every scar edit, while the hazard only exists when a pattern anchor is being authored"
14
15
  - pr: 40
15
16
  - note: scar 1 grep pattern matched only its own body, never experiments/anchor-survival/RESULTS.md
16
17
  expires:
@@ -7,10 +7,10 @@ confidence: 0.9
7
7
  created: 2026-06-30
8
8
  authors: ["claude-code", "kibukx"]
9
9
  anchors:
10
- - path: src/scar/cli.py
11
10
  - path: src/scar/output.py
12
- - pattern: "output\.render\("
11
+ - pattern: "def plain\(\):"
13
12
  evidence:
13
+ - note: "2026-09-05 anchors: path src/scar/cli.py dropped, it is 3000+ lines and the tty gate is 17 of them. The 18 output.render( call sites only invoke the already-safe dispatcher, so that pattern is replaced by def plain():, which marks the 14 non-tty contract closures this scar actually protects"
14
14
  - issue: 78
15
15
  - note: 187-test suite asserts plain substrings like '0001-bad.deadend.md' and long anchor paths on main([...]) under capsys
16
16
  expires:
@@ -7,11 +7,11 @@ confidence: 0.9
7
7
  created: 2026-06-30
8
8
  authors: ["claude-code", "Kibukx"]
9
9
  anchors:
10
- - path: src/scar/lint.py
11
- - path: src/scar/match.py
12
10
  - pattern: "rx\.search\("
13
11
  - pattern: "MAX_ANCHOR_SCAN"
12
+ - pattern: "_is_redos_prone"
14
13
  evidence:
14
+ - note: "2026-09-05 anchors: both whole-file path anchors dropped, lint.py and match.py serve many concerns beyond ReDoS. _is_redos_prone added so the GATE itself stays anchored, since the body says the gate is the defense and the 64 KiB cap is not"
15
15
  - issue: 88
16
16
  expires:
17
17
  condition: "lint gains full regex-safety analysis OR the matcher runs on a hardened regex engine with a hard timeout"
@@ -0,0 +1,40 @@
1
+ ---
2
+ id: 0
3
+ type: deadend
4
+ title: Host detection must take home from installer.CLAUDE_DIR, never Path.home() — a bare call passes locally and fails every other machine
5
+ severity: high
6
+ confidence: 0.9
7
+ created: 2026-09-07
8
+ authors: ["claude-code"]
9
+ anchors:
10
+ - pattern: "detect_hosts\("
11
+ - symbol: src/scar/cli.py::_detect
12
+ evidence:
13
+ - issue: 318
14
+ - note: "2026-09-07, during the multi-host skill install work: the skill-install refusal resolved home with Path.home(). Under HOME=empty the suite went 1031 passed / 2 FAILED, both PRE-EXISTING tests, printing 'claude not detected on this machine, nothing written'. Fixed by reusing _detect(). Branch SHAs deliberately omitted: scar #5 fired on the amend that wrote this, and squash-merge would orphan them."
15
+ expires:
16
+ condition: "detect_hosts takes home from one injected seam no caller can bypass"
17
+ review_after: 2027-03-08
18
+ status: candidate
19
+ ---
20
+
21
+ `Path.home()` in a detection path looks obviously right and is a trap. Tests
22
+ patch `installer.CLAUDE_DIR`, not the environment, and `_detect()` derives
23
+ home from `installer.CLAUDE_DIR.parent`. A bare call reads the author's real
24
+ home, finds their real `~/.claude`, and passes. On any machine without one,
25
+ every CI runner included, detection returns nothing and the caller refuses.
26
+
27
+ The failure is invisible where it is introduced: the suite was fully green on
28
+ the authoring machine, and the tests that finally failed were pre-existing
29
+ ones nobody had touched. Use `_detect(kind)`, or `installer.CLAUDE_DIR.parent`
30
+ exactly as `_detect` does. Never add a second answer to where home is.
31
+
32
+ Two anchors on purpose. `cli.py` is 143 KiB and its first `detect_hosts(` sits
33
+ at byte ~123,000, past the 64 KiB `MAX_ANCHOR_SCAN` bound, so the pattern is
34
+ blind to the very file the defect landed in. The `symbol:` anchor covers it,
35
+ at the cost of needing `scar-cli[symbols]`. Lint reports neither gap: the
36
+ pattern matches `hosts.py`, so the scar reads live while blind where it counts.
37
+
38
+ Not armed. The obvious tripwire is `Path\.home\(\)`, but `cli.py` carries that
39
+ string in a warning comment and `installer.py` uses it correctly for module
40
+ constants, so it would fire on prose and on correct code.
@@ -0,0 +1,37 @@
1
+ ---
2
+ id: 0
3
+ type: landmine
4
+ title: A whole-file or whole-directory path anchor makes the review-due threshold report anchor breadth, not code hazard
5
+ severity: medium
6
+ confidence: 0.8
7
+ created: 2026-09-04
8
+ authors: ["claude-code"]
9
+ anchors:
10
+ - path: SCAR-FORMAT.md
11
+ - pattern: "review_after_firings"
12
+ evidence:
13
+ - issue: 312
14
+ - note: "triage of all five over-threshold scars in this repo on 2026-09-04: four were over-anchored, one was a real code fix"
15
+ expires:
16
+ condition: "lint reports what share of a scar's firings touched the hazard, so the review-due warning can distinguish breadth from recurrence"
17
+ review_after: 2027-03-04
18
+ status: candidate
19
+ ---
20
+
21
+ `scar lint` warns past `review_after_firings` that either the guarded code
22
+ merits the fix or the scar merits revision. Read literally that sends you to
23
+ the code first. It is usually the anchor.
24
+
25
+ A `path:` anchor is a repo-relative PREFIX, so it fires on every edit beneath
26
+ it. Scar #8 guards a 17-line tty branch inside a 52-line module but anchored
27
+ the whole 3,090-line `src/scar/cli.py`; roughly nine of every ten of its 47
28
+ firings touched nothing related. Scar #5 guards against history rewrites and
29
+ anchored all of `.scars/`, so it fired on 22 routine promotions and zero
30
+ force-pushes, which is zero of its actual trigger events.
31
+
32
+ A firing count therefore measures anchor breadth first and hazard recurrence
33
+ second, and the two are not separable from the log. Before reading a
34
+ review-due warning as evidence that code needs fixing, check what share of the
35
+ firings touched the hazard at all. Then narrow the anchor to the smallest file
36
+ holding the branching, or switch to a `command:` anchor when the trigger is an
37
+ action rather than a file (#312 is the one case here that really was the code).
@@ -26,8 +26,11 @@ The full authoring contract — qualification criteria, the candidates-only writ
26
26
  path, mandatory YAML frontmatter, and the regex over-escaping trap — is packaged
27
27
  as the `scar-authoring` skill.
28
28
 
29
- - **Claude Code:** install the plugin (recommended) or `scar skill install` to
30
- drop the skill into `~/.claude/skills/`. It auto-loads on trigger.
29
+ - **Claude Code, Codex, Cursor, opencode, Windsurf:** `scar skill install`
30
+ detects which of those hosts are on the machine and writes the skill to each
31
+ one's native skills directory. On Claude Code the plugin is the recommended
32
+ route and carries the same skill. The per-host destination table is in
33
+ [website/docs/agents.md](website/docs/agents.md).
31
34
  - **MCP agents (Cursor/Windsurf/opencode):** the `scar_draft` tool enforces the
32
35
  candidates-only path and lints before writing; its description carries the
33
36
  digest.
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.24.0](https://github.com/Daily-Nerd/Scar/compare/v0.23.0...v0.24.0) (2026-09-08)
4
+
5
+
6
+ ### Features
7
+
8
+ * **firing-log:** one shared validated reader for the firing log ([#313](https://github.com/Daily-Nerd/Scar/issues/313)) ([d316922](https://github.com/Daily-Nerd/Scar/commit/d31692205715e63380b8ac104643de4ceac0338d))
9
+ * **skill:** install the authoring skill to every detected host ([#320](https://github.com/Daily-Nerd/Scar/issues/320)) ([1d9c985](https://github.com/Daily-Nerd/Scar/commit/1d9c985e3e5815ba67d222defa86d549886c7e94))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **promote:** record whether a promotion happened at a terminal ([#315](https://github.com/Daily-Nerd/Scar/issues/315)) ([f28e8eb](https://github.com/Daily-Nerd/Scar/commit/f28e8eb90a5fb3161aa40565dbfb218d1b9b4160)), closes [#308](https://github.com/Daily-Nerd/Scar/issues/308)
15
+
3
16
  ## [0.23.0](https://github.com/Daily-Nerd/Scar/compare/v0.22.0...v0.23.0) (2026-09-03)
4
17
 
5
18
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scar-cli
3
- Version: 0.23.0
3
+ Version: 0.24.0
4
4
  Summary: SCAR — version control for negative knowledge (deadends, fences, landmines)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -80,6 +80,8 @@ scar skill install # same detection for the scar-authoring skill
80
80
  scar hook status # every host with the channel that serves it (settings, plugin, none)
81
81
  ```
82
82
 
83
+ `scar skill install` writes the scar-authoring skill to a native path for Claude Code, Codex, Cursor, opencode, or Windsurf. Claude Code is the one confirmed to load it, and the rest are written per vendor documentation but unverified: see the [per-host table](https://daily-nerd.github.io/Scar/agents#where-scar-skill-install-writes) for which is which.
84
+
83
85
  Claude Code users: the marketplace **plugin** ships the hooks and the scar-authoring skill together. `scar hook install` and `scar skill install` see the plugin and skip Claude unless you pass `--runtime claude --force`. Re-run `scar skill install` after upgrading a manual install; the installed skill is a static copy.
84
86
 
85
87
  Codex users: run `scar hook install --runtime codex`, then open `/hooks` in Codex to review and trust the entries. The hooks push matching scars before `Bash` and `apply_patch` calls and report post-patch `violation:` tripwires without blocking. The plugin no longer carries Codex hook definitions; the installer is the one path.
@@ -65,6 +65,8 @@ scar skill install # same detection for the scar-authoring skill
65
65
  scar hook status # every host with the channel that serves it (settings, plugin, none)
66
66
  ```
67
67
 
68
+ `scar skill install` writes the scar-authoring skill to a native path for Claude Code, Codex, Cursor, opencode, or Windsurf. Claude Code is the one confirmed to load it, and the rest are written per vendor documentation but unverified: see the [per-host table](https://daily-nerd.github.io/Scar/agents#where-scar-skill-install-writes) for which is which.
69
+
68
70
  Claude Code users: the marketplace **plugin** ships the hooks and the scar-authoring skill together. `scar hook install` and `scar skill install` see the plugin and skip Claude unless you pass `--runtime claude --force`. Re-run `scar skill install` after upgrading a manual install; the installed skill is a static copy.
69
71
 
70
72
  Codex users: run `scar hook install --runtime codex`, then open `/hooks` in Codex to review and trust the entries. The hooks push matching scars before `Bash` and `apply_patch` calls and report post-patch `violation:` tripwires without blocking. The plugin no longer carries Codex hook definitions; the installer is the one path.
@@ -43,7 +43,7 @@ is deliberate: consumers in hook hot-paths parse with zero dependencies.
43
43
  | `created` | yes | ISO date | |
44
44
  | `authors` | yes | inline list | who drafted it: agents as `"claude-code"` etc. The reviewer is no longer appended here |
45
45
  | `promoted_by` | active scars | string | the human who promoted it. Written only by `scar promote`, which overwrites anything a candidate carried. Empty on scars promoted before the field existed, and never inferred from `authors` |
46
- | `promoted_by_source` | optional | `explicit` \| `git-config` | whether `promoted_by` came from `--reviewer` or from `git config user.name`. Written only alongside `promoted_by`, never on its own. Absent on scars promoted before the field existed |
46
+ | `promoted_by_source` | optional | `explicit` \| `git-config-interactive` \| `git-config` | how strongly `promoted_by` is evidenced. `explicit` = a name was typed with `--reviewer`. `git-config-interactive` = no name typed, but `promote` ran at a terminal, so a person was there. `git-config` = no name typed and no terminal recorded, which is the unattended shape `lint` warns on. Written only alongside `promoted_by`, never on its own. Absent on scars promoted before the field existed, and plain `git-config` on scars promoted before interactivity was recorded, so neither absence proves an agent did it |
47
47
  | `anchors` | yes, ≥1 | list of `- path:`, `- pattern:`, `- symbol:`, and/or `- command:` | §4 |
48
48
  | `evidence` | recommended | list of `- commit:`/`- pr:`/`- issue:`/`- incident:`/`- note:`/`- url:` | absent ⇒ challengeable on sight; `issue:`/`url:` are durable forms that survive squash-merge |
49
49
  | `expires.condition` | recommended | quoted string | what change obsoletes this scar |
@@ -468,3 +468,5 @@ ones that reach `main` wrong.
468
468
  ### 9.10 Install detection
469
469
 
470
470
  `scar hook install` and `scar skill install` without `--runtime` detect hosts (claude, codex, windsurf, cursor, opencode) by config directory or binary on PATH, and resolve for each the channel that already serves it: `settings` (Scar's own entries in the host's hook file), `plugin` (the scar marketplace plugin installed and not disabled), or `none`. windsurf is repo-scoped: its hooks file is written into the working repo, so the binary on PATH counts only when that repo is inside a git repository. On a terminal, one question per present, wirable, unserved host, default no. Without a terminal, exactly one such host is installed; zero or several means the commands are printed and nothing is written. Zero detected hosts says so, rather than naming the plugin. `--all` wires every such host. `--runtime claude` on a Claude Code already served by the plugin refuses unless `--force`. `--all` and `--force` apply to `install` only, and `--force` applies to `--runtime claude` only. A missing or unreadable plugin registry never counts as `plugin`.
471
+
472
+ Two rules differ for the skill kind. windsurf is not repo-scoped there: its global skills live under home (`~/.codeium/windsurf/skills`), so the binary on PATH counts on its own, with no git-repo guard. And `scar skill install --runtime <host>` refuses, exit 1, when detection does not find that host on the machine, rather than creating a skill directory nothing reads. Every destination resolves through the same home the detection pass used, and the Codex destination honours `$CODEX_HOME`, so a reported success always names a directory the detected host actually reads.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scar",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "SCAR — version control for negative knowledge (deadends, fences, landmines)",
5
5
  "hooks": {
6
6
  "PreToolUse": [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scar-cli"
3
- version = "0.23.0"
3
+ version = "0.24.0"
4
4
  description = "SCAR — version control for negative knowledge (deadends, fences, landmines)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -19,6 +19,7 @@ from typing import Callable
19
19
 
20
20
  from rich_argparse import RichHelpFormatter
21
21
 
22
+ from .firing_log import read_firing_log_records
22
23
  from .lint import _is_redos_prone, lint_text
23
24
  from .match import (
24
25
  find_violations_for_diff,
@@ -749,6 +750,16 @@ def _cmd_status(args) -> int:
749
750
  return 0
750
751
 
751
752
 
753
+ def _stdin_is_a_terminal() -> bool:
754
+ """True when a person is driving this invocation. Never raises: a closed
755
+ or replaced stdin has no isatty, and promote must not die over a question
756
+ it can safely answer "no" to."""
757
+ try:
758
+ return sys.stdin.isatty()
759
+ except Exception:
760
+ return False
761
+
762
+
752
763
  def _cmd_promote(args) -> int:
753
764
  store = _require_store()
754
765
  if store is None:
@@ -766,9 +777,17 @@ def _cmd_promote(args) -> int:
766
777
  if not reviewer:
767
778
  reviewer = _git(store.root, "config", "user.name").stdout.strip()
768
779
  from_git = bool(reviewer)
769
- # #295: the same distinction from_git already carries, in the vocabulary
770
- # promoted_by_source is written with.
771
- reviewer_source = "git-config" if from_git else ("explicit" if reviewer else None)
780
+ # #295 gave promoted_by_source two values; #308 splits the git-config one,
781
+ # because "the name came from git config" answered the wrong question. The
782
+ # question lint actually asks is whether a PERSON vouched, and a human
783
+ # running promote at a terminal is a person whether or not they retyped
784
+ # their own name. stdin only, deliberately: `scar promote x > log` is still
785
+ # a human at a keyboard, so a redirected stdout must not read as unattended.
786
+ if reviewer and from_git:
787
+ reviewer_source = ("git-config-interactive" if _stdin_is_a_terminal()
788
+ else "git-config")
789
+ else:
790
+ reviewer_source = "explicit" if reviewer else None
772
791
  try:
773
792
  new_path = store.promote(matches[0], reviewer=reviewer,
774
793
  reviewer_source=reviewer_source)
@@ -1365,35 +1384,24 @@ def _dedup_key(rec: dict) -> tuple | None:
1365
1384
 
1366
1385
 
1367
1386
  def _read_firing_log(log_path: Path) -> list[dict]:
1368
- """Read the machine-global firing log, keeping only dict records. The log
1369
- is written best-effort from a fail-open hook, so ANY JSON shape can appear
1370
- on a line (landmine #12) — `null`, `[]`, numbers all parse fine and then
1371
- crash at rec.get(); skip everything that isn't a dict.
1387
+ """Read the machine-global firing log, deduplicated for reporting.
1388
+
1389
+ Line-shape defence (landmine #12: blanket per-line guard, keep only dicts,
1390
+ distrust slice math) lives in firing_log.read_firing_log_records, which
1391
+ hooks calls too. Do not re-add a local copy here; two copies drifting is
1392
+ what the landmine records.
1372
1393
 
1373
1394
  Rows describing the same tool call are collapsed (#250) — see _dedup_key.
1374
1395
  """
1375
1396
  records = []
1376
1397
  seen: set[tuple] = set()
1377
- if log_path.exists():
1378
- for line in log_path.read_text(encoding="utf-8").splitlines():
1379
- line = line.strip()
1380
- if not line:
1381
- continue
1382
- try:
1383
- # scar 0012 prescribes a BLANKET per-line guard, not a narrow
1384
- # except tuple: a reader that escapes into precheck's outer
1385
- # fail-open kills injection permanently.
1386
- rec = json.loads(line)
1387
- except Exception:
1398
+ for rec in read_firing_log_records(log_path):
1399
+ key = _dedup_key(rec)
1400
+ if key is not None:
1401
+ if key in seen:
1388
1402
  continue
1389
- if not isinstance(rec, dict):
1390
- continue
1391
- key = _dedup_key(rec)
1392
- if key is not None:
1393
- if key in seen:
1394
- continue
1395
- seen.add(key)
1396
- records.append(rec)
1403
+ seen.add(key)
1404
+ records.append(rec)
1397
1405
  return records
1398
1406
 
1399
1407
 
@@ -2814,20 +2822,38 @@ def _cmd_skill_lifecycle(args) -> int:
2814
2822
  runtime = args.runtime
2815
2823
  if runtime is None and args.kind in ("install", "status"):
2816
2824
  def install_many(names: list[str], repo: Path, dry: bool) -> int:
2817
- del repo # skill has exactly one wirable host: claude
2818
- return skill_install(dry=dry) if "claude" in names else 0
2825
+ del repo # every skill destination is home-scoped
2826
+ # Worst exit code wins, same as _run_hook_installers. `|=` agrees
2827
+ # with max only while every code is 0 or 1: 1|2 is 3, which is
2828
+ # not a code this CLI ever returns.
2829
+ rc = 0
2830
+ for name in names:
2831
+ rc = max(rc, skill_install(name, dry=dry))
2832
+ return rc
2819
2833
  return _lifecycle_no_runtime("skill", args, install_many, skill_status)
2820
- # uninstall with no --runtime keeps targeting Claude, the only wirable
2821
- # host today: same rule as `hook`.
2834
+ # uninstall with no --runtime keeps targeting Claude: removal is out of
2835
+ # the detection spec's scope, same rule as `hook`.
2822
2836
  runtime = runtime or "claude"
2823
2837
  if (runtime == "claude" and args.kind == "install"
2824
2838
  and not getattr(args, "force", False)
2825
2839
  and hosts.claude_plugin_enabled(CLAUDE_DIR)):
2826
2840
  _plugin_refusal("the scar-authoring skill is", "~/.claude/skills")
2827
2841
  return 0
2828
- return {"install": lambda: skill_install(dry=dry),
2829
- "uninstall": lambda: skill_uninstall(dry=dry),
2830
- "status": skill_status}[args.kind]()
2842
+ if args.kind == "install":
2843
+ # Same detection seam _lifecycle_no_runtime uses via _detect("skill"):
2844
+ # a second way to resolve "home" here (e.g. a bare Path.home() call)
2845
+ # would ignore whatever a test or a caller has patched onto
2846
+ # installer.CLAUDE_DIR, the one fact this branch is supposed to
2847
+ # share with the rest of the CLI.
2848
+ detected = {h.name for h in _detect("skill")[0] if h.present}
2849
+ if runtime not in detected:
2850
+ # Refusing beats creating the tree: a directory scar invents is
2851
+ # one no host reads, and the failure would be silent.
2852
+ print(f"{runtime} not detected on this machine, nothing written")
2853
+ return 1
2854
+ return {"install": lambda: skill_install(runtime, dry=dry),
2855
+ "uninstall": lambda: skill_uninstall(runtime, dry=dry),
2856
+ "status": lambda: skill_status(runtime)}[args.kind]()
2831
2857
 
2832
2858
 
2833
2859
  def _scar_version() -> str:
@@ -3021,19 +3047,20 @@ def build_parser() -> argparse.ArgumentParser:
3021
3047
  help="run the Windsurf/Cascade hook adapter (stdin JSON; installed by "
3022
3048
  "`scar hook install --runtime windsurf`, not run by hand)")
3023
3049
 
3050
+ from . import installer
3051
+
3024
3052
  p = _add(sub, "skill", _cmd_skill_lifecycle, help="install, remove, or inspect the scar-authoring skill")
3025
3053
  p.add_argument("kind", choices=["install", "uninstall", "status"])
3026
3054
  p.add_argument("--dry-run", action="store_true",
3027
- help="show changes without writing to ~/.claude/skills")
3028
- # One invocation, one target: --runtime claude writes ~/.claude/skills,
3029
- # --all writes to every unserved host detection finds (only claude is
3030
- # wirable today), and no flag at all means detect and ask.
3055
+ help="show changes without writing to the host's skills directory")
3056
+ # One invocation, one target: --runtime names any host in the destination
3057
+ # registry (installer.SKILL_HOSTS), --all writes to every unserved host
3058
+ # detection finds, and no flag at all means detect and ask.
3031
3059
  target = p.add_mutually_exclusive_group()
3032
- target.add_argument("--runtime", choices=["claude"], default=None,
3060
+ target.add_argument("--runtime", choices=list(installer.SKILL_HOSTS), default=None,
3033
3061
  help="with install/uninstall/status: which host to wire. "
3034
3062
  "Omitted: detect installed hosts and ask (install), "
3035
- "or show every host's channel (status); uninstall "
3036
- "still targets claude")
3063
+ "or show every host's channel (status)")
3037
3064
  target.add_argument("--all", action="store_true",
3038
3065
  help="with install: wire every detected, unserved host "
3039
3066
  "without asking. A target like --runtime, so "
@@ -0,0 +1,71 @@
1
+ """The one reader for ``firing-log.jsonl``.
2
+
3
+ The log is append-only JSONL written best-effort from a hook that must never
4
+ fail or delay an edit, so any line shape can and does appear: ``null``, ``[]``,
5
+ numbers, truncated JSON, non-string ``ts``. Landmine #12 records three separate
6
+ bugs shipped from that fact in two days (PR #122, issue #124), and prescribes
7
+ two rules that every reader has to follow identically:
8
+
9
+ * guard every line with a BLANKET ``except Exception``, never a narrow tuple,
10
+ because a reader that escapes into precheck's outer fail-open kills injection
11
+ permanently and silently;
12
+ * distrust slice math, because ``lines[-0:]`` is the whole list rather than
13
+ nothing.
14
+
15
+ Both rules used to be re-implemented per reader. They live here instead. This
16
+ module deliberately imports nothing from ``hooks`` or ``cli`` so either can
17
+ call it: the caller supplies the path (``hooks.firing_log_path()`` still owns
18
+ where the log lives, including the ``SCAR_STATE_DIR`` override).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from pathlib import Path
25
+
26
+ __all__ = ["read_firing_log_records"]
27
+
28
+
29
+ def read_firing_log_records(path: Path, *, tail: int | None = None) -> list[dict]:
30
+ """Every well-formed dict record in the firing log, oldest first.
31
+
32
+ Lines that are blank, unparseable, or parse to a non-dict are dropped.
33
+ ``tail`` keeps only the newest N LINES, applied before parsing, so a
34
+ malformed line still consumes one of the N slots exactly as it counts
35
+ toward ``gc.truncate_firing_log``'s total.
36
+
37
+ Never raises on log content or on an unreadable log: a missing file, a
38
+ directory in its place, and undecodable bytes all read as "no records".
39
+ That is required, not merely tidy, because one caller runs inside the
40
+ fail-open precheck path.
41
+
42
+ A negative ``tail`` IS refused, because it is a caller bug rather than a
43
+ data condition: ``lines[-(-1):]`` silently returns the OLDEST rows, the
44
+ opposite of what every caller wants.
45
+ """
46
+ if tail is not None and tail < 0:
47
+ raise ValueError(f"tail must be non-negative, got {tail}")
48
+
49
+ try:
50
+ text = path.read_text(encoding="utf-8")
51
+ except Exception:
52
+ return []
53
+
54
+ lines = text.splitlines()
55
+ if tail is not None:
56
+ # Not lines[-tail:] — at tail == 0 that is lines[-0:], the whole list.
57
+ lines = lines[-tail:] if tail > 0 else []
58
+
59
+ records: list[dict] = []
60
+ for line in lines:
61
+ line = line.strip()
62
+ if not line:
63
+ continue
64
+ try:
65
+ rec = json.loads(line)
66
+ except Exception:
67
+ continue
68
+ if not isinstance(rec, dict):
69
+ continue
70
+ records.append(rec)
71
+ return records
@@ -17,6 +17,7 @@ import sys
17
17
  import time
18
18
  from pathlib import Path
19
19
 
20
+ from .firing_log import read_firing_log_records
20
21
  from .match import (MatchCensus, armed_scar_ids, find_violations, has_content_signal,
21
22
  rank_and_census_for_edit)
22
23
  from .render import injection_context
@@ -123,19 +124,16 @@ def _recently_fired(repo: str, target: str) -> set[int]:
123
124
  One-liner (demoted) showings don't count — a later content-signal match
124
125
  still deserves the full body. Best-effort: any failure returns empty set
125
126
  (module contract: never fail or delay the edit)."""
126
- try:
127
- # 200-line tail cap is LINE-COUNT-based, not time-based: on a busy log
128
- # a recent full-body showing can scroll out past 200 lines before the
129
- # cooldown window elapses, so the scar re-renders full body early.
130
- # Under-collapsing fails safe (over-inform), so this is not fixed.
131
- lines = firing_log_path().read_text(encoding="utf-8").splitlines()[-200:]
132
- except Exception:
133
- return set()
127
+ # 200-line tail cap is LINE-COUNT-based, not time-based: on a busy log
128
+ # a recent full-body showing can scroll out past 200 lines before the
129
+ # cooldown window elapses, so the scar re-renders full body early.
130
+ # Under-collapsing fails safe (over-inform), so this is not fixed.
131
+ # Line shape is the shared reader's problem (landmine #12); FIELD shape
132
+ # is still ours, so the per-record blanket guard below stays.
134
133
  cutoff = time.time() - COOLDOWN_SECONDS
135
134
  recent: set[int] = set()
136
- for line in lines:
135
+ for rec in read_firing_log_records(firing_log_path(), tail=200):
137
136
  try:
138
- rec = json.loads(line)
139
137
  if rec.get("repo") != repo or rec.get("target") != target:
140
138
  continue
141
139
  ts = time.mktime(time.strptime(rec["ts"], "%Y-%m-%dT%H:%M:%S"))
@@ -26,14 +26,16 @@ class Host:
26
26
  hint: str | None = None
27
27
 
28
28
 
29
- # name, home-relative config dir (None = repo-scoped), binary on PATH,
30
- # wirable for hooks, wirable for the skill
29
+ # name, hook config dir (None = repo-scoped), skill config dir, binary,
30
+ # wirable for hooks, wirable for the skill. The two directory columns differ
31
+ # for windsurf alone: its hooks are a committed repo file, its global skills
32
+ # live under home.
31
33
  _SPECS = (
32
- ("claude", ".claude", "claude", True, True),
33
- ("codex", ".codex", "codex", True, False),
34
- ("windsurf", None, "windsurf", True, False),
35
- ("cursor", ".cursor", "cursor", False, False),
36
- ("opencode", ".config/opencode", "opencode", False, False),
34
+ ("claude", ".claude", ".claude", "claude", True, True),
35
+ ("codex", ".codex", ".codex", "codex", True, True),
36
+ ("windsurf", None, ".codeium/windsurf", "windsurf", True, True),
37
+ ("cursor", ".cursor", ".cursor", "cursor", False, True),
38
+ ("opencode", ".config/opencode", ".config/opencode", "opencode", False, True),
37
39
  )
38
40
 
39
41
 
@@ -60,7 +62,8 @@ def detect_hosts(
60
62
  ) -> list[Host]:
61
63
  which_path = path_env if path_env is not None else os.environ.get("PATH", "")
62
64
  found: list[Host] = []
63
- for name, rel, binary, hook_ok, skill_ok in _SPECS:
65
+ for name, hook_rel, skill_rel, binary, hook_ok, skill_ok in _SPECS:
66
+ rel = skill_rel if kind == "skill" else hook_rel
64
67
  signal = ""
65
68
  cfg = None
66
69
  if name == "codex" and codex_dir is not None:
@@ -69,13 +72,16 @@ def detect_hosts(
69
72
  cfg = home / rel
70
73
  elif repo is not None:
71
74
  cfg = repo / ".windsurf"
72
- # windsurf is the one repo-scoped host: cascade_install writes
73
- # <cwd>/.windsurf/hooks.json with no repo guard of its own, so the
74
- # binary alone must not make it a candidate. Inside a git repo that
75
- # path is a file the user can review and commit; outside one it is a
76
- # stray directory in whatever folder the shell happened to be in.
75
+ # windsurf is the one repo-scoped host, and only for hooks:
76
+ # cascade_install writes <cwd>/.windsurf/hooks.json with no repo
77
+ # guard of its own, so the binary alone must not make it a
78
+ # candidate. Inside a git repo that path is a file the user can
79
+ # review and commit; outside one it is a stray directory in
80
+ # whatever folder the shell happened to be in. Its skills live at a
81
+ # home-scoped directory instead (~/.codeium/windsurf), so this guard
82
+ # does not apply to kind="skill".
77
83
  on_path = bool(shutil.which(binary, path=which_path))
78
- if name == "windsurf" and on_path:
84
+ if name == "windsurf" and on_path and kind != "skill":
79
85
  on_path = repo is not None and _in_git_repo(repo)
80
86
  if cfg is not None and cfg.is_dir():
81
87
  signal = str(cfg)
@@ -120,11 +126,16 @@ def resolve_channels(
120
126
  for h in found:
121
127
  if not (h.present and h.wirable):
122
128
  continue
129
+ if kind == "skill":
130
+ if h.name == "claude" and claude_plugin_enabled(claude_dir):
131
+ h.channel = "plugin"
132
+ elif installer.skill_present(h.name):
133
+ h.channel = "settings"
134
+ continue
123
135
  if h.name == "claude":
124
136
  if claude_plugin_enabled(claude_dir):
125
137
  h.channel = "plugin"
126
- elif (installer.skill_present() if kind == "skill"
127
- else installer.claude_hooks_present()):
138
+ elif installer.claude_hooks_present():
128
139
  h.channel = "settings"
129
140
  elif h.name == "codex" and installer.codex_hooks_present():
130
141
  h.channel = "settings"