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 +23 -0
- fscars/adapters/__init__.py +1 -0
- fscars/adapters/base.py +51 -0
- fscars/adapters/claude_code/__init__.py +5 -0
- fscars/adapters/claude_code/adapter.py +171 -0
- fscars/cli/__init__.py +1 -0
- fscars/cli/commands/__init__.py +1 -0
- fscars/cli/commands/disable.py +53 -0
- fscars/cli/commands/doctor.py +92 -0
- fscars/cli/commands/init.py +51 -0
- fscars/cli/commands/list_cmd.py +63 -0
- fscars/cli/commands/log_cmd.py +66 -0
- fscars/cli/commands/stats.py +76 -0
- fscars/cli/main.py +50 -0
- fscars/core/__init__.py +1 -0
- fscars/core/engine.py +149 -0
- fscars/core/fire.py +181 -0
- fscars/core/log.py +58 -0
- fscars/core/payload.py +117 -0
- fscars/core/scar.py +150 -0
- fscars/core/store.py +69 -0
- fscars/run_hook.py +69 -0
- fscars-0.1.0.dist-info/METADATA +438 -0
- fscars-0.1.0.dist-info/RECORD +27 -0
- fscars-0.1.0.dist-info/WHEEL +4 -0
- fscars-0.1.0.dist-info/entry_points.txt +2 -0
- fscars-0.1.0.dist-info/licenses/LICENSE +201 -0
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."""
|
fscars/adapters/base.py
ADDED
|
@@ -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,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)
|