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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scar = scar.cli:main
@@ -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.