hydra-nano 0.2.0 → 0.3.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 CHANGED
@@ -86,6 +86,18 @@ Through MCP, Hydra offers the three where it pays most: `hydra_git`, `hydra_run`
86
86
 
87
87
  Receipts: [model card](https://huggingface.co/rahul05ranjan/hydra-nano-git) and [`evals/results`](https://github.com/rahul05ranjan/hydra/tree/main/evals/results).
88
88
 
89
+ <!-- usage-stats:begin (written by evals/usage/readme_stats.mjs) -->
90
+ **In real use.** Since 2026-10-17, Hydra has handled **118 calls** in **9 coding sessions** across **3 projects** on the maintainer's machine. Of **~375,638 tokens** of command output, **~18,781** reached the agent: **95% kept out of context**.
91
+
92
+ | Work | Calls | Output read locally | Reached the agent | Kept out |
93
+ |---|---|---|---|---|
94
+ | Test and build runs | 112 | ~374,873 | ~18,199 | **95%** |
95
+ | File slices | 1 | ~508 | ~283 | **44%** |
96
+ | Git questions | 5 | ~257 | ~299 | **-16%** |
97
+
98
+ <sub>From Hydra's local ledger; tokens are estimates (characters / 4). Updated 2026-10-20.</sub>
99
+ <!-- usage-stats:end -->
100
+
89
101
  **Where Hydra does not help, measured.** In a controlled trial of 36 Claude Code sessions extracting functions from real source code, forcing that work through Hydra used **72% more tokens** than the agent's own tools. So Hydra leaves source reading and search to the agent, and routes only the noisy work: test and build logs, git history, system inspection. Full method and numbers in [evaluations](https://github.com/rahul05ranjan/hydra/blob/main/docs/evaluations.md).
90
102
 
91
103
  ## Commands
@@ -95,6 +107,9 @@ hydra ask "which branch am I on and what changed?" # git, files or os, routed
95
107
  hydra run -- pytest -q # a test or build run, digested
96
108
  hydra os "what is using port 8080?" # read-only system inspection
97
109
  hydra read src/app.py create_app # one symbol, a line range or an outline
110
+ hydra smell src/ # detect Refactoring.Guru code smells (or --diff / --staged)
111
+ hydra refactor src/app.py process_order # actionable step-by-step refactoring recipe
112
+ hydra design "need flexible algorithms at runtime" # GoF design pattern advisor & architecture guide
98
113
  hydra stats # tokens kept out of context
99
114
  hydra improve --since 2026-10-17 # what the local telemetry says to improve
100
115
  hydra agents status # Hydra in each coding tool
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hydra-nano",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Small local models that take the noisy work off your coding agent: git history, test and build digests and OS inspection run on your machine, and only the lines that matter reach Claude Code, Codex, Cursor, OpenCode, Gemini CLI or Antigravity. Skill, MCP server and hooks.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -52,7 +52,8 @@
52
52
  "eval:frontier": "node evals/trial/frontier_trial.mjs --run",
53
53
  "eval:real:prepare": "node evals/trial/real_repo_trial.mjs",
54
54
  "eval:real": "node evals/trial/real_repo_trial.mjs --run",
55
- "dashboard": "node dashboard/server.mjs"
55
+ "dashboard": "node dashboard/server.mjs",
56
+ "stats:readme": "node evals/usage/readme_stats.mjs"
56
57
  },
