scar-cli 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +2 -1
  2. {scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +2 -1
  3. scar_cli-0.2.0/.scars/candidates/history-rewrite-orphans-commit-evidence.md → scar_cli-0.3.0/.scars/0005-history-rewrite-orphans-commit-evidence.landmine.md +4 -3
  4. scar_cli-0.3.0/AGENTS.md +43 -0
  5. {scar_cli-0.2.0 → scar_cli-0.3.0}/CHANGELOG.md +7 -0
  6. scar_cli-0.2.0/README.md → scar_cli-0.3.0/PKG-INFO +26 -1
  7. scar_cli-0.2.0/PKG-INFO → scar_cli-0.3.0/README.md +17 -10
  8. {scar_cli-0.2.0 → scar_cli-0.3.0}/SPEC.md +5 -2
  9. {scar_cli-0.2.0 → scar_cli-0.3.0}/pyproject.toml +1 -1
  10. scar_cli-0.3.0/src/scar/agent.py +66 -0
  11. {scar_cli-0.2.0 → scar_cli-0.3.0}/src/scar/cli.py +55 -44
  12. {scar_cli-0.2.0 → scar_cli-0.3.0}/src/scar/hooks.py +4 -19
  13. scar_cli-0.3.0/src/scar/match.py +147 -0
  14. scar_cli-0.3.0/src/scar/mcp.py +235 -0
  15. scar_cli-0.3.0/src/scar/render.py +38 -0
  16. {scar_cli-0.2.0 → scar_cli-0.3.0}/src/scar/store.py +9 -0
  17. {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_cli.py +36 -0
  18. {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_match.py +64 -1
  19. scar_cli-0.3.0/tests/test_mcp.py +92 -0
  20. {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_store.py +12 -0
  21. {scar_cli-0.2.0 → scar_cli-0.3.0}/uv.lock +1 -1
  22. scar_cli-0.2.0/src/scar/match.py +0 -51
  23. {scar_cli-0.2.0 → scar_cli-0.3.0}/.github/workflows/ci.yml +0 -0
  24. {scar_cli-0.2.0 → scar_cli-0.3.0}/.github/workflows/pr-validation.yml +0 -0
  25. {scar_cli-0.2.0 → scar_cli-0.3.0}/.github/workflows/release.yml +0 -0
  26. {scar_cli-0.2.0 → scar_cli-0.3.0}/.gitignore +0 -0
  27. {scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
  28. {scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
  29. {scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/README.md +0 -0
  30. {scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/candidates/fp-log.txt +0 -0
  31. {scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/template.md +0 -0
  32. {scar_cli-0.2.0 → scar_cli-0.3.0}/CONTRIBUTING.md +0 -0
  33. {scar_cli-0.2.0 → scar_cli-0.3.0}/IDEA.md +0 -0
  34. {scar_cli-0.2.0 → scar_cli-0.3.0}/LICENSE +0 -0
  35. {scar_cli-0.2.0 → scar_cli-0.3.0}/ROADMAP.md +0 -0
  36. {scar_cli-0.2.0 → scar_cli-0.3.0}/SCAR-FORMAT.md +0 -0
  37. {scar_cli-0.2.0 → scar_cli-0.3.0}/STRESS-TEST.md +0 -0
  38. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/anchor-survival/PROTOCOL.md +0 -0
  39. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/anchor-survival/RESULTS.md +0 -0
  40. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/anchor-survival/long_replay.py +0 -0
  41. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/anchor-survival/replay.py +0 -0
  42. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/auto-authorship/FINDINGS.md +0 -0
  43. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/auto-authorship/PROTOCOL.md +0 -0
  44. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/.gitignore +0 -0
  45. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/PROTOCOL.md +0 -0
  46. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/RESULTS.md +0 -0
  47. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
  48. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
  49. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
  50. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/fixture/README.md +0 -0
  51. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/fixture/payments/retry.py +0 -0
  52. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/fixture/reports/export.py +0 -0
  53. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/fixture/services/sessions.py +0 -0
  54. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/grade.py +0 -0
  55. {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/harvest/harvest.py +0 -0
  56. {scar_cli-0.2.0 → scar_cli-0.3.0}/hook/scar-hooks.py +0 -0
  57. {scar_cli-0.2.0 → scar_cli-0.3.0}/src/scar/__init__.py +0 -0
  58. {scar_cli-0.2.0 → scar_cli-0.3.0}/src/scar/harvest.py +0 -0
  59. {scar_cli-0.2.0 → scar_cli-0.3.0}/src/scar/lint.py +0 -0
  60. {scar_cli-0.2.0 → scar_cli-0.3.0}/src/scar/model.py +0 -0
  61. {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_harvest.py +0 -0
  62. {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_hooks.py +0 -0
  63. {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_installer.py +0 -0
  64. {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_lifecycle.py +0 -0
  65. {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_lint.py +0 -0
  66. {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_model.py +0 -0
@@ -11,7 +11,8 @@ anchors:
11
11
  - pattern: "shutil\\.which\\([\"']scar[\"']\\)"
12
12
  evidence:
13
13
  - note: 2026-06-11 session: user ran `source .venv/bin/activate` then `python3 fabcap/hook/scar-hooks.py install` intending to rebind global hooks to ~/.local/bin/scar; installer reported 'up-to-date' and left all 3 hooks on fabcap/.venv/bin/scar
14
- status: active
14
+ - note: archived 2026-06-11: fix shipped in #8 with 4 installer tests guarding regression
15
+ status: archived
15
16
  ---
16
17
 
17
18
  `install()` resolves the scar binary with `shutil.which("scar")` and writes that
@@ -11,7 +11,8 @@ anchors:
11
11
  - pattern: "_field\(front"
12
12
  evidence:
13
13
  - note: Observed 2026-06-11 promoting 5 candidates in context-as-program (PR 3 there restores the data by hand). All 5 lost their expires block; one also lost evidence because its note contained escaped quotes.
14
- status: active
14
+ - note: archived 2026-06-11: expires.condition met by #10: parser reads nested keys and quote evidence, roundtrip test guards it
15
+ status: archived
15
16
  ---
16
17
 
17
18
  promote() does parse -> mutate -> to_text. Two asymmetries make that lossy.
@@ -1,19 +1,20 @@
1
1
  ---
2
+ id: 5
2
3
  type: landmine
3
4
  title: rewriting git history orphans commit-SHA evidence in scars — receipts break in fresh clones
4
5
  severity: medium
5
6
  confidence: 0.9
6
7
  created: 2026-06-11
7
- authors: ["claude-code"]
8
+ authors: ["claude-code", "Kibukx"]
8
9
  anchors:
9
10
  - path: .scars/
10
11
  - pattern: "push.{0,30}(--force|\\+[a-zA-Z]).{0,40}main|filter-repo|checkout --orphan"
11
12
  evidence:
12
- - note: "v0.1.0 public release (2026-06-11): fresh-start force-push orphaned 3 commit SHAs cited by scars 0001 and 0002; SHAs still resolve on GitHub by URL but fail in any fresh clone, and GitHub may GC them eventually"
13
+ - note: v0.1.0 public release (2026-06-11): fresh-start force-push orphaned 3 commit SHAs cited by scars 0001 and 0002; SHAs still resolve on GitHub by URL but fail in any fresh clone, and GitHub may GC them eventually
13
14
  expires:
14
15
  condition: "evidence schema gains a resolvable form (full URL or archived diff) or lint warns on bare SHAs at promotion"
15
16
  review_after: 2027-06-11
16
- status: candidate
17
+ status: active
17
18
  ---
18
19
 
19
20
  Scars cite commit SHAs as evidence receipts. Those receipts implicitly assume
@@ -0,0 +1,43 @@
1
+ # AGENTS.md
2
+
3
+ ## Project Overview
4
+
5
+ SCAR records negative knowledge for a repository: failed approaches
6
+ (`deadend`), intentional weirdness (`fence`), and non-obvious coupling
7
+ (`landmine`). Scars live in `.scars/` as Markdown files with mandatory YAML
8
+ frontmatter.
9
+
10
+ ## Agent Contract
11
+
12
+ - Before editing anchored code, query SCAR with `scar inject --path <path>` or,
13
+ when you have a diff, `scar inject --diff <unified-diff>`.
14
+ - Honor injected scars unless the user explicitly overrides them.
15
+ - New scars always start in `.scars/candidates/`; never write directly to
16
+ active `.scars/*.md` files.
17
+ - A human promotes candidates with `scar promote`.
18
+ - Do not silently ignore broken scar files. Run `scar lint` when changing scar
19
+ format, parsing, promotion, lifecycle, or candidate-writing behavior.
20
+
21
+ ## Agent Integrations
22
+
23
+ - MCP-capable agents can launch the local server with `scar mcp`.
24
+ - Integration snippets are available with:
25
+ - `scar agent config codex`
26
+ - `scar agent config cursor`
27
+ - `scar agent config opencode`
28
+ - `scar agent config windsurf`
29
+ - Check local readiness with `scar agent doctor`.
30
+
31
+ ## Development Commands
32
+
33
+ - Run tests: `uv run pytest`
34
+ - Run focused tests: `uv run pytest tests/test_cli.py tests/test_match.py`
35
+ - Lint scars: `uv run scar lint`
36
+
37
+ ## Repository Rules
38
+
39
+ - Do not add AI attribution or `Co-Authored-By` lines to commits.
40
+ - Use conventional commit messages.
41
+ - Do not build after changes.
42
+ - Keep runtime dependencies at zero unless there is a strong architectural
43
+ reason and tests/docs are updated with the tradeoff.
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/Daily-Nerd/Scar/compare/v0.2.0...v0.3.0) (2026-06-12)
4
+
5
+
6
+ ### Features
7
+
8
+ * **agents:** multi-agent scar integration — AGENTS.md, MCP server, agent helpers ([#21](https://github.com/Daily-Nerd/Scar/issues/21)) ([52c817f](https://github.com/Daily-Nerd/Scar/commit/52c817fc963f8f829b70de60b772c1097c6f0334))
9
+
3
10
  ## [0.2.0](https://github.com/Daily-Nerd/Scar/compare/v0.1.1...v0.2.0) (2026-06-12)
4
11
 
5
12
 
@@ -1,3 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: scar-cli
3
+ Version: 0.3.0
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
+
1
10
  # SCAR — Version Control for Negative Knowledge
2
11
 
3
12
  > Git records what your codebase **is**. Nothing records what it **refused to be**.
@@ -40,7 +49,7 @@ The flip side: agents also solve the historically fatal flaw of every knowledge-
40
49
  - Enforcement happens **at the moment of action**:
41
50
  - `scar check <path>` — CLI gate for humans and CI
42
51
  - Agent hook (Claude Code `PreToolUse`, etc.) — injects relevant scars into the agent's context *before* it edits the file
43
- - MCP serverplanned, so any agent can query the scar graph
52
+ - `scar mcp`local MCP server, so MCP-capable agents can query and draft scars
44
53
  - `scar harvest` — mines git history (reverts, add-then-remove dependencies, reopened issues) to propose candidate scars for codebases starting from zero.
45
54
  - Scars are **advisory, never blocking, by default** — and stale knowledge has a lifecycle: `scar challenge <id> --reason` disputes a scar (it still fires, marked as disputed), `scar archive <id> --reason` retires it (never fires again; `scar why` keeps the history), and `scar lint`/`scar status` surface any scar whose `review_after` date has passed. Nothing expires automatically — archiving is a human decision, same governance as promotion.
46
55
 
@@ -77,6 +86,22 @@ Wiring the Claude Code hook (auto-injects scars before any agent edit):
77
86
  scar hook install
78
87
  ```
79
88
 
89
+ Wiring MCP-capable agents:
90
+
91
+ ```bash
92
+ scar agent doctor
93
+ scar agent config opencode # or: codex, cursor, windsurf
94
+ ```
95
+
96
+ The MCP server runs as:
97
+
98
+ ```bash
99
+ scar mcp
100
+ ```
101
+
102
+ It exposes `scar_query`, `scar_why`, and `scar_draft`. Drafting writes only to
103
+ `.scars/candidates/`; active enforcement still requires human promotion.
104
+
80
105
  ## Quality discipline
81
106
 
82
107
  - **Candidates vs active:** agents and `scar harvest` only ever write to `.scars/candidates/`. A human promotes (`scar promote`) — nothing enters active enforcement without review.
@@ -1,12 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: scar-cli
3
- Version: 0.2.0
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
1
  # SCAR — Version Control for Negative Knowledge
11
2
 
12
3
  > Git records what your codebase **is**. Nothing records what it **refused to be**.
@@ -49,7 +40,7 @@ The flip side: agents also solve the historically fatal flaw of every knowledge-
49
40
  - Enforcement happens **at the moment of action**:
50
41
  - `scar check <path>` — CLI gate for humans and CI
51
42
  - Agent hook (Claude Code `PreToolUse`, etc.) — injects relevant scars into the agent's context *before* it edits the file
52
- - MCP serverplanned, so any agent can query the scar graph
43
+ - `scar mcp`local MCP server, so MCP-capable agents can query and draft scars
53
44
  - `scar harvest` — mines git history (reverts, add-then-remove dependencies, reopened issues) to propose candidate scars for codebases starting from zero.
54
45
  - Scars are **advisory, never blocking, by default** — and stale knowledge has a lifecycle: `scar challenge <id> --reason` disputes a scar (it still fires, marked as disputed), `scar archive <id> --reason` retires it (never fires again; `scar why` keeps the history), and `scar lint`/`scar status` surface any scar whose `review_after` date has passed. Nothing expires automatically — archiving is a human decision, same governance as promotion.
55
46
 
@@ -86,6 +77,22 @@ Wiring the Claude Code hook (auto-injects scars before any agent edit):
86
77
  scar hook install
87
78
  ```
88
79
 
80
+ Wiring MCP-capable agents:
81
+
82
+ ```bash
83
+ scar agent doctor
84
+ scar agent config opencode # or: codex, cursor, windsurf
85
+ ```
86
+
87
+ The MCP server runs as:
88
+
89
+ ```bash
90
+ scar mcp
91
+ ```
92
+
93
+ It exposes `scar_query`, `scar_why`, and `scar_draft`. Drafting writes only to
94
+ `.scars/candidates/`; active enforcement still requires human promotion.
95
+
89
96
  ## Quality discipline
90
97
 
91
98
  - **Candidates vs active:** agents and `scar harvest` only ever write to `.scars/candidates/`. A human promotes (`scar promote`) — nothing enters active enforcement without review.
@@ -81,7 +81,10 @@ scar why <path> # human-readable history of pain for a file/dir
81
81
  scar challenge <id> # open a challenge: contest staleness with evidence
82
82
  scar harvest # mine git history, emit candidate scars to .scars/candidates/
83
83
  scar status # active/orphaned/challenged/expiring counts; repo health
84
- scar inject --diff <d> # machine mode: top-k scars for a diff as JSON/markdown (hook backend)
84
+ scar inject --path <p> # machine mode: top-k scars for one edit as hook JSON
85
+ scar inject --diff <d> # machine mode: top-k scars for a unified diff as hook JSON
86
+ scar mcp # stdio MCP server for MCP-capable agents
87
+ scar agent config <name> # print setup snippets for supported agent runtimes
85
88
  ```
86
89
 
87
90
  ## 4. Agent integration
@@ -98,7 +101,7 @@ Also `PostToolUse`/stop-hook prompt: *"You appear to have abandoned approach X a
98
101
 
99
102
  ### 4.2 MCP server
100
103
 
101
- `scar-mcp` exposes: `scar_query(paths|symbols|pattern)`, `scar_why(path)`, `scar_draft(type, title, body, anchors, evidence)` (writes to `candidates/`, never directly to active). Works for any MCP-capable agent — Cursor, Windsurf, custom.
104
+ `scar mcp` exposes: `scar_query(paths|content|diff)`, `scar_why(path)`, `scar_draft(type, title, body, anchors, evidence)` (writes to `candidates/`, never directly to active). Works for any MCP-capable agent — Codex, Cursor, Windsurf, opencode, custom.
102
105
 
103
106
  ### 4.3 Ranking and the fatigue budget
104
107
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scar-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "SCAR — version control for negative knowledge (deadends, fences, landmines)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,66 @@
1
+ """Agent integration helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ _MCP_SERVERS_SNIPPET = """\
9
+ Configure a local MCP server named "scar" with:
10
+
11
+ {
12
+ "mcpServers": {
13
+ "scar": {
14
+ "command": "scar",
15
+ "args": ["mcp"]
16
+ }
17
+ }
18
+ }
19
+ """
20
+
21
+ # target -> setup text; adding a runtime is one entry here, no logic change
22
+ CONFIGS = {
23
+ "codex": """\
24
+ Codex-compatible setup:
25
+
26
+ 1. Keep AGENTS.md committed at the repository root.
27
+ 2. Expose SCAR through MCP with command: scar mcp
28
+ 3. For direct shell use, ask the agent to run:
29
+ scar inject --path <path> --content <new-content>
30
+ scar inject --diff <unified-diff>
31
+ """,
32
+ "cursor": _MCP_SERVERS_SNIPPET,
33
+ "opencode": """\
34
+ Add this to opencode.jsonc:
35
+
36
+ {
37
+ "$schema": "https://opencode.ai/config.json",
38
+ "mcp": {
39
+ "scar": {
40
+ "type": "local",
41
+ "command": ["scar", "mcp"],
42
+ "enabled": true
43
+ }
44
+ }
45
+ }
46
+ """,
47
+ "windsurf": "Cascade: " + _MCP_SERVERS_SNIPPET,
48
+ }
49
+
50
+ TARGETS = tuple(sorted(CONFIGS))
51
+
52
+
53
+ def doctor(repo: Path) -> list[str]:
54
+ root = repo.resolve()
55
+ findings = []
56
+ findings.append(f"AGENTS.md: {'present' if (root / 'AGENTS.md').exists() else 'missing'}")
57
+ findings.append(f".scars/: {'present' if (root / '.scars').is_dir() else 'missing'}")
58
+ findings.append(f"scar binary: {shutil.which('scar') or 'not found on PATH'}")
59
+ findings.append("MCP command: scar mcp")
60
+ return findings
61
+
62
+
63
+ def config(target: str) -> str:
64
+ if target not in CONFIGS:
65
+ raise ValueError(f"unknown target '{target}' (expected: {', '.join(TARGETS)})")
66
+ return CONFIGS[target]
@@ -14,12 +14,10 @@ import time
14
14
  from pathlib import Path
15
15
 
16
16
  from .lint import lint_text
17
- from .match import rank_for_edit
18
- from .model import ParseError, parse_scar_text
17
+ from .match import rank_for_edit, rank_matches_for_diff, rank_matches_for_edit
18
+ from .render import injection_context, label_line
19
19
  from .store import ScarStore, init_scars
20
20
 
21
- MAX_BODY_CHARS = 700 # ~120 words — the fatigue budget is a format guarantee
22
-
23
21
 
24
22
  def _require_store(start: Path | None = None) -> ScarStore | None:
25
23
  store = ScarStore.discover(start or Path.cwd())
@@ -103,8 +101,7 @@ def _cmd_check(args) -> int:
103
101
  print(f"no scars anchored to {args.path}")
104
102
  return 0
105
103
  for s in hits:
106
- label = f"challenged {s.type}" if s.status == "challenged" else s.type
107
- print(f"[{label} #{s.id} | severity: {s.severity} | confidence: {s.confidence}] {s.title}")
104
+ print(label_line(s))
108
105
  print(" " + s.body[:200].replace("\n", "\n "))
109
106
  return 0
110
107
 
@@ -132,52 +129,39 @@ def _cmd_why(args) -> int:
132
129
  if store is None:
133
130
  return 1
134
131
  rel = str(Path(args.path).resolve().relative_to(store.root))
135
- found = 0
136
- for f in store._scar_files():
137
- try:
138
- s = parse_scar_text(f.read_text(encoding="utf-8"))
139
- except ParseError:
140
- continue
141
- # bidirectional: query under an anchor (editing inside protected dir)
142
- # OR anchor under the query (asking a parent dir for its history)
143
- if any(rel.startswith(p.rstrip("/")) or p.rstrip("/").startswith(rel)
144
- for p in s.path_anchors):
145
- found += 1
146
- print(f"[{s.status} {s.type} #{s.id}] {s.title} ({f.name})")
147
- print(" " + s.body[:300].replace("\n", "\n ") + "\n")
148
- if not found:
132
+ records = store.scars_for_path(rel)
133
+ for f, s in records:
134
+ print(f"[{s.status} {s.type} #{s.id}] {s.title} ({f.name})")
135
+ print(" " + s.body[:300].replace("\n", "\n ") + "\n")
136
+ if not records:
149
137
  print(f"no recorded pain for {rel}")
150
138
  return 0
151
139
 
152
140
 
153
141
  def _cmd_inject(args) -> int:
154
142
  """Machine mode for hooks: JSON additionalContext or silence."""
155
- store = ScarStore.discover(Path(args.path).resolve())
143
+ start = Path(args.path).resolve() if args.path else Path.cwd()
144
+ store = ScarStore.discover(start)
156
145
  if store is None:
157
146
  return 0 # hooks must never fail the edit
158
- hits = rank_for_edit(store, Path(args.path).resolve(), args.content or "",
159
- top_k=args.top_k)
160
- broken = store.broken()
161
- parts = []
162
- if hits:
163
- blocks = [
164
- f"[{'challenged ' if s.status == 'challenged' else ''}{s.type} #{s.id} "
165
- f"| severity: {s.severity} | confidence: {s.confidence}] "
166
- f"{s.title}\n{s.body[:MAX_BODY_CHARS]}" for s in hits
167
- ]
168
- parts.append(
169
- "SCAR pre-edit check — negative knowledge anchored to code you are "
170
- f"about to modify ({len(hits)} match(es)). Honor these unless the "
171
- "user explicitly overrides; full records in .scars/.\n\n"
172
- + "\n\n".join(blocks))
173
- if broken:
174
- parts.append(
175
- f"SCAR warning: {len(broken)} scar file(s) unparseable and can NEVER "
176
- f"fire: {', '.join(b.name for b in broken)}. Fix frontmatter "
177
- f"(copy {store.scars_dir}/template.md).")
178
- if parts:
147
+ if args.diff:
148
+ try:
149
+ diff_text = Path(args.diff).read_text(encoding="utf-8")
150
+ except (OSError, UnicodeDecodeError, ValueError):
151
+ # ValueError covers NUL-byte paths; a hook must never crash on
152
+ # whatever lands in --diff — fall back to treating it as text
153
+ diff_text = args.diff
154
+ matches = rank_matches_for_diff(store, diff_text, top_k=args.top_k)
155
+ elif args.path:
156
+ matches = rank_matches_for_edit(store, Path(args.path).resolve(),
157
+ args.content or "", top_k=args.top_k)
158
+ else:
159
+ matches = []
160
+ context = injection_context([m.scar for m in matches], store.broken(),
161
+ store.scars_dir)
162
+ if context:
179
163
  print(json.dumps({"hookSpecificOutput": {
180
- "hookEventName": args.hook_event, "additionalContext": "\n\n".join(parts)}}))
164
+ "hookEventName": args.hook_event, "additionalContext": context}}))
181
165
  return 0
182
166
 
183
167
 
@@ -203,6 +187,20 @@ def _cmd_harvest(args) -> int:
203
187
  return 0
204
188
 
205
189
 
190
+ def _cmd_agent(args) -> int:
191
+ from .agent import config, doctor
192
+ if args.agent_command == "doctor":
193
+ for line in doctor(Path.cwd()):
194
+ print(line)
195
+ return 0
196
+ try:
197
+ print(config(args.target))
198
+ except ValueError as exc:
199
+ print(str(exc))
200
+ return 1
201
+ return 0
202
+
203
+
206
204
  def main(argv: list[str] | None = None) -> int:
207
205
  parser = argparse.ArgumentParser(prog="scar",
208
206
  description="version control for negative knowledge")
@@ -238,13 +236,25 @@ def main(argv: list[str] | None = None) -> int:
238
236
  p = sub.add_parser("hook", help="Claude Code hook handlers (payload on stdin)")
239
237
  p.add_argument("kind", choices=["precheck", "session-notice", "stop-drafter"])
240
238
 
239
+ sub.add_parser("mcp", help="run the SCAR MCP stdio server")
240
+
241
+ p = sub.add_parser("agent", help="agent integration helpers")
242
+ agent_sub = p.add_subparsers(dest="agent_command", required=True)
243
+ agent_sub.add_parser("doctor", help="show local agent integration readiness")
244
+ cfg = agent_sub.add_parser("config", help="print config for an agent runtime")
245
+ cfg.add_argument("target", choices=["codex", "cursor", "opencode", "windsurf"])
246
+
241
247
  p = sub.add_parser("inject", help="machine mode for hooks: JSON or silence")
242
- p.add_argument("--path", required=True)
248
+ p.add_argument("--path")
243
249
  p.add_argument("--content", default="")
250
+ p.add_argument("--diff", help="unified diff text, or path to a diff file")
244
251
  p.add_argument("--top-k", type=int, default=3)
245
252
  p.add_argument("--hook-event", default="PreToolUse")
246
253
 
247
254
  args = parser.parse_args(argv)
255
+ if args.command == "mcp":
256
+ from .mcp import serve
257
+ return serve()
248
258
  if args.command == "hook":
249
259
  from .hooks import HANDLERS # hot path: imports nothing beyond library
250
260
  return HANDLERS[args.kind]()
@@ -255,6 +265,7 @@ def main(argv: list[str] | None = None) -> int:
255
265
  "init": _cmd_init, "lint": _cmd_lint, "status": _cmd_status,
256
266
  "promote": _cmd_promote, "check": _cmd_check, "why": _cmd_why,
257
267
  "inject": _cmd_inject, "harvest": _cmd_harvest,
268
+ "agent": _cmd_agent,
258
269
  }[args.command]
259
270
  return handler(args)
260
271
 
@@ -18,10 +18,9 @@ import time
18
18
  from pathlib import Path
19
19
 
20
20
  from .match import rank_for_edit
21
+ from .render import injection_context
21
22
  from .store import ScarStore
22
23
 
23
- MAX_BODY_CHARS = 700
24
-
25
24
  REVERT_RE = re.compile(
26
25
  r"revert(ing|ed)?\b|roll(ing|ed)? back|undo(ing)? (the|that|my)|"
27
26
  r"abandon(ing|ed)? (the|this|that|this approach)|scrap(ping)? (the|this|that)|"
@@ -71,23 +70,9 @@ def precheck() -> int:
71
70
  new_content = " ".join(str(tool_input.get(k, ""))
72
71
  for k in ("content", "new_string", "new_source"))
73
72
  hits = rank_for_edit(store, Path(target), new_content)
74
- broken = store.broken()
75
- parts = []
76
- if hits:
77
- blocks = [f"[{'challenged ' if s.status == 'challenged' else ''}{s.type} "
78
- f"#{s.id} | severity: {s.severity} | confidence: "
79
- f"{s.confidence}] {s.title}\n{s.body[:MAX_BODY_CHARS]}" for s in hits]
80
- parts.append(
81
- "SCAR pre-edit check — negative knowledge anchored to code you are "
82
- f"about to modify ({len(hits)} match(es)). Honor these unless the "
83
- "user explicitly overrides; full records in .scars/.\n\n" + "\n\n".join(blocks))
84
- if broken:
85
- parts.append(
86
- f"SCAR warning: {len(broken)} scar file(s) unparseable and can NEVER "
87
- f"fire: {', '.join(b.name for b in broken)}. Their knowledge is "
88
- f"silently dead. Fix the frontmatter (copy {store.scars_dir}/template.md).")
89
- if parts:
90
- _emit("PreToolUse", "\n\n".join(parts))
73
+ context = injection_context(hits, store.broken(), store.scars_dir)
74
+ if context:
75
+ _emit("PreToolUse", context)
91
76
  return 0
92
77
 
93
78
 
@@ -0,0 +1,147 @@
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 dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from .model import Scar
15
+ from .store import ScarStore
16
+
17
+ SEVERITY_WEIGHT = {"low": 1, "medium": 2, "high": 3, "critical": 4}
18
+ DEFAULT_TOP_K = 3
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ScarMatch:
23
+ scar: Scar
24
+ source: Path
25
+ rank: float
26
+ anchor_strength: float
27
+ matched_by: tuple[str, ...]
28
+ path: str
29
+
30
+ def to_dict(self) -> dict:
31
+ # copy the whole Scar so a future model field can never silently
32
+ # vanish from MCP responses (guarded by a fields() test)
33
+ d = dict(self.scar.__dict__)
34
+ d["anchors"] = {"paths": d.pop("path_anchors"),
35
+ "patterns": d.pop("pattern_anchors")}
36
+ d.update(matched_by=list(self.matched_by),
37
+ anchor_strength=self.anchor_strength,
38
+ rank=self.rank, path=self.path, source=str(self.source))
39
+ return d
40
+
41
+
42
+ def _anchor_signal(scar: Scar, rel_path: str, new_content: str) -> tuple[float, tuple[str, ...]]:
43
+ score = 0.0
44
+ matched: list[str] = []
45
+ for p in scar.path_anchors:
46
+ if rel_path.startswith(p.rstrip("/")):
47
+ score = max(score, 2.0)
48
+ matched.append("path")
49
+ for pat in scar.pattern_anchors:
50
+ try:
51
+ rx = re.compile(pat, re.IGNORECASE)
52
+ except re.error:
53
+ continue # lint's job; never crash the read path
54
+ if rx.search(rel_path):
55
+ score = max(score, 1.5)
56
+ matched.append("path_pattern")
57
+ if new_content and rx.search(new_content):
58
+ score = max(score, 2.5)
59
+ matched.append("content_pattern")
60
+ return score, tuple(dict.fromkeys(matched))
61
+
62
+
63
+ def _match_target(firing: list, root: Path, rel_path: str,
64
+ new_content: str) -> list[ScarMatch]:
65
+ """Rank one target against an already-loaded firing set (no disk I/O)."""
66
+ ranked: list[ScarMatch] = []
67
+ for source, scar in firing:
68
+ strength, matched_by = _anchor_signal(scar, rel_path, new_content)
69
+ if strength > 0:
70
+ rank = strength * SEVERITY_WEIGHT.get(scar.severity, 2) * scar.confidence
71
+ ranked.append(ScarMatch(scar=scar, source=source.relative_to(root),
72
+ rank=rank, anchor_strength=strength,
73
+ matched_by=matched_by, path=rel_path))
74
+ ranked.sort(key=lambda m: -m.rank)
75
+ return ranked
76
+
77
+
78
+ def merge_best_matches(match_lists: list[list[ScarMatch]],
79
+ top_k: int = DEFAULT_TOP_K) -> list[ScarMatch]:
80
+ """Dedup matches across targets, keeping each scar's best rank."""
81
+ best: dict[int | str, ScarMatch] = {}
82
+ for matches in match_lists:
83
+ for match in matches:
84
+ key = match.scar.id if match.scar.id is not None else match.source.as_posix()
85
+ if key not in best or match.rank > best[key].rank:
86
+ best[key] = match
87
+ return sorted(best.values(), key=lambda m: -m.rank)[:top_k]
88
+
89
+
90
+ def rank_matches_for_edit(store: ScarStore, target: Path, new_content: str,
91
+ top_k: int = DEFAULT_TOP_K) -> list[ScarMatch]:
92
+ """Top-k firing scar matches relevant to editing `target`."""
93
+ try:
94
+ rel_path = str(Path(target).resolve().relative_to(store.root))
95
+ except ValueError:
96
+ return []
97
+ return _match_target(store.firing(), store.root, rel_path, new_content)[:top_k]
98
+
99
+
100
+ def rank_matches_for_paths(store: ScarStore, paths: list[str], new_content: str,
101
+ top_k: int = DEFAULT_TOP_K) -> list[ScarMatch]:
102
+ """Best matches across several paths — one store walk, not one per path."""
103
+ firing = store.firing()
104
+ lists = []
105
+ for path in paths:
106
+ try:
107
+ rel = str((store.root / str(path)).resolve().relative_to(store.root))
108
+ except ValueError:
109
+ continue
110
+ lists.append(_match_target(firing, store.root, rel, new_content)[:top_k])
111
+ return merge_best_matches(lists, top_k)
112
+
113
+
114
+ def rank_for_edit(store: ScarStore, target: Path, new_content: str,
115
+ top_k: int = DEFAULT_TOP_K) -> list[Scar]:
116
+ """Top-k firing scars (active + challenged) relevant to editing `target`."""
117
+ return [m.scar for m in rank_matches_for_edit(store, target, new_content, top_k)]
118
+
119
+
120
+ def _diff_targets(diff_text: str) -> list[tuple[str, str]]:
121
+ """Return (path, added_content) pairs from a unified diff."""
122
+ targets: list[tuple[str, str]] = []
123
+ current: str | None = None
124
+ added: list[str] = []
125
+ for line in diff_text.splitlines():
126
+ if line.startswith("+++ "):
127
+ if current:
128
+ targets.append((current, "\n".join(added)))
129
+ raw = line[4:].strip()
130
+ current = raw[2:] if raw.startswith("b/") else raw
131
+ if current == "/dev/null":
132
+ current = None
133
+ added = []
134
+ elif current and line.startswith("+") and not line.startswith("+++ "):
135
+ added.append(line[1:])
136
+ if current:
137
+ targets.append((current, "\n".join(added)))
138
+ return targets
139
+
140
+
141
+ def rank_matches_for_diff(store: ScarStore, diff_text: str,
142
+ top_k: int = DEFAULT_TOP_K) -> list[ScarMatch]:
143
+ """Top-k firing scar matches across a unified diff (one store walk)."""
144
+ firing = store.firing()
145
+ lists = [_match_target(firing, store.root, rel_path, added)[:top_k]
146
+ for rel_path, added in _diff_targets(diff_text)]
147
+ return merge_best_matches(lists, top_k)