claude-code-arcane 2.14.0 → 2.16.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 CHANGED
@@ -1,3 +1,19 @@
1
+ # [2.16.0](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.15.0...v2.16.0) (2026-10-16)
2
+
3
+
4
+ ### Features
5
+
6
+ * **profiles:** ship playwright-pro with the web profiles ([a6d3785](https://github.com/SebastianLuser/claude-code-arcane/commit/a6d37851f1c6dd2384a96e7b3a00e797e178b10f))
7
+ * **rules:** review a finished goal with a fresh subagent ([9aab70b](https://github.com/SebastianLuser/claude-code-arcane/commit/9aab70bf1a26af19d8bfe9ba9d7e75b7c651637b))
8
+
9
+ # [2.15.0](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.14.0...v2.15.0) (2026-10-16)
10
+
11
+
12
+ ### Features
13
+
14
+ * **hooks:** watch the session for loops, scope and unchecked edits ([84d7c3e](https://github.com/SebastianLuser/claude-code-arcane/commit/84d7c3eccc33380f27d7f8901affedb090ee21d5))
15
+ * **installer:** wire the session watch and feed it the real context window ([615d212](https://github.com/SebastianLuser/claude-code-arcane/commit/615d212d96744c180fe9f84f7670382ec58d2772))
16
+
1
17
  # [2.14.0](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.13.0...v2.14.0) (2026-10-16)
2
18
 
3
19
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Claude Code Arcane
2
2
 
3
- > **437 skills, 115 agents, 17 hooks and 36 rules for Claude Code — selective deploy by profile.**
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
 
@@ -194,13 +194,13 @@ Older names still install and update: `backend-ts`, `frontend`, `mobile`, `flutt
194
194
  | `backend-go` | Backend Go — Clean Arch, DB, auth, API, CI | 26 | engineering |
195
195
  | `backend-dotnet` | Backend .NET — ASP.NET Core, Vertical Slice/Clean, EF Core, JWT | 27 | engineering |
196
196
  | `backend-nestjs` | Backend NestJS — feature modules, DI, Prisma, JWT, microservices | 27 | engineering |
197
- | `backend-nextjs` | Next.js full-stack — App Router, RSC, Server Actions, SEO, AI SDK | 24 | engineering |
197
+ | `backend-nextjs` | Next.js full-stack — App Router, RSC, Server Actions, SEO, AI SDK | 25 | engineering |
198
198
 
199
199
  ### Frontend & Design
200
200
 
201
201
  | Profile | Stack | Skills | Agents |
202
202
  |---------|-------|--------|--------|
203
- | `frontend-react-vite` | React + Vite + TypeScript | 17 | engineering |
203
+ | `frontend-react-vite` | React + Vite + TypeScript | 18 | engineering |
204
204
  | `design` | Figma, UX review, design system, handoff | 10 | product |
205
205
 
206
206
  ### Mobile
@@ -304,7 +304,7 @@ It ships the in-editor skills that operate and extend the MCP (`unreal-mcp`, `cr
304
304
  | ClickUp | `/clickup` | `+clickup` |
305
305
  | Figma | `/figma`, `/figma-to-code`, `/figma-tokens` | `+design` |
306
306
  | Atlassian (Confluence) | `/confluence-expert`, `/atlassian-templates` | `+agile` |
307
- | TestRail + BrowserStack | `/playwright-pro` (ships its own `.mcp.json`) | `+testing` |
307
+ | TestRail + BrowserStack | `/playwright-pro` (ships its own `.mcp.json`) | `+testing`, `frontend-react-vite`, `backend-nextjs` |
308
308
 
309
309
  `/jira-tickets` is the exception: it talks to the Jira REST API v3 over `curl`, not MCP.
310
310
 
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
- { matcher: "", hooks: [hook(target, "session-stop.sh", 10), hook(target, "worktree-stop.sh", 10)] }
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
- const stop = hooks.Stop ??= [];
979
- if (!stop.some((g) => g.hooks?.some((h) => h.command?.includes("worktree-stop.sh")))) {
980
- const group = stop.find((g) => !g.matcher);
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 = {
@@ -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**, **17 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.
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 (16)** | 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, log-agent, log-agent-stop |
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.
@@ -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)
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-arcane",
3
- "version": "2.14.0",
3
+ "version": "2.16.0",
4
4
  "description": "Skills, agents, hooks and rules for Claude Code — installable via npx claude-code-arcane",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,7 @@ skills:
23
23
  # Frontend quality
24
24
  - accessibility
25
25
  - performance
26
+ - playwright-pro
26
27
  # Ops & quality
27
28
  - testing
28
29
  - deps-audit
@@ -58,6 +58,7 @@ hooks:
58
58
  - post-compact.sh
59
59
  - session-stop.sh
60
60
  - worktree-stop.sh
61
+ - run-py.sh
61
62
  - log-agent.sh
62
63
  - log-agent-stop.sh
63
64
 
@@ -20,6 +20,7 @@ skills:
20
20
  - file-uploads
21
21
  - frontend-dev
22
22
  - csp-headers
23
+ - playwright-pro
23
24
 
24
25
  rules:
25
26
  universal:
@@ -22,6 +22,7 @@ skills:
22
22
  - file-uploads
23
23
  - frontend-dev
24
24
  - csp-headers
25
+ - playwright-pro
25
26
 
26
27
  rules:
27
28
  universal:
@@ -55,6 +55,6 @@ paths:
55
55
  2. Screenshots at 320, 768, 1024 and 1440 px wide, in both themes if the app has a dark mode.
56
56
  3. Walk the flow you changed: clicks, forms, navigation.
57
57
  4. Keyboard: every control in the flow is reachable with Tab, usable with Enter or Space, and shows a visible focus.
58
- 5. Keep the screenshots and show them as evidence.
59
- - If Playwright is not installed, propose installing it once (`npm i -D @playwright/test`, then `npx playwright install chromium`). If the user declines, close the task with "visual result not verified".
58
+ 5. Save the screenshots to files and show them as evidence; the goal review gets their paths.
59
+ - If Playwright is not installed, propose setting it up once with `/playwright-pro init` (or `npm i -D @playwright/test`, then `npx playwright install chromium`). If the user declines, close the task with "visual result not verified".
60
60
  - In React Native or Flutter Playwright does not apply: say what was not verified visually.
@@ -34,9 +34,9 @@ Use AskUserQuestion exactly like this:
34
34
 
35
35
  ## After selection
36
36
 
37
- - **Plan + Goal:** First define the verifiable session goal with validation criteria. Then EnterPlanMode and design the implementation plan linked to the goal. Plan includes explicit stop conditions (goal met, blocker, scope creep). After approval, create tasks and execute. After each significant step, check progress against the goal. When goal is met, STOP and run validation.
37
+ - **Plan + Goal:** First define the verifiable session goal with validation criteria. Then EnterPlanMode and design the implementation plan linked to the goal. Plan includes explicit stop conditions (goal met, blocker, scope creep). After approval, create tasks and execute. After each significant step, check progress against the goal. When goal is met, STOP and run the goal review below.
38
38
  - **Action Plan:** Use EnterPlanMode, design the approach, get approval, create tasks, then implement step by step. No formal goal — implement until the plan is done.
39
- - **Goal-driven:** Help the user define a verifiable goal statement and 2-4 checkpoints, create tasks, implement toward each checkpoint. No formal plan.
39
+ - **Goal-driven:** Help the user define a verifiable goal statement and 2-4 checkpoints, create tasks, implement toward each checkpoint. No formal plan. When the last checkpoint is met, run the goal review below.
40
40
  - **Directo:** Proceed immediately without ceremony.
41
41
 
42
42
  ## Progress file
@@ -63,6 +63,26 @@ Not verified: <what has not been checked yet>
63
63
 
64
64
  Directo and Action Plan tasks do not need it.
65
65
 
66
+ ## Goal review
67
+
68
+ In **Plan + Goal** and **Goal-driven** mode, whoever did the work does not decide it is done. When the goal looks met, start one `general-purpose` subagent with a clean context. Its prompt carries only:
69
+
70
+ - the goal and the criteria from `active.md`;
71
+ - the diff against the base branch, untracked files included;
72
+ - how to verify: test, build and type-check commands, and how to start the app;
73
+ - if the task changed a web UI: the paths of the Playwright screenshots, or that the user declined Playwright;
74
+ - the instructions below, verbatim.
75
+
76
+ Never pass your reasoning or a summary of the conversation: the reviewer judges the work, not the story behind it.
77
+
78
+ > You are reviewing finished work against its goal. Do not trust any reported result: run every verification yourself. Check each criterion against the code and your own runs. If the diff touches authentication, user input, database queries, file system paths, external APIs, cryptography or payments, also check that input is validated at the boundary, queries are parameterized, no secret is in code or logs, every new path checks authorization, and errors leak no internals. If the task changed a web UI, there are no Playwright screenshots and you cannot produce them, the verdict is FAIL, unless the user declined Playwright: then note "visual result not verified". Answer with `PASS` or `FAIL` on the first line, then one line per criterion with its evidence (the command and its result, or `file:line`), then what is missing.
79
+
80
+ Then:
81
+
82
+ 1. Show the user the reviewer's verdict as it came, before your own comments.
83
+ 2. **FAIL:** fix what it found and review again with a new subagent. After a second FAIL, stop and bring both verdicts to the user.
84
+ 3. **PASS:** close `active.md` with the verdict.
85
+
66
86
  ## Before writing new code
67
87
 
68
88
  In every mode, search in this order and take the first option that fully works: