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