57
58
  "engines": {
58
59
  "node": ">=18"
@@ -1,41 +1,35 @@
1
1
  ---
2
2
  name: hydra
3
- description: Hand a read-only git question (repo state, history, blame), a where-is-it code search, targeted file slicing instead of whole-file reads, test/build failure digests, or system/OS inspections (ports, processes, disk) to hydra locally. Use before running git status, log, diff, show, blame, broad searches, whole file reads, ps/lsof dumps, or test/build commands where noisy output would flood your context.
3
+ description: Run test and build commands, git history/diff/blame questions, and machine inspections (ports, processes, disk) through hydra, a local model that returns only the lines that matter. Use before running npm test, pytest, cargo test, tsc or a build, before reading git log, diff, show or blame output, and before ps, lsof or df dumps.
4
4
  ---
5
5
 
6
6
  # Hydra
7
7
 
8
- `hydra` runs read-only commands for repo questions, file slicing, test/build runs, and system inspections on a small local model and prints the lines that answer it. The raw output stays on this machine instead of entering your context.
8
+ `hydra` runs a noisy read-only command on this machine and prints only the lines that answer the question; the raw output stays out of your context. It pays off where output is long and mostly noise: test and build logs, git history and diffs, process and port listings.
9
9
 
10
10
  ## Call it
11
11
 
12
- From the repository the question is about, naming the head that fits:
12
+ From the repository the question is about:
13
13
 
14
14
  ```
15
+ node scripts/hydra.mjs run -- <test or build command...>
15
16
  node scripts/hydra.mjs ask --head git "<question>"
16
- node scripts/hydra.mjs ask --head files "<question>"
17
- node scripts/hydra.mjs ask --head read "<question>"
18
- node scripts/hydra.mjs ask --head test "<question>"
19
- node scripts/hydra.mjs ask --head os "<question>"
20
- node scripts/hydra.mjs search <term...>
21
- node scripts/hydra.mjs read <file> [symbol|--lines <start-end>|--outline]
22
- node scripts/hydra.mjs run -- <command...>
23
17
  node scripts/hydra.mjs os [question...]
24
18
  ```
25
19
 
26
- `scripts/` is in this skill's directory. Ask one full question per call and name the file, branch, commit or author when you know it: the model may only use a path, revision or name that appears in your question.
27
- - For finding definitions & files: use `hydra search <term>` to get definition-ranked matches without noise or raw grep dumps.
28
- - For reading code: use `hydra read <file> [symbol|--lines]` instead of reading multi-thousand-line files. It returns the exact symbol or slice, or a structural outline of symbols.
29
- - For tests and builds: run `hydra run -- <command...>` to execute the command locally and get the failure evidence with its exit code.
30
- - For system and environment: use `hydra os` or `--head os` to inspect listening ports, top processes, disk space, and system info without dumping 500 lines of terminal output into context.
20
+ `scripts/` is in this skill's directory.
21
+
22
+ - **Tests and builds:** `run --` executes the command and returns the failing tests, assertion diffs and source locations with the exit code, or a short pass summary. A run you already cut to a few lines yourself (`| tail -n 20`) gains nothing from Hydra.
23
+ - **Git:** ask one full question per call and name the file, branch, commit or author when you know it; the model may only use a path, revision or name that appears in your question. A short check such as `git status --short` is cheaper to run yourself.
24
+ - **Machine:** ports, processes, disk, memory, system info and environment variable names, with secrets withheld.
25
+
26
+ Read and search source files with your own tools: a measured trial found routing that work through Hydra cost 72% more tokens. Hydra also has `search`, `read`, `smell`, `refactor` and `design` commands (`node scripts/hydra.mjs` with no arguments lists them) for when the user asks for them.
31
27
 
32
28
  ## What it answers
33
29
 
30
+ - **test**: test and build output (`npm test`, `pytest`, `cargo test`, `go test`, `tsc` and others). On failure, the failing test name, assertion diff and the user's own file:line, with node_modules and framework frames dropped.
34
31
  - **git**: current branch and sync state, uncommitted and staged changes, commit history, what this branch adds over a base branch, one commit's patch, blame, branches, worktrees, remotes, stashes, tags.
35
- - **files**: where a name is defined or used, and which files are named like it.
36
- - **read**: extracts symbol definitions (functions, classes, methods), line ranges (`--lines 10-50`), or structural definition outlines across TypeScript, JavaScript, Python, Rust, and Go without dumping whole files into context.
37
- - **test**: runs and parses test and build outputs (`npm test`, `pytest`, `cargo test`, `tsc`). If tests fail, extracts the failing test name, assertion diff, and exact user file:line location, stripping node_modules and framework traces.
38
- - **os**: inspects listening ports (`lsof`), running processes / CPU consumers (`ps`), disk space (`df -h`), and system info (`uptime`, `uname`) while strictly guarding against printing secrets.
32
+ - **os**: listening ports, running processes and CPU consumers, disk space, memory, system info, environment variable names, never their values.
39
33
 
40
34
  Changes to the repository, and questions that need reasoning about what code does, stay with you.
41
35
 
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // Shell-command hook: a test or build run goes through `hydra run --` (or the hydra_run MCP tool),
3
3
  // whose digest keeps failing test names, assertion diffs and source locations and drops the rest.
4
- // Prefix a command with HYDRA_RAW=1 when the raw log is really needed.
4
+ // A run already cut to a few lines by its own pipeline (| tail -n 15, | grep -c) passes: Hydra would
5
+ // save little and a retry costs more. Prefix a command with HYDRA_RAW=1 when the raw log is really needed.
5
6
  // --format picks the tool's hook protocol:
6
7
  // claude, codex PreToolUse; command at tool_input.command; deny via hookSpecificOutput.
7
8
  // gemini BeforeTool on run_shell_command; deny via {"decision": "deny", "reason"}.
@@ -10,7 +11,7 @@
10
11
  // that simply passes is recorded without its text.
11
12
  import path from 'node:path';
12
13
  import { fileURLToPath } from 'node:url';
13
- import { rawBypass, testRun } from '../lib/claude_hooks.mjs';
14
+ import { rawBypass, testRun, trimmedRun } from '../lib/claude_hooks.mjs';
14
15
  import { record } from '../lib/ledger.mjs';
15
16
 
16
17
  const hydra = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'hydra.mjs');
@@ -40,7 +41,7 @@ process.stdin.on('data', chunk => { input += chunk; }).on('end', () => {
40
41
  ...(session ? { session } : {}), hookMs: Math.round(process.uptime() * 1000) });
41
42
  const run = testRun(command);
