scar-cli 0.5.0__tar.gz → 0.6.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.6.0/.claude-plugin/marketplace.json +11 -0
- scar_cli-0.6.0/.scars/candidates/fp-log.txt +6 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/AGENTS.md +17 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/CHANGELOG.md +12 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/PKG-INFO +11 -1
- {scar_cli-0.5.0 → scar_cli-0.6.0}/README.md +10 -0
- scar_cli-0.6.0/plugin/plugin.json +29 -0
- scar_cli-0.6.0/plugin/skills/scar-authoring/SKILL.md +93 -0
- scar_cli-0.6.0/plugin/skills/scar-authoring/assets/template.md +25 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/pyproject.toml +1 -1
- {scar_cli-0.5.0 → scar_cli-0.6.0}/src/scar/agent.py +7 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/src/scar/cli.py +21 -1
- {scar_cli-0.5.0 → scar_cli-0.6.0}/src/scar/harvest.py +33 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/src/scar/installer.py +43 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/src/scar/mcp.py +8 -1
- scar_cli-0.6.0/src/scar/skills/scar-authoring/SKILL.md +93 -0
- scar_cli-0.6.0/src/scar/skills/scar-authoring/assets/template.md +25 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/tests/test_cli.py +8 -0
- scar_cli-0.6.0/tests/test_docs.py +14 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/tests/test_harvest.py +40 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/tests/test_installer.py +33 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/tests/test_mcp.py +11 -0
- scar_cli-0.6.0/tests/test_plugin.py +43 -0
- scar_cli-0.6.0/tests/test_skill.py +36 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/uv.lock +1 -1
- scar_cli-0.5.0/.scars/candidates/fp-log.txt +0 -3
- {scar_cli-0.5.0 → scar_cli-0.6.0}/.github/workflows/ci.yml +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/.github/workflows/pr-validation.yml +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/.github/workflows/release.yml +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/.gitignore +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/.scars/0005-history-rewrite-orphans-commit-evidence.landmine.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/.scars/0006-yaml-pattern-anchor-over-escaping.landmine.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/.scars/README.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/.scars/template.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/CONTRIBUTING.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/IDEA.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/LICENSE +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/ROADMAP.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/SCAR-FORMAT.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/SPEC.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/STRESS-TEST.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/anchor-survival/PROTOCOL.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/anchor-survival/RESULTS.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/anchor-survival/long_replay.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/anchor-survival/replay.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/auto-authorship/FINDINGS.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/auto-authorship/PROTOCOL.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/fence-honor/.gitignore +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/fence-honor/PROTOCOL.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/fence-honor/RESULTS.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/fence-honor/fixture/README.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/fence-honor/fixture/payments/retry.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/fence-honor/fixture/reports/export.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/fence-honor/fixture/services/sessions.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/fence-honor/grade.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/harvest/PROTOCOL.md +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/experiments/harvest/harvest.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/hook/scar-hooks.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/src/scar/__init__.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/src/scar/evidence.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/src/scar/hooks.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/src/scar/lint.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/src/scar/match.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/src/scar/model.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/src/scar/orphan.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/src/scar/render.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/src/scar/store.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/tests/test_evidence.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/tests/test_hooks.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/tests/test_lifecycle.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/tests/test_lint.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/tests/test_match.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/tests/test_model.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/tests/test_orphan.py +0 -0
- {scar_cli-0.5.0 → scar_cli-0.6.0}/tests/test_store.py +0 -0
|
@@ -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,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.0](https://github.com/Daily-Nerd/Scar/compare/v0.5.0...v0.6.0) (2026-06-26)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **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))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **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)
|
|
14
|
+
|
|
3
15
|
## [0.5.0](https://github.com/Daily-Nerd/Scar/compare/v0.4.0...v0.5.0) (2026-06-13)
|
|
4
16
|
|
|
5
17
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scar-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
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,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scar",
|
|
3
|
+
"version": "0.5.0",
|
|
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
|
+
{ "type": "command", "command": "scar hook precheck", "timeout": 10 }
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"SessionStart": [
|
|
15
|
+
{
|
|
16
|
+
"hooks": [
|
|
17
|
+
{ "type": "command", "command": "scar hook session-notice", "timeout": 10 }
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"Stop": [
|
|
22
|
+
{
|
|
23
|
+
"hooks": [
|
|
24
|
+
{ "type": "command", "command": "scar hook stop-drafter", "timeout": 15 }
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -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.
|
|
@@ -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":
|
|
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,43 @@
|
|
|
1
|
+
"""Claude Code plugin manifest validity + skill mirror drift guard."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
ROOT = Path(__file__).parent.parent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_plugin_manifest_is_valid_and_declares_three_hook_events():
|
|
10
|
+
manifest = json.loads((ROOT / "plugin" / "plugin.json").read_text())
|
|
11
|
+
assert manifest["name"] == "scar"
|
|
12
|
+
assert set(manifest["hooks"]) == {"PreToolUse", "SessionStart", "Stop"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_marketplace_manifest_lists_the_plugin():
|
|
16
|
+
market = json.loads((ROOT / ".claude-plugin" / "marketplace.json").read_text())
|
|
17
|
+
names = [p["name"] for p in market["plugins"]]
|
|
18
|
+
assert "scar" in names
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_plugin_skill_mirror_is_byte_identical_to_canonical():
|
|
22
|
+
canonical = (ROOT / "src" / "scar" / "skills" / "scar-authoring" / "SKILL.md").read_bytes()
|
|
23
|
+
mirror = (ROOT / "plugin" / "skills" / "scar-authoring" / "SKILL.md").read_bytes()
|
|
24
|
+
assert mirror == canonical
|
|
25
|
+
can_tmpl = (ROOT / "src" / "scar" / "skills" / "scar-authoring" / "assets" / "template.md").read_bytes()
|
|
26
|
+
mir_tmpl = (ROOT / "plugin" / "skills" / "scar-authoring" / "assets" / "template.md").read_bytes()
|
|
27
|
+
assert mir_tmpl == can_tmpl
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_plugin_hooks_use_bare_path_resolved_scar_command():
|
|
31
|
+
manifest = json.loads((ROOT / "plugin" / "plugin.json").read_text())
|
|
32
|
+
expected = {
|
|
33
|
+
"PreToolUse": "scar hook precheck",
|
|
34
|
+
"SessionStart": "scar hook session-notice",
|
|
35
|
+
"Stop": "scar hook stop-drafter",
|
|
36
|
+
}
|
|
37
|
+
for event, command in expected.items():
|
|
38
|
+
commands = [h["command"]
|
|
39
|
+
for group in manifest["hooks"][event]
|
|
40
|
+
for h in group["hooks"]]
|
|
41
|
+
assert command in commands, f"{event} must use bare '{command}'"
|
|
42
|
+
for c in commands:
|
|
43
|
+
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
|
|
@@ -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
|
{scar_cli-0.5.0 → scar_cli-0.6.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md
RENAMED
|
File without changes
|
{scar_cli-0.5.0 → scar_cli-0.6.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md
RENAMED
|
File without changes
|
{scar_cli-0.5.0 → scar_cli-0.6.0}/.scars/0005-history-rewrite-orphans-commit-evidence.landmine.md
RENAMED
|
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
|
|
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
|