@veda-hq/sarathi 0.0.0-stage → 0.4.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Veda Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,85 @@
1
- # Temporary Holding Version
1
+ # @veda-hq/sarathi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ सारथि, charioteer. Runs a project's Claude Code hooks from one settings entry
4
+ per event, with [kavach](../kavach)'s guards built in, and carries the
5
+ sarathi plugin's agents. Part of [Veda](https://github.com/veda-hq/veda).
6
+
7
+ ## Why a package and not only a plugin
8
+
9
+ A Claude Code plugin cannot carry permissions or environment (a plugin's
10
+ settings apply only `agent` and `subagentStatusLine`), and a cloud session
11
+ does not install the plugins a repository lists, because that needs the
12
+ folder-trust dialog a cloud session never shows. A pinned package installs
13
+ wherever the repository's dependencies do: a laptop, a cloud session, a second
14
+ repository. So the hooks run from the package, and the plugin carries what
15
+ only a plugin can: the agents.
16
+
17
+ ## The hooks
18
+
19
+ The project's settings keep one entry per event that runs sarathi:
20
+
21
+ ```json
22
+ {
23
+ "hooks": {
24
+ "PreToolUse": [
25
+ { "matcher": "Bash|Edit|Write|NotebookEdit",
26
+ "hooks": [{ "type": "command", "command": "npx --no-install sarathi hook PreToolUse", "timeout": 70 }] }
27
+ ],
28
+ "Stop": [
29
+ { "hooks": [{ "type": "command", "command": "npx --no-install sarathi hook Stop", "timeout": 130 }] }
30
+ ]
31
+ }
32
+ }
33
+ ```
34
+
35
+ and the handlers live in `.claude/sarathi.json`, in the shape of the
36
+ settings' own `hooks` block, one entry per handler:
37
+
38
+ ```json
39
+ {
40
+ "kavach": { "config": ".claude/kavach.json", "python": "python3" },
41
+ "hooks": {
42
+ "PreToolUse": [
43
+ { "matcher": "Bash|Edit|Write|NotebookEdit", "guard": "worktree" },
44
+ { "matcher": "Bash", "guard": "command" },
45
+ { "matcher": "Edit|Write", "command": "./scripts/check-edit.sh", "timeout": 10 }
46
+ ],
47
+ "Stop": [{ "command": "./scripts/keep-going.sh", "timeout": 120 }]
48
+ }
49
+ }
50
+ ```
51
+
52
+ | Key | Meaning |
53
+ | --- | --- |
54
+ | `matcher` | Claude Code's: absent or `*` for all, `A\|B` for exact names, else a regular expression |
55
+ | `command` | Run through `sh -c` with the event on stdin, as Claude Code runs a hook |
56
+ | `guard` | `worktree` or `command`: kavach's guard, run by `kavach.python` with `KAVACH_CONFIG` set to `kavach.config` |
57
+ | `timeout` | Seconds before the handler and its children are stopped (60) |
58
+ | `failClosed` | Any end but exit 0 refuses the call; always true for guards |
59
+
60
+ `sarathi hook <Event>` runs the matching handlers side by side and answers
61
+ Claude Code once, as Claude Code combines several hooks' answers: any exit 2
62
+ blocks with every blocker's reason; a fail-closed handler that crashes or
63
+ runs out of time blocks too; JSON answers merge with the strictest decision
64
+ winning (deny over ask over allow, block over none, `continue: false` over
65
+ true) and every handler's context kept; a handler that failed without
66
+ blocking is named in `systemMessage`, or on stderr with exit 1. A hook list
67
+ that cannot be read refuses `PreToolUse` and only warns on other events.
68
+
69
+ `sarathi check --settings .claude/settings.json` proves the settings run
70
+ sarathi for every event the list names, with a matcher that lets every
71
+ handler fire and a timeout at least the longest handler's. Run it in the
72
+ project's tests.
73
+
74
+ ## The plugin
75
+
76
+ `sarathi@veda`, from this repository's marketplace, carries two agents:
77
+ `sarathi:worker` (carries out one decided task end to end) and
78
+ `sarathi:researcher` (answers an external question with sources, read-only).
79
+
80
+ ```sh
81
+ claude plugin marketplace add veda-hq/veda
82
+ claude plugin install sarathi@veda
83
+ ```
84
+
85
+ Node 24 or later. MIT.
package/dist/bin.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { main } from './cli.js';
3
+ const readStdin = async () => {
4
+ const chunks = [];
5
+ for await (const chunk of process.stdin)
6
+ chunks.push(chunk);
7
+ return Buffer.concat(chunks).toString('utf8');
8
+ };
9
+ process.exitCode = await main({
10
+ argv: process.argv.slice(2),
11
+ env: process.env,
12
+ cwd: process.cwd(),
13
+ readStdin,
14
+ out: text => process.stdout.write(text),
15
+ err: text => process.stderr.write(text),
16
+ });
package/dist/cli.js ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `sarathi hook <Event>`: the one command a project's Claude Code settings run
3
+ * per event. It reads the event on stdin, runs the project's handlers for it
4
+ * (`.claude/sarathi.json`), and gives Claude Code their combined answer.
5
+ *
6
+ * `sarathi check`: validates the hook list, and with `--settings` that the
7
+ * settings run sarathi for every event it lists, with a matcher and timeout
8
+ * that let every handler fire.
9
+ */
10
+ import { readFileSync } from 'node:fs';
11
+ import { join, resolve } from 'node:path';
12
+ import { ConfigError, compileMatcher, entriesFor, loadConfig, longestTimeout, settingsMatcher } from './config.js';
13
+ import { merge } from './merge.js';
14
+ import { runAll } from './run.js';
15
+ export const VERSION = '0.4.0';
16
+ const USAGE = `usage: sarathi hook <Event> [--config <file>]
17
+ sarathi check [--config <file>] [--settings <file>]
18
+ sarathi --version
19
+ `;
20
+ /** Events where a broken hook list must refuse the call rather than let it through unguarded. */
21
+ const GUARDED_EVENTS = new Set(['PreToolUse']);
22
+ function parseOptions(args) {
23
+ const positional = [];
24
+ let config;
25
+ let settings;
26
+ for (let index = 0; index < args.length; index += 1) {
27
+ const arg = args[index] ?? '';
28
+ if (arg === '--config' || arg === '--settings') {
29
+ const value = args[index + 1];
30
+ if (value === undefined || value === '')
31
+ return `${arg} needs a file`;
32
+ if (arg === '--config')
33
+ config = value;
34
+ else
35
+ settings = value;
36
+ index += 1;
37
+ }
38
+ else if (arg.startsWith('--')) {
39
+ return `unknown option ${arg}`;
40
+ }
41
+ else {
42
+ positional.push(arg);
43
+ }
44
+ }
45
+ return { positional, ...(config === undefined ? {} : { config }), ...(settings === undefined ? {} : { settings }) };
46
+ }
47
+ function projectRoot(io) {
48
+ const dir = io.env.CLAUDE_PROJECT_DIR;
49
+ return dir === undefined || dir === '' ? io.cwd : dir;
50
+ }
51
+ function configPath(io, options) {
52
+ const root = projectRoot(io);
53
+ return options.config === undefined ? join(root, '.claude', 'sarathi.json') : resolve(io.cwd, options.config);
54
+ }
55
+ /** What an event's matchers are tested against: the tool, or the event's own matcher field. */
56
+ export function matchTarget(event) {
57
+ for (const key of ['tool_name', 'source', 'trigger', 'notification_type', 'agent_type']) {
58
+ const value = event[key];
59
+ if (typeof value === 'string')
60
+ return value;
61
+ }
62
+ return '';
63
+ }
64
+ async function hook(io, options) {
65
+ const event = options.positional[1];
66
+ if (event === undefined || options.positional.length > 2) {
67
+ io.err(USAGE);
68
+ return 1;
69
+ }
70
+ const refuse = GUARDED_EVENTS.has(event);
71
+ const input = await io.readStdin();
72
+ let parsed;
73
+ try {
74
+ parsed = JSON.parse(input);
75
+ }
76
+ catch {
77
+ parsed = undefined;
78
+ }
79
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
80
+ io.err(`${refuse ? 'BLOCKED: ' : ''}sarathi: the ${event} event on stdin is not a JSON object, so no handler ran.\n`);
81
+ return refuse ? 2 : 1;
82
+ }
83
+ let config;
84
+ try {
85
+ config = loadConfig(configPath(io, options), projectRoot(io));
86
+ }
87
+ catch (error) {
88
+ const message = error instanceof ConfigError ? error.message : String(error);
89
+ io.err(`${refuse ? 'BLOCKED: ' : ''}sarathi cannot use its hook list: ${message}. No ${event} handler ran.\n`);
90
+ return refuse ? 2 : 1;
91
+ }
92
+ const entries = entriesFor(config, event, matchTarget(parsed));
93
+ if (entries.length === 0)
94
+ return 0;
95
+ const outcomes = await (io.run ?? runAll)(entries, config, input, io.cwd);
96
+ const answer = merge(event, outcomes);
97
+ if (answer.stdout !== '')
98
+ io.out(answer.stdout);
99
+ if (answer.stderr !== '')
100
+ io.err(answer.stderr);
101
+ return answer.code;
102
+ }
103
+ function settingsHooks(text, path) {
104
+ let raw;
105
+ try {
106
+ raw = JSON.parse(text);
107
+ }
108
+ catch {
109
+ return `${path} is not JSON`;
110
+ }
111
+ const found = new Map();
112
+ const hooks = raw?.hooks;
113
+ if (typeof hooks !== 'object' || hooks === null)
114
+ return found;
115
+ for (const [event, groups] of Object.entries(hooks)) {
116
+ const list = [];
117
+ for (const group of Array.isArray(groups) ? groups : []) {
118
+ const { matcher, hooks: inner } = (group ?? {});
119
+ for (const item of Array.isArray(inner) ? inner : []) {
120
+ const { command, timeout } = (item ?? {});
121
+ if (typeof command !== 'string')
122
+ continue;
123
+ list.push({
124
+ matcher: typeof matcher === 'string' ? matcher : undefined,
125
+ command,
126
+ timeout: typeof timeout === 'number' ? timeout : undefined,
127
+ });
128
+ }
129
+ }
130
+ found.set(event, list);
131
+ }
132
+ return found;
133
+ }
134
+ /**
135
+ * Does a settings command run sarathi for `event`? It names sarathi (the
136
+ * program, or a launcher script named after it) and passes the event as a
137
+ * word of its own: `sarathi hook Stop`, `"$DIR"/hooks/sarathi.sh Stop`.
138
+ */
139
+ export function runsSarathiFor(command, event) {
140
+ return /\bsarathi\b/.test(command) && new RegExp(`(?:^|[\\s"'])${event}(?:$|[\\s"'])`).test(command);
141
+ }
142
+ /** The settings' problems in running `config`: a missing event, a matcher that hides a handler, a timeout shorter than one. */
143
+ export function settingsProblems(config, settings) {
144
+ const problems = [];
145
+ for (const event of Object.keys(config.hooks)) {
146
+ if ((config.hooks[event] ?? []).length === 0)
147
+ continue;
148
+ const runner = (settings.get(event) ?? []).find(item => runsSarathiFor(item.command, event));
149
+ if (runner === undefined) {
150
+ problems.push(`${event}: no settings hook runs \`sarathi hook ${event}\``);
151
+ continue;
152
+ }
153
+ const needed = settingsMatcher(config, event);
154
+ const covers = compileMatcher(runner.matcher);
155
+ if (needed === undefined) {
156
+ if (runner.matcher !== undefined && runner.matcher !== '' && runner.matcher !== '*') {
157
+ problems.push(`${event}: a handler matches every tool, so the settings entry needs no matcher (has "${runner.matcher}")`);
158
+ }
159
+ }
160
+ else {
161
+ const hidden = needed.split('|').filter(name => name !== '' && !covers(name));
162
+ if (hidden.length > 0)
163
+ problems.push(`${event}: the settings matcher leaves out ${hidden.join(', ')}`);
164
+ }
165
+ const longest = longestTimeout(config, event);
166
+ if (runner.timeout === undefined || runner.timeout < longest) {
167
+ problems.push(`${event}: the settings timeout must be at least ${longest} s, the longest handler's (has ${runner.timeout ?? 'none'})`);
168
+ }
169
+ }
170
+ return problems;
171
+ }
172
+ function check(io, options) {
173
+ if (options.positional.length > 1) {
174
+ io.err(USAGE);
175
+ return 1;
176
+ }
177
+ let config;
178
+ try {
179
+ config = loadConfig(configPath(io, options), projectRoot(io));
180
+ }
181
+ catch (error) {
182
+ io.err(`sarathi: ${error instanceof ConfigError ? error.message : String(error)}\n`);
183
+ return 1;
184
+ }
185
+ const counts = Object.entries(config.hooks).map(([event, list]) => `${event} ${list.length}`);
186
+ if (options.settings !== undefined) {
187
+ const path = resolve(io.cwd, options.settings);
188
+ let text;
189
+ try {
190
+ text = readFileSync(path, 'utf8');
191
+ }
192
+ catch {
193
+ io.err(`sarathi: cannot read ${path}\n`);
194
+ return 1;
195
+ }
196
+ const settings = settingsHooks(text, path);
197
+ if (typeof settings === 'string') {
198
+ io.err(`sarathi: ${settings}\n`);
199
+ return 1;
200
+ }
201
+ const problems = settingsProblems(config, settings);
202
+ if (problems.length > 0) {
203
+ io.err(problems.map(problem => `sarathi: ${problem}\n`).join(''));
204
+ return 1;
205
+ }
206
+ }
207
+ io.out(`sarathi: ${config.path} is valid (${counts.join(', ')})\n`);
208
+ return 0;
209
+ }
210
+ export async function main(io) {
211
+ if (io.argv.length === 1 && io.argv[0] === '--version') {
212
+ io.out(`${VERSION}\n`);
213
+ return 0;
214
+ }
215
+ const options = parseOptions(io.argv);
216
+ if (typeof options === 'string') {
217
+ io.err(`sarathi: ${options}\n${USAGE}`);
218
+ return 1;
219
+ }
220
+ const command = options.positional[0];
221
+ if (command === 'hook')
222
+ return hook(io, options);
223
+ if (command === 'check')
224
+ return check(io, options);
225
+ io.err(USAGE);
226
+ return 1;
227
+ }
package/dist/config.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The project's hook list: which commands run on which Claude Code event.
3
+ *
4
+ * A project keeps one JSON file (by default `.claude/sarathi.json`) and one
5
+ * short entry per event in its Claude Code settings that runs
6
+ * `sarathi hook <Event>`. The file has the shape of the settings' own `hooks`
7
+ * block, flattened: each event lists entries, each entry one handler.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { isAbsolute, resolve } from 'node:path';
11
+ export const DEFAULT_TIMEOUT_SECONDS = 60;
12
+ const ENTRY_KEYS = new Set(['matcher', 'command', 'guard', 'timeout', 'failClosed']);
13
+ const TOP_KEYS = new Set(['$schema', 'kavach', 'hooks']);
14
+ const KAVACH_KEYS = new Set(['config', 'python']);
15
+ const PLAIN = /^[\w|-]+$/;
16
+ export class ConfigError extends Error {
17
+ name = 'ConfigError';
18
+ }
19
+ function isRecord(value) {
20
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
21
+ }
22
+ function rejectUnknownKeys(value, known, where) {
23
+ const extra = Object.keys(value).filter(key => !known.has(key));
24
+ if (extra.length > 0)
25
+ throw new ConfigError(`${where}: unknown key ${extra.map(key => `"${key}"`).join(', ')}`);
26
+ }
27
+ /** Claude Code's matcher rules: absent, empty or `*` match all; plain names and `A|B` match exactly; anything else is a regular expression. */
28
+ export function compileMatcher(matcher, where = 'matcher') {
29
+ if (matcher === undefined || matcher === '' || matcher === '*')
30
+ return () => true;
31
+ if (PLAIN.test(matcher)) {
32
+ const names = new Set(matcher.split('|'));
33
+ return target => names.has(target);
34
+ }
35
+ let pattern;
36
+ try {
37
+ pattern = new RegExp(matcher);
38
+ }
39
+ catch (error) {
40
+ throw new ConfigError(`${where}: "matcher" is not a regular expression (${error.message})`);
41
+ }
42
+ return target => pattern.test(target);
43
+ }
44
+ function parseEntry(raw, where) {
45
+ if (!isRecord(raw))
46
+ throw new ConfigError(`${where}: an entry must be an object`);
47
+ rejectUnknownKeys(raw, ENTRY_KEYS, where);
48
+ const { matcher, command, guard, timeout, failClosed } = raw;
49
+ if (matcher !== undefined && typeof matcher !== 'string')
50
+ throw new ConfigError(`${where}: "matcher" must be a string`);
51
+ if (command !== undefined && (typeof command !== 'string' || command.trim() === '')) {
52
+ throw new ConfigError(`${where}: "command" must be a non-empty string`);
53
+ }
54
+ if (guard !== undefined && guard !== 'worktree' && guard !== 'command') {
55
+ throw new ConfigError(`${where}: "guard" must be "worktree" or "command"`);
56
+ }
57
+ if ((command === undefined) === (guard === undefined)) {
58
+ throw new ConfigError(`${where}: give exactly one of "command" and "guard"`);
59
+ }
60
+ if (timeout !== undefined && (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0)) {
61
+ throw new ConfigError(`${where}: "timeout" must be a positive number of seconds`);
62
+ }
63
+ if (failClosed !== undefined && typeof failClosed !== 'boolean') {
64
+ throw new ConfigError(`${where}: "failClosed" must be true or false`);
65
+ }
66
+ if (typeof matcher === 'string')
67
+ compileMatcher(matcher, where);
68
+ return {
69
+ ...(typeof matcher === 'string' ? { matcher } : {}),
70
+ ...(typeof command === 'string' ? { command } : {}),
71
+ ...(guard === 'worktree' || guard === 'command' ? { guard } : {}),
72
+ ...(typeof timeout === 'number' ? { timeout } : {}),
73
+ ...(typeof failClosed === 'boolean' ? { failClosed } : {}),
74
+ };
75
+ }
76
+ /** Parses the file's text; `path` names it in errors, `root` is where its relative paths start. */
77
+ export function parseConfig(text, path, root) {
78
+ let raw;
79
+ try {
80
+ raw = JSON.parse(text);
81
+ }
82
+ catch (error) {
83
+ throw new ConfigError(`${path} is not JSON (${error.message})`);
84
+ }
85
+ if (!isRecord(raw))
86
+ throw new ConfigError(`${path}: the file must hold an object`);
87
+ rejectUnknownKeys(raw, TOP_KEYS, path);
88
+ const kavachRaw = raw.kavach ?? {};
89
+ if (!isRecord(kavachRaw))
90
+ throw new ConfigError(`${path}: "kavach" must be an object`);
91
+ rejectUnknownKeys(kavachRaw, KAVACH_KEYS, `${path} kavach`);
92
+ const { config, python } = kavachRaw;
93
+ if (config !== undefined && typeof config !== 'string')
94
+ throw new ConfigError(`${path}: "kavach.config" must be a path`);
95
+ if (python !== undefined && typeof python !== 'string')
96
+ throw new ConfigError(`${path}: "kavach.python" must be a program`);
97
+ const hooksRaw = raw.hooks;
98
+ if (!isRecord(hooksRaw))
99
+ throw new ConfigError(`${path}: "hooks" must be an object of events`);
100
+ const hooks = {};
101
+ for (const [event, list] of Object.entries(hooksRaw)) {
102
+ if (!Array.isArray(list))
103
+ throw new ConfigError(`${path} ${event}: must be a list of entries`);
104
+ hooks[event] = list.map((entry, index) => parseEntry(entry, `${path} ${event}[${index}]`));
105
+ }
106
+ const fromRoot = (value) => (isAbsolute(value) ? value : resolve(root, value));
107
+ return {
108
+ path,
109
+ root,
110
+ kavach: {
111
+ ...(typeof config === 'string' ? { config: fromRoot(config) } : {}),
112
+ python: typeof python !== 'string' ? 'python3' : python.includes('/') ? fromRoot(python) : python,
113
+ },
114
+ hooks,
115
+ };
116
+ }
117
+ /** Reads the file at `path`; relative paths in it resolve from `root`. */
118
+ export function loadConfig(path, root) {
119
+ let text;
120
+ try {
121
+ text = readFileSync(path, 'utf8');
122
+ }
123
+ catch (error) {
124
+ throw new ConfigError(`cannot read ${path} (${error.code ?? 'error'})`);
125
+ }
126
+ return parseConfig(text, path, root);
127
+ }
128
+ /** The entries for `event` whose matcher takes `target` (the tool name, or the event's own matcher field). */
129
+ export function entriesFor(config, event, target) {
130
+ return (config.hooks[event] ?? []).filter(entry => compileMatcher(entry.matcher)(target));
131
+ }
132
+ /**
133
+ * The matcher the one settings entry for `event` needs so that every entry here
134
+ * can fire: the union of their names, or `undefined` (all) when one entry
135
+ * matches all or uses a regular expression.
136
+ */
137
+ export function settingsMatcher(config, event) {
138
+ const names = new Set();
139
+ for (const entry of config.hooks[event] ?? []) {
140
+ const matcher = entry.matcher;
141
+ if (matcher === undefined || matcher === '' || matcher === '*' || !PLAIN.test(matcher))
142
+ return undefined;
143
+ for (const name of matcher.split('|'))
144
+ names.add(name);
145
+ }
146
+ return [...names].sort().join('|');
147
+ }
148
+ /** The longest timeout among an event's entries, in seconds. */
149
+ export function longestTimeout(config, event) {
150
+ return Math.max(0, ...(config.hooks[event] ?? []).map(entry => entry.timeout ?? DEFAULT_TIMEOUT_SECONDS));
151
+ }
package/dist/merge.js ADDED
@@ -0,0 +1,132 @@
1
+ /** Events whose plain stdout Claude Code adds to Claude's context. */
2
+ const CONTEXT_EVENTS = new Set(['SessionStart', 'UserPromptSubmit']);
3
+ const PERMISSION_ORDER = ['deny', 'ask', 'allow'];
4
+ function isRecord(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ function parseJson(text) {
8
+ const trimmed = text.trim();
9
+ if (!trimmed.startsWith('{'))
10
+ return undefined;
11
+ try {
12
+ const value = JSON.parse(trimmed);
13
+ return isRecord(value) ? value : undefined;
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
19
+ function strings(values) {
20
+ return values.filter((value) => typeof value === 'string' && value.trim() !== '');
21
+ }
22
+ function joined(values) {
23
+ const kept = strings(values);
24
+ return kept.length === 0 ? undefined : kept.join('\n\n');
25
+ }
26
+ function ends(text) {
27
+ return text === '' || text.endsWith('\n') ? text : `${text}\n`;
28
+ }
29
+ function blocks(outcome) {
30
+ if (outcome.code === 2)
31
+ return true;
32
+ return outcome.failClosed && outcome.code !== 0;
33
+ }
34
+ function blockReason(outcome) {
35
+ if (outcome.code === 2)
36
+ return ends(outcome.stderr);
37
+ const how = outcome.timedOut ? 'ran out of time' : outcome.code === null ? 'did not finish' : `exited ${outcome.code}`;
38
+ return `${ends(outcome.stderr)}BLOCKED: ${outcome.label} ${how} instead of giving a verdict, so the call is refused.\n`;
39
+ }
40
+ function failureNote(outcome) {
41
+ const how = outcome.timedOut ? 'ran out of time' : outcome.code === null ? 'did not finish' : `exited ${outcome.code}`;
42
+ const detail = outcome.stderr.trim();
43
+ return detail === '' ? `${outcome.label} ${how}` : `${outcome.label} ${how}: ${detail}`;
44
+ }
45
+ /** Merges the hook-specific fields of several JSON answers for `event`. */
46
+ function mergeSpecific(event, answers, extraContext) {
47
+ const specifics = answers.map(answer => answer.hookSpecificOutput).filter(isRecord);
48
+ const merged = {};
49
+ for (const specific of specifics) {
50
+ for (const [key, value] of Object.entries(specific)) {
51
+ if (!(key in merged))
52
+ merged[key] = value;
53
+ }
54
+ }
55
+ const decisions = specifics.map(specific => specific.permissionDecision).filter((value) => typeof value === 'string');
56
+ const winner = PERMISSION_ORDER.find(decision => decisions.includes(decision)) ?? decisions[0];
57
+ if (winner !== undefined) {
58
+ merged.permissionDecision = winner;
59
+ const reason = joined(specifics.filter(specific => specific.permissionDecision === winner).map(specific => specific.permissionDecisionReason));
60
+ if (reason === undefined)
61
+ delete merged.permissionDecisionReason;
62
+ else
63
+ merged.permissionDecisionReason = reason;
64
+ }
65
+ const context = joined([...specifics.map(specific => specific.additionalContext), ...extraContext]);
66
+ if (context === undefined)
67
+ delete merged.additionalContext;
68
+ else
69
+ merged.additionalContext = context;
70
+ if (Object.keys(merged).length === 0)
71
+ return undefined;
72
+ merged.hookEventName = event;
73
+ return merged;
74
+ }
75
+ const MANAGED = new Set(['continue', 'stopReason', 'suppressOutput', 'decision', 'reason', 'systemMessage', 'hookSpecificOutput']);
76
+ function mergeJson(event, answers, extraContext, notes) {
77
+ const merged = {};
78
+ for (const answer of answers) {
79
+ for (const [key, value] of Object.entries(answer)) {
80
+ if (!MANAGED.has(key) && !(key in merged))
81
+ merged[key] = value;
82
+ }
83
+ }
84
+ if (answers.some(answer => answer.continue === false)) {
85
+ merged.continue = false;
86
+ const stop = joined(answers.map(answer => answer.stopReason));
87
+ if (stop !== undefined)
88
+ merged.stopReason = stop;
89
+ }
90
+ if (answers.some(answer => answer.suppressOutput === true))
91
+ merged.suppressOutput = true;
92
+ const blockers = answers.filter(answer => answer.decision === 'block');
93
+ if (blockers.length > 0) {
94
+ merged.decision = 'block';
95
+ const reason = joined(blockers.map(answer => answer.reason));
96
+ if (reason !== undefined)
97
+ merged.reason = reason;
98
+ }
99
+ const message = joined([...answers.map(answer => answer.systemMessage), ...notes]);
100
+ if (message !== undefined)
101
+ merged.systemMessage = message;
102
+ const specific = mergeSpecific(event, answers, extraContext);
103
+ if (specific !== undefined)
104
+ merged.hookSpecificOutput = specific;
105
+ return merged;
106
+ }
107
+ /** Combines the outcomes of `event`'s handlers into the one answer sarathi gives. */
108
+ export function merge(event, outcomes) {
109
+ const blockers = outcomes.filter(blocks);
110
+ if (blockers.length > 0)
111
+ return { code: 2, stdout: '', stderr: blockers.map(blockReason).join('') };
112
+ const failed = outcomes.filter(outcome => outcome.code !== 0);
113
+ const passed = outcomes.filter(outcome => outcome.code === 0);
114
+ const answers = [];
115
+ const texts = [];
116
+ for (const outcome of passed) {
117
+ const json = parseJson(outcome.stdout);
118
+ if (json !== undefined)
119
+ answers.push(json);
120
+ else if (outcome.stdout.trim() !== '')
121
+ texts.push(outcome.stdout.trim());
122
+ }
123
+ const contextTexts = CONTEXT_EVENTS.has(event) ? texts : [];
124
+ const notes = failed.map(failureNote);
125
+ if (answers.length > 0 || (failed.length > 0 && contextTexts.length > 0)) {
126
+ return { code: 0, stdout: `${JSON.stringify(mergeJson(event, answers, contextTexts, notes))}\n`, stderr: '' };
127
+ }
128
+ if (failed.length > 0) {
129
+ return { code: 1, stdout: '', stderr: failed.map(outcome => ends(failureNote(outcome))).join('') };
130
+ }
131
+ return { code: 0, stdout: texts.length === 0 ? '' : `${texts.join('\n\n')}\n`, stderr: '' };
132
+ }
package/dist/run.js ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Runs one event's handlers side by side, as Claude Code runs a settings
3
+ * block's hooks: each gets the event on stdin and the session's environment,
4
+ * and each is stopped at its own timeout.
5
+ */
6
+ import { spawn } from 'node:child_process';
7
+ import { createRequire } from 'node:module';
8
+ import { dirname, join } from 'node:path';
9
+ import { DEFAULT_TIMEOUT_SECONDS } from './config.js';
10
+ const GUARD_SCRIPTS = {
11
+ worktree: 'worktree_guard.py',
12
+ command: 'command_guard.py',
13
+ };
14
+ /** The folder holding kavach's Python guards, from this package's own dependency. */
15
+ export function kavachDir() {
16
+ const require = createRequire(import.meta.url);
17
+ return join(dirname(require.resolve('@veda-hq/kavach/package.json')), 'python', 'kavach');
18
+ }
19
+ /** How one entry runs: a command through `sh -c`, a guard through the configured Python. */
20
+ export function plan(entry, config, guardDir = kavachDir) {
21
+ const timeoutMs = (entry.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000;
22
+ if (entry.guard !== undefined) {
23
+ const env = config.kavach.config === undefined ? {} : { KAVACH_CONFIG: config.kavach.config };
24
+ return {
25
+ label: `kavach ${entry.guard} guard`,
26
+ file: config.kavach.python,
27
+ args: [join(guardDir(), GUARD_SCRIPTS[entry.guard])],
28
+ env,
29
+ timeoutMs,
30
+ failClosed: true,
31
+ };
32
+ }
33
+ const command = entry.command ?? '';
34
+ return { label: command, file: '/bin/sh', args: ['-c', command], env: {}, timeoutMs, failClosed: entry.failClosed === true };
35
+ }
36
+ /** Runs one plan with `input` on stdin. Never rejects: a handler that cannot start ends with code `null`. */
37
+ export function runPlan(step, input, cwd) {
38
+ return new Promise(done => {
39
+ const out = [];
40
+ const err = [];
41
+ let timedOut = false;
42
+ let settled = false;
43
+ const finish = (code, extra = '') => {
44
+ if (settled)
45
+ return;
46
+ settled = true;
47
+ clearTimeout(timer);
48
+ done({
49
+ label: step.label,
50
+ code,
51
+ stdout: Buffer.concat(out).toString('utf8'),
52
+ stderr: Buffer.concat(err).toString('utf8') + extra,
53
+ timedOut,
54
+ failClosed: step.failClosed,
55
+ });
56
+ };
57
+ const child = spawn(step.file, [...step.args], {
58
+ cwd,
59
+ env: { ...process.env, ...step.env },
60
+ stdio: ['pipe', 'pipe', 'pipe'],
61
+ detached: true,
62
+ });
63
+ const timer = setTimeout(() => {
64
+ timedOut = true;
65
+ try {
66
+ if (child.pid !== undefined)
67
+ process.kill(-child.pid, 'SIGKILL');
68
+ }
69
+ catch {
70
+ // already gone: the close event still comes
71
+ }
72
+ }, step.timeoutMs);
73
+ child.stdout.on('data', (chunk) => out.push(chunk));
74
+ child.stderr.on('data', (chunk) => err.push(chunk));
75
+ child.on('error', error => finish(null, `${step.label} could not start (${error.message})\n`));
76
+ child.on('close', code => finish(timedOut ? null : code));
77
+ child.stdin.on('error', () => {
78
+ // a handler that exits without reading its input is not a failure
79
+ });
80
+ child.stdin.end(input);
81
+ });
82
+ }
83
+ /** Runs every entry at once and returns their outcomes in the entries' order. */
84
+ export function runAll(entries, config, input, cwd) {
85
+ return Promise.all(entries.map(entry => runPlan(plan(entry, config), input, cwd)));
86
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@veda-hq/sarathi",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.4.0",
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
+ "keywords": [
6
+ "coding agents",
7
+ "claude code",
8
+ "hooks",
9
+ "plugin",
10
+ "guard"
11
+ ],
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "engines": {
15
+ "node": ">=24.15"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/veda-hq/veda.git",
20
+ "directory": "packages/sarathi"
21
+ },
22
+ "homepage": "https://github.com/veda-hq/veda/tree/main/packages/sarathi#readme",
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "bin": {
28
+ "sarathi": "dist/bin.js"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "!dist/**/*.test.js"
33
+ ],
34
+ "dependencies": {
35
+ "@veda-hq/kavach": "0.3.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "24.19.2",
39
+ "typescript": "7.0.2"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "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",
46
+ "check": "pnpm typecheck && pnpm test && pnpm build && pnpm smoke"
47
+ }
6
48
  }