42
43
  if (!run) {
43
- record(rawBypass(command) ? event('raw') : event('pass', false));
44
+ record(rawBypass(command) ? event('raw') : trimmedRun(command) ? event('trimmed') : event('pass', false));
44
45
  if (answer.pass) process.stdout.write(JSON.stringify(answer.pass));
45
46
  return;
46
47
  }
@@ -48,5 +49,6 @@ process.stdin.on('data', chunk => { input += chunk; }).on('end', () => {
48
49
  process.stdout.write(JSON.stringify(answer.deny(`Test/build runs go through Hydra so the full log stays out of context. Run it as:\n` +
49
50
  ` node "${hydra}" run -- ${run}\n${format === 'claude' ? '' : 'or call the hydra_run tool with that command. '}` +
50
51
  `(Keep any cd or env prefix before it.) Hydra returns the failing tests, assertion diffs and source locations ` +
51
- `verbatim, or a short pass summary. If you truly need the raw log, prefix the original command with HYDRA_RAW=1.`)));
52
+ `verbatim, or a short pass summary. A run you cut to a few lines yourself (| tail -n 40 or less, or | grep ... | head -40) also passes. ` +
53
+ `If you truly need the raw log, prefix the original command with HYDRA_RAW=1.`)));
52
54
  });
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
@@ -16,6 +16,7 @@ import { route } from './lib/route.mjs';
16
16
  import { stopLocal } from './lib/local.mjs';
17
17
  import { hookStatus, installHooks, removeHooks } from './lib/claude_hooks.mjs';
18
18
  import * as integrations from './lib/integrations.mjs';
19
+ import * as refactor from './lib/refactor.mjs';
19
20
 
20
21
  const HEADS = { git, files, test: test_logs, read, os: osHead };
21
22
 
@@ -23,6 +24,9 @@ const USAGE = `usage:
23
24
  hydra ask "<question>" [--head git|files|test|read|os] answer a question about the repo or system
24
25
  hydra search <term...> search codebase with definition ranking
25
26
  hydra read <file> [symbol|--lines <start-end>] slice a file, symbol, or show outline
27
+ hydra smell [file|dir|--diff|--staged] detect Refactoring.Guru code smells & anti-patterns
28
+ hydra refactor <file> [symbol|--lines <start-end>] step-by-step refactoring recipe for a function or range
29
+ hydra design [pattern|query] [--file <file>] GoF design pattern advisor & architecture guide
26
30
  hydra run [--head test] -- <command...> run a command and digest test/build output
27
31
  hydra os [question...] inspect ports, processes, disk, system
28
32
  hydra stats token accounting for past calls
@@ -32,7 +36,7 @@ const USAGE = `usage:
32
36
  hydra stop stop Hydra's managed llama-server
33
37
  hydra hooks install|remove|status Claude Code hooks that route tests and git questions here
34
38
  hydra agents install|remove|status [tool...] Hydra's MCP server and hooks in codex, cursor, opencode,
