scar-cli 0.1.1__py3-none-any.whl
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/__init__.py +0 -0
- scar/cli.py +224 -0
- scar/harvest.py +111 -0
- scar/hooks.py +206 -0
- scar/lint.py +49 -0
- scar/match.py +51 -0
- scar/model.py +112 -0
- scar/store.py +156 -0
- scar_cli-0.1.1.dist-info/METADATA +110 -0
- scar_cli-0.1.1.dist-info/RECORD +13 -0
- scar_cli-0.1.1.dist-info/WHEEL +4 -0
- scar_cli-0.1.1.dist-info/entry_points.txt +2 -0
- scar_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
scar/__init__.py
ADDED
|
File without changes
|
scar/cli.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""scar — the CLI. Thin argparse layer; all logic lives in the library.
|
|
2
|
+
|
|
3
|
+
Adding a command = one _cmd_* function + one subparser block. Commands that
|
|
4
|
+
read scars resolve the store once via _require_store; commands return exit
|
|
5
|
+
codes (0 ok, 1 user-visible failure) and never raise to the shell.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .lint import lint_text
|
|
16
|
+
from .match import rank_for_edit
|
|
17
|
+
from .model import ParseError, parse_scar_text
|
|
18
|
+
from .store import ScarStore, init_scars
|
|
19
|
+
|
|
20
|
+
MAX_BODY_CHARS = 700 # ~120 words — the fatigue budget is a format guarantee
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _require_store(start: Path | None = None) -> ScarStore | None:
|
|
24
|
+
store = ScarStore.discover(start or Path.cwd())
|
|
25
|
+
if store is None:
|
|
26
|
+
print("no .scars/ directory found (walked up to repo root). Run: scar init")
|
|
27
|
+
return store
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _cmd_init(_args) -> int:
|
|
31
|
+
scars = init_scars(Path.cwd())
|
|
32
|
+
print(f"initialized {scars} (README.md, template.md, candidates/)")
|
|
33
|
+
print("convention: new scars -> candidates/, humans promote via `scar promote`")
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cmd_lint(_args) -> int:
|
|
38
|
+
store = _require_store()
|
|
39
|
+
if store is None:
|
|
40
|
+
return 1
|
|
41
|
+
failed = 0
|
|
42
|
+
files = store._scar_files() + store.candidates()
|
|
43
|
+
for f in files:
|
|
44
|
+
findings = lint_text(f.read_text(encoding="utf-8"))
|
|
45
|
+
for finding in findings:
|
|
46
|
+
print(f"{f.relative_to(store.root)}: {finding}")
|
|
47
|
+
if any(fi.level == "error" for fi in findings):
|
|
48
|
+
failed += 1
|
|
49
|
+
print(f"lint: {len(files)} file(s), {failed} with errors")
|
|
50
|
+
return 1 if failed else 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cmd_status(_args) -> int:
|
|
54
|
+
store = _require_store()
|
|
55
|
+
if store is None:
|
|
56
|
+
return 1
|
|
57
|
+
active, broken, cands = store.active(), store.broken(), store.candidates()
|
|
58
|
+
print(f"{store.scars_dir}: {len(active)} active, {len(cands)} candidate(s) pending review")
|
|
59
|
+
for f, s in active:
|
|
60
|
+
print(f" [{s.type} #{s.id} | {s.severity}] {s.title}")
|
|
61
|
+
for c in cands:
|
|
62
|
+
print(f" candidate: {c.name}")
|
|
63
|
+
if broken:
|
|
64
|
+
print(f" WARNING: {len(broken)} unparseable (can NEVER fire): "
|
|
65
|
+
+ ", ".join(b.name for b in broken))
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _cmd_promote(args) -> int:
|
|
70
|
+
store = _require_store()
|
|
71
|
+
if store is None:
|
|
72
|
+
return 1
|
|
73
|
+
matches = [c for c in store.candidates() if args.candidate in c.name]
|
|
74
|
+
if len(matches) != 1:
|
|
75
|
+
opts = ", ".join(c.name for c in store.candidates()) or "(none)"
|
|
76
|
+
print(f"need exactly one candidate matching '{args.candidate}'; have: {opts}")
|
|
77
|
+
return 1
|
|
78
|
+
try:
|
|
79
|
+
new_path = store.promote(matches[0], reviewer=args.reviewer)
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
print(str(exc))
|
|
82
|
+
return 1
|
|
83
|
+
print(f"promoted -> {new_path.relative_to(store.root)}")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cmd_check(args) -> int:
|
|
88
|
+
store = _require_store(Path(args.path).resolve())
|
|
89
|
+
if store is None:
|
|
90
|
+
return 1
|
|
91
|
+
hits = rank_for_edit(store, Path(args.path).resolve(), args.content or "",
|
|
92
|
+
top_k=args.top_k)
|
|
93
|
+
if not hits:
|
|
94
|
+
print(f"no scars anchored to {args.path}")
|
|
95
|
+
return 0
|
|
96
|
+
for s in hits:
|
|
97
|
+
print(f"[{s.type} #{s.id} | severity: {s.severity} | confidence: {s.confidence}] {s.title}")
|
|
98
|
+
print(" " + s.body[:200].replace("\n", "\n "))
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _cmd_why(args) -> int:
|
|
103
|
+
"""History of pain for a path: every scar that anchors it, any status."""
|
|
104
|
+
store = _require_store(Path(args.path).resolve())
|
|
105
|
+
if store is None:
|
|
106
|
+
return 1
|
|
107
|
+
rel = str(Path(args.path).resolve().relative_to(store.root))
|
|
108
|
+
found = 0
|
|
109
|
+
for f in store._scar_files():
|
|
110
|
+
try:
|
|
111
|
+
s = parse_scar_text(f.read_text(encoding="utf-8"))
|
|
112
|
+
except ParseError:
|
|
113
|
+
continue
|
|
114
|
+
# bidirectional: query under an anchor (editing inside protected dir)
|
|
115
|
+
# OR anchor under the query (asking a parent dir for its history)
|
|
116
|
+
if any(rel.startswith(p.rstrip("/")) or p.rstrip("/").startswith(rel)
|
|
117
|
+
for p in s.path_anchors):
|
|
118
|
+
found += 1
|
|
119
|
+
print(f"[{s.status} {s.type} #{s.id}] {s.title} ({f.name})")
|
|
120
|
+
print(" " + s.body[:300].replace("\n", "\n ") + "\n")
|
|
121
|
+
if not found:
|
|
122
|
+
print(f"no recorded pain for {rel}")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _cmd_inject(args) -> int:
|
|
127
|
+
"""Machine mode for hooks: JSON additionalContext or silence."""
|
|
128
|
+
store = ScarStore.discover(Path(args.path).resolve())
|
|
129
|
+
if store is None:
|
|
130
|
+
return 0 # hooks must never fail the edit
|
|
131
|
+
hits = rank_for_edit(store, Path(args.path).resolve(), args.content or "",
|
|
132
|
+
top_k=args.top_k)
|
|
133
|
+
broken = store.broken()
|
|
134
|
+
parts = []
|
|
135
|
+
if hits:
|
|
136
|
+
blocks = [
|
|
137
|
+
f"[{s.type} #{s.id} | severity: {s.severity} | confidence: {s.confidence}] "
|
|
138
|
+
f"{s.title}\n{s.body[:MAX_BODY_CHARS]}" for s in hits
|
|
139
|
+
]
|
|
140
|
+
parts.append(
|
|
141
|
+
"SCAR pre-edit check — negative knowledge anchored to code you are "
|
|
142
|
+
f"about to modify ({len(hits)} match(es)). Honor these unless the "
|
|
143
|
+
"user explicitly overrides; full records in .scars/.\n\n"
|
|
144
|
+
+ "\n\n".join(blocks))
|
|
145
|
+
if broken:
|
|
146
|
+
parts.append(
|
|
147
|
+
f"SCAR warning: {len(broken)} scar file(s) unparseable and can NEVER "
|
|
148
|
+
f"fire: {', '.join(b.name for b in broken)}. Fix frontmatter "
|
|
149
|
+
f"(copy {store.scars_dir}/template.md).")
|
|
150
|
+
if parts:
|
|
151
|
+
print(json.dumps({"hookSpecificOutput": {
|
|
152
|
+
"hookEventName": args.hook_event, "additionalContext": "\n\n".join(parts)}}))
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _cmd_harvest(args) -> int:
|
|
157
|
+
from .harvest import harvest # subprocess-heavy; import only when used
|
|
158
|
+
result = harvest(Path(args.repo).resolve())
|
|
159
|
+
total = sum(len(v) for v in result.values())
|
|
160
|
+
print(f"# Harvest candidates — {Path(args.repo).resolve().name} "
|
|
161
|
+
f"({total} raw; curation required, expect ~13% precision)\n")
|
|
162
|
+
sections = [("reverts", "Revert-shaped commits (deadend candidates)",
|
|
163
|
+
lambda c: f"- `{c['commit']}` {c['date']} — {c['subject']}"),
|
|
164
|
+
("deleted_components", "Components tried then deleted (deadend candidates)",
|
|
165
|
+
lambda c: f"- **{c['component']}** died {c['died']} (`{c['death_commit']}` {c['death_subject']})"),
|
|
166
|
+
("flapping", "Flapping values A->B->A (fence candidates)",
|
|
167
|
+
lambda c: f"- `{c['file']}` **{c['key']}**: {c['sequence']}"),
|
|
168
|
+
("comments", "Comment archaeology (fence candidates)",
|
|
169
|
+
lambda c: f"- `{c['location']}` — {c['text']}")]
|
|
170
|
+
for key, title, fmt in sections:
|
|
171
|
+
print(f"## {title} ({len(result[key])})")
|
|
172
|
+
for c in result[key]:
|
|
173
|
+
print(fmt(c))
|
|
174
|
+
print()
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def main(argv: list[str] | None = None) -> int:
|
|
179
|
+
parser = argparse.ArgumentParser(prog="scar",
|
|
180
|
+
description="version control for negative knowledge")
|
|
181
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
182
|
+
|
|
183
|
+
sub.add_parser("init", help="create .scars/ layout in the current repo")
|
|
184
|
+
sub.add_parser("lint", help="validate every scar and candidate")
|
|
185
|
+
sub.add_parser("status", help="counts, titles, broken-file warnings")
|
|
186
|
+
|
|
187
|
+
p = sub.add_parser("promote", help="review a candidate into an active scar")
|
|
188
|
+
p.add_argument("candidate", help="candidate filename (or unique substring)")
|
|
189
|
+
p.add_argument("--reviewer", default="", help="human reviewer to add to authors")
|
|
190
|
+
|
|
191
|
+
p = sub.add_parser("check", help="scars anchored to a path")
|
|
192
|
+
p.add_argument("path")
|
|
193
|
+
p.add_argument("--content", default="", help="new code to test pattern anchors against")
|
|
194
|
+
p.add_argument("--top-k", type=int, default=10)
|
|
195
|
+
|
|
196
|
+
p = sub.add_parser("why", help="history of pain for a path (any status)")
|
|
197
|
+
p.add_argument("path")
|
|
198
|
+
|
|
199
|
+
p = sub.add_parser("harvest", help="mine git history for candidate scars")
|
|
200
|
+
p.add_argument("repo", nargs="?", default=".")
|
|
201
|
+
|
|
202
|
+
p = sub.add_parser("hook", help="Claude Code hook handlers (payload on stdin)")
|
|
203
|
+
p.add_argument("kind", choices=["precheck", "session-notice", "stop-drafter"])
|
|
204
|
+
|
|
205
|
+
p = sub.add_parser("inject", help="machine mode for hooks: JSON or silence")
|
|
206
|
+
p.add_argument("--path", required=True)
|
|
207
|
+
p.add_argument("--content", default="")
|
|
208
|
+
p.add_argument("--top-k", type=int, default=3)
|
|
209
|
+
p.add_argument("--hook-event", default="PreToolUse")
|
|
210
|
+
|
|
211
|
+
args = parser.parse_args(argv)
|
|
212
|
+
if args.command == "hook":
|
|
213
|
+
from .hooks import HANDLERS # hot path: imports nothing beyond library
|
|
214
|
+
return HANDLERS[args.kind]()
|
|
215
|
+
handler = {
|
|
216
|
+
"init": _cmd_init, "lint": _cmd_lint, "status": _cmd_status,
|
|
217
|
+
"promote": _cmd_promote, "check": _cmd_check, "why": _cmd_why,
|
|
218
|
+
"inject": _cmd_inject, "harvest": _cmd_harvest,
|
|
219
|
+
}[args.command]
|
|
220
|
+
return handler(args)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
sys.exit(main())
|
scar/harvest.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Harvest: mine git history for negative-knowledge candidates.
|
|
2
|
+
|
|
3
|
+
Port of the gate-0.1 prototype (experiments/harvest/), returning structured
|
|
4
|
+
data instead of printing. Six heuristics; each returns CANDIDATES that a
|
|
5
|
+
human curates — raw precision measured at ~13% on real history, so the CLI
|
|
6
|
+
layer must always present these as "needs confirmation", never as scars.
|
|
7
|
+
|
|
8
|
+
NB (scar 0001): no \\b in git grep patterns, no speculative extension globs.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
REVERT_RE = re.compile(
|
|
19
|
+
r"revert|rollback|roll back|downgrade|back to|undo|set back|"
|
|
20
|
+
r"retire|disable again", re.IGNORECASE)
|
|
21
|
+
TRACKED_KEYS = ("image", "replicas", "tag", "version", "cpu", "memory")
|
|
22
|
+
COMMENT_RE = (r"DO NOT|DON'T|do not (remove|change|touch)|HACK|XXX|"
|
|
23
|
+
r"load.?bearing|intentional|must (stay|remain)|workaround")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _git(repo: Path, *args: str) -> str:
|
|
27
|
+
return subprocess.run(["git", "-C", str(repo), *args],
|
|
28
|
+
capture_output=True, text=True).stdout
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _commits(repo: Path):
|
|
32
|
+
out = _git(repo, "log", "--format=%H\x01%ad\x01%s", "--date=short")
|
|
33
|
+
for line in out.splitlines():
|
|
34
|
+
h, date, subj = line.split("\x01", 2)
|
|
35
|
+
yield h, date, subj
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _reverts(repo: Path) -> list[dict]:
|
|
39
|
+
return [{"commit": h[:8], "date": d, "subject": s}
|
|
40
|
+
for h, d, s in _commits(repo) if REVERT_RE.search(s)]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _deleted_components(repo: Path) -> list[dict]:
|
|
44
|
+
out = _git(repo, "log", "--diff-filter=D", "--name-only",
|
|
45
|
+
"--format=\x02%h\x01%ad\x01%s", "--date=short")
|
|
46
|
+
comp_del: dict[str, list] = defaultdict(list)
|
|
47
|
+
commit: list[str] = []
|
|
48
|
+
for line in out.splitlines():
|
|
49
|
+
if line.startswith("\x02"):
|
|
50
|
+
commit = line[1:].split("\x01")
|
|
51
|
+
elif line and "/" in line:
|
|
52
|
+
comp_del["/".join(line.split("/")[:2])].append(commit)
|
|
53
|
+
results = []
|
|
54
|
+
for comp, dels in comp_del.items():
|
|
55
|
+
if (repo / comp).exists():
|
|
56
|
+
continue
|
|
57
|
+
last = dels[0]
|
|
58
|
+
results.append({"component": comp, "died": last[1],
|
|
59
|
+
"death_commit": last[0], "death_subject": last[2],
|
|
60
|
+
"files_deleted": len(dels)})
|
|
61
|
+
return sorted(results, key=lambda r: r["died"], reverse=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _flapping(repo: Path) -> list[dict]:
|
|
65
|
+
key_re = re.compile(r"^[+-]\s*(" + "|".join(TRACKED_KEYS) + r"):\s*(.+)$")
|
|
66
|
+
out = _git(repo, "log", "-p", "--reverse", "--format=\x02%h\x01%ad\x01%s",
|
|
67
|
+
"--date=short")
|
|
68
|
+
history: dict[tuple, list] = defaultdict(list)
|
|
69
|
+
commit: list[str] = []
|
|
70
|
+
fname = ""
|
|
71
|
+
for line in out.splitlines():
|
|
72
|
+
if line.startswith("\x02"):
|
|
73
|
+
commit = line[1:].split("\x01")
|
|
74
|
+
elif line.startswith("+++ b/"):
|
|
75
|
+
fname = line[6:]
|
|
76
|
+
elif line.startswith("+") and fname:
|
|
77
|
+
m = key_re.match(line)
|
|
78
|
+
if m:
|
|
79
|
+
history[(fname, m.group(1))].append(
|
|
80
|
+
(m.group(2).strip(), commit[0], commit[1]))
|
|
81
|
+
flaps = []
|
|
82
|
+
for (fname, key), seq in history.items():
|
|
83
|
+
values = [v for v, _, _ in seq]
|
|
84
|
+
for i in range(len(values) - 2):
|
|
85
|
+
if values[i] == values[i + 2] and values[i] != values[i + 1]:
|
|
86
|
+
flaps.append({"file": fname, "key": key,
|
|
87
|
+
"sequence": f"{values[i]} -> {values[i+1]} -> {values[i+2]}",
|
|
88
|
+
"commits": [seq[i][1], seq[i+1][1], seq[i+2][1]]})
|
|
89
|
+
break
|
|
90
|
+
return flaps
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _comment_archaeology(repo: Path) -> list[dict]:
|
|
94
|
+
out = _git(repo, "grep", "-nIE", COMMENT_RE) # -I skips binaries; no \b (scar 0001)
|
|
95
|
+
hits = []
|
|
96
|
+
for line in out.splitlines():
|
|
97
|
+
parts = line.split(":", 2)
|
|
98
|
+
if len(parts) == 3:
|
|
99
|
+
hits.append({"location": f"{parts[0]}:{parts[1]}",
|
|
100
|
+
"text": parts[2].strip()[:120]})
|
|
101
|
+
return hits
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def harvest(repo: Path) -> dict[str, list[dict]]:
|
|
105
|
+
repo = Path(repo)
|
|
106
|
+
return {
|
|
107
|
+
"reverts": _reverts(repo),
|
|
108
|
+
"deleted_components": _deleted_components(repo),
|
|
109
|
+
"flapping": _flapping(repo),
|
|
110
|
+
"comments": _comment_archaeology(repo),
|
|
111
|
+
}
|
scar/hooks.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Claude Code hook handlers — harness payload on stdin, hook JSON on stdout.
|
|
2
|
+
|
|
3
|
+
One library code path replaces three standalone scripts (which drifted within
|
|
4
|
+
two days of birth — gate 0.4 findings). Contract per handler: silent no-op on
|
|
5
|
+
any problem; a hook must NEVER fail or delay the user's action.
|
|
6
|
+
|
|
7
|
+
State (drafter markers, firing log) lives in ~/.claude/scar-state/, overridable
|
|
8
|
+
via SCAR_STATE_DIR for tests.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .match import rank_for_edit
|
|
21
|
+
from .store import ScarStore
|
|
22
|
+
|
|
23
|
+
MAX_BODY_CHARS = 700
|
|
24
|
+
|
|
25
|
+
REVERT_RE = re.compile(
|
|
26
|
+
r"revert(ing|ed)?\b|roll(ing|ed)? back|undo(ing)? (the|that|my)|"
|
|
27
|
+
r"abandon(ing|ed)? (the|this|that|this approach)|scrap(ping)? (the|this|that)|"
|
|
28
|
+
r"back to the (original|previous)", re.IGNORECASE)
|
|
29
|
+
USER_NEG_RE = re.compile(
|
|
30
|
+
r"didn'?t work|doesn'?t work|still (broken|failing|not working)|"
|
|
31
|
+
r"that broke|go back|revert|undo that|no funciona|sigue (roto|fallando)|"
|
|
32
|
+
r"volv[ée] al?", re.IGNORECASE)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _state_dir() -> Path:
|
|
36
|
+
return Path(os.environ.get("SCAR_STATE_DIR",
|
|
37
|
+
str(Path.home() / ".claude" / "scar-state")))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read_payload() -> dict | None:
|
|
41
|
+
"""Hook payload from stdin; None means 'tty — hint printed, do nothing'."""
|
|
42
|
+
if sys.stdin.isatty():
|
|
43
|
+
# interactive invocation: never hang waiting for a payload that
|
|
44
|
+
# only a hook harness would pipe in, and never mix the human hint
|
|
45
|
+
# with machine JSON (both found live)
|
|
46
|
+
print("scar hook expects a hook payload on stdin (it is run by the "
|
|
47
|
+
"agent harness, not by hand). Try: echo '{}' | scar hook <kind>")
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
return json.load(sys.stdin)
|
|
51
|
+
except (json.JSONDecodeError, OSError, ValueError):
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _emit(event: str, context: str) -> None:
|
|
56
|
+
print(json.dumps({"hookSpecificOutput": {
|
|
57
|
+
"hookEventName": event, "additionalContext": context}}))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def precheck() -> int:
|
|
61
|
+
payload = _read_payload()
|
|
62
|
+
if payload is None:
|
|
63
|
+
return 0
|
|
64
|
+
tool_input = payload.get("tool_input", {})
|
|
65
|
+
target = tool_input.get("file_path") or tool_input.get("notebook_path")
|
|
66
|
+
if not target:
|
|
67
|
+
return 0
|
|
68
|
+
store = ScarStore.discover(Path(target))
|
|
69
|
+
if store is None:
|
|
70
|
+
return 0
|
|
71
|
+
new_content = " ".join(str(tool_input.get(k, ""))
|
|
72
|
+
for k in ("content", "new_string", "new_source"))
|
|
73
|
+
hits = rank_for_edit(store, Path(target), new_content)
|
|
74
|
+
broken = store.broken()
|
|
75
|
+
parts = []
|
|
76
|
+
if hits:
|
|
77
|
+
blocks = [f"[{s.type} #{s.id} | severity: {s.severity} | confidence: "
|
|
78
|
+
f"{s.confidence}] {s.title}\n{s.body[:MAX_BODY_CHARS]}" for s in hits]
|
|
79
|
+
parts.append(
|
|
80
|
+
"SCAR pre-edit check — negative knowledge anchored to code you are "
|
|
81
|
+
f"about to modify ({len(hits)} match(es)). Honor these unless the "
|
|
82
|
+
"user explicitly overrides; full records in .scars/.\n\n" + "\n\n".join(blocks))
|
|
83
|
+
if broken:
|
|
84
|
+
parts.append(
|
|
85
|
+
f"SCAR warning: {len(broken)} scar file(s) unparseable and can NEVER "
|
|
86
|
+
f"fire: {', '.join(b.name for b in broken)}. Their knowledge is "
|
|
87
|
+
f"silently dead. Fix the frontmatter (copy {store.scars_dir}/template.md).")
|
|
88
|
+
if parts:
|
|
89
|
+
_emit("PreToolUse", "\n\n".join(parts))
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def session_notice() -> int:
|
|
94
|
+
payload = _read_payload()
|
|
95
|
+
if payload is None:
|
|
96
|
+
return 0
|
|
97
|
+
cwd = Path(payload.get("cwd") or os.getcwd())
|
|
98
|
+
store = ScarStore.discover(cwd)
|
|
99
|
+
if store is None:
|
|
100
|
+
return 0
|
|
101
|
+
active, broken, cands = store.active(), store.broken(), store.candidates()
|
|
102
|
+
state = (f"{len(active)} active scar(s)" if active
|
|
103
|
+
else "0 scars yet — the convention is live, be the first to record one")
|
|
104
|
+
pending = f", {len(cands)} candidate(s) pending review" if cands else ""
|
|
105
|
+
warn = (f" WARNING: {len(broken)} unparseable scar file(s) that can never "
|
|
106
|
+
f"fire: {', '.join(b.name for b in broken)} — fix their frontmatter."
|
|
107
|
+
if broken else "")
|
|
108
|
+
_emit("SessionStart", (
|
|
109
|
+
f"SCAR: this repository records negative knowledge in {store.scars_dir} "
|
|
110
|
+
f"({state}{pending}).{warn} Relevant scars are injected automatically "
|
|
111
|
+
"before you edit anchored code — honor them unless the user overrides. "
|
|
112
|
+
"Reciprocal duty: when you abandon an approach (deadend), keep "
|
|
113
|
+
"intentional-looking weirdness (fence), or discover non-obvious coupling "
|
|
114
|
+
f"(landmine), record it. Contract: COPY {store.scars_dir}/template.md "
|
|
115
|
+
"(YAML frontmatter is mandatory — scars without it never fire), write to "
|
|
116
|
+
f"{store.scars_dir}/candidates/<slug>.md with status: candidate, and "
|
|
117
|
+
"never write directly into .scars/ — only a human reviewer promotes."))
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _analyze_transcript(path: str) -> dict | None:
|
|
122
|
+
revert_hits = user_neg = errors = 0
|
|
123
|
+
edits_per_file: dict[str, int] = {}
|
|
124
|
+
try:
|
|
125
|
+
with open(path, encoding="utf-8") as fh:
|
|
126
|
+
lines = fh.readlines()[-4000:]
|
|
127
|
+
except OSError:
|
|
128
|
+
return None
|
|
129
|
+
for raw in lines:
|
|
130
|
+
try:
|
|
131
|
+
entry = json.loads(raw)
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
continue
|
|
134
|
+
etype = entry.get("type", "")
|
|
135
|
+
content = (entry.get("message") or {}).get("content")
|
|
136
|
+
blocks = ([{"type": "text", "text": content}] if isinstance(content, str)
|
|
137
|
+
else content if isinstance(content, list) else [])
|
|
138
|
+
for b in blocks:
|
|
139
|
+
if not isinstance(b, dict):
|
|
140
|
+
continue
|
|
141
|
+
if b.get("type") == "text":
|
|
142
|
+
if etype == "assistant" and REVERT_RE.search(b.get("text", "")):
|
|
143
|
+
revert_hits += 1
|
|
144
|
+
if etype == "user" and USER_NEG_RE.search(b.get("text", "")):
|
|
145
|
+
user_neg += 1
|
|
146
|
+
elif b.get("type") == "tool_use" and b.get("name") in ("Edit", "Write", "MultiEdit"):
|
|
147
|
+
fp = (b.get("input") or {}).get("file_path", "")
|
|
148
|
+
if fp:
|
|
149
|
+
edits_per_file[fp] = edits_per_file.get(fp, 0) + 1
|
|
150
|
+
elif b.get("type") == "tool_result" and b.get("is_error"):
|
|
151
|
+
errors += 1
|
|
152
|
+
thrash = max(edits_per_file.values(), default=0)
|
|
153
|
+
signals = {"revert_language": revert_hits, "user_corrections": user_neg,
|
|
154
|
+
"tool_errors": errors, "max_edits_one_file": thrash}
|
|
155
|
+
# Trigger on assistant revert/abandon language only. Field data (gate 0.4,
|
|
156
|
+
# 6 firings): revert_language >= 1 was present in both true positives and
|
|
157
|
+
# absent in all four false positives; the user_corrections and
|
|
158
|
+
# tool_errors+thrash paths went 0/4 (normal debugging, policy denials).
|
|
159
|
+
# The other signals stay in the log so future FN evidence can re-add a path.
|
|
160
|
+
triggered = revert_hits >= 1
|
|
161
|
+
return signals if triggered else None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def stop_drafter() -> int:
|
|
165
|
+
payload = _read_payload()
|
|
166
|
+
if payload is None or payload.get("stop_hook_active"):
|
|
167
|
+
return 0
|
|
168
|
+
session = payload.get("session_id", "unknown")
|
|
169
|
+
state = _state_dir()
|
|
170
|
+
marker = state / f"drafted-{session}"
|
|
171
|
+
if marker.exists():
|
|
172
|
+
return 0
|
|
173
|
+
store = ScarStore.discover(Path(payload.get("cwd") or os.getcwd()))
|
|
174
|
+
if store is None:
|
|
175
|
+
return 0
|
|
176
|
+
transcript = payload.get("transcript_path")
|
|
177
|
+
if not transcript:
|
|
178
|
+
return 0
|
|
179
|
+
signals = _analyze_transcript(transcript)
|
|
180
|
+
if not signals:
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
state.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
marker.touch()
|
|
185
|
+
with open(state / "drafter-log.jsonl", "a", encoding="utf-8") as fh:
|
|
186
|
+
fh.write(json.dumps({"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
187
|
+
"repo": str(store.root), "session": session,
|
|
188
|
+
"signals": signals}) + "\n")
|
|
189
|
+
candidates = store.scars_dir / "candidates"
|
|
190
|
+
print(json.dumps({"decision": "block", "reason": (
|
|
191
|
+
"SCAR auto-authorship check: this session shows abandonment signals "
|
|
192
|
+
f"({', '.join(f'{k}={v}' for k, v in signals.items() if v)}). "
|
|
193
|
+
"Before finishing: review the session. "
|
|
194
|
+
f"(1) If an approach was genuinely tried and abandoned, write a short "
|
|
195
|
+
f"candidate scar (<=15 lines) to {candidates}/<slug>.md — COPY the "
|
|
196
|
+
f"format from {store.scars_dir}/template.md (YAML frontmatter "
|
|
197
|
+
"mandatory, status: candidate); it stays a candidate until a human "
|
|
198
|
+
"reviews it. (2) If nothing was actually abandoned (false trigger), "
|
|
199
|
+
f"append one line — date + one-phrase reason — to "
|
|
200
|
+
f"{candidates}/fp-log.txt. Then finish normally. Do exactly one of "
|
|
201
|
+
"the two; do not ask the user.")}))
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
HANDLERS = {"precheck": precheck, "session-notice": session_notice,
|
|
206
|
+
"stop-drafter": stop_drafter}
|
scar/lint.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Lint rules. Each rule exists because the failure already happened once:
|
|
2
|
+
no-frontmatter = daimon 0004 (born unable to fire); the rest harden the
|
|
3
|
+
contract in .scars/README.md.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from .model import SEVERITIES, STATUSES, TYPES, ParseError, parse_scar_text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Finding:
|
|
16
|
+
level: str # "error" | "warning"
|
|
17
|
+
message: str
|
|
18
|
+
|
|
19
|
+
def __str__(self) -> str:
|
|
20
|
+
return f"{self.level}: {self.message}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def lint_text(text: str) -> list[Finding]:
|
|
24
|
+
try:
|
|
25
|
+
scar = parse_scar_text(text)
|
|
26
|
+
except ParseError:
|
|
27
|
+
return [Finding("error", "missing YAML frontmatter — this scar can NEVER fire")]
|
|
28
|
+
|
|
29
|
+
findings = []
|
|
30
|
+
if scar.type not in TYPES:
|
|
31
|
+
findings.append(Finding("error", f"unknown type '{scar.type}' (expected one of {', '.join(TYPES)})"))
|
|
32
|
+
if not scar.title:
|
|
33
|
+
findings.append(Finding("error", "missing title"))
|
|
34
|
+
if scar.severity not in SEVERITIES:
|
|
35
|
+
findings.append(Finding("error", f"invalid severity '{scar.severity}'"))
|
|
36
|
+
if scar.status not in STATUSES:
|
|
37
|
+
findings.append(Finding("error", f"invalid status '{scar.status}'"))
|
|
38
|
+
if not scar.path_anchors and not scar.pattern_anchors:
|
|
39
|
+
findings.append(Finding("error", "no anchors — scar protects nothing"))
|
|
40
|
+
for pat in scar.pattern_anchors:
|
|
41
|
+
try:
|
|
42
|
+
re.compile(pat)
|
|
43
|
+
except re.error as exc:
|
|
44
|
+
findings.append(Finding("error", f"invalid pattern anchor /{pat}/: {exc}"))
|
|
45
|
+
if not scar.evidence:
|
|
46
|
+
findings.append(Finding("warning", "no evidence links — challengeable on sight"))
|
|
47
|
+
if not scar.body:
|
|
48
|
+
findings.append(Finding("warning", "empty body — future readers get no why"))
|
|
49
|
+
return findings
|
scar/match.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Anchor matching and injection ranking.
|
|
2
|
+
|
|
3
|
+
Scoring: anchor_strength x severity_weight x confidence.
|
|
4
|
+
Anchor strengths — content-pattern hit (2.5, a dead end re-appearing in new
|
|
5
|
+
code is the strongest signal) > path prefix (2.0) > pattern on the path (1.5).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .model import Scar
|
|
14
|
+
from .store import ScarStore
|
|
15
|
+
|
|
16
|
+
SEVERITY_WEIGHT = {"low": 1, "medium": 2, "high": 3, "critical": 4}
|
|
17
|
+
DEFAULT_TOP_K = 3
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _anchor_strength(scar: Scar, rel_path: str, new_content: str) -> float:
|
|
21
|
+
score = 0.0
|
|
22
|
+
for p in scar.path_anchors:
|
|
23
|
+
if rel_path.startswith(p.rstrip("/")):
|
|
24
|
+
score = max(score, 2.0)
|
|
25
|
+
for pat in scar.pattern_anchors:
|
|
26
|
+
try:
|
|
27
|
+
rx = re.compile(pat, re.IGNORECASE)
|
|
28
|
+
except re.error:
|
|
29
|
+
continue # lint's job; never crash the read path
|
|
30
|
+
if rx.search(rel_path):
|
|
31
|
+
score = max(score, 1.5)
|
|
32
|
+
if new_content and rx.search(new_content):
|
|
33
|
+
score = max(score, 2.5)
|
|
34
|
+
return score
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def rank_for_edit(store: ScarStore, target: Path, new_content: str,
|
|
38
|
+
top_k: int = DEFAULT_TOP_K) -> list[Scar]:
|
|
39
|
+
"""Top-k active scars relevant to editing `target` with `new_content`."""
|
|
40
|
+
try:
|
|
41
|
+
rel_path = str(Path(target).resolve().relative_to(store.root))
|
|
42
|
+
except ValueError:
|
|
43
|
+
return []
|
|
44
|
+
ranked = []
|
|
45
|
+
for _, scar in store.active():
|
|
46
|
+
strength = _anchor_strength(scar, rel_path, new_content)
|
|
47
|
+
if strength > 0:
|
|
48
|
+
rank = strength * SEVERITY_WEIGHT.get(scar.severity, 2) * scar.confidence
|
|
49
|
+
ranked.append((rank, scar))
|
|
50
|
+
ranked.sort(key=lambda t: -t[0])
|
|
51
|
+
return [s for _, s in ranked[:top_k]]
|
scar/model.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The scar model — the ONE parser/serializer for the whole system.
|
|
2
|
+
|
|
3
|
+
Frontmatter is a constrained YAML subset parsed line-wise on purpose: zero
|
|
4
|
+
dependencies keeps hook startup ~20ms, and the format is ours to constrain.
|
|
5
|
+
Anything this module can't parse, no SCAR tool fires on — so every consumer
|
|
6
|
+
must go through here (hooks included, eventually) to prevent parser drift.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
TYPES = ("deadend", "fence", "landmine")
|
|
15
|
+
SEVERITIES = ("low", "medium", "high", "critical")
|
|
16
|
+
STATUSES = ("candidate", "active", "challenged", "archived", "orphaned", "template")
|
|
17
|
+
|
|
18
|
+
_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ParseError(ValueError):
|
|
22
|
+
"""Text is not a scar (no/malformed frontmatter)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Scar:
|
|
27
|
+
type: str = "deadend"
|
|
28
|
+
title: str = ""
|
|
29
|
+
id: int | None = None
|
|
30
|
+
severity: str = "medium"
|
|
31
|
+
confidence: float = 0.5
|
|
32
|
+
created: str = ""
|
|
33
|
+
authors: list[str] = field(default_factory=list)
|
|
34
|
+
path_anchors: list[str] = field(default_factory=list)
|
|
35
|
+
pattern_anchors: list[str] = field(default_factory=list)
|
|
36
|
+
evidence: list[str] = field(default_factory=list)
|
|
37
|
+
expires_condition: str = ""
|
|
38
|
+
review_after: str = ""
|
|
39
|
+
status: str = "active"
|
|
40
|
+
body: str = ""
|
|
41
|
+
|
|
42
|
+
def to_text(self) -> str:
|
|
43
|
+
lines = ["---"]
|
|
44
|
+
if self.id is not None:
|
|
45
|
+
lines.append(f"id: {self.id}")
|
|
46
|
+
lines += [f"type: {self.type}", f"title: {self.title}",
|
|
47
|
+
f"severity: {self.severity}", f"confidence: {self.confidence}"]
|
|
48
|
+
if self.created:
|
|
49
|
+
lines.append(f"created: {self.created}")
|
|
50
|
+
if self.authors:
|
|
51
|
+
lines.append("authors: [" + ", ".join(f'"{a}"' for a in self.authors) + "]")
|
|
52
|
+
lines.append("anchors:")
|
|
53
|
+
lines += [f" - path: {p}" for p in self.path_anchors]
|
|
54
|
+
lines += [f' - pattern: "{p}"' for p in self.pattern_anchors]
|
|
55
|
+
if self.evidence:
|
|
56
|
+
lines.append("evidence:")
|
|
57
|
+
lines += [f" - {e}" for e in self.evidence]
|
|
58
|
+
if self.expires_condition or self.review_after:
|
|
59
|
+
lines.append("expires:")
|
|
60
|
+
if self.expires_condition:
|
|
61
|
+
lines.append(f' condition: "{self.expires_condition}"')
|
|
62
|
+
if self.review_after:
|
|
63
|
+
lines.append(f" review_after: {self.review_after}")
|
|
64
|
+
lines += [f"status: {self.status}", "---", "", self.body.strip(), ""]
|
|
65
|
+
return "\n".join(lines)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _field(front: str, name: str, default: str = "") -> str:
|
|
69
|
+
m = re.search(rf"^\s*{name}:\s*(.+?)\s*$", front, re.MULTILINE)
|
|
70
|
+
return m.group(1) if m else default
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_scar_text(text: str) -> Scar:
|
|
74
|
+
m = _FRONTMATTER_RE.match(text)
|
|
75
|
+
if not m:
|
|
76
|
+
raise ParseError("no YAML frontmatter (--- block) — scar can never fire")
|
|
77
|
+
front, body = m.groups()
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
confidence = float(_field(front, "confidence", "0.5"))
|
|
81
|
+
except ValueError:
|
|
82
|
+
confidence = 0.5
|
|
83
|
+
raw_id = _field(front, "id")
|
|
84
|
+
try:
|
|
85
|
+
scar_id: int | None = int(raw_id) if raw_id else None
|
|
86
|
+
except ValueError:
|
|
87
|
+
scar_id = None
|
|
88
|
+
|
|
89
|
+
authors_raw = _field(front, "authors")
|
|
90
|
+
authors = [a.strip().strip('"').strip("'")
|
|
91
|
+
for a in authors_raw.strip("[]").split(",") if a.strip()] if authors_raw else []
|
|
92
|
+
|
|
93
|
+
evidence = [f"{m1.group(1)}: {m1.group(2).strip().strip('\"')}" for m1 in re.finditer(
|
|
94
|
+
r"^\s*-\s*(commit|pr|incident|note):\s*(.+)\s*$", front, re.MULTILINE)]
|
|
95
|
+
|
|
96
|
+
return Scar(
|
|
97
|
+
type=_field(front, "type", "deadend"),
|
|
98
|
+
title=_field(front, "title"),
|
|
99
|
+
id=scar_id,
|
|
100
|
+
severity=_field(front, "severity", "medium"),
|
|
101
|
+
confidence=confidence,
|
|
102
|
+
created=_field(front, "created"),
|
|
103
|
+
authors=authors,
|
|
104
|
+
path_anchors=re.findall(r"^\s*-\s*path:\s*(\S+)\s*$", front, re.MULTILINE),
|
|
105
|
+
pattern_anchors=[p.strip().strip('"')
|
|
106
|
+
for p in re.findall(r"^\s*-\s*pattern:\s*(.+?)\s*$", front, re.MULTILINE)],
|
|
107
|
+
evidence=evidence,
|
|
108
|
+
expires_condition=_field(front, "condition").strip('"'),
|
|
109
|
+
review_after=_field(front, "review_after"),
|
|
110
|
+
status=_field(front, "status", "active"),
|
|
111
|
+
body=body.strip(),
|
|
112
|
+
)
|
scar/store.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""ScarStore — filesystem layer: discovery, listing, init, promotion.
|
|
2
|
+
|
|
3
|
+
All path conventions live here and nowhere else. README/template content is
|
|
4
|
+
embedded so `scar init` works from a bare install with no data files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .lint import lint_text
|
|
13
|
+
from .model import ParseError, Scar, parse_scar_text
|
|
14
|
+
|
|
15
|
+
SKIP_NAMES = {"readme.md", "template.md"}
|
|
16
|
+
|
|
17
|
+
README = """\
|
|
18
|
+
# .scars/ — Negative knowledge for this repo
|
|
19
|
+
|
|
20
|
+
This directory records what this codebase **refused to be**: approaches that
|
|
21
|
+
were tried and failed (`deadend`), configuration that looks wrong but is
|
|
22
|
+
intentional (`fence`), and changes that break non-obvious things elsewhere
|
|
23
|
+
(`landmine`).
|
|
24
|
+
|
|
25
|
+
Before "cleaning up" anything these files anchor to — read the scar first.
|
|
26
|
+
Every scar carries evidence (commits, PRs, incidents). If a scar is stale,
|
|
27
|
+
challenge it: update or archive it with a note, don't ignore it.
|
|
28
|
+
|
|
29
|
+
## The contract (humans and agents)
|
|
30
|
+
|
|
31
|
+
1. **New scars start as candidates.** Copy `template.md`, write to
|
|
32
|
+
`candidates/<slug>.md` with `status: candidate`. Never write directly
|
|
33
|
+
into `.scars/` — only a human reviewer promotes (`scar promote`).
|
|
34
|
+
2. **YAML frontmatter is mandatory.** A scar without it is unparseable and
|
|
35
|
+
will NEVER fire in any tool. `scar lint` checks; the hooks warn loudly.
|
|
36
|
+
3. **Promotion** = human review: `scar promote candidates/<slug>.md`.
|
|
37
|
+
4. **Evidence required.** A scar without a commit/PR/incident reference is
|
|
38
|
+
an opinion and can be challenged on sight.
|
|
39
|
+
|
|
40
|
+
Format details: `template.md`. Project: SCAR.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
TEMPLATE = """\
|
|
44
|
+
---
|
|
45
|
+
# COPY THIS FILE — do not edit the template itself.
|
|
46
|
+
# New scars: write to .scars/candidates/<slug>.md with status: candidate.
|
|
47
|
+
# A human reviewer promotes via `scar promote` (assigns id + renames).
|
|
48
|
+
id: 0 # assigned at promotion
|
|
49
|
+
type: deadend # deadend = tried+failed | fence = looks wrong, intentional | landmine = touching A breaks B
|
|
50
|
+
title: One line, searchable, says the constraint
|
|
51
|
+
severity: medium # low | medium | high | critical
|
|
52
|
+
confidence: 0.7 # 0..1 — how sure are we this still holds
|
|
53
|
+
created: 1970-01-01
|
|
54
|
+
authors: ["claude-code"] # reviewer added at promotion
|
|
55
|
+
anchors:
|
|
56
|
+
- path: src/module/ # file or directory this protects
|
|
57
|
+
- pattern: "regex" # optional: fires when matching code appears in ANY new/edited file
|
|
58
|
+
evidence:
|
|
59
|
+
- commit: abc1234 # at least one receipt: commit, pr, incident, or note
|
|
60
|
+
expires:
|
|
61
|
+
condition: "what change would make this scar obsolete"
|
|
62
|
+
review_after: 1971-01-01
|
|
63
|
+
status: template # candidate | active | challenged | archived (template = never parsed)
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
Body: 5-15 lines of prose. What was tried/observed, why it failed or why the
|
|
67
|
+
weirdness is intentional, and what a future editor must do instead. Write it
|
|
68
|
+
for someone (human or agent) with zero context. Cite the evidence inline.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def init_scars(repo_root: Path) -> Path:
|
|
73
|
+
"""Create .scars/ layout. Idempotent; never clobbers existing files."""
|
|
74
|
+
scars = Path(repo_root) / ".scars"
|
|
75
|
+
scars.mkdir(exist_ok=True)
|
|
76
|
+
(scars / "candidates").mkdir(exist_ok=True)
|
|
77
|
+
for name, content in (("README.md", README), ("template.md", TEMPLATE)):
|
|
78
|
+
f = scars / name
|
|
79
|
+
if not f.exists():
|
|
80
|
+
f.write_text(content, encoding="utf-8")
|
|
81
|
+
return scars
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ScarStore:
|
|
86
|
+
root: Path # repo root
|
|
87
|
+
scars_dir: Path # root/.scars
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def discover(cls, start: Path) -> "ScarStore | None":
|
|
91
|
+
cur = Path(start).resolve()
|
|
92
|
+
cur = cur if cur.is_dir() else cur.parent
|
|
93
|
+
for d in [cur, *cur.parents]:
|
|
94
|
+
if (d / ".scars").is_dir():
|
|
95
|
+
return cls(root=d, scars_dir=d / ".scars")
|
|
96
|
+
if (d / ".git").exists():
|
|
97
|
+
return None
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def _scar_files(self):
|
|
101
|
+
return [f for f in sorted(self.scars_dir.glob("*.md"))
|
|
102
|
+
if f.name.lower() not in SKIP_NAMES and not f.name.startswith("_")]
|
|
103
|
+
|
|
104
|
+
def active(self) -> list[tuple[Path, Scar]]:
|
|
105
|
+
out = []
|
|
106
|
+
for f in self._scar_files():
|
|
107
|
+
try:
|
|
108
|
+
scar = parse_scar_text(f.read_text(encoding="utf-8"))
|
|
109
|
+
except (ParseError, OSError):
|
|
110
|
+
continue
|
|
111
|
+
if scar.status == "active":
|
|
112
|
+
out.append((f, scar))
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
def broken(self) -> list[Path]:
|
|
116
|
+
out = []
|
|
117
|
+
for f in self._scar_files():
|
|
118
|
+
try:
|
|
119
|
+
parse_scar_text(f.read_text(encoding="utf-8"))
|
|
120
|
+
except ParseError:
|
|
121
|
+
out.append(f)
|
|
122
|
+
except OSError:
|
|
123
|
+
pass
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
def candidates(self) -> list[Path]:
|
|
127
|
+
cand = self.scars_dir / "candidates"
|
|
128
|
+
return sorted(p for p in cand.glob("*.md")) if cand.is_dir() else []
|
|
129
|
+
|
|
130
|
+
def next_id(self) -> int:
|
|
131
|
+
ids = [scar.id for _, scar in self.active() if scar.id is not None]
|
|
132
|
+
for f in self._scar_files(): # count non-active numbered scars too
|
|
133
|
+
try:
|
|
134
|
+
s = parse_scar_text(f.read_text(encoding="utf-8"))
|
|
135
|
+
if s.id is not None:
|
|
136
|
+
ids.append(s.id)
|
|
137
|
+
except (ParseError, OSError):
|
|
138
|
+
continue
|
|
139
|
+
return max(ids, default=0) + 1
|
|
140
|
+
|
|
141
|
+
def promote(self, candidate: Path, reviewer: str) -> Path:
|
|
142
|
+
text = candidate.read_text(encoding="utf-8")
|
|
143
|
+
findings = lint_text(text)
|
|
144
|
+
errors = [f for f in findings if f.level == "error"]
|
|
145
|
+
if errors:
|
|
146
|
+
raise ValueError(f"refusing to promote, lint errors: {'; '.join(f.message for f in errors)}")
|
|
147
|
+
scar = parse_scar_text(text)
|
|
148
|
+
scar.id = self.next_id()
|
|
149
|
+
scar.status = "active"
|
|
150
|
+
if reviewer and reviewer not in scar.authors:
|
|
151
|
+
scar.authors.append(reviewer)
|
|
152
|
+
slug = candidate.stem
|
|
153
|
+
new_path = self.scars_dir / f"{scar.id:04d}-{slug}.{scar.type}.md"
|
|
154
|
+
new_path.write_text(scar.to_text(), encoding="utf-8")
|
|
155
|
+
candidate.unlink()
|
|
156
|
+
return new_path
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scar-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: SCAR — version control for negative knowledge (deadends, fences, landmines)
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# SCAR — Version Control for Negative Knowledge
|
|
11
|
+
|
|
12
|
+
> Git records what your codebase **is**. Nothing records what it **refused to be**.
|
|
13
|
+
|
|
14
|
+
SCAR is a git-native system for capturing, anchoring, and enforcing the *negative knowledge* of a codebase — the dead ends, the load-bearing weirdness, the invisible tripwires — and surfacing it at the exact moment someone (human or AI agent) is about to step on it.
|
|
15
|
+
|
|
16
|
+
## The one-liner
|
|
17
|
+
|
|
18
|
+
**Every codebase is a battlefield where the bodies have been removed.** SCAR puts the markers back.
|
|
19
|
+
|
|
20
|
+
## The three primitives
|
|
21
|
+
|
|
22
|
+
| Type | Meaning | Example |
|
|
23
|
+
|------|---------|---------|
|
|
24
|
+
| `deadend` | We tried X. It failed because Y. Don't retry unless Z changes. | "We tried Redis for session storage in 2024-03. Eviction under memory pressure logged users out mid-checkout. Don't retry unless sessions become re-derivable." |
|
|
25
|
+
| `fence` | This code looks wrong. It is intentional. Here's why. | "Yes, this retry loop sleeps 7 seconds, not 5. The upstream vendor's rate limiter has a 6-second window they don't document." |
|
|
26
|
+
| `landmine` | Changing A breaks B in a way nothing in the code tells you. | "The CSV export in `reports/` depends on the column order of this SELECT. Reorder it and Finance's reconciliation pipeline silently corrupts." |
|
|
27
|
+
|
|
28
|
+
## Why now
|
|
29
|
+
|
|
30
|
+
AI agents write an increasing share of all code. Agents have **zero hallway memory**. They see a weird retry loop and "clean it up." They see a missing cache layer and re-add the library that was removed after a data-corruption incident. They retry, across thousands of sessions, the exact approaches that already failed — because the repository only records positive space.
|
|
31
|
+
|
|
32
|
+
Humans at least had tribal knowledge. Agents have none. And as agents author more of the code, the negative knowledge stops even entering human memory — it evaporates entirely.
|
|
33
|
+
|
|
34
|
+
The flip side: agents also solve the historically fatal flaw of every knowledge-capture system — **authorship cost**. Nobody writes documentation after a failure. But an agent that just tried an approach and abandoned it can write a `deadend` scar in milliseconds, for free, at the moment of maximum context.
|
|
35
|
+
|
|
36
|
+
**Agents created the urgency. Agents remove the adoption barrier. That's the wedge.**
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
.scars/
|
|
42
|
+
├── 0001-redis-sessions.deadend.md
|
|
43
|
+
├── 0002-vendor-retry-window.fence.md
|
|
44
|
+
└── 0003-csv-column-order.landmine.md
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- Scars are small structured Markdown files with YAML frontmatter, tracked in git, reviewed in PRs like code.
|
|
48
|
+
- Each scar is **anchored** to code via paths, symbol names, and content fingerprints — not line numbers — so anchors survive refactors.
|
|
49
|
+
- Enforcement happens **at the moment of action**:
|
|
50
|
+
- `scar check <path>` — CLI gate for humans and CI
|
|
51
|
+
- Agent hook (Claude Code `PreToolUse`, etc.) — injects relevant scars into the agent's context *before* it edits the file
|
|
52
|
+
- MCP server — planned, so any agent can query the scar graph
|
|
53
|
+
- `scar harvest` — mines git history (reverts, add-then-remove dependencies, reopened issues) to propose candidate scars for codebases starting from zero.
|
|
54
|
+
- Scars are **advisory, never blocking, by default**. Stale knowledge is challenged via `scar challenge`, and every scar can carry expiry conditions ("valid until we drop Postgres 12").
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
uv tool install scar-cli # or: pipx install scar-cli
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Zero runtime dependencies. Python ≥3.10.
|
|
63
|
+
|
|
64
|
+
## Quickstart
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cd your-repo
|
|
68
|
+
scar init # creates .scars/ with template + README
|
|
69
|
+
|
|
70
|
+
# write your first scar
|
|
71
|
+
cp .scars/template.md .scars/candidates/redis-sessions.md
|
|
72
|
+
$EDITOR .scars/candidates/redis-sessions.md
|
|
73
|
+
|
|
74
|
+
scar lint # validate format
|
|
75
|
+
scar promote redis-sessions.md # human review gate: candidate -> active
|
|
76
|
+
|
|
77
|
+
# from then on
|
|
78
|
+
scar check src/auth/ # what's anchored here?
|
|
79
|
+
scar why src/auth/ # full history of pain for this path
|
|
80
|
+
scar harvest # mine git history for candidate scars
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Wiring the Claude Code hook (auto-injects scars before any agent edit):
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
scar hook install
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Quality discipline
|
|
90
|
+
|
|
91
|
+
- **Candidates vs active:** agents and `scar harvest` only ever write to `.scars/candidates/`. A human promotes (`scar promote`) — nothing enters active enforcement without review.
|
|
92
|
+
- **Expiry conditions:** every scar can declare when it stops being true ("valid until sessions are re-derivable"). Stale knowledge is a bug, not a feature.
|
|
93
|
+
- **Validated in use:** in a 14-day agent auto-authorship trial, agents drafted 13 keepable scars across 3 repos with 0% false positives — including one that caught a real parser bug in this repo and fired on the exact edit that fixed it.
|
|
94
|
+
|
|
95
|
+
## Read more
|
|
96
|
+
|
|
97
|
+
- [IDEA.md](IDEA.md) — the full pitch: problem, solution, why this, why now, why me
|
|
98
|
+
- [SPEC.md](SPEC.md) — scar format, anchoring model, CLI surface, agent integration
|
|
99
|
+
- [STRESS-TEST.md](STRESS-TEST.md) — adversarial analysis: failure modes, loopholes, objections, premortem
|
|
100
|
+
- [ROADMAP.md](ROADMAP.md) — phased plan from prototype to product
|
|
101
|
+
|
|
102
|
+
## Status & expectations
|
|
103
|
+
|
|
104
|
+
**Working software, shared as-is.** CLI v0 is shipped: 9 subcommands, 63 tests, zero dependencies, CI-enforced. It runs daily across the author's repos (where it has already caught real bugs — see `.scars/` in this very repo for live examples).
|
|
105
|
+
|
|
106
|
+
This is personal infrastructure published as a gift to the OSS community, not a product. Issues and PRs are welcome and read with interest, but there is no support SLA and no roadmap promise. If it's useful to you, that's the whole point.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
scar/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
scar/cli.py,sha256=_bs_VK-8Z1Z4n9h0h5szzGtYCuOdpVv3qF04bpzE70U,8888
|
|
3
|
+
scar/harvest.py,sha256=6eJJWYnAORKlAH6-ePX87u_fRI_pG7qynvRrB4z9uB4,4190
|
|
4
|
+
scar/hooks.py,sha256=S3mNEjJR-IU9kCxSy2NPVFO7Em63FoPLftdq4J4BAnc,8833
|
|
5
|
+
scar/lint.py,sha256=xHlnYpXGQogNHV2fUkPyCUOAcjIfUVxmzQk2-FYkuoQ,1768
|
|
6
|
+
scar/match.py,sha256=AVtN2SisyrgNJ2CQYs2BI35uN1hsaqEKdUOuduaWGNU,1716
|
|
7
|
+
scar/model.py,sha256=EAaeXd-wOaS7-rOCS-9Rlqg7yPgZVOAw94eUNj8hIFE,4224
|
|
8
|
+
scar/store.py,sha256=PGXN651Jw3ZhWC6z39l3x50LU5aMUiPxQvh6iQbVoyg,6051
|
|
9
|
+
scar_cli-0.1.1.dist-info/METADATA,sha256=B-VIpc2_Wn-MkE3Lyvs8owyzSTEyAtiUSMsjHktp-bk,5816
|
|
10
|
+
scar_cli-0.1.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
scar_cli-0.1.1.dist-info/entry_points.txt,sha256=y2N95d5kCcCkVybbzuYdbfJeNBnj0btQFZ9tw3whpGw,39
|
|
12
|
+
scar_cli-0.1.1.dist-info/licenses/LICENSE,sha256=0U9GZYTymBZdT5ErJalgsbW9msHCIqMdzgWWq687F8s,1063
|
|
13
|
+
scar_cli-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kibukx
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|