@isentinel/hooks 1.2.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/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-PRESENT Christopher Buss <christopher.buss@pm.me>
4
+
5
+ Copyright for portions of this project are held by Anthony Fu 2025, as part of
6
+ antfu/skills. All other copyright are held by Christopher Buss
7
+ <christopher.buss@pm.me>, 2026.
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
10
+ this software and associated documentation files (the "Software"), to deal in
11
+ the Software without restriction, including without limitation the rights to
12
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
13
+ the Software, and to permit persons to whom the Software is furnished to do so,
14
+ subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
21
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
22
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
23
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
24
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # Roblox Skills & Claude Code Extensions
2
+
3
+ Personal collection of [Agent Skills](https://agentskills.io/home), hooks, and
4
+ plugins for Claude Code, focused on Roblox development.
5
+
6
+ This started as a fork of [antfu/skills](https://github.com/antfu/skills). I'm
7
+ repurposing it for my own workflow but keeping it open source in case others
8
+ find it useful.
9
+
10
+ ## What's here
11
+
12
+ - **Skills** - Agent skills for Roblox tooling, Luau, and related ecosystems
13
+ - **Hooks** - Custom Claude Code hooks for my workflow
14
+ - **Plugins** - Any other extensions I end up building
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pnpx skills add christopher-buss/skills -skill='*'
20
+ ```
21
+
22
+ Or install everything globally:
23
+
24
+ ```bash
25
+ pnpx skills add christopher-buss/skills -skill='*' -g
26
+ ```
27
+
28
+ More on the CLI at [skills](https://github.com/vercel-labs/skills).
29
+
30
+ ## Skills
31
+
32
+ ### Hand-maintained
33
+
34
+ Manually written with personal preferences and best practices.
35
+
36
+ | Skill | Description |
37
+ | ----------------------------- | ------------------------------------------------------------- |
38
+ | [isentinel](skills/isentinel) | isentinel's opinionated preferences for roblox-ts development |
39
+ | [roblox-ts](skills/roblox-ts) | TypeScript to Roblox Lua transpiler |
40
+ | [test-driven-development](skills/test-driven-development) | How to write tests and design for testability in Roblox projects |
41
+ | [ecs-design](skills/ecs-design) | Best practices for designing Entity Component Systems in Roblox |
42
+
43
+ ### Generated from documentation
44
+
45
+ Generated from official docs.
46
+
47
+ | Skill | Description | Source |
48
+ | --------------------------------- | --------------------------------------------- | ------------------------------------------------------------- |
49
+ | [jecs](skills/jecs) | Entity Component System for Roblox | [Ukendio/jecs](https://github.com/Ukendio/jecs) |
50
+ | [pnpm](skills/pnpm) | Fast, disk-efficient package manager | [pnpm/pnpm.io](https://github.com/pnpm/pnpm.io) |
51
+ | [roblox-ts](skills/robloxTs) | TypeScript to Roblox Lua transpiler | [roblox-ts/roblox-ts](https://github.com/roblox-ts/roblox-ts) |
52
+ | [superpowers](skills/superpowers) | Agent workflow skills (customized for Roblox) | [obra/superpowers](https://github.com/obra/superpowers) |
53
+
54
+ ### Vendored
55
+
56
+ Synced from external repos that maintain their own skills.
57
+
58
+ | Skill | Description | Source |
59
+ | --------------------------------------- | ------------------------------------ | ------------------------------------------------------- |
60
+ | [humanizer](skills/humanizer) | Remove AI writing patterns from text | [blader/humanizer](https://github.com/blader/humanizer) |
61
+ | [writing-skills](skills/writing-skills) | How to write agent skills | [obra/superpowers](https://github.com/obra/superpowers) |
62
+
63
+ ## Usage
64
+
65
+ See [AGENTS.md](AGENTS.md) for how skills are generated and maintained.
66
+
67
+ ## Adding your own
68
+
69
+ 1. Fork this repo
70
+ 2. `pnpm install`
71
+ 3. Update `meta.ts` with your projects
72
+ 4. `nr start cleanup` to clear existing submodules
73
+ 5. `nr start init` to clone fresh
74
+ 6. `nr start sync` for vendored skills
75
+ 7. Have your agent generate skills one project at a time
76
+
77
+ ## Attribution
78
+
79
+ Forked from [Anthony Fu's skills](https://github.com/antfu/skills). The original
80
+ project's approach of using git submodules to reference source documentation is
81
+ clever - skills stay current with upstream changes without manual updates.
82
+
83
+ ## License
84
+
85
+ [MIT](LICENSE.md). Vendored skills keep their original licenses.
@@ -0,0 +1,47 @@
1
+ //#region node_modules/.pnpm/@constellos+claude-code-kit@0.4.0/node_modules/@constellos/claude-code-kit/dist/types/hooks/base.d.ts
2
+ /**
3
+ * Base output fields available for all hook events
4
+ */
5
+ interface BaseHookOutput {
6
+ /**
7
+ * Whether Claude should continue after hook execution.
8
+ * Setting to false will terminate execution and require user input.
9
+ * @default true
10
+ */
11
+ continue?: boolean;
12
+ /** Message shown to user when continue is false */
13
+ stopReason?: string;
14
+ /**
15
+ * Hide stdout from transcript mode.
16
+ * Useful for suppressing verbose output.
17
+ * @default false
18
+ */
19
+ suppressOutput?: boolean;
20
+ /**
21
+ * Optional warning message shown to the user.
22
+ * Useful when "continue" is true but a message to the user is still needed.
23
+ */
24
+ systemMessage?: string;
25
+ }
26
+ //#endregion
27
+ //#region node_modules/.pnpm/@constellos+claude-code-kit@0.4.0/node_modules/@constellos/claude-code-kit/dist/types/hooks/events.d.ts
28
+ /**
29
+ * PostToolUse hook output
30
+ */
31
+ type PostToolUseHookOutput = BaseHookOutput & ({
32
+ decision: undefined;
33
+ reason?: string;
34
+ hookSpecificOutput?: {
35
+ hookEventName: "PostToolUse";
36
+ additionalContext?: string;
37
+ };
38
+ } | {
39
+ decision: "block";
40
+ reason?: string; /** Required when decision is "block" */
41
+ hookSpecificOutput: {
42
+ hookEventName: "PostToolUse"; /** Required when decision is "block" */
43
+ additionalContext: string;
44
+ };
45
+ });
46
+ //#endregion
47
+ export { PostToolUseHookOutput as t };
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,13 @@
1
+ import { clearEditedFiles, clearLintAttempts, clearStopAttempts } from "../scripts/lint.mjs";
2
+ import { clearTypecheckStopAttempts } from "../scripts/type-check.mjs";
3
+ import { t as readStdinJson } from "../io.mjs";
4
+
5
+ //#region hooks/clear-lint-state.ts
6
+ const input = await readStdinJson();
7
+ clearLintAttempts();
8
+ clearStopAttempts();
9
+ clearTypecheckStopAttempts();
10
+ clearEditedFiles(input.session_id);
11
+
12
+ //#endregion
13
+ export { };
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,15 @@
1
+ import { isProtectedFile } from "../scripts/lint.mjs";
2
+ import { n as writeStdoutJson, t as readStdinJson } from "../io.mjs";
3
+ import { basename } from "node:path";
4
+
5
+ //#region hooks/lint-guard.ts
6
+ const input = await readStdinJson();
7
+ if (input.tool_name === "Write" || input.tool_name === "Edit") {
8
+ if (isProtectedFile(basename(input.tool_input.file_path))) writeStdoutJson({
9
+ decision: "block",
10
+ reason: "Modifying linter config is forbidden. Report to user if a rule blocks your task."
11
+ });
12
+ }
13
+
14
+ //#endregion
15
+ export { };
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,39 @@
1
+ import { findSourceRoot, getTransitiveDependents, isLintableFile, lint, readEditedFiles, readLintAttempts, readSettings, readStopAttempts, stopDecision, writeStopAttempts } from "../scripts/lint.mjs";
2
+ import { n as writeStdoutJson, t as readStdinJson } from "../io.mjs";
3
+ import { resolve } from "node:path";
4
+ import process from "node:process";
5
+
6
+ //#region hooks/lint-stop.ts
7
+ const settings = readSettings();
8
+ if (!settings.lint) process.exit(0);
9
+ const SESSION_ID = (await readStdinJson()).session_id;
10
+ const editedFiles = readEditedFiles(SESSION_ID);
11
+ if (editedFiles.length === 0) process.exit(0);
12
+ const dependents = /* @__PURE__ */ new Set();
13
+ const seen = /* @__PURE__ */ new Set();
14
+ for (const file of editedFiles) {
15
+ const sourceRoot = findSourceRoot(resolve(file));
16
+ if (sourceRoot === void 0 || seen.has(sourceRoot)) continue;
17
+ seen.add(sourceRoot);
18
+ for (const dependent of getTransitiveDependents(editedFiles, sourceRoot, settings.runner)) dependents.add(dependent);
19
+ }
20
+ const files = [...new Set([...editedFiles, ...dependents])].filter((file) => isLintableFile(file));
21
+ if (files.length === 0) process.exit(0);
22
+ const errorFiles = [];
23
+ for (const file of files) if (lint(file, ["--fix"], settings) !== void 0) errorFiles.push(file);
24
+ const result = stopDecision({
25
+ errorFiles,
26
+ lintAttempts: readLintAttempts(),
27
+ maxLintAttempts: settings.maxLintAttempts,
28
+ stopAttempts: readStopAttempts()
29
+ });
30
+ if (result === void 0) process.exit(0);
31
+ if (result.resetStopAttempts) {
32
+ writeStopAttempts(0);
33
+ process.exit(0);
34
+ }
35
+ writeStopAttempts(readStopAttempts() + 1);
36
+ writeStdoutJson(result);
37
+
38
+ //#endregion
39
+ export { };
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,28 @@
1
+ import { lint, readLintAttempts, readSettings, writeEditedFile, writeLintAttempts } from "../scripts/lint.mjs";
2
+ import { n as writeStdoutJson, t as readStdinJson } from "../io.mjs";
3
+ import process from "node:process";
4
+
5
+ //#region hooks/lint.ts
6
+ const settings = readSettings();
7
+ if (!settings.lint) process.exit(0);
8
+ const input = await readStdinJson();
9
+ if (input.tool_name !== "Write" && input.tool_name !== "Edit") process.exit(0);
10
+ function run(filePath) {
11
+ const attempts = readLintAttempts();
12
+ const result = lint(filePath, ["--fix"], settings);
13
+ if (result !== void 0) {
14
+ const count = (attempts[filePath] ?? 0) + 1;
15
+ attempts[filePath] = count;
16
+ writeLintAttempts(attempts);
17
+ if (count >= settings.maxLintAttempts && result.hookSpecificOutput) result.hookSpecificOutput.additionalContext = `CRITICAL: ${filePath} failed linting ${count} times. STOP editing this file and report lint errors to user.\n${result.hookSpecificOutput.additionalContext}`;
18
+ writeStdoutJson(result);
19
+ } else if (filePath in attempts) {
20
+ delete attempts[filePath];
21
+ writeLintAttempts(attempts);
22
+ }
23
+ }
24
+ run(input.tool_input.file_path);
25
+ writeEditedFile(input.session_id, input.tool_input.file_path);
26
+
27
+ //#endregion
28
+ export { };
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,48 @@
1
+ import { findSourceRoot, getTransitiveDependents, readEditedFiles, readLintAttempts, readSettings } from "../scripts/lint.mjs";
2
+ import { isTypeCheckable, readTypecheckStopAttempts, resolveTsconfig, runTypeCheck, typecheckStopDecision, writeTypecheckStopAttempts } from "../scripts/type-check.mjs";
3
+ import { n as writeStdoutJson, t as readStdinJson } from "../io.mjs";
4
+ import { join, resolve } from "node:path";
5
+ import process from "node:process";
6
+
7
+ //#region hooks/type-check-stop.ts
8
+ const settings = readSettings();
9
+ if (!settings.typecheck) process.exit(0);
10
+ const SESSION_ID = (await readStdinJson()).session_id;
11
+ const PROJECT_ROOT = process.env["CLAUDE_PROJECT_DIR"] ?? process.cwd();
12
+ const editedFiles = readEditedFiles(SESSION_ID);
13
+ if (editedFiles.length === 0) process.exit(0);
14
+ const allFiles = new Set(editedFiles);
15
+ const seenRoots = /* @__PURE__ */ new Set();
16
+ for (const file of editedFiles) {
17
+ const sourceRoot = findSourceRoot(resolve(file));
18
+ if (sourceRoot === void 0 || seenRoots.has(sourceRoot)) continue;
19
+ seenRoots.add(sourceRoot);
20
+ for (const dependent of getTransitiveDependents(editedFiles, sourceRoot, settings.runner)) allFiles.add(dependent);
21
+ }
22
+ const files = [...allFiles].filter((file) => isTypeCheckable(file));
23
+ if (files.length === 0) process.exit(0);
24
+ const errorFiles = [];
25
+ for (const file of files) {
26
+ const tsconfig = resolveTsconfig(join(PROJECT_ROOT, file), PROJECT_ROOT);
27
+ if (tsconfig === void 0) continue;
28
+ const output = runTypeCheck(tsconfig, settings.runner, settings.typecheckArgs);
29
+ if (output !== void 0) {
30
+ if (/error TS/i.test(output)) errorFiles.push(file);
31
+ }
32
+ }
33
+ const result = typecheckStopDecision({
34
+ errorFiles,
35
+ lintAttempts: readLintAttempts(),
36
+ maxLintAttempts: settings.maxLintAttempts,
37
+ stopAttempts: readTypecheckStopAttempts()
38
+ });
39
+ if (result === void 0) process.exit(0);
40
+ if (result.resetStopAttempts) {
41
+ writeTypecheckStopAttempts(0);
42
+ process.exit(0);
43
+ }
44
+ writeTypecheckStopAttempts(readTypecheckStopAttempts() + 1);
45
+ writeStdoutJson(result);
46
+
47
+ //#endregion
48
+ export { };
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,29 @@
1
+ import { readLintAttempts, readSettings, writeEditedFile, writeLintAttempts } from "../scripts/lint.mjs";
2
+ import { typeCheck } from "../scripts/type-check.mjs";
3
+ import { n as writeStdoutJson, t as readStdinJson } from "../io.mjs";
4
+ import process from "node:process";
5
+
6
+ //#region hooks/type-check.ts
7
+ const settings = readSettings();
8
+ if (!settings.typecheck) process.exit(0);
9
+ const input = await readStdinJson();
10
+ if (input.tool_name !== "Write" && input.tool_name !== "Edit") process.exit(0);
11
+ function run(filePath) {
12
+ const attempts = readLintAttempts();
13
+ const result = typeCheck(filePath, settings);
14
+ if (result !== void 0) {
15
+ const count = (attempts[filePath] ?? 0) + 1;
16
+ attempts[filePath] = count;
17
+ writeLintAttempts(attempts);
18
+ if (count >= settings.maxLintAttempts && result.hookSpecificOutput) result.hookSpecificOutput.additionalContext = `CRITICAL: ${filePath} failed type-check ${count} times. STOP editing this file and report type errors to user.\n${result.hookSpecificOutput.additionalContext}`;
19
+ writeStdoutJson(result);
20
+ } else if (filePath in attempts) {
21
+ delete attempts[filePath];
22
+ writeLintAttempts(attempts);
23
+ }
24
+ }
25
+ run(input.tool_input.file_path);
26
+ writeEditedFile(input.session_id, input.tool_input.file_path);
27
+
28
+ //#endregion
29
+ export { };
package/dist/io.mjs ADDED
@@ -0,0 +1,29 @@
1
+ import process from "node:process";
2
+ import { Buffer } from "node:buffer";
3
+
4
+ //#region hooks/io.ts
5
+ async function readStdinJson() {
6
+ return new Promise((resolve, reject) => {
7
+ const chunks = [];
8
+ process.stdin.on("data", (chunk) => {
9
+ chunks.push(chunk);
10
+ });
11
+ process.stdin.on("end", () => {
12
+ try {
13
+ const data = Buffer.concat(chunks).toString("utf8");
14
+ resolve(JSON.parse(data));
15
+ } catch (err) {
16
+ reject(/* @__PURE__ */ new Error(`Failed to parse JSON input: ${err}`));
17
+ }
18
+ });
19
+ process.stdin.on("error", (error) => {
20
+ reject(/* @__PURE__ */ new Error(`Failed to read stdin: ${error}`));
21
+ });
22
+ });
23
+ }
24
+ function writeStdoutJson(output) {
25
+ process.stdout.write(`${JSON.stringify(output)}\n`);
26
+ }
27
+
28
+ //#endregion
29
+ export { writeStdoutJson as n, readStdinJson as t };
@@ -0,0 +1,58 @@
1
+ import { t as PostToolUseHookOutput } from "../events.mjs";
2
+
3
+ //#region scripts/lint.d.ts
4
+ interface LintSettings {
5
+ cacheBust: Array<string>;
6
+ eslint: boolean;
7
+ lint: boolean;
8
+ maxLintAttempts: number;
9
+ oxlint: boolean;
10
+ runner: string;
11
+ typecheck: boolean;
12
+ typecheckArgs: Array<string>;
13
+ }
14
+ type DependencyGraph = Record<string, Array<string>>;
15
+ declare function isProtectedFile(filename: string): boolean;
16
+ declare function readEditedFiles(sessionId: string): Array<string>;
17
+ declare function writeEditedFile(sessionId: string, filePath: string): void;
18
+ declare function clearEditedFiles(sessionId: string): void;
19
+ declare function getTransitiveDependents(files: Array<string>, sourceRoot: string, runner?: string): Array<string>;
20
+ declare const DEFAULT_CACHE_BUST: string[];
21
+ interface StopDecisionResult {
22
+ decision?: "block";
23
+ reason?: string;
24
+ resetStopAttempts?: true;
25
+ }
26
+ interface StopDecisionInput {
27
+ errorFiles: Array<string>;
28
+ lintAttempts: Record<string, number>;
29
+ maxLintAttempts: number;
30
+ stopAttempts: number;
31
+ }
32
+ declare function readSettings(): LintSettings;
33
+ declare function getChangedFiles(): Array<string>;
34
+ declare function isLintableFile(filePath: string, extensions?: string[]): boolean;
35
+ declare function findEntryPoints(sourceRoot: string): Array<string>;
36
+ declare function getDependencyGraph(sourceRoot: string, entryPoints: Array<string>, runner?: string): DependencyGraph;
37
+ declare function invertGraph(graph: DependencyGraph, target: string): Array<string>;
38
+ declare function findSourceRoot(filePath: string): string | undefined;
39
+ declare function readLintAttempts(): Record<string, number>;
40
+ declare function writeLintAttempts(attempts: Record<string, number>): void;
41
+ declare function readStopAttempts(): number;
42
+ declare function writeStopAttempts(count: number): void;
43
+ declare function stopDecision(input: StopDecisionInput): StopDecisionResult | undefined;
44
+ declare function clearStopAttempts(): void;
45
+ declare function clearLintAttempts(): void;
46
+ declare function resolveBustFiles(patterns: Array<string>): Array<string>;
47
+ declare function shouldBustCache(patterns: Array<string>): boolean;
48
+ declare function clearCache(): void;
49
+ declare function invalidateCacheEntries(filePaths: Array<string>): void;
50
+ declare function runOxlint(filePath: string, extraFlags?: Array<string>, runner?: string): string | undefined;
51
+ declare function runEslint(filePath: string, extraFlags?: Array<string>, runner?: string): string | undefined;
52
+ declare function restartDaemon(runner?: string): void;
53
+ declare function formatErrors(output: string): Array<string>;
54
+ declare function buildHookOutput(filePath: string, errors: Array<string>): PostToolUseHookOutput;
55
+ declare function lint(filePath: string, extraFlags?: Array<string>, settings?: LintSettings): PostToolUseHookOutput | undefined;
56
+ declare function main(targets: Array<string>, settings?: LintSettings): void;
57
+ //#endregion
58
+ export { DEFAULT_CACHE_BUST, DependencyGraph, LintSettings, StopDecisionResult, buildHookOutput, clearCache, clearEditedFiles, clearLintAttempts, clearStopAttempts, findEntryPoints, findSourceRoot, formatErrors, getChangedFiles, getDependencyGraph, getTransitiveDependents, invalidateCacheEntries, invertGraph, isLintableFile, isProtectedFile, lint, main, readEditedFiles, readLintAttempts, readSettings, readStopAttempts, resolveBustFiles, restartDaemon, runEslint, runOxlint, shouldBustCache, stopDecision, writeEditedFile, writeLintAttempts, writeStopAttempts };
@@ -0,0 +1,398 @@
1
+ import { createFromFile } from "file-entry-cache";
2
+ import { execSync, spawn } from "node:child_process";
3
+ import { existsSync, globSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import process from "node:process";
6
+
7
+ //#region scripts/lint.ts
8
+ const PROTECTED_PATTERNS = [
9
+ "eslint.config.",
10
+ "oxlint.config.",
11
+ ".eslintrc",
12
+ ".oxlintrc."
13
+ ];
14
+ function isProtectedFile(filename) {
15
+ return PROTECTED_PATTERNS.some((pattern) => filename.startsWith(pattern) || filename === pattern);
16
+ }
17
+ const LINT_STATE_PATH = ".claude/state/lint-attempts.json";
18
+ const STOP_STATE_PATH = ".claude/state/stop-attempts.json";
19
+ const EDITED_FILES_PATH = ".claude/state/edited-files.json";
20
+ function readEditedFiles(sessionId) {
21
+ if (!existsSync(EDITED_FILES_PATH)) return [];
22
+ try {
23
+ return JSON.parse(readFileSync(EDITED_FILES_PATH, "utf-8"))[sessionId] ?? [];
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+ function writeEditedFile(sessionId, filePath) {
29
+ let state = {};
30
+ if (existsSync(EDITED_FILES_PATH)) try {
31
+ state = JSON.parse(readFileSync(EDITED_FILES_PATH, "utf-8"));
32
+ } catch {
33
+ state = {};
34
+ }
35
+ const files = state[sessionId] ?? [];
36
+ if (!files.includes(filePath)) files.push(filePath);
37
+ state[sessionId] = files;
38
+ mkdirSync(dirname(EDITED_FILES_PATH), { recursive: true });
39
+ writeFileSync(EDITED_FILES_PATH, JSON.stringify(state));
40
+ }
41
+ function clearEditedFiles(sessionId) {
42
+ if (!existsSync(EDITED_FILES_PATH)) return;
43
+ try {
44
+ const state = JSON.parse(readFileSync(EDITED_FILES_PATH, "utf-8"));
45
+ delete state[sessionId];
46
+ if (Object.keys(state).length === 0) unlinkSync(EDITED_FILES_PATH);
47
+ else writeFileSync(EDITED_FILES_PATH, JSON.stringify(state));
48
+ } catch {
49
+ unlinkSync(EDITED_FILES_PATH);
50
+ }
51
+ }
52
+ function getTransitiveDependents(files, sourceRoot, runner = DEFAULT_SETTINGS.runner) {
53
+ const entryPoints = findEntryPoints(sourceRoot);
54
+ if (entryPoints.length === 0) return [];
55
+ const graph = getDependencyGraph(sourceRoot, entryPoints, runner);
56
+ const visited = /* @__PURE__ */ new Set();
57
+ const queue = [];
58
+ for (const file of files) {
59
+ const relativePath = relative(sourceRoot, resolve(file)).replaceAll("\\", "/");
60
+ if (!visited.has(relativePath)) {
61
+ visited.add(relativePath);
62
+ queue.push(relativePath);
63
+ }
64
+ }
65
+ let current = queue.shift();
66
+ while (current !== void 0) {
67
+ const importers = invertGraph(graph, current);
68
+ for (const importer of importers) if (!visited.has(importer)) {
69
+ visited.add(importer);
70
+ queue.push(importer);
71
+ }
72
+ current = queue.shift();
73
+ }
74
+ const originals = new Set(files.map((file) => relative(sourceRoot, resolve(file)).replaceAll("\\", "/")));
75
+ return [...visited].filter((file) => !originals.has(file)).map((file) => join(sourceRoot, file));
76
+ }
77
+ const ESLINT_CACHE_PATH = ".eslintcache";
78
+ const DEFAULT_EXTENSIONS = [
79
+ ".ts",
80
+ ".tsx",
81
+ ".js",
82
+ ".jsx",
83
+ ".mjs",
84
+ ".mts",
85
+ ".json"
86
+ ];
87
+ const ENTRY_CANDIDATES = [
88
+ "index.ts",
89
+ "cli.ts",
90
+ "main.ts"
91
+ ];
92
+ const MAX_ERRORS = 5;
93
+ const SETTINGS_FILE = ".claude/sentinel.local.md";
94
+ const DEFAULT_CACHE_BUST = ["*.config.*", "**/tsconfig*.json"];
95
+ const DEFAULT_MAX_LINT_ATTEMPTS = 1;
96
+ const DEFAULT_MAX_STOP_ATTEMPTS = 1;
97
+ const DEFAULT_SETTINGS = {
98
+ cacheBust: [...DEFAULT_CACHE_BUST],
99
+ eslint: true,
100
+ lint: true,
101
+ maxLintAttempts: DEFAULT_MAX_LINT_ATTEMPTS,
102
+ oxlint: false,
103
+ runner: "pnpm exec",
104
+ typecheck: true,
105
+ typecheckArgs: []
106
+ };
107
+ function readSettings() {
108
+ if (!existsSync(SETTINGS_FILE)) return { ...DEFAULT_SETTINGS };
109
+ const fields = parseFrontmatter(readFileSync(SETTINGS_FILE, "utf-8"));
110
+ const cacheBustRaw = fields.get("cache-bust") ?? "";
111
+ const userPatterns = cacheBustRaw ? cacheBustRaw.split(",").map((entry) => entry.trim()).filter(Boolean) : [];
112
+ const maxAttemptsRaw = fields.get("max-lint-attempts");
113
+ const maxLintAttempts = maxAttemptsRaw !== void 0 ? Number(maxAttemptsRaw) : DEFAULT_MAX_LINT_ATTEMPTS;
114
+ return {
115
+ cacheBust: [...DEFAULT_CACHE_BUST, ...userPatterns],
116
+ eslint: fields.get("eslint") !== "false",
117
+ lint: fields.get("lint") !== "false",
118
+ maxLintAttempts,
119
+ oxlint: fields.get("oxlint") === "true",
120
+ runner: fields.get("runner") ?? DEFAULT_SETTINGS.runner,
121
+ typecheck: fields.get("typecheck") !== "false",
122
+ typecheckArgs: (fields.get("typecheck-args") ?? "").split(",").map((entry) => entry.trim()).filter(Boolean)
123
+ };
124
+ }
125
+ function getChangedFiles() {
126
+ const options = {
127
+ encoding: "utf-8",
128
+ stdio: "pipe"
129
+ };
130
+ const changed = execSync("git diff --name-only --diff-filter=d HEAD", options);
131
+ const untracked = execSync("git ls-files --others --exclude-standard", options);
132
+ return [...changed.trim().split("\n"), ...untracked.trim().split("\n")].filter(Boolean);
133
+ }
134
+ function isLintableFile(filePath, extensions = DEFAULT_EXTENSIONS) {
135
+ return extensions.some((extension) => filePath.endsWith(extension));
136
+ }
137
+ function findEntryPoints(sourceRoot) {
138
+ return ENTRY_CANDIDATES.map((name) => join(sourceRoot, name)).filter((path) => {
139
+ return existsSync(path);
140
+ });
141
+ }
142
+ function getDependencyGraph(sourceRoot, entryPoints, runner = DEFAULT_SETTINGS.runner) {
143
+ execSync("which madge", {
144
+ stdio: "pipe",
145
+ timeout: 1e3
146
+ });
147
+ const output = execSync(`${runner} madge --json ${entryPoints.map((ep) => `"${ep}"`).join(" ")}`, {
148
+ cwd: sourceRoot,
149
+ encoding: "utf-8",
150
+ stdio: [
151
+ "pipe",
152
+ "pipe",
153
+ "pipe"
154
+ ],
155
+ timeout: 3e4
156
+ });
157
+ return JSON.parse(output);
158
+ }
159
+ function invertGraph(graph, target) {
160
+ const importers = [];
161
+ for (const [file, dependencies] of Object.entries(graph)) if (dependencies.includes(target)) importers.push(file);
162
+ return importers;
163
+ }
164
+ function findSourceRoot(filePath) {
165
+ let current = dirname(filePath);
166
+ while (current !== dirname(current)) {
167
+ if (existsSync(join(current, "package.json"))) {
168
+ const sourceDirectory = join(current, "src");
169
+ if (existsSync(sourceDirectory)) return sourceDirectory;
170
+ return current;
171
+ }
172
+ current = dirname(current);
173
+ }
174
+ }
175
+ function readLintAttempts() {
176
+ if (!existsSync(LINT_STATE_PATH)) return {};
177
+ try {
178
+ return JSON.parse(readFileSync(LINT_STATE_PATH, "utf-8"));
179
+ } catch {
180
+ return {};
181
+ }
182
+ }
183
+ function writeLintAttempts(attempts) {
184
+ mkdirSync(dirname(LINT_STATE_PATH), { recursive: true });
185
+ writeFileSync(LINT_STATE_PATH, JSON.stringify(attempts));
186
+ }
187
+ function readStopAttempts() {
188
+ if (!existsSync(STOP_STATE_PATH)) return 0;
189
+ try {
190
+ return JSON.parse(readFileSync(STOP_STATE_PATH, "utf-8"));
191
+ } catch {
192
+ return 0;
193
+ }
194
+ }
195
+ function writeStopAttempts(count) {
196
+ mkdirSync(dirname(STOP_STATE_PATH), { recursive: true });
197
+ writeFileSync(STOP_STATE_PATH, JSON.stringify(count));
198
+ }
199
+ function stopDecision(input) {
200
+ if (input.errorFiles.length === 0) {
201
+ if (input.stopAttempts > 0) return { resetStopAttempts: true };
202
+ return;
203
+ }
204
+ if (input.errorFiles.every((file) => {
205
+ return findAttempts(file, input.lintAttempts) >= input.maxLintAttempts;
206
+ })) return;
207
+ if (input.stopAttempts >= DEFAULT_MAX_STOP_ATTEMPTS) return { reason: `Unresolved lint errors in: ${input.errorFiles.join(", ")}. These may be pre-existing.` };
208
+ return {
209
+ decision: "block",
210
+ reason: `Lint errors detected in: ${input.errorFiles.join(", ")}. If related to your changes, please fix before finishing.`
211
+ };
212
+ }
213
+ function clearStopAttempts() {
214
+ if (existsSync(STOP_STATE_PATH)) unlinkSync(STOP_STATE_PATH);
215
+ }
216
+ function clearLintAttempts() {
217
+ if (existsSync(LINT_STATE_PATH)) unlinkSync(LINT_STATE_PATH);
218
+ }
219
+ function resolveBustFiles(patterns) {
220
+ const positive = patterns.filter((pattern) => !pattern.startsWith("!"));
221
+ const negative = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1));
222
+ const matched = positive.flatMap((pattern) => globSync(pattern));
223
+ if (negative.length === 0) return matched;
224
+ const excluded = new Set(negative.flatMap((pattern) => globSync(pattern)));
225
+ return matched.filter((file) => !excluded.has(file));
226
+ }
227
+ function shouldBustCache(patterns) {
228
+ if (patterns.length === 0) return false;
229
+ if (!existsSync(ESLINT_CACHE_PATH)) return false;
230
+ const files = resolveBustFiles(patterns);
231
+ if (files.length === 0) return false;
232
+ const cacheMtime = statSync(ESLINT_CACHE_PATH).mtimeMs;
233
+ return files.some((file) => statSync(file).mtimeMs > cacheMtime);
234
+ }
235
+ function clearCache() {
236
+ if (existsSync(ESLINT_CACHE_PATH)) unlinkSync(ESLINT_CACHE_PATH);
237
+ }
238
+ function invalidateCacheEntries(filePaths) {
239
+ if (filePaths.length === 0) return;
240
+ if (!existsSync(ESLINT_CACHE_PATH)) return;
241
+ const cache = createFromFile(ESLINT_CACHE_PATH);
242
+ for (const file of filePaths) cache.removeEntry(file);
243
+ cache.reconcile();
244
+ }
245
+ function runOxlint(filePath, extraFlags = [], runner = DEFAULT_SETTINGS.runner) {
246
+ const flags = extraFlags.length > 0 ? `${extraFlags.join(" ")} ` : "";
247
+ try {
248
+ execSync(`${runner} oxlint ${flags}"${filePath}"`, { stdio: "pipe" });
249
+ return;
250
+ } catch (err_) {
251
+ const err = err_;
252
+ const stdout = err.stdout?.toString() ?? "";
253
+ const stderr = err.stderr?.toString() ?? "";
254
+ const message = err.message ?? "";
255
+ return stdout || stderr || message;
256
+ }
257
+ }
258
+ function runEslint(filePath, extraFlags = [], runner = DEFAULT_SETTINGS.runner) {
259
+ const flags = ["--cache", ...extraFlags].join(" ");
260
+ try {
261
+ execSync(`${runner} eslint_d ${flags} "${filePath}"`, {
262
+ env: {
263
+ ...process.env,
264
+ ESLINT_IN_EDITOR: "true"
265
+ },
266
+ stdio: "pipe"
267
+ });
268
+ return;
269
+ } catch (err_) {
270
+ const err = err_;
271
+ const stdout = err.stdout?.toString() ?? "";
272
+ const stderr = err.stderr?.toString() ?? "";
273
+ const message = err.message ?? "";
274
+ return stdout || stderr || message;
275
+ }
276
+ }
277
+ function restartDaemon(runner = DEFAULT_SETTINGS.runner) {
278
+ const [command = "pnpm", ...prefixArgs] = runner.split(/\s+/);
279
+ const child = spawn(command, [
280
+ ...prefixArgs,
281
+ "eslint_d",
282
+ "restart"
283
+ ], {
284
+ detached: true,
285
+ env: {
286
+ ...process.env,
287
+ ESLINT_IN_EDITOR: "true"
288
+ },
289
+ stdio: "pipe"
290
+ });
291
+ child.stderr.on("data", (data) => {
292
+ process.stderr.write(`[eslint_d restart] ${data.toString()}`);
293
+ });
294
+ child.on("error", (error) => {
295
+ process.stderr.write(`[eslint_d restart] failed: ${error.message}\n`);
296
+ });
297
+ child.unref();
298
+ }
299
+ function formatErrors(output) {
300
+ return output.split("\n").filter((line) => /error/i.test(line)).slice(0, MAX_ERRORS);
301
+ }
302
+ function buildHookOutput(filePath, errors) {
303
+ const errorText = errors.join("\n");
304
+ const isTruncated = errors.length >= MAX_ERRORS;
305
+ const userMessage = `⚠️ Lint errors in ${filePath}:\n${errorText}${isTruncated ? "\n..." : ""}`;
306
+ return {
307
+ decision: void 0,
308
+ hookSpecificOutput: {
309
+ additionalContext: `⚠️ Lint errors in ${filePath}:\n${errorText}${isTruncated ? "\n(run lint to view more)" : ""}`,
310
+ hookEventName: "PostToolUse"
311
+ },
312
+ systemMessage: userMessage
313
+ };
314
+ }
315
+ function lint(filePath, extraFlags = [], settings = DEFAULT_SETTINGS) {
316
+ if (shouldBustCache(settings.cacheBust)) clearCache();
317
+ else invalidateCacheEntries(findImporters(filePath, settings.runner));
318
+ const outputs = [];
319
+ if (settings.oxlint) {
320
+ const output = runOxlint(filePath, extraFlags, settings.runner);
321
+ if (output !== void 0) outputs.push(output);
322
+ }
323
+ if (settings.eslint) {
324
+ const output = runEslint(filePath, extraFlags, settings.runner);
325
+ if (output !== void 0) outputs.push(output);
326
+ }
327
+ if (settings.eslint) restartDaemon(settings.runner);
328
+ if (outputs.length > 0) {
329
+ const errors = formatErrors(outputs.join("\n"));
330
+ if (errors.length > 0) return buildHookOutput(filePath, errors);
331
+ }
332
+ }
333
+ function main(targets, settings = DEFAULT_SETTINGS) {
334
+ if (shouldBustCache(settings.cacheBust)) clearCache();
335
+ else invalidateCacheEntries(getChangedFiles());
336
+ let hasErrors = false;
337
+ for (const target of targets) {
338
+ const outputs = [];
339
+ if (settings.oxlint) {
340
+ const output = runOxlint(target, ["--color"], settings.runner);
341
+ if (output !== void 0) outputs.push(output);
342
+ }
343
+ if (settings.eslint) {
344
+ const output = runEslint(target, ["--color"], settings.runner);
345
+ if (output !== void 0) outputs.push(output);
346
+ }
347
+ for (const output of outputs) {
348
+ hasErrors = true;
349
+ const filtered = output.split("\n").filter((line) => !line.startsWith("[")).join("\n").trim();
350
+ if (filtered.length > 0) process.stderr.write(`${filtered}\n`);
351
+ }
352
+ }
353
+ if (settings.eslint) restartDaemon(settings.runner);
354
+ if (hasErrors) process.exit(1);
355
+ }
356
+ function parseFrontmatter(content) {
357
+ const fields = /* @__PURE__ */ new Map();
358
+ const frontmatter = /^---\n([\s\S]*?)\n---/m.exec(content)?.[1];
359
+ if (frontmatter === void 0) return fields;
360
+ for (const line of frontmatter.split("\n")) {
361
+ const colon = line.indexOf(":");
362
+ if (colon > 0) {
363
+ const key = line.slice(0, colon).trim();
364
+ const value = line.slice(colon + 1).trim().replace(/^["']|["']$/g, "");
365
+ fields.set(key, value);
366
+ }
367
+ }
368
+ return fields;
369
+ }
370
+ function endsWithSegment(haystack, needle) {
371
+ if (haystack === needle) return true;
372
+ if (!needle.includes("/")) return false;
373
+ return haystack.endsWith(`/${needle}`);
374
+ }
375
+ function findAttempts(file, lintAttempts) {
376
+ if (file in lintAttempts) return lintAttempts[file];
377
+ const normalized = file.replaceAll("\\", "/");
378
+ for (const [key, count] of Object.entries(lintAttempts)) {
379
+ const normalizedKey = key.replaceAll("\\", "/");
380
+ if (endsWithSegment(normalizedKey, normalized) || endsWithSegment(normalized, normalizedKey)) return count;
381
+ }
382
+ return 0;
383
+ }
384
+ function findImporters(filePath, runner = DEFAULT_SETTINGS.runner) {
385
+ const absPath = resolve(filePath);
386
+ const sourceRoot = findSourceRoot(absPath);
387
+ if (sourceRoot === void 0) return [];
388
+ const entryPoints = findEntryPoints(sourceRoot);
389
+ if (entryPoints.length === 0) return [];
390
+ return invertGraph(getDependencyGraph(sourceRoot, entryPoints, runner), relative(sourceRoot, absPath).replaceAll("\\", "/")).map((file) => join(sourceRoot, file));
391
+ }
392
+ if (process.argv[1]?.endsWith("scripts/lint.ts") === true) {
393
+ const settings = readSettings();
394
+ main(process.argv.length > 2 ? process.argv.slice(2) : ["."], settings);
395
+ }
396
+
397
+ //#endregion
398
+ export { DEFAULT_CACHE_BUST, buildHookOutput, clearCache, clearEditedFiles, clearLintAttempts, clearStopAttempts, findEntryPoints, findSourceRoot, formatErrors, getChangedFiles, getDependencyGraph, getTransitiveDependents, invalidateCacheEntries, invertGraph, isLintableFile, isProtectedFile, lint, main, readEditedFiles, readLintAttempts, readSettings, readStopAttempts, resolveBustFiles, restartDaemon, runEslint, runOxlint, shouldBustCache, stopDecision, writeEditedFile, writeLintAttempts, writeStopAttempts };
@@ -0,0 +1,49 @@
1
+ import { t as PostToolUseHookOutput } from "../events.mjs";
2
+
3
+ //#region scripts/type-check.d.ts
4
+ interface TsconfigCache {
5
+ hashes: Record<string, string>;
6
+ mappings: Record<string, string>;
7
+ projectRoot: string;
8
+ }
9
+ declare function readTsconfigCache(projectRoot: string): TsconfigCache | undefined;
10
+ declare function writeTsconfigCache(projectRoot: string, cache: TsconfigCache): void;
11
+ declare function resolveTsconfig(filePath: string, projectRoot: string): string | undefined;
12
+ declare function isTypeCheckable(filePath: string): boolean;
13
+ declare function resolveViaReferences(directory: string, configPath: string, targetFile: string): string | undefined;
14
+ declare function findTsconfigForFile(targetFile: string, projectRoot: string): string | undefined;
15
+ declare function runTypeCheck(tsconfig: string, runner?: string, extraArgs?: Array<string>): string | undefined;
16
+ interface TypeCheckSettings {
17
+ runner: string;
18
+ typecheck: boolean;
19
+ typecheckArgs?: Array<string>;
20
+ }
21
+ interface TypecheckStopDecisionResult {
22
+ decision?: "block";
23
+ reason?: string;
24
+ resetStopAttempts?: true;
25
+ }
26
+ interface TypeCheckOutputOptions {
27
+ dependencyErrors: Array<string>;
28
+ fileErrors: Array<string>;
29
+ totalDependencyErrors: number;
30
+ totalFileErrors: number;
31
+ }
32
+ interface TypecheckStopDecisionInput {
33
+ errorFiles: Array<string>;
34
+ lintAttempts: Record<string, number>;
35
+ maxLintAttempts: number;
36
+ stopAttempts: number;
37
+ }
38
+ declare function partitionErrors(errors: Array<string>, filePath: string, projectRoot: string): {
39
+ dependencyErrors: Array<string>;
40
+ fileErrors: Array<string>;
41
+ };
42
+ declare function buildTypeCheckOutput(options: TypeCheckOutputOptions): PostToolUseHookOutput;
43
+ declare function typeCheck(filePath: string, settings: TypeCheckSettings): PostToolUseHookOutput | undefined;
44
+ declare function typecheckStopDecision(input: TypecheckStopDecisionInput): TypecheckStopDecisionResult | undefined;
45
+ declare function readTypecheckStopAttempts(): number;
46
+ declare function writeTypecheckStopAttempts(count: number): void;
47
+ declare function clearTypecheckStopAttempts(): void;
48
+ //#endregion
49
+ export { TsconfigCache, TypeCheckSettings, TypecheckStopDecisionResult, buildTypeCheckOutput, clearTypecheckStopAttempts, findTsconfigForFile, isTypeCheckable, partitionErrors, readTsconfigCache, readTypecheckStopAttempts, resolveTsconfig, resolveViaReferences, runTypeCheck, typeCheck, typecheckStopDecision, writeTsconfigCache, writeTypecheckStopAttempts };
@@ -0,0 +1,176 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, relative } from "node:path";
4
+ import { createFilesMatcher, parseTsconfig } from "get-tsconfig";
5
+ import { createHash } from "node:crypto";
6
+
7
+ //#region scripts/type-check.ts
8
+ const TYPE_CHECK_EXTENSIONS = [".ts", ".tsx"];
9
+ const CACHE_PATH = join(".claude", "state", "tsconfig-cache.json");
10
+ const STOP_STATE_PATH = join(".claude", "state", "typecheck-stop-attempts.json");
11
+ const DEFAULT_MAX_STOP_ATTEMPTS = 3;
12
+ function readTsconfigCache(projectRoot) {
13
+ const cachePath = join(projectRoot, CACHE_PATH);
14
+ if (!existsSync(cachePath)) return;
15
+ const content = readFileSync(cachePath, "utf-8");
16
+ return JSON.parse(content);
17
+ }
18
+ function writeTsconfigCache(projectRoot, cache) {
19
+ mkdirSync(join(projectRoot, ".claude", "state"), { recursive: true });
20
+ writeFileSync(join(projectRoot, CACHE_PATH), JSON.stringify(cache));
21
+ }
22
+ function resolveTsconfig(filePath, projectRoot) {
23
+ const cache = readTsconfigCache(projectRoot);
24
+ if (cache?.projectRoot === projectRoot) {
25
+ const cachedTsconfig = cache.mappings[filePath];
26
+ if (cachedTsconfig !== void 0 && existsSync(cachedTsconfig)) {
27
+ if (hashFileContent(cachedTsconfig) === cache.hashes[cachedTsconfig]) return cachedTsconfig;
28
+ }
29
+ }
30
+ const tsconfig = findTsconfigForFile(filePath, projectRoot);
31
+ if (tsconfig !== void 0) {
32
+ const hash = hashFileContent(tsconfig);
33
+ writeTsconfigCache(projectRoot, {
34
+ hashes: {
35
+ ...cache?.hashes,
36
+ [tsconfig]: hash
37
+ },
38
+ mappings: {
39
+ ...cache?.mappings,
40
+ [filePath]: tsconfig
41
+ },
42
+ projectRoot
43
+ });
44
+ }
45
+ return tsconfig;
46
+ }
47
+ function hashFileContent(filePath) {
48
+ const content = readFileSync(filePath, "utf-8");
49
+ return createHash("sha256").update(content).digest("hex");
50
+ }
51
+ const DEFAULT_RUNNER = "pnpm exec";
52
+ function isTypeCheckable(filePath) {
53
+ return TYPE_CHECK_EXTENSIONS.some((extension) => filePath.endsWith(extension));
54
+ }
55
+ function resolveViaReferences(directory, configPath, targetFile) {
56
+ const { references } = parseTsconfig(configPath);
57
+ if (references === void 0 || references.length === 0) return;
58
+ for (const ref of references) {
59
+ const refPath = join(directory, ref.path);
60
+ const refConfigPath = refPath.endsWith(".json") ? refPath : join(refPath, "tsconfig.json");
61
+ if (!existsSync(refConfigPath)) continue;
62
+ if (createFilesMatcher({
63
+ config: parseTsconfig(refConfigPath),
64
+ path: refConfigPath
65
+ })(targetFile) !== void 0) return refConfigPath;
66
+ }
67
+ }
68
+ function findTsconfigForFile(targetFile, projectRoot) {
69
+ let directory = dirname(targetFile);
70
+ while (directory.length >= projectRoot.length) {
71
+ const candidate = join(directory, "tsconfig.json");
72
+ if (existsSync(candidate)) return resolveViaReferences(directory, candidate, targetFile) ?? candidate;
73
+ const parent = dirname(directory);
74
+ if (parent === directory) break;
75
+ directory = parent;
76
+ }
77
+ }
78
+ function runTypeCheck(tsconfig, runner = DEFAULT_RUNNER, extraArgs = []) {
79
+ const args = [`tsgo -p "${tsconfig}" --noEmit --pretty false`, ...extraArgs].join(" ");
80
+ try {
81
+ execSync(`${runner} ${args}`, { stdio: "pipe" });
82
+ return;
83
+ } catch (err_) {
84
+ const err = err_;
85
+ const stdout = err.stdout?.toString() ?? "";
86
+ const stderr = err.stderr?.toString() ?? "";
87
+ const message = err.message ?? "";
88
+ return stdout || stderr || message;
89
+ }
90
+ }
91
+ const MAX_ERRORS = 5;
92
+ function partitionErrors(errors, filePath, projectRoot) {
93
+ const relativePath = relative(projectRoot, filePath).replaceAll("\\", "/");
94
+ const fileErrors = [];
95
+ const dependencyErrors = [];
96
+ for (const error of errors) if (error.startsWith(relativePath)) fileErrors.push(error);
97
+ else dependencyErrors.push(error);
98
+ return {
99
+ dependencyErrors,
100
+ fileErrors
101
+ };
102
+ }
103
+ function buildTypeCheckOutput(options) {
104
+ const sections = [];
105
+ if (options.totalFileErrors > 0) {
106
+ const text = options.fileErrors.join("\n");
107
+ const errorSuffix = options.totalFileErrors === 1 ? "error" : "errors";
108
+ sections.push(`TypeScript found ${options.totalFileErrors} type ${errorSuffix} in edited file:\n${text}`);
109
+ }
110
+ if (options.totalDependencyErrors > 0) {
111
+ const text = options.dependencyErrors.join("\n");
112
+ const errorSuffix = options.totalDependencyErrors === 1 ? "error" : "errors";
113
+ sections.push(`TypeScript found ${options.totalDependencyErrors} type ${errorSuffix} in other files:\n${text}`);
114
+ }
115
+ const isTruncated = options.fileErrors.length < options.totalFileErrors || options.dependencyErrors.length < options.totalDependencyErrors;
116
+ const suffix = isTruncated ? "\n..." : "";
117
+ const claudeSuffix = isTruncated ? "\n(run typecheck to view more)" : "";
118
+ const userMessage = sections.join("\n\n") + suffix;
119
+ return {
120
+ decision: void 0,
121
+ hookSpecificOutput: {
122
+ additionalContext: sections.join("\n\n") + claudeSuffix,
123
+ hookEventName: "PostToolUse"
124
+ },
125
+ systemMessage: userMessage
126
+ };
127
+ }
128
+ function typeCheck(filePath, settings) {
129
+ if (!isTypeCheckable(filePath)) return;
130
+ const projectRoot = process.env["CLAUDE_PROJECT_DIR"] ?? process.cwd();
131
+ const tsconfig = resolveTsconfig(filePath, projectRoot);
132
+ if (tsconfig === void 0) return;
133
+ const output = runTypeCheck(tsconfig, settings.runner, settings.typecheckArgs);
134
+ if (output === void 0) return;
135
+ const allErrors = output.split("\n").filter((line) => /error TS/i.test(line));
136
+ if (allErrors.length === 0) return;
137
+ const { dependencyErrors, fileErrors } = partitionErrors(allErrors, filePath, projectRoot);
138
+ return buildTypeCheckOutput({
139
+ dependencyErrors: dependencyErrors.slice(0, MAX_ERRORS),
140
+ fileErrors: fileErrors.slice(0, MAX_ERRORS),
141
+ totalDependencyErrors: dependencyErrors.length,
142
+ totalFileErrors: fileErrors.length
143
+ });
144
+ }
145
+ function typecheckStopDecision(input) {
146
+ if (input.errorFiles.length === 0) {
147
+ if (input.stopAttempts > 0) return { resetStopAttempts: true };
148
+ return;
149
+ }
150
+ if (input.errorFiles.every((file) => {
151
+ return (input.lintAttempts[file] ?? 0) >= input.maxLintAttempts;
152
+ })) return;
153
+ if (input.stopAttempts >= DEFAULT_MAX_STOP_ATTEMPTS) return { reason: `Unresolved type errors in: ${input.errorFiles.join(", ")}. These may be pre-existing.` };
154
+ return {
155
+ decision: "block",
156
+ reason: `Type errors detected in: ${input.errorFiles.join(", ")}. If related to your changes, fix before finishing.`
157
+ };
158
+ }
159
+ function readTypecheckStopAttempts() {
160
+ if (!existsSync(STOP_STATE_PATH)) return 0;
161
+ try {
162
+ return JSON.parse(readFileSync(STOP_STATE_PATH, "utf-8"));
163
+ } catch {
164
+ return 0;
165
+ }
166
+ }
167
+ function writeTypecheckStopAttempts(count) {
168
+ mkdirSync(dirname(STOP_STATE_PATH), { recursive: true });
169
+ writeFileSync(STOP_STATE_PATH, JSON.stringify(count));
170
+ }
171
+ function clearTypecheckStopAttempts() {
172
+ if (existsSync(STOP_STATE_PATH)) unlinkSync(STOP_STATE_PATH);
173
+ }
174
+
175
+ //#endregion
176
+ export { buildTypeCheckOutput, clearTypecheckStopAttempts, findTsconfigForFile, isTypeCheckable, partitionErrors, readTsconfigCache, readTypecheckStopAttempts, resolveTsconfig, resolveViaReferences, runTypeCheck, typeCheck, typecheckStopDecision, writeTsconfigCache, writeTypecheckStopAttempts };
package/package.json ADDED
@@ -0,0 +1,94 @@
1
+ {
2
+ "name": "@isentinel/hooks",
3
+ "version": "1.2.0",
4
+ "description": "Claude Code hooks for linting and type-checking TypeScript projects",
5
+ "keywords": [
6
+ "claude",
7
+ "hooks",
8
+ "eslint",
9
+ "typescript",
10
+ "linting"
11
+ ],
12
+ "homepage": "https://github.com/christopher-buss/skills",
13
+ "bugs": {
14
+ "url": "https://github.com/christopher-buss/skills/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/christopher-buss/skills.git"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Christopher Buss <christopher.buss@pm.me> (https://github.com/christopher-buss)",
22
+ "type": "module",
23
+ "exports": {
24
+ "./hooks/*": "./dist/hooks/*.mjs",
25
+ "./lint": {
26
+ "types": "./dist/scripts/lint.d.mts",
27
+ "default": "./dist/scripts/lint.mjs"
28
+ },
29
+ "./type-check": {
30
+ "types": "./dist/scripts/type-check.d.mts",
31
+ "default": "./dist/scripts/type-check.mjs"
32
+ }
33
+ },
34
+ "types": "./dist/scripts/lint.d.mts",
35
+ "files": [
36
+ "dist/"
37
+ ],
38
+ "simple-git-hooks": {
39
+ "pre-commit": "pnpm lint-staged"
40
+ },
41
+ "lint-staged": {
42
+ "*.{ts,mts}": "eslint --fix"
43
+ },
44
+ "dependencies": {
45
+ "eslint_d": "^14.3.0",
46
+ "file-entry-cache": "^11.1.2",
47
+ "get-tsconfig": "^4.13.6",
48
+ "madge": "^8.0.0"
49
+ },
50
+ "devDependencies": {
51
+ "@clack/prompts": "^0.11.0",
52
+ "@constellos/claude-code-kit": "^0.4.0",
53
+ "@isentinel/eslint-config": "5.0.0-beta.8",
54
+ "@isentinel/tsconfig": "^1.2.0",
55
+ "@types/node": "^25.0.10",
56
+ "@typescript/native-preview": "7.0.0-dev.20260212.1",
57
+ "@vitest/coverage-v8": "^4.0.18",
58
+ "@vitest/eslint-plugin": "^1.6.9",
59
+ "better-typescript-lib": "^2.12.0",
60
+ "bumpp": "^10.4.1",
61
+ "eslint": "^9.39.2",
62
+ "eslint-plugin-n": "^17.23.2",
63
+ "eslint-plugin-pnpm": "^1.5.0",
64
+ "jiti": "^2.6.1",
65
+ "lint-staged": "^15.5.1",
66
+ "publint": "^0.3.18",
67
+ "simple-git-hooks": "^2.11.1",
68
+ "tsdown": "^0.20.3",
69
+ "type-fest": "^5.4.4",
70
+ "typescript": "^5.9.3",
71
+ "vitest": "^4.0.18"
72
+ },
73
+ "peerDependencies": {
74
+ "@typescript/native-preview": ">=7"
75
+ },
76
+ "peerDependenciesMeta": {
77
+ "@typescript/native-preview": {
78
+ "optional": true
79
+ }
80
+ },
81
+ "engines": {
82
+ "node": ">=24.11.0"
83
+ },
84
+ "scripts": {
85
+ "build": "tsdown",
86
+ "lint": "eslint --cache",
87
+ "pack": "pnpm pack",
88
+ "release": "bumpp",
89
+ "start": "node scripts/cli.ts",
90
+ "test": "vitest",
91
+ "test:coverage": "vitest run --coverage",
92
+ "typecheck": "tsgo --noEmit"
93
+ }
94
+ }