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.
- scar_cli-0.26.0/.release-please-manifest.json +3 -0
- scar_cli-0.26.0/.scars/0012-firing-log-readers-need-blanket-guards.landmine.md +42 -0
- scar_cli-0.26.0/.scars/candidates/violation-evidence-must-not-persist-raw-edits.md +28 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/CHANGELOG.md +21 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/PKG-INFO +2 -1
- {scar_cli-0.25.0 → scar_cli-0.26.0}/README.md +1 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/SCAR-FORMAT.md +6 -2
- {scar_cli-0.25.0 → scar_cli-0.26.0}/SPEC.md +60 -2
- {scar_cli-0.25.0 → scar_cli-0.26.0}/action.yml +7 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/plugin/plugin.json +1 -1
- {scar_cli-0.25.0/src/scar → scar_cli-0.26.0/plugin}/skills/scar-authoring/SKILL.md +3 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/pyproject.toml +1 -1
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/cli.py +66 -40
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/draftcheck.py +7 -6
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/gc.py +40 -13
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/hooks.py +29 -2
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/lint.py +25 -9
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/match.py +14 -16
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/mcp.py +2 -4
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/orphan.py +14 -1
- {scar_cli-0.25.0/plugin → scar_cli-0.26.0/src/scar}/skills/scar-authoring/SKILL.md +3 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/store.py +45 -2
- scar_cli-0.26.0/src/scar/violation_evidence.py +55 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_action_yml.py +7 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_cli.py +120 -4
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_draftcheck.py +17 -1
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_gc.py +57 -7
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_hooks.py +55 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_json_contract.py +2 -1
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_lint.py +59 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_match.py +12 -3
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_mcp.py +14 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_orphan.py +115 -1
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_store.py +69 -0
- scar_cli-0.26.0/tests/test_violation_evidence.py +288 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/uv.lock +1 -1
- scar_cli-0.25.0/.release-please-manifest.json +0 -3
- scar_cli-0.25.0/.scars/0012-firing-log-readers-need-blanket-guards.landmine.md +0 -37
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.claude-plugin/marketplace.json +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/workflows/ci.yml +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/workflows/docs.yml +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/workflows/pr-validation.yml +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.github/workflows/release.yml +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.gitignore +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.pre-commit-config.yaml +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.pre-commit-hooks.yaml +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0005-history-rewrite-orphans-commit-evidence.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0006-yaml-pattern-anchor-over-escaping.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0007-release-please-config-change-skips-open-pr.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0008-rich-output-nontty-must-bypass-rich.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0009-anchor-survival-harness-measures-symbol-not-path-anchors.deadend.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0010-git-returncode-128-vs-empty-tracked-set.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0011-lint-compile-check-lets-redos-anchors-hang-hotpath.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0013-docusaurus-future-v4-requires-faster-dep.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0014-inject-flag-dest-collides-with-subcommand-dispatch.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0015-zero-checkruns-means-approval-gate-not-workflow-bug.deadend.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0016-hook-ownership-must-be-kind-scoped.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0017-codex-pretool-context-can-replay-apply-patch.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0018-cli-subparsers-register-through-_add-wrapper.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0019-token-in-scar-body-cannot-prove-hook-delivery.deadend.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0020-one-directional-health-check-leaves-flattering-direction-unwatched.landmine.md +0 -0
- {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
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0022-codex-hook-delivery-needs-user-file-and-trust.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0023-cascade-block-exit-collides-with-argparse.fence.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0024-host-detection-must-not-read-the-process-home.deadend.md +0 -0
- {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
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/0026-path-anchor-breadth-drives-the-review-due-threshold.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/README.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/.scars/template.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/AGENTS.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/CONTRIBUTING.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/IDEA.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/LICENSE +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/ROADMAP.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/STRESS-TEST.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/codecov.yml +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/anchor-survival/PROTOCOL.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/anchor-survival/RESULTS.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/anchor-survival/long_replay.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/anchor-survival/replay.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/anchor-survival/replay_shipped.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/anchor-survival/test_replay_shipped.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/auto-authorship/FINDINGS.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/auto-authorship/PROTOCOL.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/.gitignore +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/PROTOCOL.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/RESULTS.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/fixture/README.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/fixture/payments/retry.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/fixture/reports/export.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/fixture/services/sessions.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/fence-honor/grade.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/harvest/PROTOCOL.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/experiments/harvest/harvest.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/hook/scar-hooks.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/plugin/hooks/run.sh +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/plugin/skills/scar-authoring/assets/template.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/release-please-config.json +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/__init__.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/agent.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/cascade.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/codex.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/evidence.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/firing_log.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/harvest.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/hosts.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/installer.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/model.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/output.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/reanchor.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/renames.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/render.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/review.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/skills/scar-authoring/assets/template.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/src/scar/symbols.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/conftest.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_anchor_kind.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_block_capability.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_cascade.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_codex.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_context_size.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_docs.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_evidence.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_firing_log.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_git_hook.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_harvest.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_hosts.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_installer.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_lifecycle.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_matched_by.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_model.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_output.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_plugin.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_precommit_hooks.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_reanchor.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_renames.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_render.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_review.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_skill.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_sweep.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_symbols.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/tests/test_verdict_observability.py +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/.gitignore +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/docs/agents.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/docs/concepts.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/docs/index.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/docs/methodology.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/docs/quickstart.md +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/docusaurus.config.ts +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/package-lock.json +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/package.json +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/scripts/sync-changelog.mjs +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/sidebars.ts +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/src/css/custom.css +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/static/img/favicon.svg +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/static/img/social-card.png +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/static/llms.txt +0 -0
- {scar_cli-0.25.0 → scar_cli-0.26.0}/website/tsconfig.json +0 -0
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
@@ -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
|
|
128
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
2328
|
-
|
|
2329
|
-
|
|
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
|
-
|
|
2365
|
-
print(f"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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"{
|
|
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
|
)
|