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.
- {scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +2 -1
- {scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +2 -1
- 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
- scar_cli-0.3.0/AGENTS.md +43 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/CHANGELOG.md +7 -0
- scar_cli-0.2.0/README.md → scar_cli-0.3.0/PKG-INFO +26 -1
- scar_cli-0.2.0/PKG-INFO → scar_cli-0.3.0/README.md +17 -10
- {scar_cli-0.2.0 → scar_cli-0.3.0}/SPEC.md +5 -2
- {scar_cli-0.2.0 → scar_cli-0.3.0}/pyproject.toml +1 -1
- scar_cli-0.3.0/src/scar/agent.py +66 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/src/scar/cli.py +55 -44
- {scar_cli-0.2.0 → scar_cli-0.3.0}/src/scar/hooks.py +4 -19
- scar_cli-0.3.0/src/scar/match.py +147 -0
- scar_cli-0.3.0/src/scar/mcp.py +235 -0
- scar_cli-0.3.0/src/scar/render.py +38 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/src/scar/store.py +9 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_cli.py +36 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_match.py +64 -1
- scar_cli-0.3.0/tests/test_mcp.py +92 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_store.py +12 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/uv.lock +1 -1
- scar_cli-0.2.0/src/scar/match.py +0 -51
- {scar_cli-0.2.0 → scar_cli-0.3.0}/.github/workflows/ci.yml +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/.github/workflows/pr-validation.yml +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/.github/workflows/release.yml +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/.gitignore +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/README.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/candidates/fp-log.txt +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/template.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/CONTRIBUTING.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/IDEA.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/LICENSE +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/ROADMAP.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/SCAR-FORMAT.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/STRESS-TEST.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/anchor-survival/PROTOCOL.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/anchor-survival/RESULTS.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/anchor-survival/long_replay.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/anchor-survival/replay.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/auto-authorship/FINDINGS.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/auto-authorship/PROTOCOL.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/.gitignore +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/PROTOCOL.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/RESULTS.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/fixture/README.md +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/fixture/payments/retry.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/fixture/reports/export.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/fixture/services/sessions.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/fence-honor/grade.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/experiments/harvest/harvest.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/hook/scar-hooks.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/src/scar/__init__.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/src/scar/harvest.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/src/scar/lint.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/src/scar/model.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_harvest.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_hooks.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_installer.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_lifecycle.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_lint.py +0 -0
- {scar_cli-0.2.0 → scar_cli-0.3.0}/tests/test_model.py +0 -0
{scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md
RENAMED
|
@@ -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
|
-
|
|
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
|
{scar_cli-0.2.0 → scar_cli-0.3.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md
RENAMED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
17
|
+
status: active
|
|
17
18
|
---
|
|
18
19
|
|
|
19
20
|
Scars cite commit SHAs as evidence receipts. Those receipts implicitly assume
|
scar_cli-0.3.0/AGENTS.md
ADDED
|
@@ -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
|
-
-
|
|
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
|
-
-
|
|
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 --
|
|
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
|
|
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
|
|
|
@@ -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 .
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
for f in
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if
|
|
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":
|
|
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"
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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)
|