scar-cli 0.5.0__tar.gz → 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. scar_cli-0.6.1/.claude-plugin/marketplace.json +11 -0
  2. {scar_cli-0.5.0 → scar_cli-0.6.1}/.github/workflows/release.yml +4 -1
  3. scar_cli-0.6.1/.release-please-manifest.json +3 -0
  4. scar_cli-0.6.1/.scars/candidates/fp-log.txt +6 -0
  5. {scar_cli-0.5.0 → scar_cli-0.6.1}/AGENTS.md +17 -0
  6. {scar_cli-0.5.0 → scar_cli-0.6.1}/CHANGELOG.md +19 -0
  7. {scar_cli-0.5.0 → scar_cli-0.6.1}/PKG-INFO +11 -1
  8. {scar_cli-0.5.0 → scar_cli-0.6.1}/README.md +10 -0
  9. scar_cli-0.6.1/plugin/plugin.json +41 -0
  10. scar_cli-0.6.1/plugin/skills/scar-authoring/SKILL.md +93 -0
  11. scar_cli-0.6.1/plugin/skills/scar-authoring/assets/template.md +25 -0
  12. {scar_cli-0.5.0 → scar_cli-0.6.1}/pyproject.toml +1 -1
  13. scar_cli-0.6.1/release-please-config.json +15 -0
  14. {scar_cli-0.5.0 → scar_cli-0.6.1}/src/scar/agent.py +7 -0
  15. {scar_cli-0.5.0 → scar_cli-0.6.1}/src/scar/cli.py +21 -1
  16. {scar_cli-0.5.0 → scar_cli-0.6.1}/src/scar/harvest.py +33 -0
  17. {scar_cli-0.5.0 → scar_cli-0.6.1}/src/scar/installer.py +43 -0
  18. {scar_cli-0.5.0 → scar_cli-0.6.1}/src/scar/mcp.py +8 -1
  19. scar_cli-0.6.1/src/scar/skills/scar-authoring/SKILL.md +93 -0
  20. scar_cli-0.6.1/src/scar/skills/scar-authoring/assets/template.md +25 -0
  21. {scar_cli-0.5.0 → scar_cli-0.6.1}/tests/test_cli.py +8 -0
  22. scar_cli-0.6.1/tests/test_docs.py +14 -0
  23. {scar_cli-0.5.0 → scar_cli-0.6.1}/tests/test_harvest.py +40 -0
  24. {scar_cli-0.5.0 → scar_cli-0.6.1}/tests/test_installer.py +33 -0
  25. {scar_cli-0.5.0 → scar_cli-0.6.1}/tests/test_mcp.py +11 -0
  26. scar_cli-0.6.1/tests/test_plugin.py +57 -0
  27. scar_cli-0.6.1/tests/test_skill.py +36 -0
  28. {scar_cli-0.5.0 → scar_cli-0.6.1}/uv.lock +1 -1
  29. scar_cli-0.5.0/.scars/candidates/fp-log.txt +0 -3
  30. {scar_cli-0.5.0 → scar_cli-0.6.1}/.github/workflows/ci.yml +0 -0
  31. {scar_cli-0.5.0 → scar_cli-0.6.1}/.github/workflows/pr-validation.yml +0 -0
  32. {scar_cli-0.5.0 → scar_cli-0.6.1}/.gitignore +0 -0
  33. {scar_cli-0.5.0 → scar_cli-0.6.1}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
  34. {scar_cli-0.5.0 → scar_cli-0.6.1}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
  35. {scar_cli-0.5.0 → scar_cli-0.6.1}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +0 -0
  36. {scar_cli-0.5.0 → scar_cli-0.6.1}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +0 -0
  37. {scar_cli-0.5.0 → scar_cli-0.6.1}/.scars/0005-history-rewrite-orphans-commit-evidence.landmine.md +0 -0
  38. {scar_cli-0.5.0 → scar_cli-0.6.1}/.scars/0006-yaml-pattern-anchor-over-escaping.landmine.md +0 -0
  39. {scar_cli-0.5.0 → scar_cli-0.6.1}/.scars/README.md +0 -0
  40. {scar_cli-0.5.0 → scar_cli-0.6.1}/.scars/template.md +0 -0
  41. {scar_cli-0.5.0 → scar_cli-0.6.1}/CONTRIBUTING.md +0 -0
  42. {scar_cli-0.5.0 → scar_cli-0.6.1}/IDEA.md +0 -0
  43. {scar_cli-0.5.0 → scar_cli-0.6.1}/LICENSE +0 -0
  44. {scar_cli-0.5.0 → scar_cli-0.6.1}/ROADMAP.md +0 -0
  45. {scar_cli-0.5.0 → scar_cli-0.6.1}/SCAR-FORMAT.md +0 -0
  46. {scar_cli-0.5.0 → scar_cli-0.6.1}/SPEC.md +0 -0
  47. {scar_cli-0.5.0 → scar_cli-0.6.1}/STRESS-TEST.md +0 -0
  48. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/anchor-survival/PROTOCOL.md +0 -0
  49. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/anchor-survival/RESULTS.md +0 -0
  50. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/anchor-survival/long_replay.py +0 -0
  51. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/anchor-survival/replay.py +0 -0
  52. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/auto-authorship/FINDINGS.md +0 -0
  53. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/auto-authorship/PROTOCOL.md +0 -0
  54. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/fence-honor/.gitignore +0 -0
  55. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/fence-honor/PROTOCOL.md +0 -0
  56. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/fence-honor/RESULTS.md +0 -0
  57. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
  58. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
  59. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
  60. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/fence-honor/fixture/README.md +0 -0
  61. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/fence-honor/fixture/payments/retry.py +0 -0
  62. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/fence-honor/fixture/reports/export.py +0 -0
  63. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/fence-honor/fixture/services/sessions.py +0 -0
  64. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/fence-honor/grade.py +0 -0
  65. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/harvest/PROTOCOL.md +0 -0
  66. {scar_cli-0.5.0 → scar_cli-0.6.1}/experiments/harvest/harvest.py +0 -0
  67. {scar_cli-0.5.0 → scar_cli-0.6.1}/hook/scar-hooks.py +0 -0
  68. {scar_cli-0.5.0 → scar_cli-0.6.1}/src/scar/__init__.py +0 -0
  69. {scar_cli-0.5.0 → scar_cli-0.6.1}/src/scar/evidence.py +0 -0
  70. {scar_cli-0.5.0 → scar_cli-0.6.1}/src/scar/hooks.py +0 -0
  71. {scar_cli-0.5.0 → scar_cli-0.6.1}/src/scar/lint.py +0 -0
  72. {scar_cli-0.5.0 → scar_cli-0.6.1}/src/scar/match.py +0 -0
  73. {scar_cli-0.5.0 → scar_cli-0.6.1}/src/scar/model.py +0 -0
  74. {scar_cli-0.5.0 → scar_cli-0.6.1}/src/scar/orphan.py +0 -0
  75. {scar_cli-0.5.0 → scar_cli-0.6.1}/src/scar/render.py +0 -0
  76. {scar_cli-0.5.0 → scar_cli-0.6.1}/src/scar/store.py +0 -0
  77. {scar_cli-0.5.0 → scar_cli-0.6.1}/tests/test_evidence.py +0 -0
  78. {scar_cli-0.5.0 → scar_cli-0.6.1}/tests/test_hooks.py +0 -0
  79. {scar_cli-0.5.0 → scar_cli-0.6.1}/tests/test_lifecycle.py +0 -0
  80. {scar_cli-0.5.0 → scar_cli-0.6.1}/tests/test_lint.py +0 -0
  81. {scar_cli-0.5.0 → scar_cli-0.6.1}/tests/test_match.py +0 -0
  82. {scar_cli-0.5.0 → scar_cli-0.6.1}/tests/test_model.py +0 -0
  83. {scar_cli-0.5.0 → scar_cli-0.6.1}/tests/test_orphan.py +0 -0
  84. {scar_cli-0.5.0 → scar_cli-0.6.1}/tests/test_store.py +0 -0
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "scar",
3
+ "owner": { "name": "Daily-Nerd" },
4
+ "plugins": [
5
+ {
6
+ "name": "scar",
7
+ "source": "./plugin",
8
+ "description": "Read and author negative-knowledge scars: auto-injected scars before edits, plus the scar-authoring skill."
9
+ }
10
+ ]
11
+ }
@@ -19,7 +19,10 @@ jobs:
19
19
  - id: rp
