claude-code-arcane 2.14.0 → 2.15.0
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.
- package/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/dist/cli.js +31 -14
- package/docs/USER-GUIDE.md +2 -2
- package/docs/context-management.md +1 -0
- package/docs/directory-structure.md +2 -1
- package/hooks/run-py.sh +20 -0
- package/hooks/session_watch.py +278 -0
- package/hooks/statusline.sh +15 -0
- package/package.json +1 -1
- package/profiles/core.yaml +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
# [2.15.0](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.14.0...v2.15.0) (2026-10-16)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **hooks:** watch the session for loops, scope and unchecked edits ([84d7c3e](https://github.com/SebastianLuser/claude-code-arcane/commit/84d7c3eccc33380f27d7f8901affedb090ee21d5))
|
|
7
|
+
* **installer:** wire the session watch and feed it the real context window ([615d212](https://github.com/SebastianLuser/claude-code-arcane/commit/615d212d96744c180fe9f84f7670382ec58d2772))
|
|
8
|
+
|
|
1
9
|
# [2.14.0](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.13.0...v2.14.0) (2026-10-16)
|
|
2
10
|
|
|
3
11
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Claude Code Arcane
|
|
2
2
|
|
|
3
|
-
> **437 skills, 115 agents,
|
|
3
|
+
> **437 skills, 115 agents, 18 hooks and 36 rules for Claude Code — selective deploy by profile.**
|
|
4
4
|
|
|
5
5
|
A configuration harness installable via `npx`. Pick a **profile** that matches your stack and only the relevant tools get installed into your project's `.claude/` directory.
|
|
6
6
|
|
package/dist/cli.js
CHANGED
|
@@ -880,16 +880,19 @@ var FILE_TOOLS_MATCHER = "Write|Edit|MultiEdit|NotebookEdit";
|
|
|
880
880
|
var LEGACY_COMMAND = /^bash \.claude\/hooks\/([\w.-]+\.sh)$/;
|
|
881
881
|
var ARCANE_COMMAND = /^bash "?(?:\$CLAUDE_PROJECT_DIR\/|[^"]*\/)?\.claude\/hooks\/([\w.-]+\.sh)"?$/;
|
|
882
882
|
var RETIRED_SCRIPTS = /* @__PURE__ */ new Set(["validate-push.sh", "validate-secrets.sh"]);
|
|
883
|
-
function hookCommand(target, script) {
|
|
883
|
+
function hookCommand(target, script, args = "") {
|
|
884
|
+
const suffix = args ? ` ${args}` : "";
|
|
884
885
|
if (path8.resolve(target) === path8.resolve(os2.homedir())) {
|
|
885
886
|
const absolute = path8.join(target, ".claude", "hooks", script).replace(/\\/g, "/");
|
|
886
|
-
return `bash "${absolute}"`;
|
|
887
|
+
return `bash "${absolute}"${suffix}`;
|
|
887
888
|
}
|
|
888
|
-
return `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/${script}"`;
|
|
889
|
+
return `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/${script}"${suffix}`;
|
|
889
890
|
}
|
|
890
|
-
function hook(target, script, timeout) {
|
|
891
|
-
return { type: "command", command: hookCommand(target, script), timeout };
|
|
891
|
+
function hook(target, script, timeout, args = "") {
|
|
892
|
+
return { type: "command", command: hookCommand(target, script, args), timeout };
|
|
892
893
|
}
|
|
894
|
+
var WATCH_POST_TOOL = ["run-py.sh", "session_watch.py post-tool"];
|
|
895
|
+
var WATCH_STOP = ["run-py.sh", "session_watch.py stop"];
|
|
893
896
|
function defaultHooks(target) {
|
|
894
897
|
const one = (script, timeout) => [
|
|
895
898
|
{ matcher: "", hooks: [hook(target, script, timeout)] }
|
|
@@ -921,13 +924,21 @@ function defaultHooks(target) {
|
|
|
921
924
|
{
|
|
922
925
|
matcher: "Write|Edit",
|
|
923
926
|
hooks: [hook(target, "validate-assets.sh", 10), hook(target, "validate-skill-change.sh", 5)]
|
|
924
|
-
}
|
|
927
|
+
},
|
|
928
|
+
{ matcher: "", hooks: [hook(target, WATCH_POST_TOOL[0], 10, WATCH_POST_TOOL[1])] }
|
|
925
929
|
],
|
|
926
930
|
Notification: one("notify.sh", 10),
|
|
927
931
|
PreCompact: one("pre-compact.sh", 10),
|
|
928
932
|
PostCompact: one("post-compact.sh", 10),
|
|
929
933
|
Stop: [
|
|
930
|
-
{
|
|
934
|
+
{
|
|
935
|
+
matcher: "",
|
|
936
|
+
hooks: [
|
|
937
|
+
hook(target, "session-stop.sh", 10),
|
|
938
|
+
hook(target, "worktree-stop.sh", 10),
|
|
939
|
+
hook(target, WATCH_STOP[0], 120, WATCH_STOP[1])
|
|
940
|
+
]
|
|
941
|
+
}
|
|
931
942
|
],
|
|
932
943
|
SubagentStart: one("log-agent.sh", 5),
|
|
933
944
|
SubagentStop: one("log-agent-stop.sh", 5)
|
|
@@ -975,15 +986,21 @@ function healHooks(hooks, target) {
|
|
|
975
986
|
pre.push({ matcher: FILE_TOOLS_MATCHER, hooks: [hook(target, "guard.sh", 10)] });
|
|
976
987
|
changes.push("guard for file edits");
|
|
977
988
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
if (group) group.hooks = [...group.hooks ?? [], hook(target, "worktree-stop.sh", 10)];
|
|
982
|
-
else stop.push({ matcher: "", hooks: [hook(target, "worktree-stop.sh", 10)] });
|
|
983
|
-
changes.push("worktree exit on stop");
|
|
984
|
-
}
|
|
989
|
+
ensureHook(hooks, "Stop", hook(target, "worktree-stop.sh", 10), "worktree exit on stop", changes);
|
|
990
|
+
ensureHook(hooks, "Stop", hook(target, WATCH_STOP[0], 120, WATCH_STOP[1]), "format and type check on stop", changes);
|
|
991
|
+
ensureHook(hooks, "PostToolUse", hook(target, WATCH_POST_TOOL[0], 10, WATCH_POST_TOOL[1]), "session watch", changes);
|
|
985
992
|
return changes;
|
|
986
993
|
}
|
|
994
|
+
function ensureHook(hooks, event, entry, label, changes) {
|
|
995
|
+
const signature = entry.command.replace(/^bash "[^"]*\/hooks\//, "");
|
|
996
|
+
const groups = hooks[event] ??= [];
|
|
997
|
+
const present = groups.some((g) => g.hooks?.some((h) => h.command?.endsWith(signature)));
|
|
998
|
+
if (present) return;
|
|
999
|
+
const group = groups.find((g) => !g.matcher);
|
|
1000
|
+
if (group) group.hooks = [...group.hooks ?? [], entry];
|
|
1001
|
+
else groups.push({ matcher: "", hooks: [entry] });
|
|
1002
|
+
changes.push(label);
|
|
1003
|
+
}
|
|
987
1004
|
|
|
988
1005
|
// src/installer.ts
|
|
989
1006
|
var SETTINGS_TEMPLATE = {
|
package/docs/USER-GUIDE.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## What is Arcane?
|
|
4
4
|
|
|
5
|
-
Arcane is a configuration harness for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). It provides a curated library of **437 skills**, **115 agents**, **
|
|
5
|
+
Arcane is a configuration harness for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). It provides a curated library of **437 skills**, **115 agents**, **18 hooks**, and **36 rules** that you selectively deploy into any project. Instead of shipping everything to every project (wasting tokens and degrading performance), you pick a **profile** that matches your stack and only the relevant tools get installed.
|
|
6
6
|
|
|
7
7
|
## Table of Contents
|
|
8
8
|
|
|
@@ -239,7 +239,7 @@ Every installation automatically loads `core.yaml` first:
|
|
|
239
239
|
|----------|----------|
|
|
240
240
|
| **Skills (21)** | commit, create-pr, changelog, check, code-review, context-prime, help, start, fix-issue, hotfix, brainstorm, scope-check, reverse-document, skill-improve, skill-test, tech-debt, arcane-status, arcane-list, arcane-add, arcane-remove, arcane-clean |
|
|
241
241
|
| **Rules (8)** | data-files, prototype-code, test-standards, implementation-workflow, code-comments, verification, agent-delegation, git-worktrees |
|
|
242
|
-
| **Hooks (
|
|
242
|
+
| **Hooks (17)** | session-start, detect-division, detect-unreal, detect-gaps, check-update, guard, validate-commit, validate-assets, validate-skill-change, notify, pre-compact, post-compact, session-stop, worktree-stop, run-py (session_watch), log-agent, log-agent-stop |
|
|
243
243
|
| **Agents (1 dir)** | quality |
|
|
244
244
|
| **Permissions** | 15 allow (git, ls, gh pr/issue/run and read-only gh, curl) + 13 deny (force push, reset --hard, clean -f, sudo, .env, etc.) |
|
|
245
245
|
|
|
@@ -48,6 +48,7 @@ Esto mantiene el context window con ~3-5k tokens (solo la sección actual) en ve
|
|
|
48
48
|
## Proactive Compaction
|
|
49
49
|
|
|
50
50
|
- **Compactá proactivamente** a ~60-70% de uso, no reactivamente al límite
|
|
51
|
+
- **Aviso automático:** con el profile `statusline`, `session_watch.py` avisa a Claude al 70% y al 85% de la ventana real (la statusline anota el tamaño que reporta Claude Code en `.claude/session-state/context.json`). Sin statusline no avisa: adivinar la ventana haría saltar el aviso demasiado temprano en sesiones de 1M
|
|
51
52
|
- **Usá `/clear`** entre tareas no relacionadas, o después de 2+ intentos de corrección fallidos
|
|
52
53
|
- **Puntos naturales de compaction:** después de escribir una sección al archivo, después de un commit, después de completar una tarea, antes de empezar un tema nuevo
|
|
53
54
|
- **Focused compaction:** `/compact Focus on [tarea actual] — secciones 1-3 ya escritas al archivo, working on sección 4`
|
|
@@ -125,9 +125,10 @@ Los eventos los define `src/installer.ts` al generar `settings.json`, no los pro
|
|
|
125
125
|
| PreToolUse (`Bash`) | `guard.sh` → `validate-commit.sh` |
|
|
126
126
|
| PreToolUse (`Write\|Edit\|MultiEdit\|NotebookEdit`) | `guard.sh` |
|
|
127
127
|
| PostToolUse (`Write\|Edit`) | `validate-assets.sh`, `validate-skill-change.sh` |
|
|
128
|
+
| PostToolUse (todas) | `run-py.sh session_watch.py post-tool` (ediciones, loops, alcance, aviso de compactación) |
|
|
128
129
|
| Notification | `notify.sh` |
|
|
129
130
|
| PreCompact / PostCompact | `pre-compact.sh` / `post-compact.sh` |
|
|
130
|
-
| Stop | `session-stop.sh`, `worktree-stop.sh` |
|
|
131
|
+
| Stop | `session-stop.sh`, `worktree-stop.sh`, `run-py.sh session_watch.py stop` (format + type check de lo editado) |
|
|
131
132
|
| SubagentStart / SubagentStop | `log-agent.sh` / `log-agent-stop.sh` |
|
|
132
133
|
|
|
133
134
|
Cada comando es `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/<script>"`: dentro de un worktree los hooks corren parados en el worktree, que puede no tener `.claude/hooks/` commiteado, y la variable siempre apunta al proyecto donde arrancó la sesión. `arcane update` migra las instalaciones que tenían la ruta relativa.
|
package/hooks/run-py.sh
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Runs a Python hook from this folder: run-py.sh <script.py> [args...]
|
|
3
|
+
#
|
|
4
|
+
# Uses the first Python that actually starts (on Windows `python3` can be the
|
|
5
|
+
# Microsoft Store stub, which exits non-zero instead of running). With no working
|
|
6
|
+
# Python the hook does nothing.
|
|
7
|
+
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
|
+
SCRIPT="$1"
|
|
9
|
+
shift
|
|
10
|
+
[ -f "$DIR/$SCRIPT" ] || exit 0
|
|
11
|
+
INPUT=$(cat)
|
|
12
|
+
|
|
13
|
+
for PY in python3 python py; do
|
|
14
|
+
command -v "$PY" >/dev/null 2>&1 || continue
|
|
15
|
+
if OUT=$(printf '%s' "$INPUT" | "$PY" "$DIR/$SCRIPT" "$@" 2>/dev/null); then
|
|
16
|
+
printf '%s' "$OUT"
|
|
17
|
+
exit 0
|
|
18
|
+
fi
|
|
19
|
+
done
|
|
20
|
+
exit 0
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Session watch: cheap, deterministic signals the model should not have to remember.
|
|
3
|
+
|
|
4
|
+
`session_watch.py post-tool` (PostToolUse, every tool):
|
|
5
|
+
- records each file Claude edits, for the Stop check below;
|
|
6
|
+
- warns when the same tool runs with the same input 5 times in a row (a loop);
|
|
7
|
+
- warns once when a session has edited 15 files (scope drift);
|
|
8
|
+
- warns when the context passes 70% and 85% of the real window, so active.md is
|
|
9
|
+
brought up to date before a compaction. The window size comes from the status
|
|
10
|
+
line (statusline.sh caches what Claude Code reports); without it, no warning,
|
|
11
|
+
since guessing the window would warn far too early on 1M-token sessions.
|
|
12
|
+
|
|
13
|
+
`session_watch.py stop` (Stop): formats the files edited this turn and type-checks
|
|
14
|
+
them with the project's own tools (Prettier or Biome + tsc, gofmt + go vet,
|
|
15
|
+
dotnet format). Only errors in files edited this turn block the stop, once, so
|
|
16
|
+
errors the project already had never trap a session.
|
|
17
|
+
|
|
18
|
+
State lives in .claude/session-state/watch-<session>.json, which ignores itself.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
LOOP_LENGTH = 5
|
|
31
|
+
SCOPE_FILES = 15
|
|
32
|
+
CONTEXT_WARNINGS = (70, 85)
|
|
33
|
+
FILE_TOOLS = {"Write", "Edit", "MultiEdit", "NotebookEdit"}
|
|
34
|
+
IGNORED_INPUT_KEYS = {"description", "timeout"}
|
|
35
|
+
CHECK_TIMEOUT = 90
|
|
36
|
+
MAX_REPORT_LINES = 25
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --- state -------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def state_dir(project: str) -> str:
|
|
42
|
+
path = os.path.join(project, ".claude", "session-state")
|
|
43
|
+
os.makedirs(path, exist_ok=True)
|
|
44
|
+
ignore = os.path.join(path, ".gitignore")
|
|
45
|
+
if not os.path.exists(ignore):
|
|
46
|
+
with open(ignore, "w", encoding="utf-8") as f:
|
|
47
|
+
f.write("*\n")
|
|
48
|
+
return path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_state(project: str, session: str) -> dict:
|
|
52
|
+
path = os.path.join(state_dir(project), f"watch-{safe(session)}.json")
|
|
53
|
+
try:
|
|
54
|
+
with open(path, encoding="utf-8") as f:
|
|
55
|
+
return json.load(f)
|
|
56
|
+
except (OSError, ValueError):
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_state(project: str, session: str, state: dict) -> None:
|
|
61
|
+
path = os.path.join(state_dir(project), f"watch-{safe(session)}.json")
|
|
62
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
63
|
+
json.dump(state, f)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def safe(session: str) -> str:
|
|
67
|
+
return re.sub(r"[^A-Za-z0-9_-]", "", session or "default")[:64] or "default"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --- post-tool -----------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def post_tool(payload: dict, project: str) -> list[str]:
|
|
73
|
+
"""Update the session state and return the warnings to show Claude."""
|
|
74
|
+
session = payload.get("session_id", "")
|
|
75
|
+
state = load_state(project, session)
|
|
76
|
+
warnings: list[str] = []
|
|
77
|
+
tool = payload.get("tool_name", "")
|
|
78
|
+
tool_input = payload.get("tool_input") or {}
|
|
79
|
+
|
|
80
|
+
if tool in FILE_TOOLS:
|
|
81
|
+
path = tool_input.get("file_path") or tool_input.get("notebook_path")
|
|
82
|
+
if path:
|
|
83
|
+
absolute = os.path.abspath(os.path.join(project, path))
|
|
84
|
+
pending = state.setdefault("pending", [])
|
|
85
|
+
if absolute not in pending:
|
|
86
|
+
pending.append(absolute)
|
|
87
|
+
edited = state.setdefault("edited", [])
|
|
88
|
+
if absolute not in edited:
|
|
89
|
+
edited.append(absolute)
|
|
90
|
+
if len(edited) >= SCOPE_FILES and not state.get("scope_warned"):
|
|
91
|
+
state["scope_warned"] = True
|
|
92
|
+
warnings.append(f"This session has edited {len(edited)} files. Check the change is still "
|
|
93
|
+
"within the task's scope before going further.")
|
|
94
|
+
|
|
95
|
+
# The description is Claude's label for the call ("call 2 of 6"), not what runs.
|
|
96
|
+
action = {k: v for k, v in tool_input.items() if k not in IGNORED_INPUT_KEYS}
|
|
97
|
+
digest = hashlib.sha1(json.dumps([tool, action], sort_keys=True).encode()).hexdigest()
|
|
98
|
+
recent = (state.get("recent", []) + [digest])[-LOOP_LENGTH:]
|
|
99
|
+
state["recent"] = recent
|
|
100
|
+
if len(recent) == LOOP_LENGTH and len(set(recent)) == 1 and state.get("loop_warned") != digest:
|
|
101
|
+
state["loop_warned"] = digest
|
|
102
|
+
warnings.append(f"{tool} ran {LOOP_LENGTH} times in a row with the same input. Stop repeating it: "
|
|
103
|
+
"name the assumption that may be wrong, or ask the user one diagnostic question.")
|
|
104
|
+
|
|
105
|
+
used = context_used(project, session)
|
|
106
|
+
if used is not None:
|
|
107
|
+
for level in CONTEXT_WARNINGS:
|
|
108
|
+
if used >= level and state.get("context_warned", 0) < level:
|
|
109
|
+
state["context_warned"] = level
|
|
110
|
+
warnings.append(f"Context is at {used:.0f}% of the window. At the next logical point "
|
|
111
|
+
"(a commit, a finished step), update .claude/session-state/active.md if this "
|
|
112
|
+
"task has one, then suggest the user run /compact.")
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
save_state(project, session, state)
|
|
116
|
+
return warnings
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def context_used(project: str, session: str) -> float | None:
|
|
120
|
+
"""Percent of the real context window in use, as the status line last saw it."""
|
|
121
|
+
path = os.path.join(project, ".claude", "session-state", "context.json")
|
|
122
|
+
try:
|
|
123
|
+
with open(path, encoding="utf-8") as f:
|
|
124
|
+
data = json.load(f)
|
|
125
|
+
except (OSError, ValueError):
|
|
126
|
+
return None
|
|
127
|
+
if data.get("session_id") != session:
|
|
128
|
+
return None
|
|
129
|
+
try:
|
|
130
|
+
return float(data["used_percentage"])
|
|
131
|
+
except (KeyError, TypeError, ValueError):
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --- stop ----------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def find_up(start: str, names: tuple[str, ...], stop_at: str) -> str | None:
|
|
138
|
+
"""Nearest ancestor of `start` (up to `stop_at`) containing one of `names`."""
|
|
139
|
+
current = os.path.dirname(start)
|
|
140
|
+
stop_at = os.path.abspath(stop_at)
|
|
141
|
+
while True:
|
|
142
|
+
if any(os.path.exists(os.path.join(current, n)) for n in names):
|
|
143
|
+
return current
|
|
144
|
+
if os.path.abspath(current) == stop_at or os.path.dirname(current) == current:
|
|
145
|
+
return None
|
|
146
|
+
current = os.path.dirname(current)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def local_bin(root: str, name: str) -> str | None:
|
|
150
|
+
"""A tool installed in the project; never one npx would download."""
|
|
151
|
+
for folder in (root, os.path.dirname(root)):
|
|
152
|
+
base = os.path.join(folder, "node_modules", ".bin", name)
|
|
153
|
+
for candidate in ((base + ".cmd", base) if os.name == "nt" else (base,)):
|
|
154
|
+
if os.path.exists(candidate):
|
|
155
|
+
return candidate
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def plan_checks(files: list[str], project: str) -> list[dict]:
|
|
160
|
+
"""Group edited files by project root and pick the format and check commands."""
|
|
161
|
+
plans: dict[tuple, dict] = {}
|
|
162
|
+
|
|
163
|
+
def add(key: tuple, cwd: str, fmt: list | None, check: list | None, file: str) -> None:
|
|
164
|
+
plan = plans.setdefault(key, {"cwd": cwd, "format": fmt, "check": check, "files": []})
|
|
165
|
+
plan["files"].append(file)
|
|
166
|
+
|
|
167
|
+
for file in files:
|
|
168
|
+
if not os.path.exists(file):
|
|
169
|
+
continue
|
|
170
|
+
ext = os.path.splitext(file)[1].lower()
|
|
171
|
+
if ext in (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".css", ".scss", ".md"):
|
|
172
|
+
root = find_up(file, ("package.json",), project)
|
|
173
|
+
if not root:
|
|
174
|
+
continue
|
|
175
|
+
fmt = None
|
|
176
|
+
biome = local_bin(root, "biome")
|
|
177
|
+
prettier = local_bin(root, "prettier")
|
|
178
|
+
if biome and any(os.path.exists(os.path.join(root, n)) for n in ("biome.json", "biome.jsonc")):
|
|
179
|
+
fmt = [biome, "format", "--write"]
|
|
180
|
+
elif prettier:
|
|
181
|
+
fmt = [prettier, "--write", "--log-level", "warn"]
|
|
182
|
+
check = None
|
|
183
|
+
if ext in (".ts", ".tsx"):
|
|
184
|
+
tsconfig = find_up(file, ("tsconfig.json",), project)
|
|
185
|
+
tsc = local_bin(root, "tsc")
|
|
186
|
+
if tsconfig and tsc:
|
|
187
|
+
check = [tsc, "--noEmit", "--pretty", "false", "-p", os.path.join(tsconfig, "tsconfig.json")]
|
|
188
|
+
add(("node", root, tuple(check or ())), root, fmt, check, file)
|
|
189
|
+
elif ext == ".go" and shutil.which("gofmt"):
|
|
190
|
+
root = find_up(file, ("go.mod",), project)
|
|
191
|
+
if root:
|
|
192
|
+
rel = "./" + os.path.relpath(os.path.dirname(file), root).replace("\\", "/")
|
|
193
|
+
check = ["go", "vet", rel] if shutil.which("go") else None
|
|
194
|
+
add(("go", root, rel), root, ["gofmt", "-w"], check, file)
|
|
195
|
+
elif ext == ".cs" and shutil.which("dotnet"):
|
|
196
|
+
root = find_up(file, (".editorconfig",), project) or project
|
|
197
|
+
add(("dotnet", root), root, ["dotnet", "format", "whitespace", ".", "--folder", "--include"], None, file)
|
|
198
|
+
return list(plans.values())
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def run(command: list[str], cwd: str) -> tuple[int, str]:
|
|
202
|
+
try:
|
|
203
|
+
result = subprocess.run(command, cwd=cwd, capture_output=True, text=True,
|
|
204
|
+
timeout=CHECK_TIMEOUT, encoding="utf-8", errors="replace")
|
|
205
|
+
return result.returncode, (result.stdout or "") + (result.stderr or "")
|
|
206
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
207
|
+
return 0, f"skipped: {error}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def errors_in(output: str, files: list[str], cwd: str) -> list[str]:
|
|
211
|
+
"""Error lines that name one of `files`; errors elsewhere predate this turn."""
|
|
212
|
+
wanted = {os.path.normcase(os.path.abspath(f)) for f in files}
|
|
213
|
+
lines = []
|
|
214
|
+
for line in output.splitlines():
|
|
215
|
+
match = re.match(r"^(.+?)(?:\(\d+,\d+\)|:\d+:\d+)", line.strip())
|
|
216
|
+
if not match:
|
|
217
|
+
continue
|
|
218
|
+
candidate = os.path.normcase(os.path.abspath(os.path.join(cwd, match.group(1).strip())))
|
|
219
|
+
if candidate in wanted:
|
|
220
|
+
lines.append(line.strip())
|
|
221
|
+
return lines
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def stop(payload: dict, project: str) -> dict | None:
|
|
225
|
+
session = payload.get("session_id", "")
|
|
226
|
+
state = load_state(project, session)
|
|
227
|
+
files = state.pop("pending", [])
|
|
228
|
+
save_state(project, session, state)
|
|
229
|
+
if not files or payload.get("stop_hook_active"):
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
problems: list[str] = []
|
|
233
|
+
for plan in plan_checks(files, project):
|
|
234
|
+
if plan["format"]:
|
|
235
|
+
run(plan["format"] + plan["files"], plan["cwd"])
|
|
236
|
+
if plan["check"]:
|
|
237
|
+
code, output = run(plan["check"], plan["cwd"])
|
|
238
|
+
if code != 0:
|
|
239
|
+
problems.extend(errors_in(output, plan["files"], plan["cwd"]))
|
|
240
|
+
if not problems:
|
|
241
|
+
return None
|
|
242
|
+
shown = problems[:MAX_REPORT_LINES]
|
|
243
|
+
more = f"\n... and {len(problems) - len(shown)} more" if len(problems) > len(shown) else ""
|
|
244
|
+
return {
|
|
245
|
+
"decision": "block",
|
|
246
|
+
"reason": "The type check found errors in files you edited this turn. Fix them before finishing:\n"
|
|
247
|
+
+ "\n".join(shown) + more,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# --- entry point ---------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def main() -> int:
|
|
254
|
+
mode = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
255
|
+
try:
|
|
256
|
+
payload = json.loads(sys.stdin.read() or "{}")
|
|
257
|
+
except ValueError:
|
|
258
|
+
return 0
|
|
259
|
+
project = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
|
|
260
|
+
if mode == "post-tool":
|
|
261
|
+
warnings = post_tool(payload, project)
|
|
262
|
+
if warnings:
|
|
263
|
+
print(json.dumps({"hookSpecificOutput": {
|
|
264
|
+
"hookEventName": "PostToolUse",
|
|
265
|
+
"additionalContext": "Arcane session watch: " + " ".join(warnings),
|
|
266
|
+
}}))
|
|
267
|
+
elif mode == "stop":
|
|
268
|
+
decision = stop(payload, project)
|
|
269
|
+
if decision:
|
|
270
|
+
print(json.dumps(decision))
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
try:
|
|
276
|
+
sys.exit(main())
|
|
277
|
+
except Exception: # a watch bug must never break the session
|
|
278
|
+
sys.exit(0)
|
package/hooks/statusline.sh
CHANGED
|
@@ -75,6 +75,21 @@ if isinstance(cw, dict) and cw:
|
|
|
75
75
|
pct = float(up)
|
|
76
76
|
except Exception:
|
|
77
77
|
pct = None
|
|
78
|
+
# Hooks never see the window size; session_watch.py reads it from here to warn
|
|
79
|
+
# before a compaction at a share of the real window instead of a guessed one.
|
|
80
|
+
project_dir = (data.get("workspace") or {}).get("project_dir") or data.get("cwd") or ""
|
|
81
|
+
if pct is not None and project_dir and os.path.isdir(os.path.join(project_dir, ".claude")):
|
|
82
|
+
try:
|
|
83
|
+
state = os.path.join(project_dir, ".claude", "session-state")
|
|
84
|
+
os.makedirs(state, exist_ok=True)
|
|
85
|
+
ignore = os.path.join(state, ".gitignore")
|
|
86
|
+
if not os.path.exists(ignore):
|
|
87
|
+
open(ignore, "w").write("*\n")
|
|
88
|
+
with open(os.path.join(state, "context.json"), "w") as f:
|
|
89
|
+
json.dump({"session_id": data.get("session_id"), "used_percentage": pct,
|
|
90
|
+
"context_window_size": limit}, f)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
78
93
|
|
|
79
94
|
# Fallback (older Claude Code without context_window): scan transcript usage.
|
|
80
95
|
if total is None:
|
package/package.json
CHANGED