@veda-hq/sarathi 0.5.6 → 0.5.15

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 CHANGED
@@ -71,6 +71,46 @@ 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
+
98
+ ### Forbidden variables
99
+
100
+ Some switches turn a session's tooling off from the environment, where no
101
+ committed file can see them: `CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1` turns the
102
+ advisor off whatever the settings say. The hook list's top-level `forbidEnv`
103
+ names each such variable with why:
104
+
105
+ ```json
106
+ { "forbidEnv": { "CLAUDE_CODE_DISABLE_ADVISOR_TOOL": "this project works with the advisor on" } }
107
+ ```
108
+
109
+ While one is set (anything but empty, `0` or `false`), `sarathi hook` refuses
110
+ every `PreToolUse` call before any handler runs, and at `SessionStart` and
111
+ `UserPromptSubmit` tells both Claude and the user why and how to fix it. Other
112
+ events are left alone. It acts only on events the settings route to sarathi.
113
+
74
114
  ## The plugin
75
115
 
76
116
  `sarathi@veda`, from this repository's marketplace, carries two agents:
package/dist/cli.js CHANGED
@@ -9,10 +9,10 @@
9
9
  */
10
10
  import { readFileSync } from 'node:fs';
11
11
  import { join, resolve } from 'node:path';
12
- import { ConfigError, compileMatcher, entriesFor, loadConfig, longestTimeout, settingsMatcher } from './config.js';
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.5.6';
15
+ export const VERSION = '0.5.15';
16
16
  const USAGE = `usage: sarathi hook <Event> [--config <file>]
17
17
  sarathi check [--config <file>] [--settings <file>]
18
18
  sarathi --version
@@ -61,6 +61,31 @@ export function matchTarget(event) {
61
61
  }
62
62
  return '';
63
63
  }
64
+ /** Events where a forbidden variable is told to the session and the user. */
65
+ const WARNED_EVENTS = new Set(['SessionStart', 'UserPromptSubmit']);
66
+ /**
67
+ * What sarathi answers, beside the handlers, when the session sets a variable
68
+ * the project forbids: PreToolUse refuses, SessionStart and UserPromptSubmit
69
+ * warn both Claude and the user, other events say nothing.
70
+ */
71
+ export function forbidOutcome(event, set) {
72
+ if (set.length === 0)
73
+ return undefined;
74
+ const lines = set.map(([name, why]) => `${name} is set, and this project forbids it: ${why}`);
75
+ const fix = `Unset ${set.map(([name]) => name).join(' and ')} and start a new session; until then sarathi refuses every tool call.`;
76
+ const base = { label: 'sarathi forbidEnv', timedOut: false, failClosed: true };
77
+ if (GUARDED_EVENTS.has(event))
78
+ return { ...base, code: 2, stdout: '', stderr: `BLOCKED: sarathi: ${lines.join('; ')}. ${fix}\n` };
79
+ if (!WARNED_EVENTS.has(event))
80
+ return undefined;
81
+ const message = `sarathi: ${lines.join('; ')}. ${fix}`;
82
+ return {
83
+ ...base,
84
+ code: 0,
85
+ stdout: JSON.stringify({ systemMessage: message, hookSpecificOutput: { hookEventName: event, additionalContext: message } }),
86
+ stderr: '',
87
+ };
88
+ }
64
89
  async function hook(io, options) {
65
90
  const event = options.positional[1];
66
91
  if (event === undefined || options.positional.length > 2) {
@@ -89,11 +114,19 @@ async function hook(io, options) {
89
114
  io.err(`${refuse ? 'BLOCKED: ' : ''}sarathi cannot use its hook list: ${message}. No ${event} handler ran.\n`);
90
115
  return refuse ? 2 : 1;
91
116
  }
117
+ const forbidden = forbidOutcome(event, forbiddenSet(config, io.env));
118
+ if (forbidden?.code === 2) {
119
+ io.err(forbidden.stderr);
120
+ return 2;
121
+ }
92
122
  const entries = entriesFor(config, event, matchTarget(parsed));
93
- if (entries.length === 0)
123
+ if (entries.length === 0 && forbidden === undefined)
94
124
  return 0;
95
- const outcomes = await (io.run ?? runAll)(entries, config, input, io.cwd);
96
- const answer = merge(event, outcomes);
125
+ const outcomes = entries.length === 0 ? [] : await (io.run ?? runAll)(entries, config, input, io.cwd);
126
+ const merged = merge(event, forbidden === undefined ? outcomes : [forbidden, ...outcomes]);
127
+ // A handler that blocks drops every JSON answer, the warning among them; keep it in the reason.
128
+ const warning = forbidden === undefined ? undefined : JSON.parse(forbidden.stdout).systemMessage;
129
+ const answer = merged.code === 2 && warning !== undefined ? { ...merged, stderr: `${warning}\n${merged.stderr}` } : merged;
97
130
  if (answer.stdout !== '')
98
131
  io.out(answer.stdout);
99
132
  if (answer.stderr !== '')
package/dist/config.js CHANGED
@@ -10,7 +10,8 @@ import { readFileSync } from 'node:fs';
10
10
  import { isAbsolute, resolve } from 'node:path';
11
11
  export const DEFAULT_TIMEOUT_SECONDS = 60;
12
12
  const ENTRY_KEYS = new Set(['matcher', 'command', 'guard', 'timeout', 'failClosed']);
13
- const TOP_KEYS = new Set(['$schema', 'kavach', 'hooks']);
13
+ const TOP_KEYS = new Set(['$schema', 'kavach', 'hooks', 'forbidEnv']);
14
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
14
15
  const KAVACH_KEYS = new Set(['config', 'python']);
15
16
  const PLAIN = /^[\w|-]+$/;
16
17
  export class ConfigError extends Error {
@@ -103,8 +104,23 @@ export function parseConfig(text, path, root) {
103
104
  throw new ConfigError(`${path} ${event}: must be a list of entries`);
104
105
  hooks[event] = list.map((entry, index) => parseEntry(entry, `${path} ${event}[${index}]`));
105
106
  }
107
+ // Only an absent key means none: `null` is malformed, not empty.
108
+ const forbidRaw = Object.hasOwn(raw, 'forbidEnv') ? raw.forbidEnv : {};
109
+ if (!isRecord(forbidRaw))
110
+ throw new ConfigError(`${path}: "forbidEnv" must be an object of variable names`);
111
+ const forbidden = [];
112
+ for (const [name, why] of Object.entries(forbidRaw)) {
113
+ if (!ENV_NAME.test(name))
114
+ throw new ConfigError(`${path} forbidEnv: "${name}" is not an environment variable name`);
115
+ if (typeof why !== 'string' || why.trim() === '')
116
+ throw new ConfigError(`${path} forbidEnv ${name}: give why it is forbidden`);
117
+ forbidden.push([name, why]);
118
+ }
119
+ // fromEntries defines own properties, so a name like `__proto__` is kept, not sent to the prototype setter.
120
+ const forbidEnv = Object.fromEntries(forbidden);
106
121
  const fromRoot = (value) => (isAbsolute(value) ? value : resolve(root, value));
107
122
  return {
123
+ forbidEnv,
108
124
  path,
109
125
  root,
110
126
  kavach: {
@@ -145,6 +161,18 @@ export function settingsMatcher(config, event) {
145
161
  }
146
162
  return [...names].sort().join('|');
147
163
  }
164
+ /** Is a variable set, as a switch reads it? Absent, empty, `0` and `false` are off. */
165
+ export function isOn(value) {
166
+ return value !== undefined && !['', '0', 'false'].includes(value.trim().toLowerCase());
167
+ }
168
+ /** The forbidden variables `env` sets, each with why. */
169
+ export function forbiddenSet(config, env) {
170
+ // Only the environment's own string values: `toString` is a legal name an unset variable must not inherit.
171
+ return Object.entries(config.forbidEnv).filter(([name]) => {
172
+ const value = Object.hasOwn(env, name) ? env[name] : undefined;
173
+ return typeof value === 'string' && isOn(value);
174
+ });
175
+ }
148
176
  /** The longest timeout among an event's entries, in seconds. */
149
177
  export function longestTimeout(config, event) {
150
178
  return Math.max(0, ...(config.hooks[event] ?? []).map(entry => entry.timeout ?? DEFAULT_TIMEOUT_SECONDS));
@@ -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
- /** How one entry runs: a command through `sh -c`, a guard through the configured Python. */
20
- export function plan(entry, config, guardDir = kavachDir) {
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.5.6",
3
+ "version": "0.5.15",
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,6 +29,7 @@
29
29
  },
30
30
  "files": [
31
31
  "dist",
32
+ "hooks",
32
33
  "!dist/**/*.test.js"
33
34
  ],
34
35
  "dependencies": {
@@ -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
  }