fscars 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fscars/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Functional Scars — bolt-on correction primitive for AI coding agents.
2
+
3
+ Reference implementation of the framework described in
4
+ *Lucy Syndrome in LLM Agents: A Practitioner Framework for Cross-Session
5
+ Correction Persistence* (Del Puerto, 2026), DOI 10.5281/zenodo.19555971.
6
+ """
7
+
8
+ from fscars.core.fire import Fire, FireRecord, Severity
9
+ from fscars.core.payload import HookEventType, HookPayload
10
+ from fscars.core.scar import FunctionalScar, Scope
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = [
15
+ "Fire",
16
+ "FireRecord",
17
+ "FunctionalScar",
18
+ "HookEventType",
19
+ "HookPayload",
20
+ "Scope",
21
+ "Severity",
22
+ "__version__",
23
+ ]
@@ -0,0 +1 @@
1
+ """Platform-specific adapters that translate hook payloads to fscars HookPayload."""
@@ -0,0 +1,51 @@
1
+ """Adapter base class.
2
+
3
+ Each adapter wraps one AI coding agent platform (Claude Code, Codex CLI,
4
+ Cursor, etc.). The adapter knows:
5
+
6
+ - How to parse that platform's hook stdin payload.
7
+ - How to write the platform's settings/config so a single fscars entrypoint
8
+ receives every hook event.
9
+ - How to format the output the platform expects.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from abc import ABC, abstractmethod
15
+ from pathlib import Path
16
+
17
+ from fscars.core.payload import HookPayload
18
+ from fscars.core.scar import ScarOutput
19
+
20
+
21
+ class Adapter(ABC):
22
+ """Common interface for platform adapters."""
23
+
24
+ name: str = "abstract"
25
+
26
+ @abstractmethod
27
+ def parse_stdin(self, raw: dict) -> HookPayload | None:
28
+ """Translate a platform-specific JSON payload into a HookPayload.
29
+
30
+ Returns None if the payload is malformed. The engine treats None
31
+ as "do nothing".
32
+ """
33
+ ...
34
+
35
+ @abstractmethod
36
+ def emit_output(self, output: ScarOutput) -> str:
37
+ """Serialize ScarOutput in the format the platform expects on stdout."""
38
+ ...
39
+
40
+ @abstractmethod
41
+ def install(self, project_root: Path) -> None:
42
+ """Wire the entrypoint into the platform's hook config under project_root."""
43
+ ...
44
+
45
+ @abstractmethod
46
+ def uninstall(self, project_root: Path) -> None:
47
+ """Reverse install — remove the entrypoint registration."""
48
+ ...
49
+
50
+
51
+ __all__ = ["Adapter"]
@@ -0,0 +1,5 @@
1
+ """Claude Code adapter."""
2
+
3
+ from fscars.adapters.claude_code.adapter import ClaudeCodeAdapter
4
+
5
+ __all__ = ["ClaudeCodeAdapter"]
@@ -0,0 +1,171 @@
1
+ """Claude Code adapter — Anthropic's claude-code CLI.
2
+
3
+ Reads JSON from stdin in the shape Claude Code uses and writes
4
+ hookSpecificOutput in the shape it expects.
5
+
6
+ Reference: Claude Code hooks documentation
7
+ https://docs.anthropic.com/claude/docs/claude-code/hooks
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+
15
+ from fscars.adapters.base import Adapter
16
+ from fscars.core.payload import HookEventType, HookPayload
17
+ from fscars.core.scar import ScarOutput
18
+
19
+ # Map Claude Code event names → canonical fscars event types
20
+ _CC_TO_CANONICAL = {
21
+ "SessionStart": HookEventType.SESSION_START,
22
+ "SessionEnd": HookEventType.SESSION_END,
23
+ "UserPromptSubmit": HookEventType.USER_PROMPT_SUBMIT,
24
+ "PreToolUse": HookEventType.PRE_TOOL_USE,
25
+ "PostToolUse": HookEventType.POST_TOOL_USE,
26
+ "Stop": HookEventType.STOP,
27
+ "Notification": HookEventType.NOTIFICATION,
28
+ }
29
+
30
+
31
+ class ClaudeCodeAdapter(Adapter):
32
+ """Adapter for Anthropic Claude Code."""
33
+
34
+ name = "claude_code"
35
+
36
+ def parse_stdin(self, raw: dict) -> HookPayload | None:
37
+ """Convert Claude Code's stdin payload to a normalized HookPayload."""
38
+ if not isinstance(raw, dict):
39
+ return None
40
+
41
+ # Claude Code sometimes uses "hook_event_name", sometimes "event"
42
+ event_name = (
43
+ raw.get("hook_event_name")
44
+ or raw.get("event")
45
+ or raw.get("hookEventName")
46
+ or ""
47
+ )
48
+ canonical = _CC_TO_CANONICAL.get(event_name)
49
+ if canonical is None:
50
+ return None
51
+
52
+ try:
53
+ return HookPayload(
54
+ event_type=canonical,
55
+ tool_name=raw.get("tool_name"),
56
+ tool_input=raw.get("tool_input") or {},
57
+ prompt=raw.get("prompt"),
58
+ cwd=raw.get("cwd") or raw.get("workspace") or "",
59
+ session_id=raw.get("session_id") or "",
60
+ raw=raw,
61
+ )
62
+ except Exception:
63
+ return None
64
+
65
+ def emit_output(self, output: ScarOutput) -> str:
66
+ """Build the JSON string Claude Code expects on stdout.
67
+
68
+ On block, the run_hook script also exits with code 2 — the
69
+ decision="block" field is for surface visibility.
70
+ """
71
+ if output.is_empty:
72
+ return "{}"
73
+
74
+ payload: dict = {}
75
+ hook_specific: dict = {}
76
+ if output.additional_context:
77
+ hook_specific["additionalContext"] = output.additional_context
78
+ if output.block:
79
+ hook_specific["decision"] = "block"
80
+ if hook_specific:
81
+ payload["hookSpecificOutput"] = hook_specific
82
+ if output.system_message:
83
+ payload["systemMessage"] = output.system_message
84
+ return json.dumps(payload, ensure_ascii=False)
85
+
86
+ # -----------------------------------------------------------------
87
+ # install / uninstall — wire up `.claude/settings.json`
88
+ # -----------------------------------------------------------------
89
+
90
+ SETTINGS_FILE = ".claude/settings.json"
91
+ HOOK_COMMAND = "python -m fscars.run_hook --adapter claude_code"
92
+
93
+ def install(self, project_root: Path) -> None:
94
+ """Add fscars entries to .claude/settings.json under project_root.
95
+
96
+ Idempotent: re-running install does not duplicate entries.
97
+ """
98
+ settings_path = project_root / self.SETTINGS_FILE
99
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
100
+
101
+ if settings_path.exists():
102
+ try:
103
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
104
+ except json.JSONDecodeError:
105
+ settings = {}
106
+ else:
107
+ settings = {}
108
+
109
+ hooks = settings.setdefault("hooks", {})
110
+
111
+ wanted_events = (
112
+ "SessionStart",
113
+ "UserPromptSubmit",
114
+ "PreToolUse",
115
+ "PostToolUse",
116
+ "Stop",
117
+ )
118
+ entry = {"command": self.HOOK_COMMAND}
119
+
120
+ for event in wanted_events:
121
+ current = hooks.get(event) or []
122
+ if not isinstance(current, list):
123
+ current = [current]
124
+ already = any(
125
+ isinstance(c, dict) and c.get("command") == self.HOOK_COMMAND
126
+ for c in current
127
+ )
128
+ if not already:
129
+ current.append(entry)
130
+ hooks[event] = current
131
+
132
+ settings_path.write_text(
133
+ json.dumps(settings, indent=2, ensure_ascii=False) + "\n",
134
+ encoding="utf-8",
135
+ )
136
+
137
+ def uninstall(self, project_root: Path) -> None:
138
+ """Remove fscars entries from .claude/settings.json. Leaves other hooks alone."""
139
+ settings_path = project_root / self.SETTINGS_FILE
140
+ if not settings_path.exists():
141
+ return
142
+ try:
143
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
144
+ except json.JSONDecodeError:
145
+ return
146
+
147
+ hooks = settings.get("hooks") or {}
148
+ for event, entries in list(hooks.items()):
149
+ if not isinstance(entries, list):
150
+ continue
151
+ kept = [
152
+ e
153
+ for e in entries
154
+ if not (isinstance(e, dict) and e.get("command") == self.HOOK_COMMAND)
155
+ ]
156
+ if kept:
157
+ hooks[event] = kept
158
+ else:
159
+ hooks.pop(event)
160
+ if hooks:
161
+ settings["hooks"] = hooks
162
+ elif "hooks" in settings:
163
+ settings.pop("hooks")
164
+
165
+ settings_path.write_text(
166
+ json.dumps(settings, indent=2, ensure_ascii=False) + "\n",
167
+ encoding="utf-8",
168
+ )
169
+
170
+
171
+ __all__ = ["ClaudeCodeAdapter"]
fscars/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI package — exposes the `fscar` command via Typer."""
@@ -0,0 +1 @@
1
+ """Subcommand modules for the fscar CLI."""
@@ -0,0 +1,53 @@
1
+ """`fscar disable` — disable a scar without deleting it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from fscars.core.store import default_store
10
+
11
+
12
+ def run(
13
+ scar_id: str = typer.Argument(..., help="The scar_id to disable."),
14
+ project: Path = typer.Option(
15
+ Path("."),
16
+ "--project",
17
+ "-p",
18
+ help="Project root.",
19
+ file_okay=False,
20
+ resolve_path=True,
21
+ ),
22
+ enable: bool = typer.Option(
23
+ False, "--enable", help="Re-enable a previously disabled scar."
24
+ ),
25
+ ) -> None:
26
+ """Add or remove a scar_id from .fscars/disabled.txt."""
27
+ store = default_store(project)
28
+ if not store.exists():
29
+ typer.secho(
30
+ f"No fscars store at {store.root}. Run `fscar init` first.",
31
+ fg=typer.colors.RED,
32
+ )
33
+ raise typer.Exit(code=1)
34
+
35
+ disabled = store.disabled_scars()
36
+ if enable:
37
+ if scar_id not in disabled:
38
+ typer.echo(f"{scar_id} is not currently disabled.")
39
+ return
40
+ disabled.discard(scar_id)
41
+ typer.secho(f"[OK] Re-enabled {scar_id}", fg=typer.colors.GREEN)
42
+ else:
43
+ if scar_id in disabled:
44
+ typer.echo(f"{scar_id} is already disabled.")
45
+ return
46
+ disabled.add(scar_id)
47
+ typer.secho(f"[OK] Disabled {scar_id}", fg=typer.colors.YELLOW)
48
+
49
+ lines = sorted(disabled)
50
+ store.disabled_file.write_text(
51
+ "\n".join(lines) + ("\n" if lines else ""),
52
+ encoding="utf-8",
53
+ )
@@ -0,0 +1,92 @@
1
+ """`fscar doctor` — diagnose installation and hook wiring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from fscars import __version__
11
+ from fscars.core.store import default_store
12
+
13
+
14
+ def run(
15
+ project: Path = typer.Option(
16
+ Path("."),
17
+ "--project",
18
+ "-p",
19
+ help="Project root.",
20
+ file_okay=False,
21
+ resolve_path=True,
22
+ ),
23
+ ) -> None:
24
+ """Run a self-check on a project and report PASS / WARN / FAIL items."""
25
+ typer.echo(f"fscars version: {__version__}")
26
+ typer.echo(f"project root : {project}")
27
+ typer.echo("")
28
+
29
+ store = default_store(project)
30
+
31
+ items: list[tuple[str, str, str]] = []
32
+
33
+ items.append(
34
+ ("PASS" if store.root.exists() else "FAIL", ".fscars/ exists", str(store.root))
35
+ )
36
+ items.append(
37
+ (
38
+ "PASS" if store.config_file.exists() else "WARN",
39
+ "config.toml present",
40
+ str(store.config_file),
41
+ )
42
+ )
43
+ items.append(
44
+ (
45
+ "PASS" if store.logs_dir.exists() else "WARN",
46
+ "logs/ directory ready",
47
+ str(store.logs_dir),
48
+ )
49
+ )
50
+
51
+ settings_path = project / ".claude" / "settings.json"
52
+ settings_ok = False
53
+ if settings_path.exists():
54
+ try:
55
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
56
+ hooks = settings.get("hooks") or {}
57
+ settings_ok = any(
58
+ isinstance(v, list)
59
+ and any(
60
+ isinstance(item, dict)
61
+ and "fscars.run_hook" in str(item.get("command", ""))
62
+ for item in v
63
+ )
64
+ for v in hooks.values()
65
+ )
66
+ except json.JSONDecodeError:
67
+ settings_ok = False
68
+ items.append(
69
+ (
70
+ "PASS" if settings_ok else "WARN",
71
+ "Claude Code hook wired",
72
+ str(settings_path),
73
+ )
74
+ )
75
+
76
+ fail = False
77
+ for status, label, detail in items:
78
+ color = {
79
+ "PASS": typer.colors.GREEN,
80
+ "WARN": typer.colors.YELLOW,
81
+ "FAIL": typer.colors.RED,
82
+ }.get(status, typer.colors.WHITE)
83
+ typer.secho(f"[{status}] ", fg=color, nl=False)
84
+ typer.echo(f"{label} — {detail}")
85
+ if status == "FAIL":
86
+ fail = True
87
+
88
+ typer.echo("")
89
+ if fail:
90
+ typer.secho("doctor: one or more checks failed.", fg=typer.colors.RED)
91
+ raise typer.Exit(code=1)
92
+ typer.secho("doctor: all checks passed.", fg=typer.colors.GREEN)
@@ -0,0 +1,51 @@
1
+ """`fscar init` — wire fscars into the current project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from fscars.adapters.claude_code import ClaudeCodeAdapter
10
+ from fscars.core.store import default_store
11
+
12
+
13
+ def run(
14
+ path: Path = typer.Argument(
15
+ Path("."),
16
+ help="Project root (defaults to the current directory).",
17
+ file_okay=False,
18
+ resolve_path=True,
19
+ ),
20
+ adapter: str = typer.Option(
21
+ "claude_code",
22
+ "--adapter",
23
+ "-a",
24
+ help="Which AI coding agent to wire up.",
25
+ ),
26
+ ) -> None:
27
+ """Create .fscars/ and register the hook entrypoint with the chosen adapter."""
28
+ project_root = path
29
+ project_root.mkdir(parents=True, exist_ok=True)
30
+
31
+ store = default_store(project_root)
32
+ fresh = not store.exists()
33
+ store.initialize()
34
+
35
+ if adapter == "claude_code":
36
+ ClaudeCodeAdapter().install(project_root)
37
+ wired = ".claude/settings.json"
38
+ else:
39
+ raise typer.BadParameter(f"Unknown adapter: {adapter}")
40
+
41
+ if fresh:
42
+ typer.secho(
43
+ f"[OK] Initialized fscars at {store.root}",
44
+ fg=typer.colors.GREEN,
45
+ )
46
+ else:
47
+ typer.echo(f"[OK] fscars already initialized at {store.root}")
48
+ typer.echo(f"[OK] Wired hook entry into {project_root / wired}")
49
+ typer.echo("")
50
+ typer.echo("Next: copy a starter scar from `cookbook/scars/`, or run")
51
+ typer.echo(" `fscar fire <name> \"<rule>\"` to register one inline.")
@@ -0,0 +1,63 @@
1
+ """`fscar list` — show registered scars and their fire counts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from fscars.core.engine import ScarRegistry
13
+ from fscars.core.log import read_fires
14
+ from fscars.core.store import default_store
15
+
16
+
17
+ def run(
18
+ project: Path = typer.Option(
19
+ Path("."),
20
+ "--project",
21
+ "-p",
22
+ help="Project root (defaults to the current directory).",
23
+ file_okay=False,
24
+ resolve_path=True,
25
+ ),
26
+ ) -> None:
27
+ """List active scars with last-fire timestamps."""
28
+ registry = ScarRegistry.load_builtins()
29
+ store = default_store(project)
30
+
31
+ fires = read_fires(root=store.root)
32
+ counts: Counter[str] = Counter(f.scar_id for f in fires)
33
+ last_fire: dict[str, str] = {}
34
+ for f in fires:
35
+ last_fire.setdefault(f.scar_id, f.timestamp)
36
+ if f.timestamp > last_fire[f.scar_id]:
37
+ last_fire[f.scar_id] = f.timestamp
38
+
39
+ table = Table(title="Functional Scars", show_lines=False)
40
+ table.add_column("scar_id")
41
+ table.add_column("name")
42
+ table.add_column("event")
43
+ table.add_column("severity")
44
+ table.add_column("fires", justify="right")
45
+ table.add_column("last fire")
46
+ table.add_column("enabled")
47
+
48
+ scars = registry.all()
49
+ if not scars:
50
+ typer.echo("No scars registered. Add one in cookbook/scars/.")
51
+ return
52
+
53
+ for scar in scars:
54
+ table.add_row(
55
+ scar.scar_id,
56
+ scar.name or "-",
57
+ scar.event_type.value,
58
+ scar.severity.value,
59
+ str(counts.get(scar.scar_id, 0)),
60
+ last_fire.get(scar.scar_id, "-"),
61
+ "yes" if scar.enabled else "no",
62
+ )
63
+ Console().print(table)
@@ -0,0 +1,66 @@
1
+ """`fscar log` — show recent fires."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from fscars.core.log import read_fires
12
+ from fscars.core.store import default_store
13
+
14
+
15
+ def run(
16
+ project: Path = typer.Option(
17
+ Path("."),
18
+ "--project",
19
+ "-p",
20
+ help="Project root.",
21
+ file_okay=False,
22
+ resolve_path=True,
23
+ ),
24
+ scar: str | None = typer.Option(
25
+ None, "--scar", "-s", help="Filter to a single scar_id."
26
+ ),
27
+ session: str | None = typer.Option(
28
+ None, "--session", help="Filter to a single session_id."
29
+ ),
30
+ n: int = typer.Option(20, "-n", help="Number of recent fires to show."),
31
+ ) -> None:
32
+ """Show the most recent fires from .fscars/logs/fires.jsonl."""
33
+ store = default_store(project)
34
+ fires = read_fires(root=store.root)
35
+
36
+ if scar:
37
+ fires = [f for f in fires if f.scar_id == scar]
38
+ if session:
39
+ fires = [f for f in fires if f.session_id == session]
40
+
41
+ fires = fires[-n:]
42
+ if not fires:
43
+ typer.echo("No fires recorded yet.")
44
+ return
45
+
46
+ table = Table(title=f"Recent fires (showing {len(fires)})", show_lines=False)
47
+ table.add_column("timestamp")
48
+ table.add_column("scar_id")
49
+ table.add_column("event")
50
+ table.add_column("action")
51
+ table.add_column("tool")
52
+ table.add_column("trigger")
53
+ table.add_column("ms", justify="right")
54
+
55
+ for f in fires:
56
+ table.add_row(
57
+ f.timestamp,
58
+ f.scar_id,
59
+ f.event_type if isinstance(f.event_type, str) else f.event_type.value,
60
+ f.action if isinstance(f.action, str) else f.action.value,
61
+ f.tool_name or "-",
62
+ (f.trigger_match or "")[:40],
63
+ f"{f.latency_ms:.1f}" if f.latency_ms is not None else "-",
64
+ )
65
+
66
+ Console().print(table)
@@ -0,0 +1,76 @@
1
+ """`fscar stats` — compute persistence metrics from fires.jsonl."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import statistics
6
+ from collections import Counter
7
+ from pathlib import Path
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from fscars.core.log import read_fires
14
+ from fscars.core.store import default_store
15
+
16
+
17
+ def run(
18
+ project: Path = typer.Option(
19
+ Path("."),
20
+ "--project",
21
+ "-p",
22
+ help="Project root.",
23
+ file_okay=False,
24
+ resolve_path=True,
25
+ ),
26
+ ) -> None:
27
+ """Show fire counts, average latency, and tokens added by scar."""
28
+ store = default_store(project)
29
+ fires = read_fires(root=store.root)
30
+
31
+ if not fires:
32
+ typer.echo("No fires recorded yet — nothing to compute.")
33
+ return
34
+
35
+ counts: Counter[str] = Counter(f.scar_id for f in fires)
36
+ by_scar: dict[str, list[float]] = {}
37
+ tokens_total: Counter[str] = Counter()
38
+ blocked_total: Counter[str] = Counter()
39
+
40
+ for f in fires:
41
+ if f.latency_ms is not None and f.latency_ms >= 0:
42
+ by_scar.setdefault(f.scar_id, []).append(f.latency_ms)
43
+ tokens_total[f.scar_id] += f.tokens_added
44
+ action_value = f.action if isinstance(f.action, str) else f.action.value
45
+ if action_value == "blocked":
46
+ blocked_total[f.scar_id] += 1
47
+
48
+ table = Table(title=f"fscar stats ({len(fires)} fires)", show_lines=False)
49
+ table.add_column("scar_id")
50
+ table.add_column("fires", justify="right")
51
+ table.add_column("blocked", justify="right")
52
+ table.add_column("p50 ms", justify="right")
53
+ table.add_column("p99 ms", justify="right")
54
+ table.add_column("tokens added", justify="right")
55
+
56
+ for scar_id, n_fires in counts.most_common():
57
+ latencies = by_scar.get(scar_id, [])
58
+ if latencies:
59
+ p50 = f"{statistics.median(latencies):.1f}"
60
+ p99 = (
61
+ f"{statistics.quantiles(latencies, n=100)[98]:.1f}"
62
+ if len(latencies) >= 100
63
+ else f"{max(latencies):.1f}"
64
+ )
65
+ else:
66
+ p50 = p99 = "-"
67
+ table.add_row(
68
+ scar_id,
69
+ str(n_fires),
70
+ str(blocked_total.get(scar_id, 0)),
71
+ p50,
72
+ p99,
73
+ str(tokens_total[scar_id]),
74
+ )
75
+
76
+ Console().print(table)