scar-cli 0.25.0__tar.gz → 0.26.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 (169) hide show
  1. scar_cli-0.26.0/.release-please-manifest.json +3 -0
  2. scar_cli-0.26.0/.scars/0012-firing-log-readers-need-blanket-guards.landmine.md +42 -0
  3. scar_cli-0.26.0/.scars/candidates/violation-evidence-must-not-persist-raw-edits.md +28 -0
  4. {scar_cli-0.25.0 → scar_cli-0.26.0}/CHANGELOG.md +21 -0
  5. {scar_cli-0.25.0 → scar_cli-0.26.0}/PKG-INFO +2 -1
  6. {scar_cli-0.25.0 → scar_cli-0.26.0}/README.md +1 -0
  7. {scar_cli-0.25.0 → scar_cli-0.26.0}/SCAR-FORMAT.md +6 -2
  8. {scar_cli-0.25.0 → scar_cli-0.26.0}/SPEC.md +60 -2
  9. {scar_cli-0.25.0 → scar_cli-0.26.0}/action.yml +7 -0
  10. {scar_cli-0.25.0 → scar_cli-0.26.0}/plugin/plugin.json +1 -1
  11. {scar_cli-0.25.0/src/scar → scar_cli-0.26.0/plugin}/skills/scar-authoring/SKILL.md +3 -0
  12. {scar_cli-0.25.0 → scar_cli-0.26.0}/pyproject.toml +1 -1
  13. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/cli.py +66 -40
  14. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/draftcheck.py +7 -6
  15. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/gc.py +40 -13
  16. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/hooks.py +29 -2
  17. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/lint.py +25 -9
  18. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/match.py +14 -16
  19. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/mcp.py +2 -4
  20. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/orphan.py +14 -1
  21. {scar_cli-0.25.0/plugin → scar_cli-0.26.0/src/scar}/skills/scar-authoring/SKILL.md +3 -0
  22. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/store.py +45 -2
  23. scar_cli-0.26.0/src/scar/violation_evidence.py +55 -0
  24. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_action_yml.py +7 -0
  25. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_cli.py +120 -4
  26. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_draftcheck.py +17 -1
  27. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_gc.py +57 -7
  28. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_hooks.py +55 -0
  29. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_json_contract.py +2 -1
  30. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_lint.py +59 -0
  31. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_match.py +12 -3
  32. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_mcp.py +14 -0
  33. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_orphan.py +115 -1
  34. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_store.py +69 -0
  35. scar_cli-0.26.0/tests/test_violation_evidence.py +288 -0
  36. {scar_cli-0.25.0 → scar_cli-0.26.0}/uv.lock +1 -1
  37. scar_cli-0.25.0/.release-please-manifest.json +0 -3
  38. scar_cli-0.25.0/.scars/0012-firing-log-readers-need-blanket-guards.landmine.md +0 -37
  39. {scar_cli-0.25.0 → scar_cli-0.26.0}/.claude-plugin/marketplace.json +0 -0
  40. {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  41. {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  42. {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  43. {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  44. {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/workflows/ci.yml +0 -0
  45. {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/workflows/docs.yml +0 -0
  46. {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/workflows/pr-validation.yml +0 -0
  47. {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/workflows/release.yml +0 -0
  48. {scar_cli-0.25.0 → scar_cli-0.26.0}/.gitignore +0 -0
  49. {scar_cli-0.25.0 → scar_cli-0.26.0}/.pre-commit-config.yaml +0 -0
  50. {scar_cli-0.25.0 → scar_cli-0.26.0}/.pre-commit-hooks.yaml +0 -0
  51. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
  52. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
  53. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +0 -0
  54. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +0 -0
  55. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0005-history-rewrite-orphans-commit-evidence.landmine.md +0 -0
  56. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0006-yaml-pattern-anchor-over-escaping.landmine.md +0 -0
  57. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0007-release-please-config-change-skips-open-pr.landmine.md +0 -0
  58. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0008-rich-output-nontty-must-bypass-rich.landmine.md +0 -0
  59. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0009-anchor-survival-harness-measures-symbol-not-path-anchors.deadend.md +0 -0
  60. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0010-git-returncode-128-vs-empty-tracked-set.landmine.md +0 -0
  61. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0011-lint-compile-check-lets-redos-anchors-hang-hotpath.landmine.md +0 -0
  62. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0013-docusaurus-future-v4-requires-faster-dep.landmine.md +0 -0
  63. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0014-inject-flag-dest-collides-with-subcommand-dispatch.landmine.md +0 -0
  64. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0015-zero-checkruns-means-approval-gate-not-workflow-bug.deadend.md +0 -0
  65. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0016-hook-ownership-must-be-kind-scoped.landmine.md +0 -0
  66. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0017-codex-pretool-context-can-replay-apply-patch.landmine.md +0 -0
  67. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0018-cli-subparsers-register-through-_add-wrapper.landmine.md +0 -0
  68. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0019-token-in-scar-body-cannot-prove-hook-delivery.deadend.md +0 -0
  69. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0020-one-directional-health-check-leaves-flattering-direction-unwatched.landmine.md +0 -0
  70. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0021-posttool-fix-in-hooks-does-not-reach-codex-or-cascade.landmine.md +0 -0
  71. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0022-codex-hook-delivery-needs-user-file-and-trust.landmine.md +0 -0
  72. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0023-cascade-block-exit-collides-with-argparse.fence.md +0 -0
  73. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0024-host-detection-must-not-read-the-process-home.deadend.md +0 -0
  74. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0025-one-plugin-hook-manifest-two-hosts-two-root-variables.landmine.md +0 -0
  75. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0026-path-anchor-breadth-drives-the-review-due-threshold.landmine.md +0 -0
  76. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/README.md +0 -0
  77. {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/template.md +0 -0
  78. {scar_cli-0.25.0 → scar_cli-0.26.0}/AGENTS.md +0 -0
  79. {scar_cli-0.25.0 → scar_cli-0.26.0}/CONTRIBUTING.md +0 -0
  80. {scar_cli-0.25.0 → scar_cli-0.26.0}/IDEA.md +0 -0
  81. {scar_cli-0.25.0 → scar_cli-0.26.0}/LICENSE +0 -0
  82. {scar_cli-0.25.0 → scar_cli-0.26.0}/ROADMAP.md +0 -0
  83. {scar_cli-0.25.0 → scar_cli-0.26.0}/STRESS-TEST.md +0 -0
  84. {scar_cli-0.25.0 → scar_cli-0.26.0}/codecov.yml +0 -0
  85. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/anchor-survival/PROTOCOL.md +0 -0
  86. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/anchor-survival/RESULTS.md +0 -0
  87. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/anchor-survival/long_replay.py +0 -0
  88. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/anchor-survival/replay.py +0 -0
  89. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/anchor-survival/replay_shipped.py +0 -0
  90. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/anchor-survival/test_replay_shipped.py +0 -0
  91. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/auto-authorship/FINDINGS.md +0 -0
  92. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/auto-authorship/PROTOCOL.md +0 -0
  93. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/.gitignore +0 -0
  94. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/PROTOCOL.md +0 -0
  95. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/RESULTS.md +0 -0
  96. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
  97. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
  98. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
  99. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/fixture/README.md +0 -0
  100. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/fixture/payments/retry.py +0 -0
  101. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/fixture/reports/export.py +0 -0
  102. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/fixture/services/sessions.py +0 -0
  103. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/grade.py +0 -0
  104. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/harvest/PROTOCOL.md +0 -0
  105. {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/harvest/harvest.py +0 -0
  106. {scar_cli-0.25.0 → scar_cli-0.26.0}/hook/scar-hooks.py +0 -0
  107. {scar_cli-0.25.0 → scar_cli-0.26.0}/plugin/hooks/run.sh +0 -0
  108. {scar_cli-0.25.0 → scar_cli-0.26.0}/plugin/skills/scar-authoring/assets/template.md +0 -0
  109. {scar_cli-0.25.0 → scar_cli-0.26.0}/release-please-config.json +0 -0
  110. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/__init__.py +0 -0
  111. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/agent.py +0 -0
  112. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/cascade.py +0 -0
  113. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/codex.py +0 -0
  114. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/evidence.py +0 -0
  115. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/firing_log.py +0 -0
  116. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/harvest.py +0 -0
  117. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/hosts.py +0 -0
  118. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/installer.py +0 -0
  119. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/model.py +0 -0
  120. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/output.py +0 -0
  121. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/reanchor.py +0 -0
  122. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/renames.py +0 -0
  123. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/render.py +0 -0
  124. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/review.py +0 -0
  125. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/skills/scar-authoring/assets/template.md +0 -0
  126. {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/symbols.py +0 -0
  127. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/conftest.py +0 -0
  128. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_anchor_kind.py +0 -0
  129. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_block_capability.py +0 -0
  130. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_cascade.py +0 -0
  131. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_codex.py +0 -0
  132. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_context_size.py +0 -0
  133. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_docs.py +0 -0
  134. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_evidence.py +0 -0
  135. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_firing_log.py +0 -0
  136. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_git_hook.py +0 -0
  137. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_harvest.py +0 -0
  138. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_hosts.py +0 -0
  139. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_installer.py +0 -0
  140. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_lifecycle.py +0 -0
  141. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_matched_by.py +0 -0
  142. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_model.py +0 -0
  143. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_output.py +0 -0
  144. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_plugin.py +0 -0
  145. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_precommit_hooks.py +0 -0
  146. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_reanchor.py +0 -0
  147. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_renames.py +0 -0
  148. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_render.py +0 -0
  149. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_review.py +0 -0
  150. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_skill.py +0 -0
  151. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_sweep.py +0 -0
  152. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_symbols.py +0 -0
  153. {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_verdict_observability.py +0 -0
  154. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/.gitignore +0 -0
  155. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/docs/agents.md +0 -0
  156. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/docs/concepts.md +0 -0
  157. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/docs/index.md +0 -0
  158. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/docs/methodology.md +0 -0
  159. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/docs/quickstart.md +0 -0
  160. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/docusaurus.config.ts +0 -0
  161. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/package-lock.json +0 -0
  162. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/package.json +0 -0
  163. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/scripts/sync-changelog.mjs +0 -0
  164. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/sidebars.ts +0 -0
  165. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/src/css/custom.css +0 -0
  166. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/static/img/favicon.svg +0 -0
  167. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/static/img/social-card.png +0 -0
  168. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/static/llms.txt +0 -0
  169. {scar_cli-0.25.0 → scar_cli-0.26.0}/website/tsconfig.json +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.26.0"
3
+ }
@@ -0,0 +1,42 @@
1
+ ---
2
+ id: 12
3
+ type: landmine
4
+ title: firing-log.jsonl has one reader: read through read_firing_log_records, never hand-roll, distrust slice math
5
+ severity: high
6
+ confidence: 0.85
7
+ created: 2026-07-02
8
+ authors: ["claude-code", "kibukx"]
9
+ anchors:
10
+ - path: src/scar/firing_log.py
11
+ - path: src/scar/gc.py
12
+ - pattern: "firing_log_path\("
13
+ violation: "except \((KeyError|TypeError|ValueError|AttributeError|IndexError)[^)]*\)"
14
+ evidence:
15
+ - pr: 122
16
+ - issue: 124
17
+ - issue: 312
18
+ expires:
19
+ condition: "the shared reader validates field types (ts is a string, scar_ids is a list), not only row shape, so callers stop re-guarding fields"
20
+ review_after: 2027-03-01
21
+ status: active
22
+ ---
23
+
24
+ The firing log is append-only JSONL written best-effort from a hook that must
25
+ never fail, so ANY line shape can and will appear: `null`, `[]`, numbers,
26
+ truncated JSON, non-string `ts`, non-list `scar_ids`. Three bugs shipped from
27
+ that fact in two days (PR #122, issue #124):
28
+
29
+ 1. Narrow per-line except tuples let `ts: null` (TypeError) and bare `null`
30
+ lines (AttributeError at `rec.get`) escape a reader into precheck's outer
31
+ fail-open, killing injection silently and PERMANENTLY.
32
+ 2. `lines[-max_firings:]` with 0 is `[-0:]`, the whole list, while the report
33
+ arithmetic claimed everything was dropped (issue #124).
34
+
35
+ Since #312 both rules live in ONE place: `read_firing_log_records` in
36
+ `src/scar/firing_log.py` (blanket `except Exception` per line, `tail == 0`
37
+ special-cased, negative tail refused). Rule now: every new reader calls it.
38
+ Do not parse the log by hand anywhere else. When editing the reader itself,
39
+ keep the blanket guard. It checks row SHAPE only (dicts kept), so callers
40
+ must still treat every field as untrusted. `gc.py` counts and truncates raw
41
+ lines on purpose (a malformed line still counts); keep its slice math
42
+ special-casing 0, and verify report numbers against the file, not arithmetic.
@@ -0,0 +1,28 @@
1
+ ---
2
+ id: 0
3
+ type: fence
4
+ title: Violation evidence omits raw edits deliberately because excerpts can contain secrets
5
+ severity: high
6
+ confidence: 0.9
7
+ created: 2026-11-05
8
+ authors: ["codex"]
9
+ anchors:
10
+ - path: src/scar/violation_evidence.py
11
+ - path: src/scar/hooks.py
12
+ evidence:
13
+ - issue: 317
14
+ expires:
15
+ condition: "a reviewed capture design can retain original edits without exposing arbitrary secrets"
16
+ status: candidate
17
+ ---
18
+
19
+ The display excerpt is arbitrary edited content, not safe log text (#317).
20
+ Truncating to 120 characters can retain an entire credential. The shared
21
+ verdict writer therefore persists a bounded summary, never the raw excerpt
22
+ or violation regex. Do not add either string to make the log more readable.
23
+ Capture positions and fingerprints from the original regex match; searching
24
+ the truncated excerpt again can miss the actual match on a long line.
25
+ Hashes still permit guessing low-entropy content, so the log remains sensitive.
26
+ A summary needs an independently retained edit for review; it is not a
27
+ confirmed true positive. Old rows without evidence must stay unknown, never
28
+ be backfilled from current files or current scar definitions.
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.26.0](https://github.com/Daily-Nerd/Scar/compare/v0.25.1...v0.26.0) (2026-11-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * **firing-log:** capture bounded violation evidence at match time ([#349](https://github.com/Daily-Nerd/Scar/issues/349)) ([9789b37](https://github.com/Daily-Nerd/Scar/commit/9789b370d78e3590abed69bee249db42e0baa2b0))
9
+ * **lint:** opt-in --fail-partial-rot gate ([#355](https://github.com/Daily-Nerd/Scar/issues/355)) ([aa0346b](https://github.com/Daily-Nerd/Scar/commit/aa0346b3ed18f9968df27619d53b9bf766f5d344)), closes [#353](https://github.com/Daily-Nerd/Scar/issues/353)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **promote:** derive the active filename from one shared slug normaliser ([#356](https://github.com/Daily-Nerd/Scar/issues/356)) ([04e8def](https://github.com/Daily-Nerd/Scar/commit/04e8defd70b59f135e6cf0a0d3c90ba99efa9805)), closes [#352](https://github.com/Daily-Nerd/Scar/issues/352)
15
+
16
+ ## [0.25.1](https://github.com/Daily-Nerd/Scar/compare/v0.25.0...v0.25.1) (2026-10-07)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **lint:** name a flow-style anchors list as the cause, and list the four anchor kinds in the unsupported-kind warning ([#344](https://github.com/Daily-Nerd/Scar/issues/344)) ([#346](https://github.com/Daily-Nerd/Scar/issues/346)) ([03c632c](https://github.com/Daily-Nerd/Scar/commit/03c632cf1e92415d2c3e052deaf34194e4d311de))
22
+ * **orphan:** report a dead symbol anchor as partial rot when another anchor is still live ([#345](https://github.com/Daily-Nerd/Scar/issues/345)) ([4d26d61](https://github.com/Daily-Nerd/Scar/commit/4d26d6120e7b46b3c273a23c6b827a22d2833cab)), closes [#343](https://github.com/Daily-Nerd/Scar/issues/343)
23
+
3
24
  ## [0.25.0](https://github.com/Daily-Nerd/Scar/compare/v0.24.0...v0.25.0) (2026-10-06)
4
25
 
5
26
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scar-cli
3
- Version: 0.25.0
3
+ Version: 0.26.0
4
4
  Summary: SCAR — version control for negative knowledge (deadends, fences, landmines)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -118,6 +118,7 @@ CI without pre-commit:
118
118
  - uses: Daily-Nerd/Scar@main # or pin a tag, e.g. @v0.11.0 (release-please)
119
119
  with:
120
120
  fail-orphans: 'true' # default; set 'false' to only fail on parse errors
121
+ fail-partial-rot: 'false' # opt in; also fail when a scar has a dead anchor among live ones
121
122
  version: '' # empty = latest scar-cli from PyPI; or pin e.g. '0.11.0'
122
123
  args: '' # extra args appended to `scar lint`
123
124
  ```
@@ -103,6 +103,7 @@ CI without pre-commit:
103
103
  - uses: Daily-Nerd/Scar@main # or pin a tag, e.g. @v0.11.0 (release-please)
104
104
  with:
105
105
  fail-orphans: 'true' # default; set 'false' to only fail on parse errors
106
+ fail-partial-rot: 'false' # opt in; also fail when a scar has a dead anchor among live ones
106
107
  version: '' # empty = latest scar-cli from PyPI; or pin e.g. '0.11.0'
107
108
  args: '' # extra args appended to `scar lint`
108
109
  ```
@@ -13,10 +13,14 @@ remove or re-type the fields below.
13
13
  ├── template.md # copy-me — not a scar (status: template)
14
14
  ├── NNNN-<slug>.<type>.md # active/archived scars, NNNN zero-padded
15
15
  └── candidates/
16
- ├── <slug>.md # proposed scars awaiting human review
17
- └── fp-log.txt # self-reported false triggers (free text)
16
+ └── <slug>.md # proposed scars awaiting human review
18
17
  ```
19
18
 
19
+ Self-reported false triggers (free text, one dated line each) are not repo
20
+ content. The reference tool keeps them per machine in its state directory,
21
+ `<state dir>/fp-log-<sha1 of the resolved repo path>.txt`; `scar gc` prints
22
+ the exact path.
23
+
20
24
  Files named `README.md`/`template.md` (case-insensitive) or prefixed `_` are
21
25
  never parsed as scars. The directory is `.scars` — plural, exactly.
22
26
 
@@ -75,7 +75,7 @@ Four anchor classes, used in combination:
75
75
 
76
76
  1. **Path anchors** — file or directory prefixes. A directory with a trailing slash or a file path both work; wildcards are not expanded. Cheap, survive content change, die on rename (mitigated by git rename tracking during re-anchor).
77
77
  2. **Symbol anchors** — function/class/method names resolved via tree-sitter. Survive moves within and across files in the same repo. Primary anchor class for fences.
78
- For orphan detection (#337) a symbol anchor is live while its definition resolves: `path::name` in that file, a bare name in any tracked file the `[symbols]` extra can parse. Without the extra it cannot be checked, so it holds the scar alive the way a command anchor does, and `scar lint` prints one `symbols-unchecked` hint per run.
78
+ For orphan detection (#337) a symbol anchor is live while its definition resolves: `path::name` in that file, a bare name in any tracked file the `[symbols]` extra can parse. Without the extra it cannot be checked, so it holds the scar alive the way a command anchor does, and `scar lint` prints one `symbols-unchecked` hint per run. A symbol anchor that resolves nowhere is also reported as partial rot (#343) when another anchor of the same scar is still live, and `scar lint --json` lists it in `partial_rot[].dead_symbol_anchors`.
79
79
  3. **Pattern anchors** — regex/AST patterns over *new* code (diff-scoped, not whole-repo). The only anchor class that can catch a dead end being re-attempted in a brand-new file. Powers `deadend` enforcement.
80
80
  4. **Command anchors** (#175) — regexes over a *shell command about to execute* (`PreToolUse:Bash` and `scar inject --command`). The only anchor class with a firing surface for run-a-command mistakes — knowledge like "bare `uv sync` strips extras" has no edit to anchor to. Never matched against paths or content (structurally immune to self-match/partial-rot) and exempt from content liveness; `review_after` is the freshness mechanism.
81
81
 
@@ -137,6 +137,7 @@ candidate ──promote──▶ active ──challenge──▶ challenged
137
137
  - Confidence is a static, human-authored ranking weight (0..1); the tool does not currently mutate it. Dynamic confidence — decay toward a floor without confirmations, an uphold bump on a survived challenge, and an explicit `scar confirm <id>` — is deferred until scar volume and confirmation data can calibrate a decay policy without unvalidated constants (issue #95).
138
138
  - `review_after` forces periodic human contact with old scars; CI can warn on overdue reviews.
139
139
  - `review_after_firings` is the count-based sibling of `review_after`: once a scar has fired N times *since the last commit that touched its file*, `scar status` and `scar lint` escalate it from an ambient warning to an explicit review flag. Revising the scar re-affirms it and restarts the count; archiving it silences the flag because archived scars do not fire. Absent ⇒ per-type default (`landmine` 10, `fence` 15, `deadend` disabled); `0` ⇒ never escalate. Advisory by default — `scar lint --fail-firing-review` opts into a non-zero exit. Escalation reports only; it never archives or challenges a scar (ADR-4 keeps lifecycle transitions human).
140
+ - Partial rot (a dead anchor on a scar whose other anchors still live) is advisory by default. `scar lint --fail-partial-rot` opts into a non-zero exit when any is found, and the GitHub Action exposes it as the `fail-partial-rot` input (default `false`). It is independent of `--fail-orphans`: neither implies the other. Partial rot is computed from git, so when git is unavailable the list is empty and the gate passes, matching `--fail-orphans` (#91).
140
141
  - When the reset point cannot be determined (untracked scar file, or no git history), the count reported is a **lifetime** count and says so. A since-revision count and a lifetime count are different quantities and must never share one label.
141
142
  - Archive keeps everything: SCAR's own history is negative knowledge.
142
143
 
@@ -231,7 +232,7 @@ Top-level keys, and the keys of each object inside the listed arrays.
231
232
  | `sweep` | `files` int (tracked files swept), `skipped` int (tracked files with no readable content: oversized or binary), `scars` int (scars armed on at least one swept file), `hits` array | `hits[]`: `id` int, `type`, `severity`, `title`, `source` (scar file, repo-relative), `path`, `line` int (1-based), `excerpt` |
232
233
  | `why` | `path` str, `records` array | `records[]`: `id`, `type`, `status`, `title`, `file`, `body` |
233
234
  | `stats` | `repo` str, `total_firings` int, `per_scar` array, `most_fired` int, `last_fired` str, `never_fired` array of int, `demotions` int, `demotions_path_only` int, `demotions_cooldown` int, `demotions_reason_unknown` int, `census_known` int, `census_unknown` int, `cofires_per_edit` float **or null**, `edits_multi_fire` int, `path_only_ratio` float **or null**, `edits_observed` int, `injection_rate` float **or null**, `retrieval_misses` int **or null**, `instrument_disconnected` bool, `posttool_silent` bool, `last_fired_age_days` int, `armed_firings` int, `armed_unknown` int, `verdicts_expected` int, `verdicts_observed` int, `verdicts_unresolved` int, `verdicts_unplaceable` int, `firings_block_capable` int, `firings_advisory` int, `firings_block_unknown` int, `all_firings_advisory` bool, `firings_context_known` int, `firings_context_unknown` int, `command_firings` int, `firings_kind_unknown` int, `advisories` array. `window` object **only when `--since`/`--until` is given** | `per_scar[]`: `id`, `count`, `violations`. `window`: `since`, `until`, `excluded_undated` |
234
- | `gc` | `removed_markers` int, `dropped_firings` int, `dry_run` bool, `candidates` array, `fp_log` object | `candidates[]`: `name`, `age_days`. `fp_log`: `present`, `size`, `lines` |
235
+ | `gc` | `removed_markers` int, `dropped_firings` int, `dry_run` bool, `candidates` array, `fp_log` object | `candidates[]`: `name`, `age_days`. `fp_log`: `present`, `size`, `lines`, `path` (the state-dir log, #341), `legacy` (`null`, or `path` + `lines` of a leftover `.scars/candidates/fp-log.txt`) |
235
236
  | `orphan` | `orphan_detected`, `partial_rot` arrays | — |
236
237
  | `reanchor` | `proposals`, `pattern_diagnostics` arrays | — |
237
238
  | `draft-check` | `triggered` bool, `revert_language`, `revert_commits`, `reset_hard`, `churn` ints. `message` str **only when `triggered` is true** | — |
@@ -239,6 +240,13 @@ Top-level keys, and the keys of each object inside the listed arrays.
239
240
  `gc` mutates state. A consumer that only wants to observe MUST pass
240
241
  `--dry-run`; `dry_run` echoes back which mode ran.
241
242
 
243
+ `stats` also guarantees integer `violation_evidence_summarized` and
244
+ `violation_evidence_unknown` keys, including in each `--all-repos` group.
245
+ They count individual violations, not rows: a row with two violated scars
246
+ contributes two. Summarized means a valid write-time summary exists; unknown
247
+ means missing, malformed, or an unsupported summary format. Neither means
248
+ adjudicated. See §9.11 for the capture policy and its limits.
249
+
242
250
  `stats.armed_firings` counts firings on scars that carried a `violation:`
243
251
  tripwire at the moment they fired; `armed_unknown` counts firings on log rows
244
252
  written before that was recorded. A scar with no tripwire can never be recorded
@@ -496,3 +504,53 @@ ones that reach `main` wrong.
496
504
  `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`.
497
505
 
498
506
  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.
507
+
508
+ ### 9.11 Violation evidence is captured at match time
509
+
510
+ Claude Code, Codex, and Windsurf use one verdict writer. New verdict rows
511
+ carry `violation_evidence`, keyed by the string scar id, alongside the existing
512
+ `violation_ids`. Each violated scar contributes one bounded `summary-v1`
513
+ object describing its first regex match on that edited target:
514
+
515
+ | Field | Meaning |
516
+ |---|---|
517
+ | `format` | `summary-v1`; consumers must not interpret an unknown format as this one |
518
+ | `pattern_sha256` | SHA-256 of the exact violation regex used at capture time |
519
+ | `match_sha256` | SHA-256 of the full matched substring, even when it falls outside the display excerpt |
520
+ | `excerpt_sha256` | SHA-256 of the same at-most-120-character line prefix used for display |
521
+ | `line`, `column` | 1-based character position of the match start in the adapter's scanned edit buffer |
522
+ | `match_chars`, `excerpt_chars` | Character lengths, not byte lengths; zero-length regex matches are valid |
523
+ | `excerpt_truncated` | Whether that line in the scanned buffer exceeded 120 characters |
524
+
525
+ The evidence is derived from the same regex match as the verdict, not from a
526
+ second search on a truncated excerpt or a later read of the target. The matcher
527
+ scans at most the first 65,536 characters, as before. Positions describe that
528
+ buffer, **not file line numbers**: adapters combine edits or patch additions,
529
+ and may insert joining whitespace. Truncation describes only the captured
530
+ buffer, not text beyond the scan cap. Digests use UTF-8 with `surrogatepass`
531
+ for lone surrogate characters accepted from JSON payloads.
532
+
533
+ **Privacy tradeoff:** no edited text or regex text is persisted in the summary.
534
+ The display excerpt can contain credentials, and limiting it to 120 characters
535
+ is not redaction. This implementation deliberately chooses structured summaries
536
+ instead of claiming an incomplete secret filter makes arbitrary source safe.
537
+ Hashes are not encryption or anonymization: low-entropy values can be guessed
538
+ and compared. The machine-global log still contains repository and target paths;
539
+ it remains sensitive local state and must not be published unreviewed.
540
+
541
+ **Audit limit:** a reviewer with an independently retained edit and regex can
542
+ compare their hashes and positions with the captured summary. A summary alone
543
+ cannot recover the source or decide whether a match was a real violation, a
544
+ quoted example, or another false positive. `violation_evidence_summarized`
545
+ therefore never means "adjudicated" or "confirmed violation". Stats report the
546
+ summarized and unknown counts separately, with this limitation in human output.
547
+
548
+ Clean observed verdicts carry an empty evidence object. Legacy rows without
549
+ evidence remain unknown for every recorded violation; missing or malformed
550
+ per-scar entries are also unknown. Reads never backfill from present-day code,
551
+ Git history, or current scar definitions. Old rows and existing violation counts
552
+ remain unchanged. Codex multi-file edits retain a separate verdict and evidence
553
+ map for each target. Files under `.scars/` remain eligible for matching other
554
+ scars; there is no blanket directory exclusion. Logging remains best-effort:
555
+ failure to append cannot block an edit, and a failed write does not claim that
556
+ evidence was recorded.
@@ -7,6 +7,10 @@ inputs:
7
7
  description: "Exit non-zero when any scar is orphan-detected (passes --fail-orphans)."
8
8
  required: false
9
9
  default: 'true'
10
+ fail-partial-rot:
11
+ description: "Exit non-zero when any scar has a dead anchor among live ones (passes --fail-partial-rot). Passes when git is unavailable."
12
+ required: false
13
+ default: 'false'
10
14
  version:
11
15
  description: "scar-cli version to install (PyPI version specifier, e.g. '0.11.0'). Empty = latest."
12
16
  required: false
@@ -40,6 +44,9 @@ runs:
40
44
  if [ "${{ inputs.fail-orphans }}" = "true" ]; then
41
45
  set -- "$@" --fail-orphans
42
46
  fi
47
+ if [ "${{ inputs.fail-partial-rot }}" = "true" ]; then
48
+ set -- "$@" --fail-partial-rot
49
+ fi
43
50
  if [ -n "${{ inputs.args }}" ]; then
44
51
  set -- "$@" ${{ inputs.args }}
45
52
  fi
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scar",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "SCAR — version control for negative knowledge (deadends, fences, landmines)",
5
5
  "hooks": {
6
6
  "PreToolUse": [
@@ -75,6 +75,9 @@ A file without `---`-fenced YAML frontmatter is **not a scar at all** — it nev
75
75
  fires. Minimum valid block: `type`, `title`, `severity`, `confidence`,
76
76
  `created`, `authors`, at least one `anchors` entry, and `status: candidate`.
77
77
 
78
+ Write `anchors` as a block list, one `- kind: value` line per anchor. A flow-style
79
+ list (`anchors: [...]`) is not read, so the scar loads with no anchors.
80
+
78
81
  ## Anti-Over-Escape (the #1 silent failure)
79
82
 
80
83
  Prefer a `path:` anchor — it cannot self-match and needs no escaping. If you
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scar-cli"
3
- version = "0.25.0"
3
+ version = "0.26.0"
4
4
  description = "SCAR — version control for negative knowledge (deadends, fences, landmines)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -51,7 +51,8 @@ from .reanchor import (
51
51
  )
52
52
  from .render import injection_context, label_line, rule_line
53
53
  from .renames import apply_anchor_rewrite, apply_rename_fix
54
- from .store import ScarStore, init_scars
54
+ from .store import ScarStore, init_scars, sanitize_signal_text, slugify
55
+ from .violation_evidence import is_violation_summary
55
56
  from . import output
56
57
 
57
58
 
@@ -124,9 +125,8 @@ def _dead_anchor_summary(finding) -> str:
124
125
  _dead_path_text(finding, p) for p in finding.dead_path_anchors))
125
126
  if finding.dead_pattern_anchors:
126
127
  dead.append("patterns: " + ", ".join(f"/{p}/" for p in finding.dead_pattern_anchors))
127
- # Symbol anchors that resolve nowhere (#337). getattr: only OrphanFinding
128
- # carries the field, partial rot does not check symbols.
129
- dead_symbols = getattr(finding, "dead_symbol_anchors", None)
128
+ # Symbol anchors that resolve nowhere (#337, #343 for partial rot).
129
+ dead_symbols = finding.dead_symbol_anchors
130
130
  if dead_symbols:
131
131
  dead.append("symbols: " + ", ".join(
132
132
  f"{s} no longer resolves" for s in dead_symbols))
@@ -155,7 +155,7 @@ def _orphan_reason(finding) -> str:
155
155
  """Human description of why a finding is an orphan — distinguishes a scar
156
156
  with NO anchors (protects nothing) from one whose every anchor went dead."""
157
157
  if (not finding.dead_path_anchors and not finding.dead_pattern_anchors
158
- and not getattr(finding, "dead_symbol_anchors", None)):
158
+ and not finding.dead_symbol_anchors):
159
159
  return "no anchors — scar protects nothing"
160
160
  return ("all anchors dead (" + _dead_anchor_summary(finding) + ")"
161
161
  + _violation_migration_hint(finding))
@@ -476,12 +476,32 @@ def _gc_rich(data: dict, *, days: int, max_firings: int, state_dir: Path) -> Non
476
476
  else:
477
477
  console.print("[dim]0 candidates pending review[/]")
478
478
 
479
- fp_log = data["fp_log"]
479
+ from rich.markup import escape
480
+
481
+ for line in _fp_log_lines(data["fp_log"]):
482
+ # soft_wrap: the path and the mv hint must stay copy-pasteable.
483
+ console.print(f"[dim]{escape(line)}[/]", soft_wrap=True)
484
+
485
+
486
+ def _fp_log_lines(fp_log: dict) -> list[str]:
487
+ """The fp-log lines `scar gc` prints, shared by plain and rich output:
488
+ count plus path (#341), then a move hint while a pre-#341 in-repo file
489
+ is still around. gc never moves it; the human runs the command."""
490
+ import shlex
491
+
480
492
  if fp_log["present"]:
481
- console.print(f"[dim]fp-log.txt: {fp_log['lines']} line(s), {fp_log['size']} byte(s) "
482
- "— drafter-precision instrument data, not auto-cleaned[/]")
493
+ lines = [f"fp-log: {fp_log['lines']} line(s), {fp_log['size']} byte(s) "
494
+ f"in {fp_log['path']}: drafter-precision instrument data, "
495
+ "not auto-cleaned"]
483
496
  else:
484
- console.print("[dim]fp-log.txt: not present[/]")
497
+ lines = [f"fp-log: not present ({fp_log['path']})"]
498
+ # TODO(#341): drop the legacy hint one release after 0.25.x.
499
+ legacy = fp_log.get("legacy")
500
+ if legacy:
501
+ lines.append(f"legacy fp-log.txt ({legacy['lines']} line(s)) is still in the "
502
+ f"repo; move it: mv {shlex.quote(legacy['path'])} "
503
+ f"{shlex.quote(fp_log['path'])}")
504
+ return lines
485
505
 
486
506
 
487
507
  def _orphan_rich(findings, partial) -> None:
@@ -726,7 +746,9 @@ def _cmd_lint(args) -> int:
726
746
  "findings": [{"file": rel, "level": fi.level, "message": fi.message}
727
747
  for rel, fs in findings_by_file for fi in fs],
728
748
  "orphans": [{"scar_id": of.scar_id, "reason": _orphan_reason(of)} for of in orphans],
729
- "partial_rot": [{"scar_id": pr.scar_id, "reason": _partial_rot_reason(pr)} for pr in partial],
749
+ "partial_rot": [{"scar_id": pr.scar_id, "reason": _partial_rot_reason(pr),
750
+ "dead_symbol_anchors": list(pr.dead_symbol_anchors)}
751
+ for pr in partial],
730
752
  "symbol_drift": [{"scar_id": d.scar_id, "symbol": d.symbol,
731
753
  "sha": d.sha, "similarity": d.similarity} for d in drift],
732
754
  "revivals": [{"scar_id": r.scar_id, "predicate": r.predicate} for r in revivals],
@@ -749,7 +771,8 @@ def _cmd_lint(args) -> int:
749
771
  for of in orphans:
750
772
  print(f"WARNING orphan-detected: scar #{of.scar_id} — {_orphan_reason(of)}")
751
773
  # partial rot (#35): firing scars with a dead anchor among live ones.
752
- # Advisory only — never a blocking gate, even under --fail-orphans.
774
+ # Advisory by default; --fail-partial-rot opts into a blocking gate.
775
+ # --fail-orphans does not imply it (the two gates are independent).
753
776
  for pr in partial:
754
777
  print(f"HINT partial-rot: scar #{pr.scar_id} — {_partial_rot_reason(pr)} "
755
778
  "— re-anchor to restore full coverage")
@@ -793,6 +816,8 @@ def _cmd_lint(args) -> int:
793
816
  return 1
794
817
  if orphans and getattr(args, "fail_orphans", False):
795
818
  return 1
819
+ if partial and getattr(args, "fail_partial_rot", False):
820
+ return 1
796
821
  if reviews and getattr(args, "fail_firing_review", False):
797
822
  return 1
798
823
  return 0
@@ -1657,6 +1682,7 @@ STAGE_KEYS = frozenset({"retrieval_misses", "demotions",
1657
1682
  "edits_observed", "injection_rate",
1658
1683
  "command_firings", "firings_kind_unknown",
1659
1684
  "armed_firings", "armed_unknown",
1685
+ "violation_evidence_summarized", "violation_evidence_unknown",
1660
1686
  "firings_block_capable", "firings_advisory",
1661
1687
  "firings_block_unknown", "all_firings_advisory",
1662
1688
  "firings_context_known", "firings_context_unknown"})
@@ -1778,6 +1804,12 @@ def _stage_lines(block: dict, per_scar: list[dict], total_firings: int) -> list[
1778
1804
  if cap_unknown:
1779
1805
  enforcement += (f", {cap_unknown} of unknown capability (logged "
1780
1806
  "before it was recorded)")
1807
+ evidence_summarized = block.get("violation_evidence_summarized", 0)
1808
+ evidence_unknown = block.get("violation_evidence_unknown", 0)
1809
+ if evidence_summarized or evidence_unknown:
1810
+ enforcement += (f"; evidence: {evidence_summarized} summarized (no raw text), "
1811
+ f"{evidence_unknown} unknown (missing or invalid evidence); "
1812
+ "summaries alone cannot adjudicate violations")
1781
1813
  # #284: what the demotions are EVIDENCE of. A path-only demotion is a
1782
1814
  # near-miss on the file, a cooldown demotion is a content hit we chose not
1783
1815
  # to repeat; one blended number hides which precision story this repo
@@ -1841,6 +1873,8 @@ def _aggregate_firings(records: list[dict]) -> dict:
1841
1873
  from .hooks import DEMOTED_COOLDOWN, DEMOTED_PATH_ONLY # one spelling, both sides
1842
1874
  counts: dict[int, int] = {}
1843
1875
  violations: dict[int, int] = {}
1876
+ violation_evidence_summarized = 0
1877
+ violation_evidence_unknown = 0
1844
1878
  # (scar_id, target) -> earliest firing ts, for the retrieval-miss check.
1845
1879
  fired_on: dict[tuple[int, str], str] = {}
1846
1880
  pending_violations: list[tuple[int, str, str]] = []
@@ -2022,12 +2056,18 @@ def _aggregate_firings(records: list[dict]) -> dict:
2022
2056
  if isinstance(vid, int):
2023
2057
  pending_violations.append((vid, target, ts_rec))
2024
2058
  if isinstance(vids, list):
2059
+ evidence = rec.get("violation_evidence")
2025
2060
  # Guard: violation_ids might be garbage in corrupted records —
2026
2061
  # skip the whole record unless it's a list, and skip any
2027
2062
  # non-int element within it, rather than coercing or crashing.
2028
2063
  for vid in vids:
2029
2064
  if isinstance(vid, int):
2030
2065
  violations[vid] = violations.get(vid, 0) + 1
2066
+ summary = evidence.get(str(vid)) if isinstance(evidence, dict) else None
2067
+ if is_violation_summary(summary):
2068
+ violation_evidence_summarized += 1
2069
+ else:
2070
+ violation_evidence_unknown += 1
2031
2071
  # `last_fired` means LAST FIRED. Only records where a scar actually
2032
2072
  # fired may move it — not posttool violation records, not zero-hit
2033
2073
  # precheck passes. Otherwise a repo with zero firings still reports a
@@ -2108,6 +2148,8 @@ def _aggregate_firings(records: list[dict]) -> dict:
2108
2148
  "firings_kind_unknown": firings_kind_unknown,
2109
2149
  "armed_firings": armed_firings,
2110
2150
  "armed_unknown": armed_unknown,
2151
+ "violation_evidence_summarized": violation_evidence_summarized,
2152
+ "violation_evidence_unknown": violation_evidence_unknown,
2111
2153
  # #277: the posttool half's own liveness. `posttool_silent` is
2112
2154
  # ONE-DIRECTIONAL like every other health field here — it can show
2113
2155
  # the half is dead, and observing a verdict never certifies it
@@ -2324,9 +2366,11 @@ def _cmd_gc(args) -> int:
2324
2366
  """Clean machine state (~/.claude/scar-state/ or SCAR_STATE_DIR), report
2325
2367
  repo hygiene. Posture (#115): machine state is regenerable, so gc ACTS on
2326
2368
  it (deletes stale drafted-* markers, truncates firing-log.jsonl); .scars/
2327
- is human-gated, so gc only ever REPORTS on it — candidate ages, fp-log.txt
2328
- presence — never writes there (same ethos as promote/reanchor)."""
2329
- from .hooks import _state_dir, firing_log_path
2369
+ is human-gated, so gc only ever REPORTS on it: candidate ages and a
2370
+ leftover pre-#341 fp-log.txt; never writes there (same ethos as
2371
+ promote/reanchor). The fp-log itself is state-dir data since #341 and is
2372
+ reported, never pruned."""
2373
+ from .hooks import _state_dir, firing_log_path, fp_log_path
2330
2374
  from . import gc as gc_mod
2331
2375
 
2332
2376
  store = _require_store()
@@ -2339,7 +2383,7 @@ def _cmd_gc(args) -> int:
2339
2383
  dropped_firings = gc_mod.truncate_firing_log(
2340
2384
  firing_log_path(), args.max_firings, dry_run=dry_run)
2341
2385
  candidates = gc_mod.candidate_ages(store)
2342
- fp_log = gc_mod.fp_log_report(store)
2386
+ fp_log = gc_mod.fp_log_report(store, fp_log_path(store))
2343
2387
 
2344
2388
  data = {
2345
2389
  "removed_markers": len(removed_markers),
@@ -2361,11 +2405,8 @@ def _cmd_gc(args) -> int:
2361
2405
  print(f" {c['name']} — {c['age_days']}d old")
2362
2406
  if candidates:
2363
2407
  print(" review with `scar promote <path>` or delete rejected files")
2364
- if fp_log["present"]:
2365
- print(f" fp-log.txt: {fp_log['lines']} line(s), {fp_log['size']} byte(s) "
2366
- "— drafter-precision instrument data, not auto-cleaned")
2367
- else:
2368
- print(" fp-log.txt: not present")
2408
+ for line in _fp_log_lines(fp_log):
2409
+ print(f" {line}")
2369
2410
 
2370
2411
  output.render(data=data, json_flag=getattr(args, "json", False),
2371
2412
  tty=lambda: _gc_rich(data, days=args.days,
@@ -2633,26 +2674,8 @@ _WRITE_SCAR_TYPES = {"reverts": "deadend", "deleted_components": "deadend",
2633
2674
  "flapping": "fence", "comments": "fence"}
2634
2675
 
2635
2676
 
2636
- def _sanitize_signal_text(s: str) -> str:
2637
- """Mined git text (revert subjects, squash-merge subjects) can carry
2638
- markdown links, emphasis markers, and bare URLs; leaked into a candidate
2639
- they make the title unreadable and a URL eats the whole slug budget (#159)."""
2640
- import re as _re
2641
- s = _re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", s) # [text](url) -> text
2642
- s = _re.sub(r"https?://\S+", "", s)
2643
- s = _re.sub(r"[*_`]+", "", s)
2644
- return " ".join(s.split())
2645
-
2646
-
2647
2677
  def _write_slug(title: str) -> str:
2648
- import re as _re
2649
- s = _re.sub(r"[^a-z0-9]+", "-", _sanitize_signal_text(title).lower()).strip("-")
2650
- if len(s) > 60:
2651
- cut = s[:60]
2652
- if s[60] != "-" and "-" in cut: # sliced mid-token: drop the fragment
2653
- cut = cut.rsplit("-", 1)[0]
2654
- s = cut.rstrip("-")
2655
- return s or "signal"
2678
+ return slugify(title, fallback="signal")
2656
2679
 
2657
2680
 
2658
2681
  def _write_anchors(repo: Path, section: str, c: dict, tracked: set[str]) -> list[str]:
@@ -2679,7 +2702,7 @@ def _write_title(section: str, c: dict) -> str:
2679
2702
  raw = f"Flapping value {c['key']} in {c['file']}"
2680
2703
  else:
2681
2704
  raw = "Keep-out comment in " + c["location"].split(":", 1)[0]
2682
- return _sanitize_signal_text(raw)
2705
+ return sanitize_signal_text(raw)
2683
2706
 
2684
2707
 
2685
2708
  def _write_evidence(section: str, c: dict) -> str:
@@ -3087,6 +3110,9 @@ def build_parser() -> argparse.ArgumentParser:
3087
3110
  p = _add(sub, "lint", _cmd_lint, help="validate every scar and candidate")
3088
3111
  p.add_argument("--fail-orphans", action="store_true",
3089
3112
  help="exit non-zero when any scar is orphan-detected")
3113
+ p.add_argument("--fail-partial-rot", action="store_true",
3114
+ help="exit non-zero when any scar has a dead anchor among live "
3115
+ "ones (passes when git is unavailable, like --fail-orphans)")
3090
3116
  p.add_argument("--fail-firing-review", action="store_true",
3091
3117
  help="exit non-zero when any scar has crossed its "
3092
3118
  "review_after_firings threshold")
@@ -32,14 +32,13 @@ GitError distinction between "no signal" and "git itself is broken").
32
32
 
33
33
  from __future__ import annotations
34
34
 
35
- import hashlib
36
35
  import re
37
36
  import subprocess
38
37
  import time
39
38
  from dataclasses import dataclass, field
40
39
  from pathlib import Path
41
40
 
42
- from .hooks import REVERT_RE
41
+ from .hooks import REVERT_RE, fp_log_path, repo_key
43
42
  from .store import ScarStore
44
43
 
45
44
  ONE_DAY_SECONDS = 86400
@@ -96,8 +95,9 @@ def marker_key(repo: Path) -> str:
96
95
  """Stable per-repo id for marker filenames. The firing log (hooks.py
97
96
  #106) keys by a ``repo`` field inside each JSON line, not by filename —
98
97
  there's no such record here (draft-check has no log to key), so the
99
- marker file itself is keyed by hashing the resolved repo path."""
100
- return hashlib.sha1(str(repo.resolve()).encode("utf-8")).hexdigest()
98
+ marker file itself is keyed by hashing the resolved repo path. The hash
99
+ lives in ``hooks.repo_key`` so the per-repo fp-log (#341) shares it."""
100
+ return repo_key(repo)
101
101
 
102
102
 
103
103
  def lastcheck_marker(state_dir: Path, repo: Path) -> Path:
@@ -235,7 +235,8 @@ def analyze(state_dir: Path, repo: Path) -> DraftCheckResult | None:
235
235
  def contract_text(store: ScarStore, result: DraftCheckResult) -> str:
236
236
  """Mirrors ``hooks.stop_drafter``'s contract text (hooks.py ~194-225):
237
237
  same two-branch instruction (write a candidate, or log a false
238
- positive), same <=15 line cap, same template/candidates paths. Adapted:
238
+ positive), same <=15 line cap, same template/candidates paths, same
239
+ state-dir fp-log path (``hooks.fp_log_path``, #341). Adapted:
239
240
  no transcript signals (git evidence instead) and the fp-log branch tells
240
241
  the agent to tag its line ``draft-check`` so drafter-precision data keeps
241
242
  the two trigger sources (transcript vs git) separable."""
@@ -251,7 +252,7 @@ def contract_text(store: ScarStore, result: DraftCheckResult) -> str:
251
252
  "mandatory, status: candidate); it stays a candidate until a human "
252
253
  "reviews it. (2) If nothing was actually abandoned (false trigger), "
253
254
  f"append one line — date + one-phrase reason — to "
254
- f"{candidates}/fp-log.txt, tagged `draft-check` (e.g. '2026-07-01 "
255
+ f"{fp_log_path(store)}, tagged `draft-check` (e.g. '2026-07-01 "
255
256
  "draft-check: <reason>') so drafter-precision data stays separated "
256
257
  "by trigger source. Do exactly one of the two; do not ask the user."
257
258
  )