@veda-hq/sarathi 0.5.7 → 0.6.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/README.md +24 -0
- package/dist/cli.js +1 -1
- package/dist/hooks/format-before-write.js +207 -0
- package/dist/run.js +8 -3
- package/hooks/arch-guard.sh +49 -0
- package/hooks/code-quality-guard.sh +50 -0
- package/hooks/format-before-write.sh +6 -0
- package/hooks/format-on-edit.sh +59 -0
- package/hooks/lib/session-root.sh +41 -0
- package/hooks/session-dedupe-guard.sh +82 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -71,6 +71,30 @@ sarathi for every event the list names, with a matcher that lets every
|
|
|
71
71
|
handler fire and a timeout at least the longest handler's. Run it in the
|
|
72
72
|
project's tests.
|
|
73
73
|
|
|
74
|
+
### Handlers it carries
|
|
75
|
+
|
|
76
|
+
Every `command` runs with `SARATHI_HOOKS` set to the package's `hooks/`
|
|
77
|
+
folder, which holds handlers any TypeScript repository can list:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{ "matcher": "Edit|Write", "command": "bash \"$SARATHI_HOOKS\"/code-quality-guard.sh" }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| Handler | Event | What it does |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| `format-before-write.sh` | PreToolUse, `Edit\|Write` | Hands the tool prettier-formatted input, so the file is written formatted and Claude Code has no "hook modified your file" notice to keep in context. Never blocks. |
|
|
86
|
+
| `format-on-edit.sh` | PostToolUse, `Edit\|Write` | Formats the written file with prettier: the net for what the first one skips. Never blocks. |
|
|
87
|
+
| `code-quality-guard.sh` | PostToolUse, `Edit\|Write` | Refuses `@ts-ignore`, `@ts-nocheck`, `eslint-disable`, `any` and skipped tests in a `.ts`/`.tsx` edit. `.claude/`, `.agent/` and `docs/` are exempt. |
|
|
88
|
+
| `arch-guard.sh` | PostToolUse, `Edit\|Write` | Runs the repository's `.dependency-cruiser.js` on a `.ts`/`.tsx` edit that touches imports; refuses what it refuses, and refuses when it cannot run. `scripts/` is exempt too. |
|
|
89
|
+
| `session-dedupe-guard.sh` | PreToolUse, session starts | Refuses starting a session unless the conversation called `list_sessions` successfully in the last 15 minutes. |
|
|
90
|
+
|
|
91
|
+
They act on the session's checkout, the git toplevel of the event's `cwd`:
|
|
92
|
+
the desktop app runs a worktree session's hooks with `$CLAUDE_PROJECT_DIR`
|
|
93
|
+
set to the main checkout. They use that checkout's prettier and
|
|
94
|
+
dependency-cruiser, and the project checkout's prettier when a fresh worktree
|
|
95
|
+
has none installed. They need `bash`, `jq` and `git`;
|
|
96
|
+
`session-dedupe-guard.sh` runs `HOOK_PYTHON`, or `python3`.
|
|
97
|
+
|
|
74
98
|
### Forbidden variables
|
|
75
99
|
|
|
76
100
|
Some switches turn a session's tooling off from the environment, where no
|
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import { join, resolve } from 'node:path';
|
|
|
12
12
|
import { ConfigError, compileMatcher, entriesFor, forbiddenSet, loadConfig, longestTimeout, settingsMatcher } from './config.js';
|
|
13
13
|
import { merge } from './merge.js';
|
|
14
14
|
import { runAll } from './run.js';
|
|
15
|
-
export const VERSION = '0.
|
|
15
|
+
export const VERSION = '0.6.0';
|
|
16
16
|
const USAGE = `usage: sarathi hook <Event> [--config <file>]
|
|
17
17
|
sarathi check [--config <file>] [--settings <file>]
|
|
18
18
|
sarathi --version
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PreToolUse handler (Edit|Write): hands the tool prettier-formatted input,
|
|
3
|
+
* so the file is formatted as it is written.
|
|
4
|
+
*
|
|
5
|
+
* Formatting after the write (hooks/format-on-edit.sh) makes Claude Code tell
|
|
6
|
+
* the model "PostToolUse hook modified <file> after your edit", one notice per
|
|
7
|
+
* reformatting edit, kept in context for the rest of the session. Formatting
|
|
8
|
+
* the input instead leaves the post-write formatter nothing to change, so no
|
|
9
|
+
* notice. format-on-edit stays as the net for anything this skips.
|
|
10
|
+
*
|
|
11
|
+
* Write: `content` is formatted whole. Edit: the edit is applied to the file
|
|
12
|
+
* in memory, the result formatted, and the edit rewritten as the smallest
|
|
13
|
+
* unique old_string → new_string that yields the formatted file.
|
|
14
|
+
*
|
|
15
|
+
* Never blocks. On every doubt (unparseable code mid-edit, a non-unique old_string,
|
|
16
|
+
* a file outside the checkout, no prettier) it prints nothing and the tool runs
|
|
17
|
+
* as asked.
|
|
18
|
+
*
|
|
19
|
+
* Run: node <sarathi>/dist/hooks/format-before-write.js
|
|
20
|
+
*/
|
|
21
|
+
import { execFileSync } from 'node:child_process';
|
|
22
|
+
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import { dirname, extname, join, sep } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
const FORMATTED = new Set(['.ts', '.tsx', '.js', '.jsx', '.json', '.md', '.yml', '.yaml', '.css']);
|
|
27
|
+
/**
|
|
28
|
+
* The session's checkout: the git toplevel of the hook input's `cwd`. Not
|
|
29
|
+
* $CLAUDE_PROJECT_DIR: the desktop app sets that to the main checkout for a
|
|
30
|
+
* worktree session, and every desktop worktree lies under main, so it would
|
|
31
|
+
* admit any worktree's files and judge them by main's .prettierignore.
|
|
32
|
+
* Without a usable `cwd`, $CLAUDE_PROJECT_DIR, then the current directory.
|
|
33
|
+
*/
|
|
34
|
+
export function sessionRoot(event, env = process.env) {
|
|
35
|
+
const cwd = event.cwd;
|
|
36
|
+
if (typeof cwd === 'string' && cwd !== '') {
|
|
37
|
+
try {
|
|
38
|
+
const top = execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
41
|
+
}).trim();
|
|
42
|
+
if (top !== '')
|
|
43
|
+
return realpathSync(top);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Not a directory or not a work tree: fall back.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return realpathSync(env['CLAUDE_PROJECT_DIR'] || process.cwd());
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The session checkout's prettier; the project checkout's when that one has
|
|
53
|
+
* none installed (a fresh worktree before its first install). `null` when
|
|
54
|
+
* neither has one.
|
|
55
|
+
*/
|
|
56
|
+
export function loadPrettier(root, env = process.env) {
|
|
57
|
+
const bases = [root, env['CLAUDE_PROJECT_DIR']].filter((b) => typeof b === 'string' && b !== '');
|
|
58
|
+
for (const base of bases) {
|
|
59
|
+
try {
|
|
60
|
+
return createRequire(join(base, 'package.json'))('prettier');
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Try the next checkout.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/** Same scope as format-on-edit.sh: inside the checkout, not scratch, not a link. */
|
|
69
|
+
function inScope(filePath, mustExist, repoRoot) {
|
|
70
|
+
if (!FORMATTED.has(extname(filePath)))
|
|
71
|
+
return false;
|
|
72
|
+
if (/(^|\/)\.(tmp|local)\//.test(filePath))
|
|
73
|
+
return false;
|
|
74
|
+
let dir;
|
|
75
|
+
try {
|
|
76
|
+
dir = realpathSync(dirname(filePath));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (dir !== repoRoot && !dir.startsWith(repoRoot + sep))
|
|
82
|
+
return false;
|
|
83
|
+
try {
|
|
84
|
+
if (lstatSync(filePath).isSymbolicLink())
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
if (mustExist)
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
/** Index of the only occurrence of needle in haystack, or -1. */
|
|
94
|
+
function uniqueIndex(haystack, needle) {
|
|
95
|
+
const at = haystack.indexOf(needle);
|
|
96
|
+
if (at === -1 || haystack.indexOf(needle, at + 1) !== -1)
|
|
97
|
+
return -1;
|
|
98
|
+
return at;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The smallest old → new replacement turning `before` into `after`, grown a
|
|
102
|
+
* line at a time until old is non-empty and unique in `before`.
|
|
103
|
+
*/
|
|
104
|
+
export function minimalEdit(before, after) {
|
|
105
|
+
let start = 0;
|
|
106
|
+
const shortest = Math.min(before.length, after.length);
|
|
107
|
+
while (start < shortest && before[start] === after[start])
|
|
108
|
+
start++;
|
|
109
|
+
let tail = 0;
|
|
110
|
+
while (tail < shortest - start && before[before.length - 1 - tail] === after[after.length - 1 - tail])
|
|
111
|
+
tail++;
|
|
112
|
+
let from = start;
|
|
113
|
+
let beforeEnd = before.length - tail;
|
|
114
|
+
let afterEnd = after.length - tail;
|
|
115
|
+
for (;;) {
|
|
116
|
+
const old = before.slice(from, beforeEnd);
|
|
117
|
+
if (old !== '' && uniqueIndex(before, old) === from) {
|
|
118
|
+
return { old_string: old, new_string: after.slice(from, afterEnd) };
|
|
119
|
+
}
|
|
120
|
+
if (from === 0 && beforeEnd === before.length)
|
|
121
|
+
return null;
|
|
122
|
+
// Back to the start of this line, or of the previous one when already there.
|
|
123
|
+
const lineStart = from < 2 ? -1 : before.lastIndexOf('\n', from - 2);
|
|
124
|
+
from = lineStart === -1 ? 0 : lineStart + 1;
|
|
125
|
+
const lineEnd = before.indexOf('\n', beforeEnd + 1);
|
|
126
|
+
const grow = (lineEnd === -1 ? before.length : lineEnd) - beforeEnd;
|
|
127
|
+
beforeEnd += grow;
|
|
128
|
+
afterEnd += grow;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** The Edit tool's own semantics: exactly one match, or every match with replace_all. */
|
|
132
|
+
function applyEdit(current, input) {
|
|
133
|
+
const { old_string: oldString, new_string: newString, replace_all: all } = input;
|
|
134
|
+
if (typeof oldString !== 'string' || typeof newString !== 'string' || oldString === '')
|
|
135
|
+
return null;
|
|
136
|
+
if (all === true)
|
|
137
|
+
return current.split(oldString).join(newString);
|
|
138
|
+
const at = uniqueIndex(current, oldString);
|
|
139
|
+
if (at === -1)
|
|
140
|
+
return null;
|
|
141
|
+
return current.slice(0, at) + newString + current.slice(at + oldString.length);
|
|
142
|
+
}
|
|
143
|
+
async function format(prettier, filePath, text, repoRoot) {
|
|
144
|
+
const info = await prettier.getFileInfo(filePath, { ignorePath: join(repoRoot, '.prettierignore') });
|
|
145
|
+
if (info.ignored || !info.inferredParser)
|
|
146
|
+
return null;
|
|
147
|
+
const options = (await prettier.resolveConfig(filePath)) ?? {};
|
|
148
|
+
return prettier.format(text, { ...options, filepath: filePath });
|
|
149
|
+
}
|
|
150
|
+
/** The tool input to run instead, or `null` to let the tool run as asked. */
|
|
151
|
+
export async function updatedInput(event, prettier, root) {
|
|
152
|
+
const input = event.tool_input ?? {};
|
|
153
|
+
const filePath = input['file_path'];
|
|
154
|
+
if (typeof filePath !== 'string' || filePath === '')
|
|
155
|
+
return null;
|
|
156
|
+
if (event.tool_name === 'Write') {
|
|
157
|
+
const content = input['content'];
|
|
158
|
+
if (typeof content !== 'string' || !inScope(filePath, false, root))
|
|
159
|
+
return null;
|
|
160
|
+
const formatted = await format(prettier, filePath, content, root);
|
|
161
|
+
if (formatted === null || formatted === content)
|
|
162
|
+
return null;
|
|
163
|
+
return { ...input, content: formatted };
|
|
164
|
+
}
|
|
165
|
+
if (event.tool_name === 'Edit') {
|
|
166
|
+
if (!inScope(filePath, true, root))
|
|
167
|
+
return null;
|
|
168
|
+
const current = readFileSync(filePath, 'utf8');
|
|
169
|
+
const edited = applyEdit(current, input);
|
|
170
|
+
if (edited === null)
|
|
171
|
+
return null;
|
|
172
|
+
const formatted = await format(prettier, filePath, edited, root);
|
|
173
|
+
if (formatted === null || formatted === edited || formatted === current)
|
|
174
|
+
return null;
|
|
175
|
+
const edit = minimalEdit(current, formatted);
|
|
176
|
+
if (edit === null)
|
|
177
|
+
return null;
|
|
178
|
+
return { file_path: filePath, ...edit, replace_all: false };
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
/** The hook's whole answer for one raw stdin: the JSON to print, or '' for none. Never throws. */
|
|
183
|
+
export async function respond(raw, env = process.env) {
|
|
184
|
+
try {
|
|
185
|
+
const event = JSON.parse(raw);
|
|
186
|
+
const root = sessionRoot(event, env);
|
|
187
|
+
const prettier = loadPrettier(root, env);
|
|
188
|
+
if (prettier === null)
|
|
189
|
+
return '';
|
|
190
|
+
const next = await updatedInput(event, prettier, root);
|
|
191
|
+
if (next === null)
|
|
192
|
+
return '';
|
|
193
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput: next } });
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return '';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function main() {
|
|
200
|
+
let raw = '';
|
|
201
|
+
for await (const chunk of process.stdin)
|
|
202
|
+
raw += String(chunk);
|
|
203
|
+
process.stdout.write(await respond(raw));
|
|
204
|
+
}
|
|
205
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
206
|
+
void main();
|
|
207
|
+
}
|
package/dist/run.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
9
10
|
import { DEFAULT_TIMEOUT_SECONDS } from './config.js';
|
|
10
11
|
const GUARD_SCRIPTS = {
|
|
11
12
|
worktree: 'worktree_guard.py',
|
|
@@ -16,8 +17,12 @@ export function kavachDir() {
|
|
|
16
17
|
const require = createRequire(import.meta.url);
|
|
17
18
|
return join(dirname(require.resolve('@veda-hq/kavach/package.json')), 'python', 'kavach');
|
|
18
19
|
}
|
|
19
|
-
/**
|
|
20
|
-
export function
|
|
20
|
+
/** The folder holding sarathi's own handlers (hooks/), given to every command as SARATHI_HOOKS. */
|
|
21
|
+
export function hooksDir() {
|
|
22
|
+
return fileURLToPath(new URL('../hooks', import.meta.url));
|
|
23
|
+
}
|
|
24
|
+
/** How one entry runs: a command through `sh -c` with SARATHI_HOOKS set, a guard through the configured Python. */
|
|
25
|
+
export function plan(entry, config, guardDir = kavachDir, handlers = hooksDir) {
|
|
21
26
|
const timeoutMs = (entry.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000;
|
|
22
27
|
if (entry.guard !== undefined) {
|
|
23
28
|
const env = config.kavach.config === undefined ? {} : { KAVACH_CONFIG: config.kavach.config };
|
|
@@ -31,7 +36,7 @@ export function plan(entry, config, guardDir = kavachDir) {
|
|
|
31
36
|
};
|
|
32
37
|
}
|
|
33
38
|
const command = entry.command ?? '';
|
|
34
|
-
return { label: command, file: '/bin/sh', args: ['-c', command], env: {}, timeoutMs, failClosed: entry.failClosed === true };
|
|
39
|
+
return { label: command, file: '/bin/sh', args: ['-c', command], env: { SARATHI_HOOKS: handlers() }, timeoutMs, failClosed: entry.failClosed === true };
|
|
35
40
|
}
|
|
36
41
|
/** Runs one plan with `input` on stdin. Never rejects: a handler that cannot start ends with code `null`. */
|
|
37
42
|
export function runPlan(step, input, cwd) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PostToolUse handler for Edit/Write on TypeScript files: runs the session
|
|
3
|
+
# checkout's dependency-cruiser rules (.dependency-cruiser.js) on the edited
|
|
4
|
+
# file, so a forbidden import is refused at edit time.
|
|
5
|
+
# depcruise costs about 2 s, so it runs only when the edit touches imports.
|
|
6
|
+
# Exit 2 = the violation goes back to the agent to fix at once.
|
|
7
|
+
#
|
|
8
|
+
# Runs depcruise in the SESSION's checkout (hook input `cwd`), with that
|
|
9
|
+
# checkout's config and node_modules (lib/session-root.sh).
|
|
10
|
+
|
|
11
|
+
input=$(cat)
|
|
12
|
+
file_path=$(jq -r '.tool_input.file_path // ""' <<<"$input")
|
|
13
|
+
|
|
14
|
+
case "$file_path" in
|
|
15
|
+
*.ts | *.tsx) ;;
|
|
16
|
+
*) exit 0 ;;
|
|
17
|
+
esac
|
|
18
|
+
[ -f "$file_path" ] || exit 0
|
|
19
|
+
|
|
20
|
+
# Only pay the depcruise cost when the edit could change the dependency graph
|
|
21
|
+
content=$(jq -r '.tool_input.new_string // .tool_input.content // ""' <<<"$input")
|
|
22
|
+
if ! grep -qE '(^|\n)[[:space:]]*(import[[:space:]]|export[[:space:]]+.*[[:space:]]from[[:space:]]|.*require\()' <<<"$content"; then
|
|
23
|
+
exit 0
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
# Checked before sourcing: bash exits on a missing `.` file before any `||`.
|
|
27
|
+
lib="$(dirname "$0")/lib/session-root.sh"
|
|
28
|
+
# shellcheck source=lib/session-root.sh
|
|
29
|
+
if [ ! -r "$lib" ] || ! . "$lib"; then
|
|
30
|
+
echo 'arch-guard: cannot load lib/session-root.sh — blocking edit (guard could not run)' >&2
|
|
31
|
+
exit 2
|
|
32
|
+
fi
|
|
33
|
+
root=$(session_root "$input" "${CLAUDE_PROJECT_DIR:-$PWD}")
|
|
34
|
+
case "$(rel_to_root "$file_path" "$root")" in
|
|
35
|
+
*/.claude/* | .claude/* | */.agent/* | .agent/* | */docs/* | docs/* | */scripts/* | scripts/*) exit 0 ;;
|
|
36
|
+
esac
|
|
37
|
+
|
|
38
|
+
cd "$root" || { echo 'arch-guard: cannot cd to repo root — blocking edit (guard could not run)' >&2; exit 2; }
|
|
39
|
+
result=$(npx depcruise --config .dependency-cruiser.js "$file_path" 2>&1)
|
|
40
|
+
if [ $? -ne 0 ]; then
|
|
41
|
+
{
|
|
42
|
+
echo "ARCHITECTURE VIOLATION in $file_path (this repository's dependency rules, .dependency-cruiser.js):"
|
|
43
|
+
echo "$result" | grep -v '^$' | head -15
|
|
44
|
+
echo "Fix the import structure — do not suppress the rule."
|
|
45
|
+
} >&2
|
|
46
|
+
exit 2
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
exit 0
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PostToolUse handler for Edit/Write on TypeScript files: refuses the escape
|
|
3
|
+
# hatches a strict TypeScript codebase forbids (@ts-ignore, @ts-nocheck,
|
|
4
|
+
# eslint-disable, `any`, skipped tests), so the agent fixes the cause at once.
|
|
5
|
+
# Exit 2 = the reason goes back to the agent as an error it must address.
|
|
6
|
+
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
input=$(cat)
|
|
10
|
+
file_path=$(jq -r '.tool_input.file_path // ""' <<<"$input")
|
|
11
|
+
|
|
12
|
+
case "$file_path" in
|
|
13
|
+
*.ts | *.tsx) ;;
|
|
14
|
+
*) exit 0 ;;
|
|
15
|
+
esac
|
|
16
|
+
|
|
17
|
+
# Agent, skill and doc sources may legitimately mention the patterns. Judged
|
|
18
|
+
# relative to the session's checkout: a desktop worktree sits under
|
|
19
|
+
# <main>/.claude/worktrees/, so its absolute paths all match `*/.claude/*`.
|
|
20
|
+
# Checked before sourcing: bash exits on a missing `.` file before any `||`.
|
|
21
|
+
lib="$(dirname "$0")/lib/session-root.sh"
|
|
22
|
+
# shellcheck source=lib/session-root.sh
|
|
23
|
+
if [ ! -r "$lib" ] || ! . "$lib"; then
|
|
24
|
+
echo 'code-quality-guard: cannot load lib/session-root.sh — blocking edit (guard could not run)' >&2
|
|
25
|
+
exit 2
|
|
26
|
+
fi
|
|
27
|
+
root=$(session_root "$input" "${CLAUDE_PROJECT_DIR:-$PWD}")
|
|
28
|
+
case "$(rel_to_root "$file_path" "$root")" in
|
|
29
|
+
*/.claude/* | .claude/* | */.agent/* | .agent/* | */docs/* | docs/*) exit 0 ;;
|
|
30
|
+
esac
|
|
31
|
+
|
|
32
|
+
content=$(jq -r '.tool_input.new_string // .tool_input.content // ""' <<<"$input")
|
|
33
|
+
[ -z "$content" ] && exit 0
|
|
34
|
+
|
|
35
|
+
violations=()
|
|
36
|
+
|
|
37
|
+
grep -qE '@ts-ignore|@ts-nocheck' <<<"$content" && violations+=("@ts-ignore/@ts-nocheck forbidden (strict mode: fix the type error)")
|
|
38
|
+
grep -qE 'eslint-disable' <<<"$content" && violations+=("eslint-disable forbidden (fix the source, not the linter)")
|
|
39
|
+
grep -qE ':[[:space:]]*any\b|\bas[[:space:]]+any\b' <<<"$content" && violations+=("'any' type forbidden (use a precise type, unknown, or a generic)")
|
|
40
|
+
grep -qE '\b(describe|it|test)\.skip\(|\bxit\(|\bxtest\(|\bxdescribe\(' <<<"$content" && violations+=("skipped tests forbidden (every test runs: fix or delete it)")
|
|
41
|
+
|
|
42
|
+
if [ ${#violations[@]} -gt 0 ]; then
|
|
43
|
+
{
|
|
44
|
+
echo "CODE QUALITY VIOLATION in $file_path — fix before proceeding:"
|
|
45
|
+
for v in "${violations[@]}"; do echo " - $v"; done
|
|
46
|
+
} >&2
|
|
47
|
+
exit 2
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
exit 0
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse handler (Edit|Write): hands the tool prettier-formatted input.
|
|
3
|
+
# The handler itself is dist/hooks/format-before-write.js (source:
|
|
4
|
+
# src/hooks/format-before-write.ts); this names it beside the others, so a
|
|
5
|
+
# hook list runs every handler as `bash "$SARATHI_HOOKS"/<name>.sh`.
|
|
6
|
+
exec node "${BASH_SOURCE[0]%/*}/../dist/hooks/format-before-write.js"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PostToolUse handler: formats every edited or written file with the session
|
|
3
|
+
# checkout's prettier, so a commit-time format check finds nothing to fix.
|
|
4
|
+
# Never blocks: formatting problems surface at commit time anyway.
|
|
5
|
+
# format-before-write is the first line; this is the net for what it skips.
|
|
6
|
+
|
|
7
|
+
input=$(cat)
|
|
8
|
+
file_path=$(jq -r '.tool_input.file_path // ""' <<<"$input")
|
|
9
|
+
|
|
10
|
+
[ -z "$file_path" ] && exit 0
|
|
11
|
+
[ -f "$file_path" ] || exit 0
|
|
12
|
+
|
|
13
|
+
# Never format through a symlink. Only the leaf's PARENT is resolved below, so
|
|
14
|
+
# a link sitting inside the checkout could otherwise name a target outside it
|
|
15
|
+
# and be judged by where the link lives rather than what it rewrites.
|
|
16
|
+
[ -L "$file_path" ] && exit 0
|
|
17
|
+
|
|
18
|
+
# Skip local scratch and tmp artifacts: nothing there is ever committed.
|
|
19
|
+
case "$file_path" in
|
|
20
|
+
*/.local/* | .local/* | */.tmp/* | .tmp/*) exit 0 ;;
|
|
21
|
+
esac
|
|
22
|
+
|
|
23
|
+
# Skip anything outside this checkout. Agent memory is writable and is
|
|
24
|
+
# markdown, so it would otherwise be reflowed by this repository's prettier
|
|
25
|
+
# settings, which are not its own.
|
|
26
|
+
#
|
|
27
|
+
# Both sides are resolved PHYSICALLY (pwd -P) before comparing. A lexical
|
|
28
|
+
# compare breaks whenever the checkout is reached through a symlink (on macOS
|
|
29
|
+
# /tmp is /private/tmp), classifying a repository file as outside or a file
|
|
30
|
+
# merely *named* under the repository as inside.
|
|
31
|
+
#
|
|
32
|
+
# "This checkout" is the SESSION's (hook input `cwd`), not
|
|
33
|
+
# $CLAUDE_PROJECT_DIR: the desktop app sets that to the main checkout for a
|
|
34
|
+
# worktree session, and every desktop worktree lies under main
|
|
35
|
+
# (lib/session-root.sh).
|
|
36
|
+
lib="$(dirname "$0")/lib/session-root.sh"
|
|
37
|
+
# shellcheck source=lib/session-root.sh
|
|
38
|
+
[ -r "$lib" ] && . "$lib" || exit 0
|
|
39
|
+
repo_root=$(session_root "$input" "${CLAUDE_PROJECT_DIR:-$PWD}")
|
|
40
|
+
repo_root=$(cd "$repo_root" && pwd -P) || exit 0
|
|
41
|
+
file_dir=$(cd "$(dirname "$file_path")" 2>/dev/null && pwd -P) || exit 0
|
|
42
|
+
case "$file_dir" in
|
|
43
|
+
"$repo_root" | "$repo_root"/*) ;;
|
|
44
|
+
*) exit 0 ;;
|
|
45
|
+
esac
|
|
46
|
+
|
|
47
|
+
# The session checkout's prettier; a fresh worktree has no node_modules until
|
|
48
|
+
# its first install, so then the project checkout's. Either way it runs in the
|
|
49
|
+
# session checkout, so that checkout's config and .prettierignore decide.
|
|
50
|
+
prettier="$repo_root/node_modules/.bin/prettier"
|
|
51
|
+
[ -x "$prettier" ] || prettier="${CLAUDE_PROJECT_DIR:-$repo_root}/node_modules/.bin/prettier"
|
|
52
|
+
|
|
53
|
+
case "$file_path" in
|
|
54
|
+
*.ts | *.tsx | *.js | *.jsx | *.json | *.md | *.yml | *.yaml | *.css)
|
|
55
|
+
cd "$repo_root" && "$prettier" --write --ignore-unknown "$file_path" >/dev/null 2>&1 || true
|
|
56
|
+
;;
|
|
57
|
+
esac
|
|
58
|
+
|
|
59
|
+
exit 0
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Sourced by sarathi's shell handlers: which checkout is the session's.
|
|
3
|
+
#
|
|
4
|
+
# The Claude desktop app launches worktree sessions with
|
|
5
|
+
# `--project-config-root <main checkout>`. Every hook then runs with its cwd
|
|
6
|
+
# and $CLAUDE_PROJECT_DIR set to the MAIN checkout, so neither of those names
|
|
7
|
+
# the checkout the session works in. The hook input's `cwd` still does.
|
|
8
|
+
|
|
9
|
+
# session_root <hook-input-json> <fallback-dir>
|
|
10
|
+
# The git toplevel of the input's `cwd`; <fallback-dir> when the input has
|
|
11
|
+
# no usable `cwd` (absent, not a directory, not inside a work tree).
|
|
12
|
+
session_root() {
|
|
13
|
+
local cwd root
|
|
14
|
+
cwd=$(jq -r 'if (.cwd | type) == "string" then .cwd else "" end' <<<"$1" 2>/dev/null) || cwd=""
|
|
15
|
+
if [ -n "$cwd" ] && [ -d "$cwd" ]; then
|
|
16
|
+
root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) || root=""
|
|
17
|
+
if [ -n "$root" ]; then
|
|
18
|
+
printf '%s\n' "$root"
|
|
19
|
+
return 0
|
|
20
|
+
fi
|
|
21
|
+
fi
|
|
22
|
+
printf '%s\n' "$2"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# rel_to_root <path> <root>
|
|
26
|
+
# <path> relative to <root> when it lies inside it, else <path> unchanged.
|
|
27
|
+
# Compared physically: git prints a resolved toplevel (/private/tmp on
|
|
28
|
+
# macOS), the tools a logical path. Path filters must use this, because the
|
|
29
|
+
# desktop app's worktrees live under <main>/.claude/worktrees/ and an
|
|
30
|
+
# absolute path there always matches `*/.claude/*`.
|
|
31
|
+
rel_to_root() {
|
|
32
|
+
local dir real_root base
|
|
33
|
+
dir=$(cd "$(dirname "$1")" 2>/dev/null && pwd -P) || { printf '%s\n' "$1"; return 0; }
|
|
34
|
+
real_root=$(cd "$2" 2>/dev/null && pwd -P) || { printf '%s\n' "$1"; return 0; }
|
|
35
|
+
base=$(basename "$1")
|
|
36
|
+
case "$dir" in
|
|
37
|
+
"$real_root") printf '%s\n' "$base" ;;
|
|
38
|
+
"$real_root"/*) printf '%s\n' "${dir#"$real_root"/}/$base" ;;
|
|
39
|
+
*) printf '%s\n' "$1" ;;
|
|
40
|
+
esac
|
|
41
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse handler: look before starting a session. Refuses create_session
|
|
3
|
+
# and start_session unless this conversation called list_sessions, and got a
|
|
4
|
+
# result that is not an error, in the last LOOK_WINDOW_MINUTES (two sessions
|
|
5
|
+
# fixing the same bug minutes apart is what it stops). A hook cannot call
|
|
6
|
+
# list_sessions itself, so it enforces the look, not the verdict.
|
|
7
|
+
# start_session is judged only where the transcript records list_sessions as
|
|
8
|
+
# available (a deferred-tools entry naming it, or a call to it), so a desktop
|
|
9
|
+
# session without the tool is never stuck. Anything it cannot read (input,
|
|
10
|
+
# transcript) is allowed.
|
|
11
|
+
#
|
|
12
|
+
# The input is read from stdin, so no size limit applies to it.
|
|
13
|
+
# HOOK_PYTHON is the launcher's interpreter; python3 without one.
|
|
14
|
+
|
|
15
|
+
PY_SRC=$(cat <<'PYEOF'
|
|
16
|
+
import datetime, json, sys
|
|
17
|
+
|
|
18
|
+
LOOK_WINDOW_MINUTES = 15
|
|
19
|
+
LIST = "mcp__claude-code-remote__list_sessions"
|
|
20
|
+
CREATE = "mcp__claude-code-remote__create_session"
|
|
21
|
+
START = "mcp__ccd_session__start_session"
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
data = json.loads(sys.stdin.read() or "{}")
|
|
25
|
+
tool = data.get("tool_name")
|
|
26
|
+
path = data.get("transcript_path")
|
|
27
|
+
if tool not in (CREATE, START) or not path:
|
|
28
|
+
sys.exit(0)
|
|
29
|
+
with open(path) as handle:
|
|
30
|
+
lines = handle.readlines()
|
|
31
|
+
except Exception:
|
|
32
|
+
sys.exit(0)
|
|
33
|
+
|
|
34
|
+
calls, good, listed = {}, set(), False
|
|
35
|
+
for line in lines:
|
|
36
|
+
try:
|
|
37
|
+
entry = json.loads(line)
|
|
38
|
+
record = entry.get("attachment") or {}
|
|
39
|
+
content = (entry.get("message") or {}).get("content")
|
|
40
|
+
except Exception:
|
|
41
|
+
continue
|
|
42
|
+
if isinstance(record, dict) and record.get("type") == "deferred_tools_record":
|
|
43
|
+
for tool_entry in record.get("entries") or []:
|
|
44
|
+
if isinstance(tool_entry, dict) and tool_entry.get("name") == LIST:
|
|
45
|
+
listed = True
|
|
46
|
+
if not isinstance(content, list):
|
|
47
|
+
continue
|
|
48
|
+
for item in content:
|
|
49
|
+
if not isinstance(item, dict):
|
|
50
|
+
continue
|
|
51
|
+
if item.get("type") == "tool_use" and item.get("name") == LIST:
|
|
52
|
+
calls[item.get("id")] = entry.get("timestamp")
|
|
53
|
+
elif item.get("type") == "tool_result" and item.get("is_error") is not True:
|
|
54
|
+
good.add(item.get("tool_use_id"))
|
|
55
|
+
|
|
56
|
+
# The tool is available when the transcript records it (a deferred-tools entry
|
|
57
|
+
# naming it, or a call to it), not when some text merely mentions its name.
|
|
58
|
+
if tool == START and not listed and not calls:
|
|
59
|
+
sys.exit(0)
|
|
60
|
+
|
|
61
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
62
|
+
for use_id, stamp in calls.items():
|
|
63
|
+
if use_id not in good or not isinstance(stamp, str):
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
when = datetime.datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
|
67
|
+
except ValueError:
|
|
68
|
+
continue
|
|
69
|
+
if now - when <= datetime.timedelta(minutes=LOOK_WINDOW_MINUTES):
|
|
70
|
+
sys.exit(0)
|
|
71
|
+
|
|
72
|
+
print(
|
|
73
|
+
"BLOCKED: before starting a session, look for one already doing this work: "
|
|
74
|
+
"call " + LIST + " (mine: true), read its titles, and start only if none "
|
|
75
|
+
"covers it; then retry. A look counts for " + str(LOOK_WINDOW_MINUTES)
|
|
76
|
+
+ " minutes. Why: two sessions doing the same work duplicate it.",
|
|
77
|
+
file=sys.stderr,
|
|
78
|
+
)
|
|
79
|
+
sys.exit(2)
|
|
80
|
+
PYEOF
|
|
81
|
+
)
|
|
82
|
+
exec "${HOOK_PYTHON:-python3}" -c "$PY_SRC"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veda-hq/sarathi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Runs a project's Claude Code hooks from one entry per event, with kavach's guards built in, and carries the sarathi plugin's agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"coding agents",
|
|
@@ -29,10 +29,11 @@
|
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"dist",
|
|
32
|
+
"hooks",
|
|
32
33
|
"!dist/**/*.test.js"
|
|
33
34
|
],
|
|
34
35
|
"dependencies": {
|
|
35
|
-
"@veda-hq/kavach": "0.
|
|
36
|
+
"@veda-hq/kavach": "0.6.0"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"@types/node": "24.19.2",
|
|
@@ -41,8 +42,8 @@
|
|
|
41
42
|
"scripts": {
|
|
42
43
|
"build": "tsc -p tsconfig.build.json",
|
|
43
44
|
"typecheck": "tsc -p tsconfig.json",
|
|
44
|
-
"test": "node --test --experimental-test-coverage --test-coverage-lines=95 --test-coverage-functions=95 --test-coverage-branches=90 --test-coverage-exclude=src/**/*.test.ts --test-coverage-exclude=src/bin.ts src/**/*.test.ts",
|
|
45
|
-
"smoke": "node dist/bin.js --version && node dist/bin.js check --config test/fixture/sarathi.json --settings test/fixture/settings.json",
|
|
45
|
+
"test": "node --test --experimental-test-coverage --test-coverage-lines=95 --test-coverage-functions=95 --test-coverage-branches=90 --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude=src/bin.ts 'src/**/*.test.ts'",
|
|
46
|
+
"smoke": "node dist/bin.js --version && node dist/bin.js check --config test/fixture/sarathi.json --settings test/fixture/settings.json && echo {} | bash hooks/format-before-write.sh",
|
|
46
47
|
"check": "pnpm typecheck && pnpm test && pnpm build && pnpm smoke"
|
|
47
48
|
}
|
|
48
49
|
}
|