hydra-nano 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -156
- package/package.json +13 -3
- package/skills/hydra/scripts/hooks/route-tests.mjs +42 -12
- package/skills/hydra/scripts/hooks/session-guide.mjs +21 -5
- package/skills/hydra/scripts/hydra.mjs +74 -16
- package/skills/hydra/scripts/install.mjs +20 -1
- package/skills/hydra/scripts/lib/claude_hooks.mjs +69 -4
- package/skills/hydra/scripts/lib/integrations.mjs +251 -0
- package/skills/hydra/scripts/lib/ledger.mjs +106 -7
- package/skills/hydra/scripts/lib/test_logs.mjs +10 -6
- package/skills/hydra/scripts/mcp.mjs +154 -0
|
@@ -7,6 +7,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
7
7
|
import { installRuntime, findNpx } from './lib/bootstrap.mjs';
|
|
8
8
|
import { ensureLocal, home } from './lib/local.mjs';
|
|
9
9
|
import { claudeDir, installHooks } from './lib/claude_hooks.mjs';
|
|
10
|
+
import * as integrations from './lib/integrations.mjs';
|
|
10
11
|
|
|
11
12
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
13
|
try {
|
|
@@ -15,7 +16,10 @@ try {
|
|
|
15
16
|
if (!manifest.model) throw new Error('GGUF publication is not ready; no downloads or installation performed');
|
|
16
17
|
// Install for the agents named with --agent, or else for every agent found on this machine.
|
|
17
18
|
const named = process.argv.flatMap((arg, i) => arg === '--agent' ? [process.argv[i + 1]] : []);
|
|
18
|
-
|
|
19
|
+
// The skill goes where an agent has no hook to route it: Claude Code (plus its hooks), Codex and Antigravity.
|
|
20
|
+
// Other tools get the MCP server and a hook instead (below), so their context does not carry both.
|
|
21
|
+
const found = [['claude-code', claudeDir()], ['codex', path.join(os.homedir(), '.codex')], ['antigravity', path.join(os.homedir(), '.gemini', 'antigravity')]]
|
|
22
|
+
.filter(([, dir]) => existsSync(dir)).map(([id]) => id);
|
|
19
23
|
const agents = named.length ? named : found;
|
|
20
24
|
if (!process.argv.includes('--runtime-only')) {
|
|
21
25
|
if (!agents.length) throw new Error('No Claude Code (~/.claude) or Codex (~/.codex) found; name one with --agent <skills CLI agent ID>');
|
|
@@ -42,5 +46,20 @@ The server stays running until stopped or the computer restarts. Download time d
|
|
|
42
46
|
Test and build runs now go through Hydra, and new sessions are told to ask Hydra about git history.
|
|
43
47
|
Remove them with: node "${path.join(skill, 'hydra.mjs')}" hooks remove`);
|
|
44
48
|
}
|
|
49
|
+
// Every other coding tool found here: Hydra's MCP server, plus its test/build hook where the tool has hooks.
|
|
50
|
+
if (!process.argv.includes('--runtime-only') && !process.argv.includes('--no-agents')) {
|
|
51
|
+
const scripts = integrations.installSharedScripts(here);
|
|
52
|
+
const tools = integrations.detected(scripts);
|
|
53
|
+
if (tools.length) {
|
|
54
|
+
console.log(`Registering Hydra's MCP server${process.argv.includes('--no-hooks') ? '' : ' and test/build hook'} in ${tools.join(', ')} ` +
|
|
55
|
+
`(each edited file is backed up once as <file>.before-hydra; skip with --no-agents):`);
|
|
56
|
+
for (const result of integrations.apply(tools, 'install', { scripts, hooks: !process.argv.includes('--no-hooks') })) {
|
|
57
|
+
console.log(` ${result.id}: ${result.changes.join('; ') || 'nothing changed'}`);
|
|
58
|
+
for (const message of result.manual) console.log(` ${message.replace(/\n/g, '\n ')}`);
|
|
59
|
+
if (result.note) console.log(` ${result.note}`);
|
|
60
|
+
}
|
|
61
|
+
console.log(` Remove them with: node "${path.join(scripts, 'hydra.mjs')}" agents remove`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
45
64
|
console.log('Hydra skill and local runtime ready. Run the installed scripts/hydra.mjs doctor to verify.');
|
|
46
65
|
} catch (error) { console.error(`hydra-install: ${error.message}`); process.exitCode = 2; }
|
|
@@ -29,14 +29,79 @@ const RUNNERS = [
|
|
|
29
29
|
export const claudeDir = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
30
30
|
export const settingsFile = () => path.join(claudeDir(), 'settings.json');
|
|
31
31
|
|
|
32
|
-
/** The
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
/** The simple commands of a shell line: split on &&, ||, ;, | and newlines outside quotes, so a
|
|
33
|
+
* quoted grep pattern such as "test\|pytest" is never mistaken for a command of its own. A heredoc
|
|
34
|
+
* body (<<EOF ... EOF) is data, not commands, and is skipped whole. */
|
|
35
|
+
export function simpleCommands(command) {
|
|
36
|
+
const parts = [];
|
|
37
|
+
let current = '';
|
|
38
|
+
let quote = null;
|
|
39
|
+
const heredocs = [];
|
|
40
|
+
for (let i = 0; i < command.length; i++) {
|
|
41
|
+
const c = command[i];
|
|
42
|
+
const heredoc = !quote && c === '<' && command[i + 1] === '<' && command[i + 2] !== '<'
|
|
43
|
+
&& /^<<-?\s*(['"]?)([A-Za-z_]\w*)\1/.exec(command.slice(i));
|
|
44
|
+
if (heredoc) {
|
|
45
|
+
heredocs.push(heredoc[2]);
|
|
46
|
+
current += heredoc[0];
|
|
47
|
+
i += heredoc[0].length - 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!quote && c === '\n' && heredocs.length) {
|
|
51
|
+
parts.push(current);
|
|
52
|
+
current = '';
|
|
53
|
+
// Skip each body up to the line that holds only its delimiter.
|
|
54
|
+
for (const delimiter of heredocs.splice(0)) {
|
|
55
|
+
const end = command.slice(i + 1).split('\n').findIndex(line => line.trim() === delimiter);
|
|
56
|
+
if (end < 0) return parts.map(part => part.trim()).filter(Boolean);
|
|
57
|
+
i += command.slice(i + 1).split('\n').slice(0, end + 1).join('\n').length + 1;
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (quote) {
|
|
62
|
+
if (quote === '"' && c === '\\') { current += c + (command[++i] ?? ''); continue; }
|
|
63
|
+
if (c === quote) quote = null;
|
|
64
|
+
current += c;
|
|
65
|
+
} else if (c === "'" || c === '"') {
|
|
66
|
+
quote = c;
|
|
67
|
+
current += c;
|
|
68
|
+
} else if (c === '\\') {
|
|
69
|
+
current += c + (command[++i] ?? '');
|
|
70
|
+
} else if ((c === '&' || c === '|') && command[i + 1] === c) {
|
|
71
|
+
parts.push(current);
|
|
72
|
+
current = '';
|
|
73
|
+
i++;
|
|
74
|
+
} else if (c === ';' || c === '|' || c === '\n') {
|
|
75
|
+
parts.push(current);
|
|
76
|
+
current = '';
|
|
77
|
+
} else {
|
|
78
|
+
current += c;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
parts.push(current);
|
|
82
|
+
return parts.map(part => part.trim()).filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ownRun = command => /hydra(?:\.mjs)?"?\s+(?:run|test)\b/.test(command);
|
|
86
|
+
|
|
87
|
+
function runnerIn(command) {
|
|
88
|
+
const run = simpleCommands(command).map(part => part.replace(/^(?:\w+=\S*\s+)+/, ''))
|
|
36
89
|
.find(part => RUNNERS.some(re => re.test(part)));
|
|
37
90
|
return run ? run.replace(/\s+2>&1\s*$/, '') : null;
|
|
38
91
|
}
|
|
39
92
|
|
|
93
|
+
/** The test/build run inside a shell command, or null. Hydra's own runs and HYDRA_RAW=1 pass. */
|
|
94
|
+
export function testRun(command) {
|
|
95
|
+
if (!command || ownRun(command) || /\bHYDRA_RAW=1\b/.test(command)) return null;
|
|
96
|
+
return runnerIn(command);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A test/build run let through by HYDRA_RAW=1: the agent wanted more than Hydra's digest. */
|
|
100
|
+
export function rawBypass(command) {
|
|
101
|
+
return Boolean(command) && /\bHYDRA_RAW=1\b/.test(command) && !ownRun(command)
|
|
102
|
+
&& runnerIn(command.replace(/\bHYDRA_RAW=1\s+/g, '')) !== null;
|
|
103
|
+
}
|
|
104
|
+
|
|
40
105
|
function load(file) {
|
|
41
106
|
if (!existsSync(file)) return {};
|
|
42
107
|
const text = readFileSync(file, 'utf8');
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Hydra in every coding tool: the MCP server (mcp.mjs) for tools, and a shell-command hook that sends
|
|
2
|
+
// test/build runs through Hydra wherever the tool has one. Claude Code keeps its skill and hooks
|
|
3
|
+
// (claude_hooks.mjs) and gets no MCP entry, since that would duplicate the same tools.
|
|
4
|
+
//
|
|
5
|
+
// tool MCP entry hook
|
|
6
|
+
// codex ~/.codex/config.toml [mcp_servers.hydra] ~/.codex/hooks.json PreToolUse + SessionStart (trust with /hooks)
|
|
7
|
+
// cursor ~/.cursor/mcp.json mcpServers.hydra ~/.cursor/hooks.json beforeShellExecution + sessionStart
|
|
8
|
+
// opencode ~/.config/opencode/opencode.json(c) mcp.hydra ~/.config/opencode/plugins/hydra.js tool.execute.before
|
|
9
|
+
// gemini-cli ~/.gemini/settings.json mcpServers.hydra same file, hooks.BeforeTool on run_shell_command
|
|
10
|
+
// antigravity ~/.gemini/antigravity/mcp_config.json none known; the MCP server's instructions guide it
|
|
11
|
+
//
|
|
12
|
+
// Every file Hydra edits is backed up once as <file>.before-hydra, written atomically, and only
|
|
13
|
+
// Hydra's own entries are added or removed. A file Hydra cannot edit safely (JSON with comments, or
|
|
14
|
+
// invalid) is left alone and the entry to add by hand is reported instead.
|
|
15
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
const home = () => process.env.HYDRA_TOOLS_HOME || os.homedir();
|
|
20
|
+
const OURS = /[\\/](?:\.hydra[\\/]scripts|skills[\\/]hydra[\\/]scripts)[\\/]/;
|
|
21
|
+
const BEGIN = '# >>> hydra (managed by Hydra; remove with: hydra agents remove codex) >>>';
|
|
22
|
+
const END = '# <<< hydra <<<';
|
|
23
|
+
|
|
24
|
+
/** Hydra's own copy of its scripts, which every tool's MCP entry and hook points at. */
|
|
25
|
+
export const sharedScripts = () => path.join(process.env.HYDRA_HOME ?? path.join(os.homedir(), '.hydra'), 'scripts');
|
|
26
|
+
|
|
27
|
+
/** Copy the skill's scripts to sharedScripts(), so registrations never point into an npx cache. */
|
|
28
|
+
export function installSharedScripts(from) {
|
|
29
|
+
const target = sharedScripts();
|
|
30
|
+
if (path.resolve(from) === path.resolve(target)) return target; // Already running from the shared copy.
|
|
31
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
32
|
+
rmSync(`${target}.hydra-tmp`, { recursive: true, force: true });
|
|
33
|
+
cpSync(from, `${target}.hydra-tmp`, { recursive: true });
|
|
34
|
+
rmSync(target, { recursive: true, force: true });
|
|
35
|
+
renameSync(`${target}.hydra-tmp`, target);
|
|
36
|
+
return target;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const quote = value => JSON.stringify(value);
|
|
40
|
+
const node = () => process.execPath;
|
|
41
|
+
const hookCommand = (scripts, name, format) => `${quote(node())} ${quote(path.join(scripts, 'hooks', name))} --format ${format}`;
|
|
42
|
+
|
|
43
|
+
function backup(file) {
|
|
44
|
+
if (existsSync(file) && !existsSync(`${file}.before-hydra`)) copyFileSync(file, `${file}.before-hydra`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function write(file, text) {
|
|
48
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
49
|
+
backup(file);
|
|
50
|
+
writeFileSync(`${file}.hydra-tmp`, text);
|
|
51
|
+
renameSync(`${file}.hydra-tmp`, file);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class Manual extends Error {
|
|
55
|
+
constructor(file, snippet, why) { super(`${file} ${why}; add this by hand:\n${snippet}`); this.manual = true; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read a JSON settings file Hydra may rewrite. Comments would be lost on rewrite, so they stop it. */
|
|
59
|
+
function loadJson(file, snippet) {
|
|
60
|
+
if (!existsSync(file)) return {};
|
|
61
|
+
const text = readFileSync(file, 'utf8');
|
|
62
|
+
if (!text.trim()) return {};
|
|
63
|
+
const withoutStrings = text.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
|
64
|
+
if (/\/\/|\/\*/.test(withoutStrings)) throw new Manual(file, snippet, 'has comments that a rewrite would drop');
|
|
65
|
+
let value;
|
|
66
|
+
try { value = JSON.parse(text); } catch { value = null; }
|
|
67
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Manual(file, snippet, 'is not valid JSON');
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const saveJson = (file, value) => write(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
72
|
+
const mentionsOurs = value => OURS.test(JSON.stringify(value ?? ''));
|
|
73
|
+
|
|
74
|
+
/** Add or remove Hydra's entry in a JSON object of named servers, such as mcpServers. */
|
|
75
|
+
function jsonServer(file, key, entry) {
|
|
76
|
+
return {
|
|
77
|
+
install() {
|
|
78
|
+
const snippet = JSON.stringify({ [key]: { hydra: entry } }, null, 2);
|
|
79
|
+
const settings = loadJson(file, snippet);
|
|
80
|
+
settings[key] = { ...(settings[key] ?? {}), hydra: entry };
|
|
81
|
+
saveJson(file, settings);
|
|
82
|
+
return `${file}: ${key}.hydra`;
|
|
83
|
+
},
|
|
84
|
+
remove() {
|
|
85
|
+
if (!existsSync(file)) return null;
|
|
86
|
+
const settings = loadJson(file, '');
|
|
87
|
+
if (!settings[key]?.hydra || !mentionsOurs(settings[key].hydra)) return null;
|
|
88
|
+
delete settings[key].hydra;
|
|
89
|
+
if (!Object.keys(settings[key]).length) delete settings[key];
|
|
90
|
+
saveJson(file, settings);
|
|
91
|
+
return `${file}: ${key}.hydra`;
|
|
92
|
+
},
|
|
93
|
+
status: () => existsSync(file) && (() => { try { return mentionsOurs(loadJson(file, '')[key]?.hydra); } catch { return false; } })(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Claude-style hook lists ({event: [{matcher, hooks: [{type, command}]}]}) under `root` in a JSON file. */
|
|
98
|
+
function jsonHooks(file, events, { root = 'hooks', extra = {} } = {}) {
|
|
99
|
+
const without = settings => {
|
|
100
|
+
for (const [event, groups] of Object.entries(settings[root] ?? {})) {
|
|
101
|
+
if (!Array.isArray(groups)) continue;
|
|
102
|
+
const kept = groups.map(group => (group.hooks ? { ...group, hooks: group.hooks.filter(hook => !OURS.test(hook.command ?? '')) } : group))
|
|
103
|
+
.filter(group => (group.hooks ? group.hooks.length : !OURS.test(group.command ?? '')));
|
|
104
|
+
if (kept.length) settings[root][event] = kept; else delete settings[root][event];
|
|
105
|
+
}
|
|
106
|
+
if (settings[root] && !Object.keys(settings[root]).length) delete settings[root];
|
|
107
|
+
return settings;
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
install() {
|
|
111
|
+
const snippet = JSON.stringify({ ...extra, [root]: events }, null, 2);
|
|
112
|
+
const settings = without(loadJson(file, snippet));
|
|
113
|
+
Object.assign(settings, Object.fromEntries(Object.entries(extra).filter(([k]) => settings[k] === undefined)));
|
|
114
|
+
settings[root] ??= {};
|
|
115
|
+
for (const [event, groups] of Object.entries(events)) (settings[root][event] ??= []).push(...groups);
|
|
116
|
+
saveJson(file, settings);
|
|
117
|
+
return `${file}: ${Object.keys(events).join(', ')}`;
|
|
118
|
+
},
|
|
119
|
+
remove() {
|
|
120
|
+
if (!existsSync(file)) return null;
|
|
121
|
+
const settings = loadJson(file, '');
|
|
122
|
+
const before = JSON.stringify(settings);
|
|
123
|
+
without(settings);
|
|
124
|
+
if (JSON.stringify(settings) === before) return null;
|
|
125
|
+
saveJson(file, settings);
|
|
126
|
+
return `${file}: hooks`;
|
|
127
|
+
},
|
|
128
|
+
status: () => existsSync(file) && OURS.test(readFileSync(file, 'utf8')),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Codex keeps MCP servers in TOML; Hydra's table sits between marker comments it alone writes. */
|
|
133
|
+
function tomlServer(file, scripts) {
|
|
134
|
+
const block = `${BEGIN}\n[mcp_servers.hydra]\ncommand = ${quote(node())}\nargs = [${quote(path.join(scripts, 'mcp.mjs'))}]\nstartup_timeout_sec = 20\ntool_timeout_sec = 1800\n${END}\n`;
|
|
135
|
+
// Install appends "\n" + block to text ending in a newline, so removing "\n" + block restores it exactly.
|
|
136
|
+
const strip = text => text.replace(new RegExp(`\\n?${BEGIN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${END}\\n?`), '');
|
|
137
|
+
return {
|
|
138
|
+
install() {
|
|
139
|
+
const text = existsSync(file) ? strip(readFileSync(file, 'utf8')) : '';
|
|
140
|
+
if (/^\[mcp_servers\.hydra\]/m.test(text)) throw new Manual(file, block, 'already has an [mcp_servers.hydra] table that Hydra did not write');
|
|
141
|
+
// Appended at the end: a TOML table runs until the next header, so nothing after it is captured.
|
|
142
|
+
write(file, `${text.replace(/\s*$/, '\n')}\n${block}`);
|
|
143
|
+
return `${file}: [mcp_servers.hydra]`;
|
|
144
|
+
},
|
|
145
|
+
remove() {
|
|
146
|
+
if (!existsSync(file)) return null;
|
|
147
|
+
const text = readFileSync(file, 'utf8');
|
|
148
|
+
if (!text.includes(BEGIN)) return null;
|
|
149
|
+
write(file, strip(text));
|
|
150
|
+
return `${file}: [mcp_servers.hydra]`;
|
|
151
|
+
},
|
|
152
|
+
status: () => existsSync(file) && readFileSync(file, 'utf8').includes(BEGIN),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** OpenCode loads every file in its plugins folder; Hydra's is one small file that imports the shared scripts. */
|
|
157
|
+
function opencodePlugin(file, scripts) {
|
|
158
|
+
const url = name => quote(new URL(`file://${path.join(scripts, name).replace(/\\/g, '/')}`).href);
|
|
159
|
+
const source = `// Hydra (managed by Hydra; remove with: hydra agents remove opencode): send test/build runs through Hydra.
|
|
160
|
+
import { testRun } from ${url('lib/claude_hooks.mjs')};
|
|
161
|
+
import { record } from ${url('lib/ledger.mjs')};
|
|
162
|
+
|
|
163
|
+
export const Hydra = async () => ({
|
|
164
|
+
'tool.execute.before': async (input, output) => {
|
|
165
|
+
if (input.tool !== 'bash') return;
|
|
166
|
+
const command = String(output.args?.command ?? '');
|
|
167
|
+
const run = testRun(command);
|
|
168
|
+
if (!run) return;
|
|
169
|
+
record({ ts: new Date().toISOString(), kind: 'hook', hook: 'route-tests', agent: 'opencode', decision: 'deny',
|
|
170
|
+
command: command.slice(0, 300), run, ...(input.sessionID ? { session: input.sessionID } : {}) });
|
|
171
|
+
throw new Error('Test/build runs go through Hydra so the full log stays out of context. Run it as: ' +
|
|
172
|
+
'node ' + ${quote(JSON.stringify(path.join(scripts, 'hydra.mjs')))} + ' run -- ' + run + ', or call the hydra_run tool with that command. ' +
|
|
173
|
+
'If you truly need the raw log, prefix the original command with HYDRA_RAW=1.');
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
`;
|
|
177
|
+
return {
|
|
178
|
+
install() { write(file, source); return file; },
|
|
179
|
+
remove() {
|
|
180
|
+
if (!existsSync(file) || !readFileSync(file, 'utf8').includes('managed by Hydra')) return null;
|
|
181
|
+
rmSync(file);
|
|
182
|
+
if (existsSync(`${file}.before-hydra`)) rmSync(`${file}.before-hydra`);
|
|
183
|
+
return file;
|
|
184
|
+
},
|
|
185
|
+
status: () => existsSync(file) && readFileSync(file, 'utf8').includes('managed by Hydra'),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const opencodeConfig = dir => ['opencode.json', 'opencode.jsonc'].map(name => path.join(dir, name)).find(existsSync) ?? path.join(dir, 'opencode.json');
|
|
190
|
+
|
|
191
|
+
/** Every tool Hydra can join, with where it lives and what Hydra registers there. */
|
|
192
|
+
export function tools(scripts = sharedScripts()) {
|
|
193
|
+
const h = home();
|
|
194
|
+
const mcp = { command: node(), args: [path.join(scripts, 'mcp.mjs')] };
|
|
195
|
+
const codex = path.join(h, '.codex');
|
|
196
|
+
const cursor = path.join(h, '.cursor');
|
|
197
|
+
const opencode = path.join(h, '.config', 'opencode');
|
|
198
|
+
const gemini = path.join(h, '.gemini');
|
|
199
|
+
return {
|
|
200
|
+
codex: { dir: codex, parts: {
|
|
201
|
+
mcp: tomlServer(path.join(codex, 'config.toml'), scripts),
|
|
202
|
+
hooks: jsonHooks(path.join(codex, 'hooks.json'), {
|
|
203
|
+
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: hookCommand(scripts, 'route-tests.mjs', 'codex'), timeout: 10 }] }],
|
|
204
|
+
SessionStart: [{ hooks: [{ type: 'command', command: hookCommand(scripts, 'session-guide.mjs', 'codex'), timeout: 10 }] }] }) },
|
|
205
|
+
note: 'Codex runs new hooks only after you trust them: open Codex and run /hooks.' },
|
|
206
|
+
cursor: { dir: cursor, parts: {
|
|
207
|
+
mcp: jsonServer(path.join(cursor, 'mcp.json'), 'mcpServers', mcp),
|
|
208
|
+
hooks: jsonHooks(path.join(cursor, 'hooks.json'), {
|
|
209
|
+
beforeShellExecution: [{ command: hookCommand(scripts, 'route-tests.mjs', 'cursor'), timeout: 10 }],
|
|
210
|
+
sessionStart: [{ command: hookCommand(scripts, 'session-guide.mjs', 'cursor'), timeout: 10 }] }, { extra: { version: 1 } }) } },
|
|
211
|
+
opencode: { dir: opencode, parts: {
|
|
212
|
+
mcp: jsonServer(opencodeConfig(opencode), 'mcp', { type: 'local', command: [mcp.command, ...mcp.args], enabled: true, timeout: 1_800_000 }),
|
|
213
|
+
hooks: opencodePlugin(path.join(opencode, 'plugins', 'hydra.js'), scripts) } },
|
|
214
|
+
'gemini-cli': { dir: path.join(gemini, 'settings.json'), parts: {
|
|
215
|
+
mcp: jsonServer(path.join(gemini, 'settings.json'), 'mcpServers', { ...mcp, timeout: 1_800_000 }),
|
|
216
|
+
hooks: jsonHooks(path.join(gemini, 'settings.json'), {
|
|
217
|
+
BeforeTool: [{ matcher: 'run_shell_command', hooks: [{ name: 'hydra', type: 'command', command: hookCommand(scripts, 'route-tests.mjs', 'gemini'), timeout: 10_000 }] }] }) } },
|
|
218
|
+
antigravity: { dir: path.join(gemini, 'antigravity'), parts: {
|
|
219
|
+
mcp: jsonServer(path.join(gemini, 'antigravity', 'mcp_config.json'), 'mcpServers', mcp) } },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The tools whose folders exist on this machine. */
|
|
224
|
+
export const detected = (scripts) => Object.entries(tools(scripts)).filter(([, tool]) => existsSync(tool.dir)).map(([id]) => id);
|
|
225
|
+
|
|
226
|
+
/** Register (or with remove, take out) Hydra for each tool; one tool failing never stops the others. */
|
|
227
|
+
export function apply(ids, action, { scripts = sharedScripts(), hooks = true } = {}) {
|
|
228
|
+
const all = tools(scripts);
|
|
229
|
+
return ids.map(id => {
|
|
230
|
+
const tool = all[id];
|
|
231
|
+
if (!tool) return { id, error: `unknown tool; known: ${Object.keys(all).join(', ')}` };
|
|
232
|
+
const changes = [];
|
|
233
|
+
const manual = [];
|
|
234
|
+
for (const [name, part] of Object.entries(tool.parts)) {
|
|
235
|
+
if (action === 'install' && name === 'hooks' && !hooks) continue;
|
|
236
|
+
try {
|
|
237
|
+
const change = part[action]();
|
|
238
|
+
if (change) changes.push(change);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (!error.manual) throw error;
|
|
241
|
+
manual.push(error.message);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { id, changes, manual, ...(action === 'install' && tool.note && tool.parts.hooks && hooks ? { note: tool.note } : {}) };
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function status(scripts = sharedScripts()) {
|
|
249
|
+
return Object.entries(tools(scripts)).map(([id, tool]) => ({ id, present: existsSync(tool.dir),
|
|
250
|
+
...Object.fromEntries(Object.entries(tool.parts).map(([name, part]) => [name, Boolean(part.status())])) }));
|
|
251
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// Local telemetry: one JSON line per Hydra call or hook decision in ~/.hydra/ledger.jsonl.
|
|
2
|
+
// Nothing leaves the machine. HYDRA_TELEMETRY=0 turns recording off.
|
|
3
|
+
// kind 'call' (or no kind, rows written before telemetry v2): a head answered; raw/returned token estimates.
|
|
4
|
+
// kind 'hook': the PreToolUse hook turned a command back ('deny'), let HYDRA_RAW=1 through ('raw') or
|
|
5
|
+
// passed it ('pass', recorded without the command). hookMs is the hook process's whole lifetime.
|
|
6
|
+
// kind 'error': a command failed (model unavailable, crash).
|
|
1
7
|
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
8
|
import os from 'node:os';
|
|
3
9
|
import path from 'node:path';
|
|
@@ -8,27 +14,120 @@ const file = () => path.join(home(), 'ledger.jsonl');
|
|
|
8
14
|
// A rough count that needs no tokenizer; every figure built on it is an estimate.
|
|
9
15
|
export const tokens = chars => Math.ceil(chars / 4);
|
|
10
16
|
|
|
17
|
+
export const enabled = () => process.env.HYDRA_TELEMETRY !== '0';
|
|
18
|
+
|
|
19
|
+
/** Who is calling: the agent family and its session when the agent says so, else a plain shell.
|
|
20
|
+
* AI_AGENT differs inside hooks (claude-code_<version>_harness) and the agent's shell (..._agent),
|
|
21
|
+
* so `agent` keeps only the family and `agentDetail` the full value. */
|
|
22
|
+
export function caller(session = process.env.CLAUDE_CODE_SESSION_ID) {
|
|
23
|
+
// HYDRA_AGENT is the MCP client's own name (codex-mcp-client, gemini-cli, opencode, ...), set by mcp.mjs.
|
|
24
|
+
const detail = process.env.HYDRA_AGENT || process.env.AI_AGENT;
|
|
25
|
+
const agent = process.env.HYDRA_AGENT ? detail.toLowerCase().replace(/[-_ ]?mcp[-_ ]?client$/, '')
|
|
26
|
+
: detail ? detail.split('_')[0] : process.env.CLAUDECODE ? 'claude-code' : 'shell';
|
|
27
|
+
return { agent, ...(detail ? { agentDetail: detail } : {}), ...(process.env.HYDRA_VIA ? { via: process.env.HYDRA_VIA } : {}),
|
|
28
|
+
...(session ? { session } : {}) };
|
|
29
|
+
}
|
|
30
|
+
|
|
11
31
|
export function record(entry) {
|
|
32
|
+
if (!enabled()) return;
|
|
12
33
|
try {
|
|
13
34
|
mkdirSync(home(), { recursive: true });
|
|
14
|
-
appendFileSync(file(), `${JSON.stringify(entry)}\n`);
|
|
35
|
+
appendFileSync(file(), `${JSON.stringify({ v: 2, kind: 'call', ...caller(), ...entry })}\n`);
|
|
15
36
|
} catch {
|
|
16
37
|
// Accounting must never fail the answer.
|
|
17
38
|
}
|
|
18
39
|
}
|
|
19
40
|
|
|
20
|
-
|
|
21
|
-
|
|
41
|
+
/** Wrap a chat function so a call's record can say how often and how long the local model ran. */
|
|
42
|
+
export function metered(chat) {
|
|
43
|
+
const meter = { modelCalls: 0, modelSeconds: 0 };
|
|
44
|
+
meter.chat = async opts => {
|
|
45
|
+
const started = Date.now();
|
|
46
|
+
meter.modelCalls += 1;
|
|
47
|
+
try {
|
|
48
|
+
return await chat(opts);
|
|
49
|
+
} finally {
|
|
50
|
+
meter.modelSeconds = Number((meter.modelSeconds + (Date.now() - started) / 1000).toFixed(2));
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
return meter;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function rows() {
|
|
22
57
|
try {
|
|
23
|
-
|
|
58
|
+
return readFileSync(file(), 'utf8').split('\n').filter(Boolean).flatMap(line => {
|
|
59
|
+
try { return [JSON.parse(line)]; } catch { return []; }
|
|
60
|
+
});
|
|
24
61
|
} catch {
|
|
25
|
-
// No ledger yet.
|
|
62
|
+
return []; // No ledger yet.
|
|
26
63
|
}
|
|
27
|
-
|
|
28
|
-
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isCall = row => (row.kind ?? 'call') === 'call';
|
|
67
|
+
|
|
68
|
+
export function stats(all = rows()) {
|
|
69
|
+
const total = { calls: 0, raw: 0, returned: 0, heads: {} };
|
|
70
|
+
for (const row of all.filter(isCall)) {
|
|
71
|
+
total.calls += 1;
|
|
29
72
|
total.raw += row.raw;
|
|
30
73
|
total.returned += row.returned;
|
|
31
74
|
total.heads[row.head] = (total.heads[row.head] ?? 0) + 1;
|
|
32
75
|
}
|
|
33
76
|
return total;
|
|
34
77
|
}
|
|
78
|
+
|
|
79
|
+
const percentile = (values, p) => {
|
|
80
|
+
if (!values.length) return 0;
|
|
81
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
82
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What the ledger says Hydra should improve, newest data first. `since` is an ISO time.
|
|
87
|
+
* Per head: calls, tokens saved, calls that returned more than the raw output, latency, model use.
|
|
88
|
+
* Hooks: denials, how many the agent followed with a Hydra run in the same session, and
|
|
89
|
+
* HYDRA_RAW=1 bypasses, which are often a pattern the hook should not have matched.
|
|
90
|
+
*/
|
|
91
|
+
export function findings(all = rows(), { since } = {}) {
|
|
92
|
+
const recent = since ? all.filter(row => (row.ts ?? '') >= since) : all;
|
|
93
|
+
const calls = recent.filter(isCall);
|
|
94
|
+
const heads = {};
|
|
95
|
+
for (const row of calls) {
|
|
96
|
+
const head = heads[row.head] ??= { calls: 0, raw: 0, returned: 0, costMore: [], seconds: [], modelCalls: 0, failedRuns: 0 };
|
|
97
|
+
head.calls += 1;
|
|
98
|
+
head.raw += row.raw;
|
|
99
|
+
head.returned += row.returned;
|
|
100
|
+
head.seconds.push(row.seconds ?? 0);
|
|
101
|
+
head.modelCalls += row.modelCalls ?? 0;
|
|
102
|
+
if (row.exit) head.failedRuns += 1;
|
|
103
|
+
if (row.returned > row.raw) head.costMore.push(row);
|
|
104
|
+
}
|
|
105
|
+
const perHead = Object.fromEntries(Object.entries(heads).map(([name, h]) => [name, {
|
|
106
|
+
calls: h.calls, saved: h.raw - h.returned, savedShare: h.raw ? Number(((h.raw - h.returned) / h.raw).toFixed(3)) : 0,
|
|
107
|
+
costMore: h.costMore.length, p50Seconds: percentile(h.seconds, 0.5), p90Seconds: percentile(h.seconds, 0.9),
|
|
108
|
+
modelCalls: h.modelCalls, failedRuns: h.failedRuns,
|
|
109
|
+
costMoreExamples: h.costMore.slice(-3).map(row => ({ question: row.question, raw: row.raw, returned: row.returned })),
|
|
110
|
+
}]));
|
|
111
|
+
const hooks = recent.filter(row => row.kind === 'hook');
|
|
112
|
+
const hookMs = hooks.map(row => row.hookMs).filter(Number.isFinite);
|
|
113
|
+
const denials = hooks.filter(row => row.decision === 'deny');
|
|
114
|
+
const followed = denials.filter(deny => calls.some(call => call.session && call.session === deny.session
|
|
115
|
+
&& call.head === 'test' && call.ts >= deny.ts && Date.parse(call.ts) - Date.parse(deny.ts) <= 5 * 60_000));
|
|
116
|
+
const agents = {};
|
|
117
|
+
for (const row of recent) {
|
|
118
|
+
const agent = agents[row.agent ?? 'unknown'] ??= { calls: 0, viaMcp: 0, denials: 0, sessions: 0 };
|
|
119
|
+
if (isCall(row)) { agent.calls += 1; if (row.via === 'mcp') agent.viaMcp += 1; }
|
|
120
|
+
if (row.kind === 'hook' && row.decision === 'deny') agent.denials += 1;
|
|
121
|
+
if (row.kind === 'session') agent.sessions += 1;
|
|
122
|
+
}
|
|
123
|
+
const sessions = new Set(recent.map(row => row.session).filter(Boolean));
|
|
124
|
+
const sessionsUsing = new Set(calls.map(row => row.session).filter(Boolean));
|
|
125
|
+
return {
|
|
126
|
+
since: since ?? recent[0]?.ts ?? null, calls: calls.length, perHead, agents,
|
|
127
|
+
hooks: { decisions: hooks.length, p50Ms: percentile(hookMs, 0.5), p90Ms: percentile(hookMs, 0.9),
|
|
128
|
+
denials: denials.length, followedByHydra: followed.length,
|
|
129
|
+
rawBypasses: hooks.filter(row => row.decision === 'raw').map(row => row.command).slice(-5) },
|
|
130
|
+
errors: recent.filter(row => row.kind === 'error').slice(-5).map(row => ({ ts: row.ts, command: row.command, error: row.error })),
|
|
131
|
+
sessions: { seen: sessions.size, usingHydra: sessionsUsing.size },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -7,6 +7,9 @@ const PASS_THROUGH = 800;
|
|
|
7
7
|
const MAX_CANDIDATES = 60;
|
|
8
8
|
const MAX_CITED = 12;
|
|
9
9
|
const WHOLE_REGIONS = 2000;
|
|
10
|
+
// The command as the agent typed it, minus the program's directory: a long venv or toolchain path
|
|
11
|
+
// costs tokens on every reply and tells the agent nothing it does not know.
|
|
12
|
+
export const label = cmd => cmd.replace(/^(?:"[^"]*[\\/]|[^\s"]*[\\/])([^\s"\\/]+)"?/, '$1');
|
|
10
13
|
// Colour codes and the prefixes CI runners put on each line (timestamps, BuildKit step markers,
|
|
11
14
|
// GitLab section markers) are removed before matching. Output always keeps the original line.
|
|
12
15
|
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
@@ -145,10 +148,11 @@ export async function digest({ cmd, out = '', err = '', exitCode = 0 }, chat) {
|
|
|
145
148
|
const rawText = (out + (err ? (out ? '\n' : '') + err : '')).trimEnd();
|
|
146
149
|
const rawChars = rawText.length;
|
|
147
150
|
|
|
148
|
-
// Short output passes through verbatim with no model call
|
|
151
|
+
// Short output passes through verbatim with no model call. A short pass needs no command line:
|
|
152
|
+
// the agent knows what it ran, and the line would cost more than the output it labels.
|
|
149
153
|
if (rawChars <= PASS_THROUGH) {
|
|
150
154
|
return {
|
|
151
|
-
text:
|
|
155
|
+
text: exitCode !== 0 ? `$ ${label(cmd)} (exit ${exitCode})\n${rawText}` : rawText || '(no output)',
|
|
152
156
|
rawChars,
|
|
153
157
|
exitCode,
|
|
154
158
|
};
|
|
@@ -162,7 +166,7 @@ export async function digest({ cmd, out = '', err = '', exitCode = 0 }, chat) {
|
|
|
162
166
|
.slice(-8)
|
|
163
167
|
.join('\n');
|
|
164
168
|
return {
|
|
165
|
-
text: `$ ${cmd} (exit 0)\nAll tests passed.\n${summaryLines ? `${summaryLines}\n` : ''}(Verbose output withheld)`,
|
|
169
|
+
text: `$ ${label(cmd)} (exit 0)\nAll tests passed.\n${summaryLines ? `${summaryLines}\n` : ''}(Verbose output withheld)`,
|
|
166
170
|
rawChars,
|
|
167
171
|
exitCode,
|
|
168
172
|
};
|
|
@@ -176,7 +180,7 @@ export async function digest({ cmd, out = '', err = '', exitCode = 0 }, chat) {
|
|
|
176
180
|
const indices = new Set([...lines.slice(0, 3).map((_, i) => i), ...lines.slice(-12).map((_, i) => Math.max(0, lines.length - 12) + i)]);
|
|
177
181
|
const excerpt = [...indices].sort((a, b) => a - b).map(i => lines[i]).join('\n');
|
|
178
182
|
return {
|
|
179
|
-
text: `$ ${cmd} (exit ${exitCode})\n${excerpt}\n(... ${lines.length - indices.size} lines withheld)`,
|
|
183
|
+
text: `$ ${label(cmd)} (exit ${exitCode})\n${excerpt}\n(... ${lines.length - indices.size} lines withheld)`,
|
|
180
184
|
rawChars,
|
|
181
185
|
exitCode,
|
|
182
186
|
};
|
|
@@ -185,7 +189,7 @@ export async function digest({ cmd, out = '', err = '', exitCode = 0 }, chat) {
|
|
|
185
189
|
// Failure regions this small cost less to return whole than to have the model cut down, and whole is always complete.
|
|
186
190
|
const regions = candidates.map(c => c.text);
|
|
187
191
|
if (regions.length && regions.join('\n').length <= WHOLE_REGIONS) {
|
|
188
|
-
return { text: `$ ${cmd} (exit ${exitCode})\n${regions.join('\n')}`, rawChars, exitCode };
|
|
192
|
+
return { text: `$ ${label(cmd)} (exit ${exitCode})\n${regions.join('\n')}`, rawChars, exitCode };
|
|
189
193
|
}
|
|
190
194
|
|
|
191
195
|
const numbered = candidates.map(c => `[${c.id}] ${c.text}`).join('\n');
|
|
@@ -215,7 +219,7 @@ export async function digest({ cmd, out = '', err = '', exitCode = 0 }, chat) {
|
|
|
215
219
|
evidenceLines = candidates.slice(0, 12).map(c => c.text);
|
|
216
220
|
}
|
|
217
221
|
|
|
218
|
-
const text = `$ ${cmd} (exit ${exitCode})\n${evidenceLines.join('\n')}`;
|
|
222
|
+
const text = `$ ${label(cmd)} (exit ${exitCode})\n${evidenceLines.join('\n')}`;
|
|
219
223
|
return { text, rawChars, exitCode };
|
|
220
224
|
}
|
|
221
225
|
|