20
20
  uses: googleapis/release-please-action@v4
21
21
  with:
22
- release-type: python
22
+ # config-file + manifest replace the inline release-type so extra-files
23
+ # can keep plugin/plugin.json's version in lock-step with pyproject (#62).
24
+ config-file: release-please-config.json
25
+ manifest-file: .release-please-manifest.json
23
26
 
24
27
  publish:
25
28
  needs: release-please
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.6.1"
3
+ }
@@ -0,0 +1,6 @@
1
+ 2026-06-12 false trigger: meta-session — we tuned the revert-language detector itself, so assistant prose ('revert language', 'reverting' in test fixtures/PR text) matched REVERT_RE; nothing abandoned (tool_errors were expected CLI probes/rejections). First post-tune FP pattern: self-referential sessions about the drafter trip the drafter.
2
+ 2026-06-13 false trigger: tool_errors were external API hiccups (pypistats rate-limit/404, bq schema field); no code approach tried-and-abandoned this session (design-only work)
3
+ 2026-06-12 false trigger: orphan-detection impl — 'revert' is feature-domain ('revert case' reverse hint = anchors-live-again) + a planned AC#1 refactor swapping batch-1 copied anchor logic for a shared match.py primitive; replacement was design-mandated, not a deadend discovered by failure
4
+ 2026-06-13 — false trigger: signals from iterative re-anchoring + a caught staging bug (fixed #51) + gh CLI flakiness; all genuine lessons already captured as scars #5/#6 and issues #50
5
+ 2026-06-13 false trigger: harvest-labeling session — 'revert/reverted/abandoned' prose describes ANOTHER repo's history (homelab-apps reverts judged as scar candidates), not an approach abandoned here; tool_errors were a wc-on-wrong-path + benign exit-1 during label recording. Curation work trips the drafter like meta-sessions do (FP #1).
6
+ 2026-06-24 false trigger: #54 scorer investigation — 'revert' is feature-domain (the revert heuristic) + 'user_corrections'/'wrong' is me correcting the ISSUE BODY's mechanism claim (flapping base vs uncapped osc bonus), not abandoning a tried code approach. Read-only mapping + scope question; no code written, nothing deadended. Same self-ref class as FP #1/#5: sessions reasoning about the scorer trip the scorer.
@@ -18,6 +18,23 @@ frontmatter.
18
18
  - Do not silently ignore broken scar files. Run `scar lint` when changing scar
19
19
  format, parsing, promotion, lifecycle, or candidate-writing behavior.
20
20
 
21
+ ## Authoring scars
22
+
23
+ When you abandon an approach (deadend), keep intentional-looking weirdness
24
+ (fence), or discover non-obvious coupling (landmine), record it as a scar.
25
+ The full authoring contract — qualification criteria, the candidates-only write
26
+ path, mandatory YAML frontmatter, and the regex over-escaping trap — is packaged
27
+ as the `scar-authoring` skill.
28
+
29
+ - **Claude Code:** install the plugin (recommended) or `scar skill install` to
30
+ drop the skill into `~/.claude/skills/`. It auto-loads on trigger.
31
+ - **MCP agents (Cursor/Windsurf/opencode):** the `scar_draft` tool enforces the
32
+ candidates-only path and lints before writing; its description carries the
33
+ digest.
34
+ - **Any runtime / manual:** run `scar agent skill` to print the full skill body
35
+ and load it into context. The canonical file is
36
+ `src/scar/skills/scar-authoring/SKILL.md`.
37
+
21
38
  ## Agent Integrations
22
39
 
23
40
  - MCP-capable agents can launch the local server with `scar mcp`.
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.1](https://github.com/Daily-Nerd/Scar/compare/v0.6.0...v0.6.1) (2026-06-26)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **plugin:** sync plugin.json + uv.lock to 0.6.0 + drift guard ([#63](https://github.com/Daily-Nerd/Scar/issues/63)) ([fe21ed6](https://github.com/Daily-Nerd/Scar/commit/fe21ed6046ea4cabc8abd2ca681e85d02991be6a))
9
+
10
+ ## [0.6.0](https://github.com/Daily-Nerd/Scar/compare/v0.5.0...v0.6.0) (2026-06-26)
11
+
12
+
13
+ ### Features
14
+
15
+ * **agent:** Packaged scar-authoring skill + Claude Code plugin ([#32](https://github.com/Daily-Nerd/Scar/issues/32)) ([#58](https://github.com/Daily-Nerd/Scar/issues/58)) ([b1eaca7](https://github.com/Daily-Nerd/Scar/commit/b1eaca7f702d86a4e5429c8c085c1537f5505d0d))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **harvest:** exclude .scars/ tree from candidates (self-ref noise) ([#56](https://github.com/Daily-Nerd/Scar/issues/56)) ([29c2d61](https://github.com/Daily-Nerd/Scar/commit/29c2d6116e23a748df8455170c311023c6578c5c)), closes [#55](https://github.com/Daily-Nerd/Scar/issues/55)
21
+
3
22
  ## [0.5.0](https://github.com/Daily-Nerd/Scar/compare/v0.4.0...v0.5.0) (2026-06-13)
4
23
 
5
24
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scar-cli
3
- Version: 0.5.0
3
+ Version: 0.6.1
4
4
  Summary: SCAR — version control for negative knowledge (deadends, fences, landmines)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -95,6 +95,16 @@ stop all automatic injection and drafting while keeping the repository's
95
95
  scar hook uninstall
96
96
  ```
97
97
 
98
+ **Recommended (Claude Code): install the plugin** so the hooks *and* the
99
+ scar-authoring skill arrive together via the marketplace.
100
+
101
+ **Fallback / non-marketplace:** `scar hook install` registers the hooks, and
102
+ `scar skill install` drops the authoring skill into `~/.claude/skills/`. Both are
103
+ explicit — you run them, nothing is installed as a side effect.
104
+
105
+ Non-Claude agents: `scar agent skill` prints the authoring skill for any runtime;
106
+ MCP agents get the digest via the `scar_draft` tool description.
107
+
98
108
  Wiring MCP-capable agents:
99
109
 
100
110
  ```bash
@@ -86,6 +86,16 @@ stop all automatic injection and drafting while keeping the repository's
86
86
  scar hook uninstall
87
87
  ```
88
88
 
89
+ **Recommended (Claude Code): install the plugin** so the hooks *and* the
90
+ scar-authoring skill arrive together via the marketplace.
91
+
92
+ **Fallback / non-marketplace:** `scar hook install` registers the hooks, and
93
+ `scar skill install` drops the authoring skill into `~/.claude/skills/`. Both are
94
+ explicit — you run them, nothing is installed as a side effect.
95
+
96
+ Non-Claude agents: `scar agent skill` prints the authoring skill for any runtime;
97
+ MCP agents get the digest via the `scar_draft` tool description.
98
+
89
99
  Wiring MCP-capable agents:
90
100
 
91
101
  ```bash
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "scar",
3
+ "version": "0.6.1",
4
+ "description": "SCAR — version control for negative knowledge (deadends, fences, landmines)",
5
+ "hooks": {
6
+ "PreToolUse": [
7
+ {
8
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
9
+ "hooks": [
10
+ {
11
+ "type": "command",
12
+ "command": "scar hook precheck",
13
+ "timeout": 10
14
+ }
15
+ ]
16
+ }
17
+ ],
18
+ "SessionStart": [
19
+ {
20
+ "hooks": [
21
+ {
22
+ "type": "command",
23
+ "command": "scar hook session-notice",
24
+ "timeout": 10
25
+ }
26
+ ]
27
+ }
28
+ ],
29
+ "Stop": [
30
+ {
31
+ "hooks": [
32
+ {
33
+ "type": "command",
34
+ "command": "scar hook stop-drafter",
35
+ "timeout": 15
36
+ }
37
+ ]
38
+ }
39
+ ]
40
+ }
41
+ }
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: scar-authoring
3
+ description: >
4
+ Author negative-knowledge scars (deadend/fence/landmine) for a repo's .scars/
5
+ directory — qualification criteria, the candidates-only write path, and the
6
+ mandatory-frontmatter / regex-escaping traps that silently break scars.
7
+ Trigger: when you abandon an approach after trying it, when you keep code that
8
+ looks wrong on purpose, or when you discover that changing one thing breaks
9
+ another non-obviously — and you want to record it so the next agent does not
10
+ repeat the pain.
11
+ license: MIT
12
+ metadata:
13
+ author: Daily-Nerd
14
+ version: "1.0"
15
+ ---
16
+
17
+ # Authoring Scars
18
+
19
+ A scar records *negative knowledge*: a thing that was tried and failed, code
20
+ that looks wrong but is intentional, or a non-obvious coupling. Scars fire
21
+ automatically — the next editor sees the relevant scar injected before they
22
+ touch anchored code. Your job here is to write a good one.
23
+
24
+ ## When to Use
25
+
26
+ - You **abandoned an approach** after trying it → `deadend`
27
+ - You **kept code that looks wrong on purpose** → `fence`
28
+ - You **found that changing A breaks B non-obviously** → `landmine`
29
+
30
+ Not a scar: routine debugging that eventually succeeded. If you tried something,
31
+ it worked, and you moved on — there is nothing to record.
32
+
33
+ ## The Three Types
34
+
35
+ **`deadend` — tried and failed.** Protects against re-attempting a dead path.
36
+ Primary anchor is usually `pattern` (the shape of the failed approach).
37
+ *Example (scar #2):* an agent tried to install Claude Code hooks by writing
38
+ `~/.claude/settings.json` directly; the permission classifier denies it. Anchor
39
+ `path: src/scar/installer.py`; body says the *user* must run `scar hook install`.
40
+
41
+ **`fence` — looks wrong, is intentional.** Protects existing code from a
42
+ "cleanup" that would break it. Primary anchor is `path`.
43
+ *Example (scar #3):* the installer deliberately ignores an active virtualenv so
44
+ hooks bind to a stable `scar` on PATH, not a venv shim that disappears. Anchor
45
+ `path: src/scar/installer.py`; body says "do not 'simplify' this to plain
46
+ `shutil.which`."
47
+
48
+ **`landmine` — touching A breaks B.** Anchor the trigger site; the body names
49
+ the blast radius. *Example (scar #6):* a regex in a scar's `pattern:` field is
50
+ double-quoted YAML, so `\b` collapses and the anchor silently self-matches only
51
+ its own `.scars/` body — the protection is dead but the gauge reads green.
52
+
53
+ ## The Write Contract (non-negotiable)
54
+
55
+ 1. COPY `.scars/template.md` (or this skill's `assets/template.md`) — do not
56
+ edit the template itself.
57
+ 2. Write to `.scars/candidates/<slug>.md` with `status: candidate`.
58
+ 3. **Never** write into `.scars/*.md` directly. A human promotes via
59
+ `scar promote`.
60
+ 4. If an MCP server is wired, prefer the `scar_draft` tool — it enforces the
61
+ path and runs lint before writing.
62
+
63
+ ## Mandatory Frontmatter
64
+
65
+ A file without `---`-fenced YAML frontmatter is **not a scar at all** — it never
66
+ fires. Minimum valid block: `type`, `title`, `severity`, `confidence`,
67
+ `created`, `authors`, at least one `anchors` entry, and `status: candidate`.
68
+
69
+ ## Anti-Over-Escape (the #1 silent failure)
70
+
71
+ Prefer a `path:` anchor — it cannot self-match and needs no escaping. If you
72
+ must use a `pattern:` regex: backslashes in double-quoted YAML collapse (`\b`
73
+ dies), and the pattern is matched against all tracked content **including the
74
+ scar's own body**, so a broken pattern keeps itself alive by self-reference.
75
+ Run `scar lint` and confirm the scar does NOT appear under partial-rot
76
+ (self-match only).
77
+
78
+ Wrong: `pattern: "\\bwiden\\b"` → Right: `path: src/widen/` (no escaping, no
79
+ self-match).
80
+
81
+ ## Anchors, Severity, Size
82
+
83
+ - `path:` = repo-relative prefix (file or directory). `pattern:` =
84
+ case-insensitive regex over path + new content.
85
+ - Severity: `low | medium | high | critical`.
86
+ - Injection is capped at ~3 scars / ~700 chars each — write tight: 5–15 lines,
87
+ evidence cited inline.
88
+
89
+ ## Verify Before Finishing
90
+
91
+ - `scar lint` must pass.
92
+ - At least one `evidence` receipt (commit / pr / incident / note) — without it
93
+ the scar is challengeable on sight.
@@ -0,0 +1,25 @@
1
+ ---
2
+ # COPY THIS FILE — do not edit the template itself.
3
+ # New scars: write to .scars/candidates/<slug>.md with status: candidate.
4
+ # A human reviewer promotes to .scars/NNNN-<slug>.<type>.md with status: active.
5
+ id: 0 # assigned at promotion (next free NNNN)
6
+ type: deadend # deadend = tried+failed | fence = looks wrong, intentional | landmine = touching A breaks B
7
+ title: One line, searchable, says the constraint
8
+ severity: medium # low | medium | high | critical
9
+ confidence: 0.7 # 0..1 — how sure are we this still holds
10
+ created: 1970-01-01
11
+ authors: ["claude-code"] # add the human reviewer at promotion
12
+ anchors:
13
+ - path: src/module/ # file or directory this protects
14
+ - pattern: "regex" # optional: fires when matching code appears in ANY new/edited file
15
+ evidence:
16
+ - commit: abc1234 # at least one receipt: commit, pr, incident, or note
17
+ expires:
18
+ condition: "what change would make this scar obsolete"
19
+ review_after: 1971-01-01 # force a freshness look even if condition never triggers
20
+ status: template # candidate | active | challenged | archived (template = never parsed)
21
+ ---
22
+
23
+ Body: 5-15 lines of prose. What was tried/observed, why it failed or why the
24
+ weirdness is intentional, and what a future editor must do instead. Write it
25
+ for someone (human or agent) with zero context. Cite the evidence inline.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scar-cli"
3
- version = "0.5.0"
3
+ version = "0.6.1"
4
4
  description = "SCAR — version control for negative knowledge (deadends, fences, landmines)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
+ "packages": {
4
+ ".": {
5
+ "release-type": "python",
6
+ "extra-files": [
7
+ {
8
+ "type": "json",
9
+ "path": "plugin/plugin.json",
10
+ "jsonpath": "$.version"
11
+ }
12
+ ]
13
+ }
14
+ }
15
+ }
@@ -64,3 +64,10 @@ def config(target: str) -> str:
64
64
  if target not in CONFIGS:
65
65
  raise ValueError(f"unknown target '{target}' (expected: {', '.join(TARGETS)})")
66
66
  return CONFIGS[target]
67
+
68
+
69
+ def skill() -> str:
70
+ """Return the full scar-authoring SKILL.md body (packaged, runtime-neutral)."""
71
+ from importlib.resources import files
72
+ resource = files("scar").joinpath("skills/scar-authoring/SKILL.md")
73
+ return resource.read_text(encoding="utf-8")
@@ -480,11 +480,14 @@ def _cmd_harvest(args) -> int:
480
480
 
481
481
 
482
482
  def _cmd_agent(args) -> int:
483
- from .agent import config, doctor
483
+ from .agent import config, doctor, skill
484
484
  if args.agent_command == "doctor":
485
485
  for line in doctor(Path.cwd()):
486
486
  print(line)
487
487
  return 0
488
+ if args.agent_command == "skill":
489
+ print(skill())
490
+ return 0
488
491
  try:
489
492
  print(config(args.target))
490
493
  except ValueError as exc:
@@ -502,6 +505,15 @@ def _cmd_hook_lifecycle(args) -> int:
502
505
  return status()
503
506
 
504
507
 
508
+ def _cmd_skill_lifecycle(args) -> int:
509
+ from .installer import skill_install, skill_status, skill_uninstall
510
+ if args.kind == "install":
511
+ return skill_install(dry=args.dry_run)
512
+ if args.kind == "uninstall":
513
+ return skill_uninstall(dry=args.dry_run)
514
+ return skill_status()
515
+
516
+
505
517
  def main(argv: list[str] | None = None) -> int:
506
518
  parser = argparse.ArgumentParser(prog="scar",
507
519
  description="version control for negative knowledge")
@@ -562,6 +574,11 @@ def main(argv: list[str] | None = None) -> int:
562
574
  p.add_argument("--dry-run", action="store_true",
563
575
  help="show lifecycle changes without writing settings")
564
576
 
577
+ p = sub.add_parser("skill", help="install, remove, or inspect the scar-authoring skill")
578
+ p.add_argument("kind", choices=["install", "uninstall", "status"])
579
+ p.add_argument("--dry-run", action="store_true",
580
+ help="show changes without writing to ~/.claude/skills")
581
+
565
582
  sub.add_parser("mcp", help="run the SCAR MCP stdio server")
566
583
 
567
584
  p = sub.add_parser("agent", help="agent integration helpers")
@@ -569,6 +586,7 @@ def main(argv: list[str] | None = None) -> int:
569
586
  agent_sub.add_parser("doctor", help="show local agent integration readiness")
570
587
  cfg = agent_sub.add_parser("config", help="print config for an agent runtime")
571
588
  cfg.add_argument("target", choices=["codex", "cursor", "opencode", "windsurf"])
589
+ agent_sub.add_parser("skill", help="print the scar-authoring skill body")
572
590
 
573
591
  p = sub.add_parser("inject", help="machine mode for hooks: JSON or silence")
574
592
  p.add_argument("--path")
@@ -586,6 +604,8 @@ def main(argv: list[str] | None = None) -> int:
586
604
  return _cmd_hook_lifecycle(args)
587
605
  from .hooks import HANDLERS # hot path: imports nothing beyond library
588
606
  return HANDLERS[args.kind]()
607
+ if args.command == "skill":
608
+ return _cmd_skill_lifecycle(args)
589
609
  if args.command in ("challenge", "archive"):
590
610
  status = {"challenge": "challenged", "archive": "archived"}[args.command]
591
611
  return _cmd_transition(args, status)
@@ -326,6 +326,31 @@ _SECTION_TYPES = {
326
326
  }
327
327
 
328
328
 
329
+ def _candidate_path(signal_type: str, c: dict) -> str:
330
+ """Repo-relative path a candidate points at ('' if it has none, e.g. reverts)."""
331
+ if signal_type == "comment":
332
+ return c.get("location", "").split(":", 1)[0]
333
+ if signal_type == "deleted_component":
334
+ return c.get("component", "")
335
+ if signal_type == "flapping":
336
+ return c.get("file", "")
337
+ return ""
338
+
339
+
340
+ def _in_scars(path: str) -> bool:
341
+ """True if a candidate path lives under the repo's own .scars/ tree.
342
+
343
+ Harvest must not re-surface a repo's own negative knowledge as a fresh
344
+ candidate. Comment-archaeology is the loud case — scar bodies quote the very
345
+ DO-NOT/load-bearing/intentional prose the grep hunts for, so every promoted
346
+ scar self-matches (same self-ref class as #35). Excluded uniformly (#55).
347
+
348
+ Matches `.scars` as any path segment, not just the root — nested scar trees
349
+ (e.g. an experiment fixture's own .scars/) are the same self-ref noise.
350
+ """
351
+ return ".scars" in path.split("/")
352
+
353
+
329
354
  def _annotate_and_sort(candidates: list[dict], signal_type: str,
330
355
  now_months: int | None = None) -> list[dict]:
331
356
  """Add 'score' and 'id' fields to each candidate, then sort by score desc."""
@@ -345,6 +370,14 @@ def harvest(repo: Path) -> dict[str, list[dict]]:
345
370
  "flapping": _flapping(repo),
346
371
  "comments": _comment_archaeology(repo),
347
372
  }
373
+ # Drop a repo's own .scars/ tree — self-ref noise, never a fresh candidate
374
+ # (#55). Filtered in code, not via a git grep pathspec (landmine #1: a
375
+ # zero-match pathspec silently empties the whole result set).
376
+ raw = {
377
+ section: [c for c in candidates
378
+ if not _in_scars(_candidate_path(_SECTION_TYPES[section], c))]
379
+ for section, candidates in raw.items()
380
+ }
348
381
  return {
349
382
  section: _annotate_and_sort(candidates, _SECTION_TYPES[section], now_months)
350
383
  for section, candidates in raw.items()
@@ -16,6 +16,8 @@ from pathlib import Path
16
16
  CLAUDE_DIR = Path.home() / ".claude"
17
17
  HOOKS_DIR = CLAUDE_DIR / "hooks"
18
18
  SETTINGS = CLAUDE_DIR / "settings.json"
19
+ SKILLS_DIR = CLAUDE_DIR / "skills"
20
+ SKILL_NAME = "scar-authoring"
19
21
 
20
22
  LEGACY_SCRIPTS = ("scar-precheck.py", "scar-session-notice.py", "scar-stop-drafter.py")
21
23
  OURS_RE = re.compile(r"(scar[^ ]*) hook (precheck|session-notice|stop-drafter)"
@@ -152,3 +154,44 @@ def status() -> int:
152
154
  else "installed" if ours else "not installed")
153
155
  print(f"{spec['kind']:16} {spec['event']:13} {state}")
154
156
  return 0
157
+
158
+
159
+ def _skill_source() -> Path:
160
+ from importlib.resources import files
161
+ return Path(str(files("scar").joinpath("skills") / SKILL_NAME))
162
+
163
+
164
+ def skill_install(dry: bool = False) -> int:
165
+ src = _skill_source()
166
+ if not src.is_dir():
167
+ print(f"skill source not found: {src}")
168
+ return 1
169
+ dest = SKILLS_DIR / SKILL_NAME
170
+ print(f"[skill] install {SKILL_NAME} -> {dest}")
171
+ if dry:
172
+ print("install: done (dry-run, nothing written)")
173
+ return 0
174
+ SKILLS_DIR.mkdir(parents=True, exist_ok=True)
175
+ if dest.exists():
176
+ shutil.rmtree(dest)
177
+ shutil.copytree(src, dest)
178
+ print("install: done.")
179
+ return 0
180
+
181
+
182
+ def skill_uninstall(dry: bool = False) -> int:
183
+ dest = SKILLS_DIR / SKILL_NAME
184
+ if not dest.exists():
185
+ print(f"[skill] {SKILL_NAME}: not installed")
186
+ return 0
187
+ print(f"[skill] remove {dest}")
188
+ if not dry:
189
+ shutil.rmtree(dest)
190
+ print("uninstall: done" + (" (dry-run, nothing written)" if dry else "."))
191
+ return 0
192
+
193
+
194
+ def skill_status() -> int:
195
+ dest = SKILLS_DIR / SKILL_NAME
196
+ print(f"skill {SKILL_NAME}: {'installed' if dest.exists() else 'not installed'} ({dest})")
197
+ return 0
@@ -173,7 +173,14 @@ TOOLS = [
173
173
  },
174
174
  {
175
175
  "name": "scar_draft",
176
- "description": "Write a candidate scar to .scars/candidates/ for human promotion.",
176
+ "description": (
177
+ "Write a candidate scar to .scars/candidates/ for human promotion. "
178
+ "Use for negative knowledge: deadend (tried+failed), fence (looks "
179
+ "wrong but intentional), or landmine (touching A breaks B). Prefer a "
180
+ "path: anchor — pattern: regexes over-escape in YAML and silently "
181
+ "self-match. Writes only to candidates/ and lints before writing; a "
182
+ "human promotes. See the scar-authoring skill (scar agent skill)."
183
+ ),
177
184
  "inputSchema": {
178
185
  "type": "object",
179
186
  "required": ["type", "title", "anchors", "body"],
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: scar-authoring
3
+ description: >
4
+ Author negative-knowledge scars (deadend/fence/landmine) for a repo's .scars/
5
+ directory — qualification criteria, the candidates-only write path, and the
6
+ mandatory-frontmatter / regex-escaping traps that silently break scars.
7
+ Trigger: when you abandon an approach after trying it, when you keep code that
8
+ looks wrong on purpose, or when you discover that changing one thing breaks
9
+ another non-obviously — and you want to record it so the next agent does not
10
+ repeat the pain.
11
+ license: MIT
12
+ metadata:
13
+ author: Daily-Nerd
14
+ version: "1.0"
15
+ ---
16
+
17
+ # Authoring Scars
18
+
19
+ A scar records *negative knowledge*: a thing that was tried and failed, code
20
+ that looks wrong but is intentional, or a non-obvious coupling. Scars fire
21
+ automatically — the next editor sees the relevant scar injected before they
22
+ touch anchored code. Your job here is to write a good one.
23
+
24
+ ## When to Use
25
+
26
+ - You **abandoned an approach** after trying it → `deadend`
27
+ - You **kept code that looks wrong on purpose** → `fence`
28
+ - You **found that changing A breaks B non-obviously** → `landmine`
29
+
30
+ Not a scar: routine debugging that eventually succeeded. If you tried something,
31
+ it worked, and you moved on — there is nothing to record.
32
+
33
+ ## The Three Types
34
+
35
+ **`deadend` — tried and failed.** Protects against re-attempting a dead path.
36
+ Primary anchor is usually `pattern` (the shape of the failed approach).
37
+ *Example (scar #2):* an agent tried to install Claude Code hooks by writing
38
+ `~/.claude/settings.json` directly; the permission classifier denies it. Anchor
39
+ `path: src/scar/installer.py`; body says the *user* must run `scar hook install`.
40
+
41
+ **`fence` — looks wrong, is intentional.** Protects existing code from a
42
+ "cleanup" that would break it. Primary anchor is `path`.
43
+ *Example (scar #3):* the installer deliberately ignores an active virtualenv so
44
+ hooks bind to a stable `scar` on PATH, not a venv shim that disappears. Anchor
45
+ `path: src/scar/installer.py`; body says "do not 'simplify' this to plain
46
+ `shutil.which`."
47
+
48
+ **`landmine` — touching A breaks B.** Anchor the trigger site; the body names
49
+ the blast radius. *Example (scar #6):* a regex in a scar's `pattern:` field is
50
+ double-quoted YAML, so `\b` collapses and the anchor silently self-matches only
51
+ its own `.scars/` body — the protection is dead but the gauge reads green.
52
+
53
+ ## The Write Contract (non-negotiable)
54
+
55
+ 1. COPY `.scars/template.md` (or this skill's `assets/template.md`) — do not
56
+ edit the template itself.
57
+ 2. Write to `.scars/candidates/<slug>.md` with `status: candidate`.
58
+ 3. **Never** write into `.scars/*.md` directly. A human promotes via
59
+ `scar promote`.
60
+ 4. If an MCP server is wired, prefer the `scar_draft` tool — it enforces the
61
+ path and runs lint before writing.
62
+
63
+ ## Mandatory Frontmatter
64
+
65
+ A file without `---`-fenced YAML frontmatter is **not a scar at all** — it never
66
+ fires. Minimum valid block: `type`, `title`, `severity`, `confidence`,
67
+ `created`, `authors`, at least one `anchors` entry, and `status: candidate`.
68
+
69
+ ## Anti-Over-Escape (the #1 silent failure)
70
+
71
+ Prefer a `path:` anchor — it cannot self-match and needs no escaping. If you
72
+ must use a `pattern:` regex: backslashes in double-quoted YAML collapse (`\b`
73
+ dies), and the pattern is matched against all tracked content **including the
74
+ scar's own body**, so a broken pattern keeps itself alive by self-reference.
75
+ Run `scar lint` and confirm the scar does NOT appear under partial-rot
76
+ (self-match only).
77
+
78
+ Wrong: `pattern: "\\bwiden\\b"` → Right: `path: src/widen/` (no escaping, no
79
+ self-match).
80
+
81
+ ## Anchors, Severity, Size
82
+
83
+ - `path:` = repo-relative prefix (file or directory). `pattern:` =
84
+ case-insensitive regex over path + new content.
85
+ - Severity: `low | medium | high | critical`.
86
+ - Injection is capped at ~3 scars / ~700 chars each — write tight: 5–15 lines,
87
+ evidence cited inline.
88
+
89
+ ## Verify Before Finishing
90
+
91
+ - `scar lint` must pass.
92
+ - At least one `evidence` receipt (commit / pr / incident / note) — without it
93
+ the scar is challengeable on sight.
@@ -0,0 +1,25 @@
1
+ ---
2
+ # COPY THIS FILE — do not edit the template itself.
3
+ # New scars: write to .scars/candidates/<slug>.md with status: candidate.
4
+ # A human reviewer promotes to .scars/NNNN-<slug>.<type>.md with status: active.
5
+ id: 0 # assigned at promotion (next free NNNN)
6
+ type: deadend # deadend = tried+failed | fence = looks wrong, intentional | landmine = touching A breaks B
7
+ title: One line, searchable, says the constraint
8
+ severity: medium # low | medium | high | critical
9
+ confidence: 0.7 # 0..1 — how sure are we this still holds
10
+ created: 1970-01-01
11
+ authors: ["claude-code"] # add the human reviewer at promotion
12
+ anchors:
13
+ - path: src/module/ # file or directory this protects
14
+ - pattern: "regex" # optional: fires when matching code appears in ANY new/edited file
15
+ evidence:
16
+ - commit: abc1234 # at least one receipt: commit, pr, incident, or note
17
+ expires:
18
+ condition: "what change would make this scar obsolete"
19
+ review_after: 1971-01-01 # force a freshness look even if condition never triggers
20
+ status: template # candidate | active | challenged | archived (template = never parsed)
21
+ ---
22
+
23
+ Body: 5-15 lines of prose. What was tried/observed, why it failed or why the
24
+ weirdness is intentional, and what a future editor must do instead. Write it
25
+ for someone (human or agent) with zero context. Cite the evidence inline.
@@ -649,3 +649,11 @@ def test_harvest_label_date_is_monkeypatchable(harvest_repo, tmp_path, monkeypat
649
649
  main(["harvest", str(harvest_repo), "--label", cid, "keep"])
650
650
  rec = json.loads(labels.read_text(encoding="utf-8").splitlines()[0])
651
651
  assert rec["date"] == "1999-12-31"
652
+
653
+
654
+ def test_agent_skill_prints_body_with_three_types(repo, capsys):
655
+ assert main(["agent", "skill"]) == 0
656
+ out = capsys.readouterr().out
657
+ assert "name: scar-authoring" in out
658
+ for kind in ("deadend", "fence", "landmine"):
659
+ assert kind in out
@@ -0,0 +1,14 @@
1
+ from pathlib import Path
2
+
3
+ ROOT = Path(__file__).parent.parent
4
+
5
+
6
+ def test_agents_md_documents_authoring_skill_access():
7
+ text = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
8
+ assert "scar agent skill" in text
9
+ assert "scar-authoring" in text
10
+
11
+
12
+ def test_readme_documents_plugin_and_cli_install():
13
+ text = (ROOT / "README.md").read_text(encoding="utf-8")
14
+ assert "scar skill install" in text
@@ -65,6 +65,46 @@ def test_clean_repo_yields_empty_sections(tmp_path):
65
65
  assert result["reverts"] == [] and result["deleted_components"] == []
66
66
 
67
67
 
68
+ def test_excludes_scars_dir_from_candidates(tmp_path):
69
+ """Scar #55: harvest must not surface candidates pointing into .scars/.
70
+
71
+ Comment-archaeology greps DO-NOT/load-bearing/intentional prose, and a
72
+ repo's own scar bodies are full of exactly that. Without an exclusion every
73
+ promoted scar self-matches and reads as a fresh candidate (same self-ref
74
+ class as #35). .scars/** must produce zero candidates across all detectors.
75
+ """
76
+ git(tmp_path.parent, "init", "-q", "-b", "main", str(tmp_path))
77
+ git(tmp_path, "config", "user.email", "t@t")
78
+ git(tmp_path, "config", "user.name", "t")
79
+ # real code comment — SHOULD still be harvested
80
+ (tmp_path / "app.py").write_text(
81
+ "# DO NOT remove this init — load-bearing\nx = 1\n")
82
+ # promoted scar bodies full of trigger prose — must NOT be harvested,
83
+ # at the repo root AND nested (e.g. a fixture's own .scars/ tree).
84
+ body = ("---\ntype: deadend\n---\n"
85
+ "This is intentional. DO NOT remove; load-bearing workaround.\n")
86
+ root_scars = tmp_path / ".scars"
87
+ root_scars.mkdir()
88
+ (root_scars / "0001-x.deadend.md").write_text(body)
89
+ nested_scars = tmp_path / "experiments" / "fixture" / ".scars"
90
+ nested_scars.mkdir(parents=True)
91
+ (nested_scars / "0001-y.fence.md").write_text(body)
92
+ git(tmp_path, "add", "-A")
93
+ git(tmp_path, "commit", "-qm", "feat: code + scars")
94
+
95
+ result = harvest(tmp_path)
96
+ locations = [c["location"] for c in result["comments"]]
97
+ assert any(loc.startswith("app.py") for loc in locations), \
98
+ "real code comment should still be harvested"
99
+ all_paths = (
100
+ locations
101
+ + [c["component"] for c in result["deleted_components"]]
102
+ + [c["file"] for c in result["flapping"]]
103
+ )
104
+ assert not any(".scars" in p.split("/") for p in all_paths), \
105
+ "no candidate may point into any .scars/ tree (self-ref noise, #55)"
106
+
107
+
68
108
  # ---------------------------------------------------------------------------
69
109
  # Ranking / scoring tests
70
110
  # ---------------------------------------------------------------------------
@@ -102,3 +102,36 @@ def test_cli_hook_status_reports_each_hook(isolated_settings, capsys):
102
102
  assert "session-notice" in out
103
103
  assert "stop-drafter" in out
104
104
  assert out.count("not installed") == 3
105
+
106
+
107
+ def test_skill_install_dry_run_reports_target_without_writing(tmp_path, monkeypatch):
108
+ monkeypatch.setattr(installer, "CLAUDE_DIR", tmp_path / ".claude")
109
+ monkeypatch.setattr(installer, "SKILLS_DIR", tmp_path / ".claude" / "skills")
110
+ rc = main(["skill", "install", "--dry-run"])
111
+ assert rc == 0
112
+ assert not (tmp_path / ".claude" / "skills" / "scar-authoring").exists()
113
+
114
+
115
+ def test_skill_install_copies_skill_then_uninstall_removes_it(tmp_path, monkeypatch):
116
+ monkeypatch.setattr(installer, "CLAUDE_DIR", tmp_path / ".claude")
117
+ monkeypatch.setattr(installer, "SKILLS_DIR", tmp_path / ".claude" / "skills")
118
+ assert main(["skill", "install"]) == 0
119
+ dest = tmp_path / ".claude" / "skills" / "scar-authoring" / "SKILL.md"
120
+ assert dest.exists() and "scar-authoring" in dest.read_text()
121
+ assert main(["skill", "uninstall"]) == 0
122
+ assert not dest.parent.exists()
123
+
124
+
125
+ def test_skill_reinstall_over_existing_removes_stale_files(tmp_path, monkeypatch):
126
+ # Reinstall must rmtree the existing dir before copy: a stale file left from
127
+ # a prior install must NOT survive a second install. Guards against a refactor
128
+ # to copytree(dirs_exist_ok=True), which would leave orphaned files behind.
129
+ monkeypatch.setattr(installer, "CLAUDE_DIR", tmp_path / ".claude")
130
+ monkeypatch.setattr(installer, "SKILLS_DIR", tmp_path / ".claude" / "skills")
131
+ assert main(["skill", "install"]) == 0
132
+ dest = tmp_path / ".claude" / "skills" / "scar-authoring"
133
+ stale = dest / "STALE-LEFTOVER.txt"
134
+ stale.write_text("orphan from a previous layout", encoding="utf-8")
135
+ assert main(["skill", "install"]) == 0
136
+ assert not stale.exists()
137
+ assert (dest / "SKILL.md").exists()
@@ -90,3 +90,14 @@ def test_scar_draft_writes_candidate_only(tmp_path):
90
90
  assert data["status"] == "candidate"
91
91
  assert data["candidate"].startswith(".scars/candidates/")
92
92
  assert not list((tmp_path / ".scars").glob("*.deadend.md"))
93
+
94
+
95
+ from scar.mcp import TOOLS
96
+
97
+
98
+ def test_scar_draft_description_carries_authoring_digest():
99
+ draft = next(t for t in TOOLS if t["name"] == "scar_draft")
100
+ desc = draft["description"]
101
+ for kind in ("deadend", "fence", "landmine"):
102
+ assert kind in desc
103
+ assert "candidate" in desc
@@ -0,0 +1,57 @@
1
+ """Claude Code plugin manifest validity + skill mirror drift guard."""
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+
7
+ ROOT = Path(__file__).parent.parent
8
+
9
+
10
+ def test_plugin_version_matches_pyproject():
11
+ # plugin.json carries its own version; release-please bumps only pyproject,
12
+ # so the two silently drift unless guarded. Regex (not tomllib) keeps this
13
+ # stdlib-only and Python 3.10-safe.
14
+ pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
15
+ match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', pyproject)
16
+ assert match, "version not found in pyproject.toml"
17
+ py_version = match.group(1)
18
+ plugin = json.loads((ROOT / "plugin" / "plugin.json").read_text(encoding="utf-8"))
19
+ assert plugin["version"] == py_version, (
20
+ f"plugin.json version {plugin['version']!r} != pyproject {py_version!r}")
21
+
22
+
23
+ def test_plugin_manifest_is_valid_and_declares_three_hook_events():
24
+ manifest = json.loads((ROOT / "plugin" / "plugin.json").read_text())
25
+ assert manifest["name"] == "scar"
26
+ assert set(manifest["hooks"]) == {"PreToolUse", "SessionStart", "Stop"}
27
+
28
+
29
+ def test_marketplace_manifest_lists_the_plugin():
30
+ market = json.loads((ROOT / ".claude-plugin" / "marketplace.json").read_text())
31
+ names = [p["name"] for p in market["plugins"]]
32
+ assert "scar" in names
33
+
34
+
35
+ def test_plugin_skill_mirror_is_byte_identical_to_canonical():
36
+ canonical = (ROOT / "src" / "scar" / "skills" / "scar-authoring" / "SKILL.md").read_bytes()
37
+ mirror = (ROOT / "plugin" / "skills" / "scar-authoring" / "SKILL.md").read_bytes()
38
+ assert mirror == canonical
39
+ can_tmpl = (ROOT / "src" / "scar" / "skills" / "scar-authoring" / "assets" / "template.md").read_bytes()
40
+ mir_tmpl = (ROOT / "plugin" / "skills" / "scar-authoring" / "assets" / "template.md").read_bytes()
41
+ assert mir_tmpl == can_tmpl
42
+
43
+
44
+ def test_plugin_hooks_use_bare_path_resolved_scar_command():
45
+ manifest = json.loads((ROOT / "plugin" / "plugin.json").read_text())
46
+ expected = {
47
+ "PreToolUse": "scar hook precheck",
48
+ "SessionStart": "scar hook session-notice",
49
+ "Stop": "scar hook stop-drafter",
50
+ }
51
+ for event, command in expected.items():
52
+ commands = [h["command"]
53
+ for group in manifest["hooks"][event]
54
+ for h in group["hooks"]]
55
+ assert command in commands, f"{event} must use bare '{command}'"
56
+ for c in commands:
57
+ assert not c.startswith("/"), f"{event} command must be PATH-resolved, not absolute: {c}"
@@ -0,0 +1,36 @@
1
+ """The packaged scar-authoring skill: canonical content + drift guards."""
2
+
3
+ from pathlib import Path
4
+
5
+ ROOT = Path(__file__).parent.parent
6
+ SKILL = ROOT / "src" / "scar" / "skills" / "scar-authoring"
7
+
8
+
9
+ def _frontmatter(text: str) -> dict:
10
+ assert text.startswith("---\n"), "SKILL.md must open with YAML frontmatter"
11
+ end = text.index("\n---", 4)
12
+ block = text[4:end]
13
+ fields = {}
14
+ for line in block.splitlines():
15
+ if line and not line[0].isspace() and ":" in line:
16
+ key = line.split(":", 1)[0].strip()
17
+ fields[key] = True
18
+ return fields
19
+
20
+
21
+ def test_skill_frontmatter_has_name_and_description():
22
+ text = (SKILL / "SKILL.md").read_text(encoding="utf-8")
23
+ fields = _frontmatter(text)
24
+ assert "name" in fields and "description" in fields
25
+
26
+
27
+ def test_skill_covers_all_three_types():
28
+ text = (SKILL / "SKILL.md").read_text(encoding="utf-8")
29
+ for kind in ("deadend", "fence", "landmine"):
30
+ assert kind in text
31
+
32
+
33
+ def test_bundled_template_is_byte_identical_to_dot_scars():
34
+ canonical = (ROOT / ".scars" / "template.md").read_bytes()
35
+ bundled = (SKILL / "assets" / "template.md").read_bytes()
36
+ assert bundled == canonical
@@ -79,7 +79,7 @@ wheels = [
79
79
 
80
80
  [[package]]
81
81
  name = "scar-cli"
82
- version = "0.4.0"
82
+ version = "0.6.0"
83
83
  source = { editable = "." }
84
84
 
85
85
  [package.dev-dependencies]
@@ -1,3 +0,0 @@
1
- 2026-06-12 false trigger: meta-session — we tuned the revert-language detector itself, so assistant prose ('revert language', 'reverting' in test fixtures/PR text) matched REVERT_RE; nothing abandoned (tool_errors were expected CLI probes/rejections). First post-tune FP pattern: self-referential sessions about the drafter trip the drafter.
2
- 2026-06-13 false trigger: tool_errors were external API hiccups (pypistats rate-limit/404, bq schema field); no code approach tried-and-abandoned this session (design-only work)
3
- 2026-06-12 false trigger: orphan-detection impl — 'revert' is feature-domain ('revert case' reverse hint = anchors-live-again) + a planned AC#1 refactor swapping batch-1 copied anchor logic for a shared match.py primitive; replacement was design-mandated, not a deadend discovered by failure
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes