scar-cli 0.1.1__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.1.1 → scar_cli-0.3.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +2 -1
- {scar_cli-0.1.1 → scar_cli-0.3.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +2 -1
- scar_cli-0.1.1/.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/.scars/candidates/fp-log.txt +1 -0
- scar_cli-0.3.0/AGENTS.md +43 -0
- scar_cli-0.3.0/CHANGELOG.md +27 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/PKG-INFO +19 -3
- {scar_cli-0.1.1 → scar_cli-0.3.0}/README.md +18 -2
- {scar_cli-0.1.1 → scar_cli-0.3.0}/SPEC.md +5 -2
- {scar_cli-0.1.1 → scar_cli-0.3.0}/pyproject.toml +1 -1
- scar_cli-0.3.0/src/scar/agent.py +66 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/src/scar/cli.py +92 -42
- {scar_cli-0.1.1 → scar_cli-0.3.0}/src/scar/hooks.py +4 -18
- {scar_cli-0.1.1 → scar_cli-0.3.0}/src/scar/lint.py +10 -1
- 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.1.1 → scar_cli-0.3.0}/src/scar/store.py +35 -4
- {scar_cli-0.1.1 → scar_cli-0.3.0}/tests/test_cli.py +36 -0
- scar_cli-0.3.0/tests/test_lifecycle.py +137 -0
- {scar_cli-0.1.1 → 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.1.1 → scar_cli-0.3.0}/tests/test_store.py +12 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/uv.lock +1 -1
- scar_cli-0.1.1/CHANGELOG.md +0 -8
- scar_cli-0.1.1/src/scar/match.py +0 -51
- {scar_cli-0.1.1 → scar_cli-0.3.0}/.github/workflows/ci.yml +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/.github/workflows/pr-validation.yml +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/.github/workflows/release.yml +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/.gitignore +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/.scars/README.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/.scars/template.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/CONTRIBUTING.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/IDEA.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/LICENSE +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/ROADMAP.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/SCAR-FORMAT.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/STRESS-TEST.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/anchor-survival/PROTOCOL.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/anchor-survival/RESULTS.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/anchor-survival/long_replay.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/anchor-survival/replay.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/auto-authorship/FINDINGS.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/auto-authorship/PROTOCOL.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/fence-honor/.gitignore +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/fence-honor/PROTOCOL.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/fence-honor/RESULTS.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/fence-honor/fixture/README.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/fence-honor/fixture/payments/retry.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/fence-honor/fixture/reports/export.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/fence-honor/fixture/services/sessions.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/fence-honor/grade.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/experiments/harvest/harvest.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/hook/scar-hooks.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/src/scar/__init__.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/src/scar/harvest.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/src/scar/model.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/tests/test_harvest.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/tests/test_hooks.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/tests/test_installer.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/tests/test_lint.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.3.0}/tests/test_model.py +0 -0
{scar_cli-0.1.1 → 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.1.1 → 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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2026-06-12 false trigger: meta-session — we tuned the revert-language detector itself, so assistant prose ('revert language', 'reverting' in test fixtures/PR text) matched REVERT_RE; nothing abandoned (tool_errors were expected CLI probes/rejections). First post-tune FP pattern: self-referential sessions about the drafter trip the drafter.
|
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.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
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
|
+
|
|
10
|
+
## [0.2.0](https://github.com/Daily-Nerd/Scar/compare/v0.1.1...v0.2.0) (2026-06-12)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **cli:** lifecycle v0 — challenge, archive, review_after surfacing ([#16](https://github.com/Daily-Nerd/Scar/issues/16)) ([0c6fb05](https://github.com/Daily-Nerd/Scar/commit/0c6fb05fbdbb57f8ac9b2a5b558e4cf121c3d5c0)), closes [#14](https://github.com/Daily-Nerd/Scar/issues/14)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Documentation
|
|
19
|
+
|
|
20
|
+
* **readme:** scar challenge is planned, not shipped — point to lifecycle issue ([8c6b021](https://github.com/Daily-Nerd/Scar/commit/8c6b021c95299cf40bf6c2d978a0421bb9705cb6))
|
|
21
|
+
|
|
22
|
+
## [0.1.1](https://github.com/Daily-Nerd/Scar/compare/v0.1.0...v0.1.1) (2026-06-12)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Bug Fixes
|
|
26
|
+
|
|
27
|
+
* **hooks:** drafter triggers on revert language only ([#12](https://github.com/Daily-Nerd/Scar/issues/12)) ([547c4bb](https://github.com/Daily-Nerd/Scar/commit/547c4bb21e3521682b6a4046602d6703d88c2cf1)), closes [#11](https://github.com/Daily-Nerd/Scar/issues/11)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scar-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: SCAR — version control for negative knowledge (deadends, fences, landmines)
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -49,9 +49,9 @@ The flip side: agents also solve the historically fatal flaw of every knowledge-
|
|
|
49
49
|
- Enforcement happens **at the moment of action**:
|
|
50
50
|
- `scar check <path>` — CLI gate for humans and CI
|
|
51
51
|
- Agent hook (Claude Code `PreToolUse`, etc.) — injects relevant scars into the agent's context *before* it edits the file
|
|
52
|
-
-
|
|
52
|
+
- `scar mcp` — local MCP server, so MCP-capable agents can query and draft scars
|
|
53
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
|
|
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.
|
|
55
55
|
|
|
56
56
|
## Install
|
|
57
57
|
|
|
@@ -86,6 +86,22 @@ Wiring the Claude Code hook (auto-injects scars before any agent edit):
|
|
|
86
86
|
scar hook install
|
|
87
87
|
```
|
|
88
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
|
+
|
|
89
105
|
## Quality discipline
|
|
90
106
|
|
|
91
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.
|
|
@@ -40,9 +40,9 @@ The flip side: agents also solve the historically fatal flaw of every knowledge-
|
|
|
40
40
|
- Enforcement happens **at the moment of action**:
|
|
41
41
|
- `scar check <path>` — CLI gate for humans and CI
|
|
42
42
|
- Agent hook (Claude Code `PreToolUse`, etc.) — injects relevant scars into the agent's context *before* it edits the file
|
|
43
|
-
-
|
|
43
|
+
- `scar mcp` — local MCP server, so MCP-capable agents can query and draft scars
|
|
44
44
|
- `scar harvest` — mines git history (reverts, add-then-remove dependencies, reopened issues) to propose candidate scars for codebases starting from zero.
|
|
45
|
-
- Scars are **advisory, never blocking, by default
|
|
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.
|
|
46
46
|
|
|
47
47
|
## Install
|
|
48
48
|
|
|
@@ -77,6 +77,22 @@ Wiring the Claude Code hook (auto-injects scars before any agent edit):
|
|
|
77
77
|
scar hook install
|
|
78
78
|
```
|
|
79
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
|
+
|
|
80
96
|
## Quality discipline
|
|
81
97
|
|
|
82
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]
|
|
@@ -10,15 +10,14 @@ from __future__ import annotations
|
|
|
10
10
|
import argparse
|
|
11
11
|
import json
|
|
12
12
|
import sys
|
|
13
|
+
import time
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
15
16
|
from .lint import lint_text
|
|
16
|
-
from .match import rank_for_edit
|
|
17
|
-
from .
|
|
17
|
+
from .match import rank_for_edit, rank_matches_for_diff, rank_matches_for_edit
|
|
18
|
+
from .render import injection_context, label_line
|
|
18
19
|
from .store import ScarStore, init_scars
|
|
19
20
|
|
|
20
|
-
MAX_BODY_CHARS = 700 # ~120 words — the fatigue budget is a format guarantee
|
|
21
|
-
|
|
22
21
|
|
|
23
22
|
def _require_store(start: Path | None = None) -> ScarStore | None:
|
|
24
23
|
store = ScarStore.discover(start or Path.cwd())
|
|
@@ -58,8 +57,16 @@ def _cmd_status(_args) -> int:
|
|
|
58
57
|
print(f"{store.scars_dir}: {len(active)} active, {len(cands)} candidate(s) pending review")
|
|
59
58
|
for f, s in active:
|
|
60
59
|
print(f" [{s.type} #{s.id} | {s.severity}] {s.title}")
|
|
60
|
+
for f, s in store.parsed():
|
|
61
|
+
if s.status == "challenged":
|
|
62
|
+
print(f" [challenged {s.type} #{s.id}] {s.title}")
|
|
61
63
|
for c in cands:
|
|
62
64
|
print(f" candidate: {c.name}")
|
|
65
|
+
today = time.strftime("%Y-%m-%d")
|
|
66
|
+
due = [s for _, s in store.firing() if s.review_after and s.review_after < today]
|
|
67
|
+
for s in due:
|
|
68
|
+
print(f" REVIEW DUE [{s.type} #{s.id}] review_after {s.review_after} — "
|
|
69
|
+
"re-verify, then update the date or archive")
|
|
63
70
|
if broken:
|
|
64
71
|
print(f" WARNING: {len(broken)} unparseable (can NEVER fire): "
|
|
65
72
|
+ ", ".join(b.name for b in broken))
|
|
@@ -94,62 +101,67 @@ def _cmd_check(args) -> int:
|
|
|
94
101
|
print(f"no scars anchored to {args.path}")
|
|
95
102
|
return 0
|
|
96
103
|
for s in hits:
|
|
97
|
-
print(
|
|
104
|
+
print(label_line(s))
|
|
98
105
|
print(" " + s.body[:200].replace("\n", "\n "))
|
|
99
106
|
return 0
|
|
100
107
|
|
|
101
108
|
|
|
109
|
+
def _cmd_transition(args, new_status: str) -> int:
|
|
110
|
+
store = _require_store()
|
|
111
|
+
if store is None:
|
|
112
|
+
return 1
|
|
113
|
+
try:
|
|
114
|
+
path = store.transition(args.id, new_status, reason=args.reason,
|
|
115
|
+
date=time.strftime("%Y-%m-%d"))
|
|
116
|
+
except ValueError as exc:
|
|
117
|
+
print(str(exc))
|
|
118
|
+
return 1
|
|
119
|
+
verb = ("still fires, marked as disputed — resolve by archiving or "
|
|
120
|
+
"re-validating" if new_status == "challenged"
|
|
121
|
+
else "never fires again; history kept (scar why still shows it)")
|
|
122
|
+
print(f"{new_status} -> {path.relative_to(store.root)} ({verb})")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
102
126
|
def _cmd_why(args) -> int:
|
|
103
127
|
"""History of pain for a path: every scar that anchors it, any status."""
|
|
104
128
|
store = _require_store(Path(args.path).resolve())
|
|
105
129
|
if store is None:
|
|
106
130
|
return 1
|
|
107
131
|
rel = str(Path(args.path).resolve().relative_to(store.root))
|
|
108
|
-
|
|
109
|
-
for f in
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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:
|
|
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:
|
|
122
137
|
print(f"no recorded pain for {rel}")
|
|
123
138
|
return 0
|
|
124
139
|
|
|
125
140
|
|
|
126
141
|
def _cmd_inject(args) -> int:
|
|
127
142
|
"""Machine mode for hooks: JSON additionalContext or silence."""
|
|
128
|
-
|
|
143
|
+
start = Path(args.path).resolve() if args.path else Path.cwd()
|
|
144
|
+
store = ScarStore.discover(start)
|
|
129
145
|
if store is None:
|
|
130
146
|
return 0 # hooks must never fail the edit
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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:
|
|
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:
|
|
151
163
|
print(json.dumps({"hookSpecificOutput": {
|
|
152
|
-
"hookEventName": args.hook_event, "additionalContext":
|
|
164
|
+
"hookEventName": args.hook_event, "additionalContext": context}}))
|
|
153
165
|
return 0
|
|
154
166
|
|
|
155
167
|
|
|
@@ -175,6 +187,20 @@ def _cmd_harvest(args) -> int:
|
|
|
175
187
|
return 0
|
|
176
188
|
|
|
177
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
|
+
|
|
178
204
|
def main(argv: list[str] | None = None) -> int:
|
|
179
205
|
parser = argparse.ArgumentParser(prog="scar",
|
|
180
206
|
description="version control for negative knowledge")
|
|
@@ -196,26 +222,50 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
196
222
|
p = sub.add_parser("why", help="history of pain for a path (any status)")
|
|
197
223
|
p.add_argument("path")
|
|
198
224
|
|
|
225
|
+
p = sub.add_parser("challenge", help="dispute a scar (still fires, marked challenged)")
|
|
226
|
+
p.add_argument("id", type=int)
|
|
227
|
+
p.add_argument("--reason", required=True, help="why the scar may no longer hold")
|
|
228
|
+
|
|
229
|
+
p = sub.add_parser("archive", help="retire a scar (never fires; history kept)")
|
|
230
|
+
p.add_argument("id", type=int)
|
|
231
|
+
p.add_argument("--reason", required=True, help="why it is retired (e.g. expiry condition met)")
|
|
232
|
+
|
|
199
233
|
p = sub.add_parser("harvest", help="mine git history for candidate scars")
|
|
200
234
|
p.add_argument("repo", nargs="?", default=".")
|
|
201
235
|
|
|
202
236
|
p = sub.add_parser("hook", help="Claude Code hook handlers (payload on stdin)")
|
|
203
237
|
p.add_argument("kind", choices=["precheck", "session-notice", "stop-drafter"])
|
|
204
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
|
+
|
|
205
247
|
p = sub.add_parser("inject", help="machine mode for hooks: JSON or silence")
|
|
206
|
-
p.add_argument("--path"
|
|
248
|
+
p.add_argument("--path")
|
|
207
249
|
p.add_argument("--content", default="")
|
|
250
|
+
p.add_argument("--diff", help="unified diff text, or path to a diff file")
|
|
208
251
|
p.add_argument("--top-k", type=int, default=3)
|
|
209
252
|
p.add_argument("--hook-event", default="PreToolUse")
|
|
210
253
|
|
|
211
254
|
args = parser.parse_args(argv)
|
|
255
|
+
if args.command == "mcp":
|
|
256
|
+
from .mcp import serve
|
|
257
|
+
return serve()
|
|
212
258
|
if args.command == "hook":
|
|
213
259
|
from .hooks import HANDLERS # hot path: imports nothing beyond library
|
|
214
260
|
return HANDLERS[args.kind]()
|
|
261
|
+
if args.command in ("challenge", "archive"):
|
|
262
|
+
status = {"challenge": "challenged", "archive": "archived"}[args.command]
|
|
263
|
+
return _cmd_transition(args, status)
|
|
215
264
|
handler = {
|
|
216
265
|
"init": _cmd_init, "lint": _cmd_lint, "status": _cmd_status,
|
|
217
266
|
"promote": _cmd_promote, "check": _cmd_check, "why": _cmd_why,
|
|
218
267
|
"inject": _cmd_inject, "harvest": _cmd_harvest,
|
|
268
|
+
"agent": _cmd_agent,
|
|
219
269
|
}[args.command]
|
|
220
270
|
return handler(args)
|
|
221
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,22 +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"[{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))
|
|
73
|
+
context = injection_context(hits, store.broken(), store.scars_dir)
|
|
74
|
+
if context:
|
|
75
|
+
_emit("PreToolUse", context)
|
|
90
76
|
return 0
|
|
91
77
|
|
|
92
78
|
|
|
@@ -6,6 +6,7 @@ contract in .scars/README.md.
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import re
|
|
9
|
+
import time
|
|
9
10
|
from dataclasses import dataclass
|
|
10
11
|
|
|
11
12
|
from .model import SEVERITIES, STATUSES, TYPES, ParseError, parse_scar_text
|
|
@@ -20,11 +21,12 @@ class Finding:
|
|
|
20
21
|
return f"{self.level}: {self.message}"
|
|
21
22
|
|
|
22
23
|
|
|
23
|
-
def lint_text(text: str) -> list[Finding]:
|
|
24
|
+
def lint_text(text: str, today: str | None = None) -> list[Finding]:
|
|
24
25
|
try:
|
|
25
26
|
scar = parse_scar_text(text)
|
|
26
27
|
except ParseError:
|
|
27
28
|
return [Finding("error", "missing YAML frontmatter — this scar can NEVER fire")]
|
|
29
|
+
today = today or time.strftime("%Y-%m-%d")
|
|
28
30
|
|
|
29
31
|
findings = []
|
|
30
32
|
if scar.type not in TYPES:
|
|
@@ -44,6 +46,13 @@ def lint_text(text: str) -> list[Finding]:
|
|
|
44
46
|
findings.append(Finding("error", f"invalid pattern anchor /{pat}/: {exc}"))
|
|
45
47
|
if not scar.evidence:
|
|
46
48
|
findings.append(Finding("warning", "no evidence links — challengeable on sight"))
|
|
49
|
+
# ISO dates compare correctly as strings; never an error — a human
|
|
50
|
+
# decides whether to archive (ADR-4), lint only surfaces the due date
|
|
51
|
+
if (scar.status in ("active", "challenged") and scar.review_after
|
|
52
|
+
and scar.review_after < today):
|
|
53
|
+
findings.append(Finding(
|
|
54
|
+
"warning", f"review_after {scar.review_after} is past — re-verify "
|
|
55
|
+
"the scar still holds, then update the date or archive it"))
|
|
47
56
|
if not scar.body:
|
|
48
57
|
findings.append(Finding("warning", "empty body — future readers get no why"))
|
|
49
58
|
return findings
|