35
- gemini-cli and antigravity (default: every tool found)
39
+ gemini-cli, antigravity and antigravity-ide (default: every tool found)
36
40
  hydra mcp run the MCP server on stdio`;
37
41
 
38
42
  function fail(code, message) {
@@ -40,6 +44,11 @@ function fail(code, message) {
40
44
  process.exit(code);
41
45
  }
42
46
 
47
+ // The footer reports a saving; when there is none (short output passed through whole) it is pure cost.
48
+ function withFooter(text, head, raw, returned, seconds) {
49
+ return returned < raw ? `${text}\n\n[hydra ${head} · read ~${raw} tok locally, returned ~${returned} tok · ${seconds}s]` : text;
50
+ }
51
+
43
52
  async function ask(args) {
44
53
  const flag = args.indexOf('--head');
45
54
  const forced = flag >= 0 ? args.splice(flag, 2)[1] : undefined;
@@ -60,7 +69,7 @@ async function ask(args) {
60
69
  const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
61
70
 
62
71
  record({ ts: new Date().toISOString(), head, question, raw, returned, seconds, modelCalls: meter.modelCalls, modelSeconds: meter.modelSeconds });
63
- console.log(`${text}\n\n[hydra ${head} · read ~${raw} tok locally, returned ~${returned} tok · ${seconds}s]`);
72
+ console.log(withFooter(text, head, raw, returned, seconds));
64
73
  }
65
74
 
66
75
  async function runCommand(args) {
@@ -78,7 +87,7 @@ async function runCommand(args) {
78
87
  const returned = tokens(text.length);
79
88
  const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
80
89
  record({ ts: new Date().toISOString(), head: 'test', question: 'pipe', raw, returned, seconds, modelCalls: meter.modelCalls, modelSeconds: meter.modelSeconds });
81
- console.log(`${text}\n\n[hydra test · read ~${raw} tok locally, returned ~${returned} tok · ${seconds}s]`);
90
+ console.log(withFooter(text, 'test', raw, returned, seconds));
82
91
  if (exitCode !== 0) process.exit(exitCode);
83
92
  return;
84
93
  }
@@ -103,7 +112,7 @@ async function runCommand(args) {
103
112
  const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
104
113
  record({ ts: new Date().toISOString(), head: 'test', question: cmdArgs.join(' '), raw, returned, seconds,
105
114
  commandSeconds, exit: exitCode, modelCalls: meter.modelCalls, modelSeconds: meter.modelSeconds });
106
- console.log(`${text}\n\n[hydra test · read ~${raw} tok locally, returned ~${returned} tok · ${seconds}s]`);
115
+ console.log(withFooter(text, 'test', raw, returned, seconds));
107
116
  if (exitCode !== 0) process.exit(exitCode);
108
117
  }
109
118
 
@@ -154,7 +163,7 @@ async function readCommand(args) {
154
163
  seconds,
155
164
  });
156
165
 
157
- console.log(`${res.text}\n\n[hydra read · read ~${raw} tok locally, returned ~${returned} tok · ${seconds}s]`);
166
+ console.log(withFooter(res.text, 'read', raw, returned, seconds));
158
167
  }
159
168
 
160
169
  async function osCommand(args) {
@@ -168,7 +177,7 @@ async function osCommand(args) {
168
177
  const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
169
178
 
170
179
  record({ ts: new Date().toISOString(), head: 'os', question, raw, returned, seconds, modelCalls: meter.modelCalls, modelSeconds: meter.modelSeconds });
171
- console.log(`${text}\n\n[hydra os · read ~${raw} tok locally, returned ~${returned} tok · ${seconds}s]`);
180
+ console.log(withFooter(text, 'os', raw, returned, seconds));
172
181
  }
173
182
 
174
183
  async function searchCommand(args) {
@@ -208,7 +217,7 @@ async function searchCommand(args) {
208
217
  seconds,
209
218
  });
210
219
 
211
- console.log(`${text}\n\n[hydra files · read ~${raw} tok locally, returned ~${returned} tok · ${seconds}s]`);
220
+ console.log(withFooter(text, 'files', raw, returned, seconds));
212
221
  }
213
222
 
214
223
  function showStats() {
@@ -240,10 +249,10 @@ function improve(args) {
240
249
  for (const [agent, a] of Object.entries(report.agents)) {
241
250
  lines.push(`agent ${agent}: ${a.calls} calls (${a.viaMcp} through MCP), ${a.denials} runs turned back, ${a.sessions} sessions started`);
242
251
  }
243
- const { denials, followedByHydra, rawBypasses } = report.hooks;
252
+ const { denials, followedByHydra, trimmed, rawBypasses } = report.hooks;
244
253
  const { decisions, p50Ms, p90Ms } = report.hooks;
245
254
  lines.push(`hooks ${decisions} Bash calls checked (p50 ${p50Ms} ms, p90 ${p90Ms} ms); ${denials} test/build runs turned back, ` +
246
- `${followedByHydra} followed by a Hydra run within 5 minutes`);
255
+ `${followedByHydra} followed by a Hydra run within 5 minutes; ${trimmed} let through already cut short (| tail -n N)`);
247
256
  for (const command of rawBypasses) lines.push(` HYDRA_RAW=1 bypass (check for a false match): ${command.slice(0, 100)}`);
248
257
  for (const error of report.errors) lines.push(`error ${error.ts} ${error.command}: ${error.error}`);
249
258
  console.log(lines.join('\n'));
@@ -289,6 +298,180 @@ This command prints instructions only; it does not install, download or launch a
289
298
  }
290
299
  }
291
300
 
301
+ async function smellCommand(args) {
302
+ const isJson = args.includes('--json');
303
+ const isDiff = args.includes('--diff');
304
+ const isStaged = args.includes('--staged');
305
+ const target = args.filter(a => !a.startsWith('--'))[0] || (isDiff || isStaged ? '.' : process.cwd());
306
+
307
+ const started = Date.now();
308
+ let text = '';
309
+ let rawChars = 0;
310
+
311
+ if (isDiff || isStaged) {
312
+ const gitArgs = ['diff'];
313
+ if (isStaged) gitArgs.push('--staged');
314
+ const diffRes = await run('git', gitArgs, process.cwd());
315
+ const diffOutput = diffRes.out || '';
316
+ rawChars = diffOutput.length;
317
+ const diffReport = refactor.scanDiff(diffOutput);
318
+ if (isJson) {
319
+ text = JSON.stringify(diffReport, null, 2);
320
+ } else {
321
+ if (!diffReport.filesWithSmells) {
322
+ text = '✔ No code smells introduced in this diff.';
323
+ } else {
324
+ text = `[hydra smell] ${diffReport.filesWithSmells} changed files with smells:\n\n` +
325
+ diffReport.reports.map(r => refactor.formatSmellReport(r)).join('\n\n');
326
+ }
327
+ }
328
+ } else {
329
+ const resolved = path.resolve(target);
330
+ if (!existsSync(resolved)) fail(1, `hydra smell: path not found: ${target}`);
331
+ const isDir = statSync(resolved).isDirectory();
332
+ if (isDir) {
333
+ const dirReport = refactor.scanDirectory(resolved);
334
+ rawChars = dirReport.reports.reduce((s, r) => s + r.totalLines * 30, 100);
335
+ if (isJson) {
336
+ text = JSON.stringify(dirReport, null, 2);
337
+ } else {
338
+ if (!dirReport.filesWithSmells) {
339
+ text = `✔ ${target}: No code smells detected across scanned files.`;
340
+ } else {
341
+ text = `[hydra smell] ${dirReport.totalSmells} smells across ${dirReport.filesWithSmells} files in ${target}:\n\n` +
342
+ dirReport.reports.map(r => refactor.formatSmellReport(r)).join('\n\n');
343
+ }
344
+ }
345
+ } else {
346
+ const report = refactor.scanFile(resolved);
347
+ rawChars = report.totalLines * 35;
348
+ text = isJson ? JSON.stringify(report, null, 2) : refactor.formatSmellReport(report);
349
+ }
350
+ }
351
+
352
+ const raw = tokens(rawChars);
353
+ const returned = tokens(text.length);
354
+ const seconds = Number(((Date.now() - started) / 1000).toFixed(2));
355
+
356
+ record({
357
+ ts: new Date().toISOString(),
358
+ head: 'refactor',
359
+ question: `smell ${args.join(' ')}`,
360
+ raw,
361
+ returned,
362
+ seconds,
363
+ });
364
+
365
+ console.log(withFooter(text, 'smell', raw, returned, seconds));
366
+ }
367
+
368
+ async function refactorCommand(args) {
369
+ const isJson = args.includes('--json');
370
+ const cleanArgs = args.filter(a => a !== '--json');
371
+ if (cleanArgs.length === 0) fail(1, 'usage: hydra refactor <file> [symbol|--lines <start-end>]');
372
+
373
+ let fileArg = null;
374
+ let symbol = null;
375
+ let lines = null;
376
+
377
+ for (let i = 0; i < cleanArgs.length; i++) {
378
+ const arg = cleanArgs[i];
379
+ if (arg === '--lines' && i + 1 < cleanArgs.length) {
380
+ lines = cleanArgs[++i];
381
+ } else if (arg.startsWith('--lines=')) {
382
+ lines = arg.slice('--lines='.length);
383
+ } else if (/^\d+[:-]\d+$/.test(arg)) {
384
+ lines = arg;
385
+ } else if (!fileArg) {
386
+ fileArg = arg;
387
+ } else if (!symbol) {
388
+ symbol = arg;
389
+ }
390
+ }
391
+
392
+ if (!fileArg) fail(1, 'usage: hydra refactor <file> [symbol|--lines <start-end>]');
393
+ const resolved = path.resolve(fileArg);
394
+ if (!existsSync(resolved) || statSync(resolved).isDirectory()) {
395
+ fail(1, `hydra refactor: file not found: ${fileArg}`);
396
+ }
397
+
398
+ const started = Date.now();
399
+ const content = readFileSync(resolved, 'utf8');
400
+
401
+ let startLine, endLine;
402
+ if (lines) {
403
+ const [s, e] = lines.split(/[:-]/).map(Number);
404
+ startLine = s;
405
+ endLine = e;
406
+ }
407
+
408
+ const recipe = refactor.generateRecipe(content, { symbol, startLine, endLine });
409
+ const rawChars = content.length;
410
+ const text = isJson ? JSON.stringify(recipe, null, 2) : refactor.formatRecipe(recipe);
411
+
412
+ const raw = tokens(rawChars);
413
+ const returned = tokens(text.length);
414
+ const seconds = Number(((Date.now() - started) / 1000).toFixed(2));
415
+
416
+ record({
417
+ ts: new Date().toISOString(),
418
+ head: 'refactor',
419
+ question: `refactor ${fileArg}${symbol ? ` ${symbol}` : ''}`,
420
+ raw,
421
+ returned,
422
+ seconds,
423
+ });
424
+
425
+ console.log(withFooter(text, 'refactor', raw, returned, seconds));
426
+ }
427
+
428
+ async function designCommand(args) {
429
+ const isJson = args.includes('--json');
430
+ let fileArg = null;
431
+ const words = [];
432
+
433
+ for (let i = 0; i < args.length; i++) {
434
+ const arg = args[i];
435
+ if (arg === '--json') continue;
436
+ if (arg === '--file' && i + 1 < args.length) {
437
+ fileArg = args[++i];
438
+ } else if (arg.startsWith('--file=')) {
439
+ fileArg = arg.slice('--file='.length);
440
+ } else {
441
+ words.push(arg);
442
+ }
443
+ }
444
+
445
+ const query = words.join(' ').trim();
446
+ let codeSnippet = '';
447
+ if (fileArg) {
448
+ const resolved = path.resolve(fileArg);
449
+ if (existsSync(resolved) && !statSync(resolved).isDirectory()) {
450
+ codeSnippet = readFileSync(resolved, 'utf8').slice(0, 5000);
451
+ }
452
+ }
453
+
454
+ const started = Date.now();
455
+ const adv = refactor.advisePattern(query, codeSnippet);
456
+ const text = isJson ? JSON.stringify(adv, null, 2) : refactor.formatPattern(adv);
457
+
458
+ const seconds = Number(((Date.now() - started) / 1000).toFixed(2));
459
+
460
+ // A pattern lookup replaces no command output, so it has no raw baseline: it counts as neither a
461
+ // saving nor a cost, and its size is kept apart.
462
+ record({
463
+ ts: new Date().toISOString(),
464
+ head: 'refactor',
465
+ question: `design ${query}`,
466
+ raw: 0,
467
+ returned: 0,
468
+ replyTokens: tokens(text.length),
469
+ seconds,
470
+ });
471
+
472
+ console.log(text);
473
+ }
474
+
292
475
  const [command, ...rest] = process.argv.slice(2);
293
476
  const commands = {
294
477
  ask: () => ask(rest),
@@ -297,6 +480,9 @@ const commands = {
297
480
  run: () => runCommand(rest),
298
481
  test: () => runCommand(rest),
299
482
  read: () => readCommand(rest),
483
+ smell: () => smellCommand(rest),
484
+ refactor: () => refactorCommand(rest),
485
+ design: () => designCommand(rest),
300
486
  os: () => osCommand(rest),
301
487
  stats: showStats,
302
488
  improve: () => improve(rest),
@@ -313,16 +499,16 @@ function agentsCommand(args) {
313
499
  const ids = words.filter(word => !word.startsWith('--'));
314
500
  if (action === 'status') {
315
501
  for (const row of integrations.status()) {
316
- console.log(`${row.id.padEnd(12)} ${row.present ? 'found ' : 'not found'} mcp ${row.mcp ? 'yes' : 'no '}${'hooks' in row ? ` hooks ${row.hooks ? 'yes' : 'no'}` : ''}`);
502
+ console.log(`${row.id.padEnd(15)} ${row.present ? 'found ' : 'not found'} mcp ${row.mcp ? 'yes' : 'no '}${'hooks' in row ? ` hooks ${row.hooks ? 'yes' : 'no'}` : ''}${'skill' in row ? ` skill ${row.skill ? 'yes' : 'no'}` : ''}`);
317
503
  }
318
- console.log(`claude-code ${hookStatus().length ? 'hooks yes' : 'hooks no'} (skill and hooks; see hydra hooks status)`);
504
+ console.log(`claude-code ${hookStatus().length ? 'hooks yes' : 'hooks no'} (skill and hooks; see hydra hooks status)`);
319
505
  return;
320
506
  }
321
- if (!['install', 'remove'].includes(action)) fail(1, 'usage: hydra agents install|remove|status [codex cursor opencode gemini-cli antigravity] [--no-hooks]');
507
+ if (!['install', 'remove'].includes(action)) fail(1, 'usage: hydra agents install|remove|status [codex cursor opencode gemini-cli antigravity antigravity-ide] [--no-hooks]');
322
508
  const scripts = action === 'install'
323
509
  ? integrations.installSharedScripts(path.dirname(fileURLToPath(import.meta.url))) : integrations.sharedScripts();
324
510
  const targets = ids.length ? ids : action === 'install' ? integrations.detected(scripts) : Object.keys(integrations.tools(scripts));
325
- if (!targets.length) return console.log('No supported coding tool found (codex, cursor, opencode, gemini-cli, antigravity).');
511
+ if (!targets.length) return console.log('No supported coding tool found (codex, cursor, opencode, gemini-cli, antigravity, antigravity-ide).');
326
512
  for (const result of integrations.apply(targets, action, { scripts, hooks: !words.includes('--no-hooks') })) {
327
513
  if (result.error) { console.log(`${result.id}: ${result.error}`); continue; }
328
514
  console.log(`${result.id}: ${result.changes.length ? result.changes.join('; ') : action === 'install' ? 'nothing changed' : 'nothing to remove'}`);
@@ -31,8 +31,9 @@ export const settingsFile = () => path.join(claudeDir(), 'settings.json');
31
31
 
32
32
  /** The simple commands of a shell line: split on &&, ||, ;, | and newlines outside quotes, so a
33
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) {
34
+ * body (<<EOF ... EOF) is data, not commands, and is skipped whole. With pipes false, a pipeline
35
+ * (a | b) stays one piece. */
36
+ export function simpleCommands(command, { pipes = true } = {}) {
36
37
  const parts = [];
37
38
  let current = '';
38
39
  let quote = null;
@@ -71,6 +72,8 @@ export function simpleCommands(command) {
71
72
  parts.push(current);
72
73
  current = '';
73
74
  i++;
75
+ } else if (c === '|' && !pipes) {
76
+ current += c;
74
77
  } else if (c === ';' || c === '|' || c === '\n') {
75
78
  parts.push(current);
76
79
  current = '';
@@ -84,22 +87,58 @@ export function simpleCommands(command) {
84
87
 
85
88
  const ownRun = command => /hydra(?:\.mjs)?"?\s+(?:run|test)\b/.test(command);
86
89
 
87
- function runnerIn(command) {
88
- const run = simpleCommands(command).map(part => part.replace(/^(?:\w+=\S*\s+)+/, ''))
89
- .find(part => RUNNERS.some(re => re.test(part)));
90
- return run ? run.replace(/\s+2>&1\s*$/, '') : null;
90
+ // A later pipeline stage that keeps at most this many lines leaves little for Hydra to save.
91
+ const FEW_LINES = 40;
92
+
93
+ /** A pipeline stage that cuts its input to a few lines or a count: tail/head of at most FEW_LINES
94
+ * lines (10 when not given), grep -c, or wc. tail -f and tail -n +N can return everything. */
95
+ function cutsShort(stage) {
96
+ const [name, ...args] = stage.split(/\s+/);
97
+ if (name === 'wc') return true;
98
+ if (name === 'grep' || name === 'rg') return args.some(arg => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(arg) || arg === '--count');
99
+ if (name !== 'tail' && name !== 'head') return false;
100
+ let lines = 10;
101
+ for (let i = 0; i < args.length; i++) {
102
+ const arg = args[i];
103
+ if (/^-[a-zA-Z]*[fF]/.test(arg) || arg.startsWith('--follow')) return false;
104
+ const value = /^-\d+$/.test(arg) ? arg.slice(1) : arg === '-n' || arg === '--lines' ? args[++i]
105
+ : /^-n./.test(arg) ? arg.slice(2) : arg.startsWith('--lines=') ? arg.slice(8) : null;
106
+ if (value === null) continue;
107
+ if (!/^\d+$/.test(value ?? '')) return false;
108
+ lines = Number(value);
109
+ }
110
+ return lines <= FEW_LINES;
111
+ }
112
+
113
+ /** Each test/build run in a shell line, and whether its own pipeline cuts the output short. */
114
+ function runsIn(command) {
115
+ return simpleCommands(command, { pipes: false }).flatMap(pipeline => {
116
+ const stages = simpleCommands(pipeline).map(part => part.replace(/^(?:\w+=\S*\s+)+/, ''));
117
+ const at = stages.findIndex(part => RUNNERS.some(re => re.test(part)));
118
+ return at < 0 ? [] : [{ run: stages[at].replace(/\s+2>&1\s*$/, ''), cut: stages.slice(at + 1).some(cutsShort) }];
119
+ });
91
120
  }
92
121
 
93
- /** The test/build run inside a shell command, or null. Hydra's own runs and HYDRA_RAW=1 pass. */
122
+ const passesAnyway = command => !command || ownRun(command) || /\bHYDRA_RAW=1\b/.test(command);
123
+
124
+ /** The test/build run inside a shell command that would put its whole log in context, or null.
125
+ * Hydra's own runs, HYDRA_RAW=1 and runs already cut to a few lines (| tail -n 15) pass. */
94
126
  export function testRun(command) {
95
- if (!command || ownRun(command) || /\bHYDRA_RAW=1\b/.test(command)) return null;
96
- return runnerIn(command);
127
+ if (passesAnyway(command)) return null;
128
+ return runsIn(command).find(run => !run.cut)?.run ?? null;
129
+ }
130
+
131
+ /** A command whose every test/build run is already cut to a few lines by its own pipeline. */
132
+ export function trimmedRun(command) {
133
+ if (passesAnyway(command)) return false;
134
+ const runs = runsIn(command);
135
+ return runs.length > 0 && runs.every(run => run.cut);
97
136
  }
98
137
 
99
138
  /** A test/build run let through by HYDRA_RAW=1: the agent wanted more than Hydra's digest. */
100
139
  export function rawBypass(command) {
101
140
  return Boolean(command) && /\bHYDRA_RAW=1\b/.test(command) && !ownRun(command)
102
- && runnerIn(command.replace(/\bHYDRA_RAW=1\s+/g, '')) !== null;
141
+ && runsIn(command.replace(/\bHYDRA_RAW=1\s+/g, '')).length > 0;
103
142
  }
104
143
 
105
144
  function load(file) {
@@ -8,6 +8,10 @@
8
8
  // opencode ~/.config/opencode/opencode.json(c) mcp.hydra ~/.config/opencode/plugins/hydra.js tool.execute.before
9
9
  // gemini-cli ~/.gemini/settings.json mcpServers.hydra same file, hooks.BeforeTool on run_shell_command
10
10
  // antigravity ~/.gemini/antigravity/mcp_config.json none known; the MCP server's instructions guide it
11
+ // antigravity-ide ~/.gemini/config/mcp_config.json none known; the skill in ~/.gemini/config/skills/hydra guides it
12
+ //
13
+ // Antigravity IDE (com.google.antigravity-ide, data in ~/.gemini/antigravity-ide) is a different app from
14
+ // the older Antigravity (~/.gemini/antigravity): it reads MCP servers and skills from ~/.gemini/config.
11
15
  //
12
16
  // Every file Hydra edits is backed up once as <file>.before-hydra, written atomically, and only
13
17
  // Hydra's own entries are added or removed. A file Hydra cannot edit safely (JSON with comments, or
@@ -24,7 +28,8 @@ const END = '# <<< hydra <<<';
24
28
  /** Hydra's own copy of its scripts, which every tool's MCP entry and hook points at. */
25
29
  export const sharedScripts = () => path.join(process.env.HYDRA_HOME ?? path.join(os.homedir(), '.hydra'), 'scripts');
26
30
 
27
- /** Copy the skill's scripts to sharedScripts(), so registrations never point into an npx cache. */
31
+ /** Copy the skill's scripts to sharedScripts(), so registrations never point into an npx cache.
32
+ * Its SKILL.md goes beside them, for tools that load skills from a folder of their own. */
28
33
  export function installSharedScripts(from) {
29
34
  const target = sharedScripts();
30
35
  if (path.resolve(from) === path.resolve(target)) return target; // Already running from the shared copy.
@@ -33,6 +38,8 @@ export function installSharedScripts(from) {
33
38
  cpSync(from, `${target}.hydra-tmp`, { recursive: true });
34
39
  rmSync(target, { recursive: true, force: true });
35
40
  renameSync(`${target}.hydra-tmp`, target);
41
+ const skill = path.join(from, '..', 'SKILL.md');
42
+ if (existsSync(skill)) copyFileSync(skill, path.join(path.dirname(target), 'SKILL.md'));
36
43
  return target;
37
44
  }
38
45
 
@@ -186,6 +193,33 @@ export const Hydra = async () => ({
186
193
  };
187
194
  }
188
195
 
196
+ /** The hydra skill folder (SKILL.md and the scripts it names) in a tool's own skills folder.
197
+ * An older hydra skill there is replaced; a folder that is not the hydra skill is left alone. */
198
+ function skillFolder(dir, scripts) {
199
+ const skillFile = path.join(dir, 'SKILL.md');
200
+ const ours = () => existsSync(skillFile) && /^name:\s*hydra\s*$/m.test(readFileSync(skillFile, 'utf8'));
201
+ return {
202
+ install() {
203
+ const source = path.join(path.dirname(scripts), 'SKILL.md');
204
+ if (!existsSync(source)) throw new Manual(dir, 'npx github:rahul05ranjan/hydra', `needs the hydra skill, but ${source} is missing; run the installer again`);
205
+ if (existsSync(dir) && !ours()) throw new Manual(dir, `the hydra skill folder (SKILL.md and scripts/) from ${path.dirname(scripts)}`, 'exists and is not the hydra skill');
206
+ rmSync(`${dir}.hydra-tmp`, { recursive: true, force: true });
207
+ mkdirSync(`${dir}.hydra-tmp`, { recursive: true });
208
+ copyFileSync(source, path.join(`${dir}.hydra-tmp`, 'SKILL.md'));
209
+ cpSync(scripts, path.join(`${dir}.hydra-tmp`, 'scripts'), { recursive: true });
210
+ rmSync(dir, { recursive: true, force: true });
211
+ renameSync(`${dir}.hydra-tmp`, dir);
212
+ return `${dir}: skill`;
213
+ },
214
+ remove() {
215
+ if (!ours()) return null;
216
+ rmSync(dir, { recursive: true, force: true });
217
+ return `${dir}: skill`;
218
+ },
219
+ status: () => ours() && existsSync(path.join(dir, 'scripts', 'hydra.mjs')),
220
+ };
221
+ }
222
+
189
223
  const opencodeConfig = dir => ['opencode.json', 'opencode.jsonc'].map(name => path.join(dir, name)).find(existsSync) ?? path.join(dir, 'opencode.json');
190
224
 
191
225
  /** Every tool Hydra can join, with where it lives and what Hydra registers there. */
@@ -217,6 +251,9 @@ export function tools(scripts = sharedScripts()) {
217
251
  BeforeTool: [{ matcher: 'run_shell_command', hooks: [{ name: 'hydra', type: 'command', command: hookCommand(scripts, 'route-tests.mjs', 'gemini'), timeout: 10_000 }] }] }) } },
218
252
  antigravity: { dir: path.join(gemini, 'antigravity'), parts: {
219
253
  mcp: jsonServer(path.join(gemini, 'antigravity', 'mcp_config.json'), 'mcpServers', mcp) } },
254
+ 'antigravity-ide': { dir: path.join(gemini, 'antigravity-ide'), parts: {
255
+ mcp: jsonServer(path.join(gemini, 'config', 'mcp_config.json'), 'mcpServers', mcp),
256
+ skill: skillFolder(path.join(gemini, 'config', 'skills', 'hydra'), scripts) } },
220
257
  };
221
258
  }
222
259