impulso 0.19.1

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.
Files changed (85) hide show
  1. package/README.md +425 -0
  2. package/agents/ImpulsoOrchestrator.md +112 -0
  3. package/agents/debugger.md +38 -0
  4. package/agents/generalist.md +26 -0
  5. package/agents/implementer-frontend.md +41 -0
  6. package/agents/implementer-infra.md +41 -0
  7. package/agents/implementer.md +35 -0
  8. package/agents/planner.md +52 -0
  9. package/agents/reviewer.md +40 -0
  10. package/agents/scout-writer.md +24 -0
  11. package/agents/scout.md +22 -0
  12. package/agents/shipper.md +64 -0
  13. package/commands/impulso-address-pr-comments.md +162 -0
  14. package/commands/impulso-brainstorm.md +54 -0
  15. package/commands/impulso-context-pack.md +55 -0
  16. package/commands/impulso-debug.md +50 -0
  17. package/commands/impulso-directspeech-compress.md +137 -0
  18. package/commands/impulso-directspeech-help.md +33 -0
  19. package/commands/impulso-directspeech-stats.md +16 -0
  20. package/commands/impulso-implement.md +86 -0
  21. package/commands/impulso-plan.md +92 -0
  22. package/commands/impulso-ship.md +46 -0
  23. package/commands/impulso-spec.md +106 -0
  24. package/commands/impulso-verify.md +99 -0
  25. package/commands/plannotator-annotate.md +47 -0
  26. package/commands/plannotator-last.md +45 -0
  27. package/commands/plannotator-review.md +46 -0
  28. package/harnesses/claude/README.md +109 -0
  29. package/harnesses/claude/hooks/bash-state-guard.cjs +84 -0
  30. package/harnesses/claude/hooks/directspeech-context.cjs +29 -0
  31. package/harnesses/claude/hooks/path-guard.cjs +106 -0
  32. package/harnesses/claude/hooks/preflight.cjs +39 -0
  33. package/harnesses/claude/hooks/pretooluse-bash.cjs +69 -0
  34. package/harnesses/claude/hooks/rtk.cjs +87 -0
  35. package/harnesses/claude/hooks/subagent-start.cjs +83 -0
  36. package/harnesses/claude/hooks/task-exit.cjs +189 -0
  37. package/harnesses/claude/hooks/tdd-observer.cjs +118 -0
  38. package/harnesses/claude/hooks/tokensave-stale.cjs +63 -0
  39. package/harnesses/claude/hooks/userpromptsubmit.cjs +42 -0
  40. package/harnesses/claude/install.sh +120 -0
  41. package/harnesses/claude/merge-settings.cjs +71 -0
  42. package/harnesses/claude/uninstall.sh +89 -0
  43. package/harnesses/opencode/bin/darwin-amd64/impulso-hub +0 -0
  44. package/harnesses/opencode/bin/darwin-arm64/impulso-hub +0 -0
  45. package/harnesses/opencode/bin/impulso-hub +0 -0
  46. package/harnesses/opencode/bin/linux-amd64/impulso-hub +0 -0
  47. package/harnesses/opencode/bin/linux-arm64/impulso-hub +0 -0
  48. package/harnesses/opencode/commands-loader.js +101 -0
  49. package/harnesses/opencode/compose.js +111 -0
  50. package/harnesses/opencode/directspeech/README.md +43 -0
  51. package/harnesses/opencode/directspeech/plugin.js +47 -0
  52. package/harnesses/opencode/frontmatter.js +60 -0
  53. package/harnesses/opencode/install.sh +121 -0
  54. package/harnesses/opencode/orchestration/index.js +362 -0
  55. package/harnesses/opencode/plugin.js +84 -0
  56. package/harnesses/opencode/rtk/README.md +100 -0
  57. package/harnesses/opencode/rtk/rtk.ts +41 -0
  58. package/harnesses/opencode/session-name/plugin.js +27 -0
  59. package/harnesses/opencode/sync-commands.js +98 -0
  60. package/harnesses/opencode/uninstall.sh +36 -0
  61. package/opencode/opencode.jsonc +35 -0
  62. package/package.json +95 -0
  63. package/scripts/bump-plugin-version.cjs +28 -0
  64. package/scripts/install-hub.js +78 -0
  65. package/scripts/sync-agents.cjs +167 -0
  66. package/shared/directspeech-config.cjs +18 -0
  67. package/shared/orchestration-config.cjs +281 -0
  68. package/shared/orchestration-data.json +48 -0
  69. package/shared/state-guard.cjs +56 -0
  70. package/shared/tokensave-freshness.cjs +62 -0
  71. package/skills/impulso-address-pr-comments/SKILL.md +159 -0
  72. package/skills/impulso-brainstorm/SKILL.md +51 -0
  73. package/skills/impulso-context-pack/SKILL.md +52 -0
  74. package/skills/impulso-debug/SKILL.md +47 -0
  75. package/skills/impulso-directspeech-compress/SKILL.md +134 -0
  76. package/skills/impulso-directspeech-help/SKILL.md +30 -0
  77. package/skills/impulso-directspeech-stats/SKILL.md +13 -0
  78. package/skills/impulso-implement/SKILL.md +83 -0
  79. package/skills/impulso-plan/SKILL.md +89 -0
  80. package/skills/impulso-ship/SKILL.md +43 -0
  81. package/skills/impulso-spec/SKILL.md +103 -0
  82. package/skills/impulso-verify/SKILL.md +96 -0
  83. package/skills/plannotator-annotate/SKILL.md +45 -0
  84. package/skills/plannotator-last/SKILL.md +43 -0
  85. package/skills/plannotator-review/SKILL.md +44 -0
@@ -0,0 +1,45 @@
1
+ ---
2
+ description:
3
+ 'Open Plannotator on the latest rendered assistant message and use the
4
+ returned annotations to revise that message or continue.'
5
+ ---
6
+
7
+ # Plannotator Last
8
+
9
+ Use when user wants latest assistant response annotated in Plannotator.
10
+
11
+ No commentary before launch — preamble becomes the thing annotated.
12
+
13
+ ## Protocol
14
+
15
+ 1. Launch plannotator immediately (no preamble):
16
+
17
+ ```bash
18
+ impulso-hub plannotator launch last
19
+ ```
20
+
21
+ Returns JSON with `url`, `remoteUrl`, `port`, `pid`. Non-blocking.
22
+
23
+ 2. **IMMEDIATELY relay URL:** "Open Plannotator at `<URL>` (remote: `<Remote>`).
24
+ Annotate the last message, then tell me when done." WAIT for user.
25
+
26
+ 3. After user confirms:
27
+
28
+ ```bash
29
+ impulso-hub plannotator wait
30
+ ```
31
+
32
+ Returns `{"decision":"approved"|"dismissed"|"annotated","feedback":"..."}`.
33
+
34
+ 4. Apply the decision:
35
+ - `approved` → acknowledge, proceed.
36
+ - `dismissed` → session closed, say so, continue.
37
+ - `annotated` → address the feedback text.
38
+
39
+ 5. Launch timeout (exit 2): check stderr, ask whether to retry.
40
+
41
+ Run commands yourself. Never ask user to paste shell commands into chat.
42
+
43
+ ---
44
+
45
+ **User arguments:** $ARGUMENTS
@@ -0,0 +1,46 @@
1
+ ---
2
+ description:
3
+ "Open Plannotator's browser-based code review UI for the current worktree or a
4
+ pull request URL, then act on the feedback that comes back."
5
+ ---
6
+
7
+ # Plannotator Review
8
+
9
+ Use when user wants code changes reviewed in Plannotator.
10
+
11
+ ## Protocol
12
+
13
+ 1. Determine the review target. User gave PR URL → set `TARGET` to the PR URL.
14
+ Worktree review → leave `TARGET` empty.
15
+
16
+ 2. Launch plannotator and get the URL:
17
+
18
+ ```bash
19
+ impulso-hub plannotator launch review [TARGET]
20
+ ```
21
+
22
+ Returns JSON with `url`, `remoteUrl`, `port`, `pid`. Non-blocking.
23
+
24
+ 3. **IMMEDIATELY relay URL:** "Open Plannotator at `<URL>` (remote: `<Remote>`).
25
+ Review the changes, then tell me when done." WAIT for user to confirm.
26
+
27
+ 4. After user confirms, wait for plannotator to finish:
28
+
29
+ ```bash
30
+ impulso-hub plannotator wait
31
+ ```
32
+
33
+ Returns `{"decision":"approved"|"dismissed"|"annotated","feedback":"..."}`.
34
+
35
+ 5. Apply the decision:
36
+ - `approved` → acknowledge, proceed.
37
+ - `dismissed` → session closed, say so, continue.
38
+ - `annotated` → address the feedback text.
39
+
40
+ 6. Launch timeout (exit 2): check stderr, ask whether to retry.
41
+
42
+ Run commands yourself. Never ask user to paste shell commands into chat.
43
+
44
+ ---
45
+
46
+ **User arguments:** $ARGUMENTS
@@ -0,0 +1,109 @@
1
+ # Impulso — Claude Code Harness
2
+
3
+ Glue for running Impulso on Claude Code.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ ./harnesses/claude/install.sh
9
+ ```
10
+
11
+ Idempotent. Hard-requires `rtk` and `tokensave` on PATH (aborts otherwise).
12
+ Creates an isolated profile at `~/.config/impulso-profile/claude/`:
13
+
14
+ - Symlinks `skills/` and `commands/` → repo dirs. If the target already has a
15
+ real dir (shared profiles), bundled entries are linked individually inside it
16
+ — foreign entries untouched
17
+ - Copies the ruleset as `IMPULSO-AGENTS.md` (never clobbers a user `AGENTS.md`)
18
+ and ensures `CLAUDE.md` imports it — created if missing, appended otherwise
19
+ (Claude Code reads `CLAUDE.md`, not `AGENTS.md`)
20
+ - `settings.json`: generated from the self-boot `claude/settings.json` (single
21
+ source, hooks pinned to this repo) and **merged** into any existing settings
22
+ via `merge-settings.cjs` — foreign hooks and keys survive, stale impulso
23
+ entries are replaced
24
+ - Registers the `tokensave` MCP server (user scope)
25
+
26
+ Uninstall reverses exactly that — targeted removals only, never touches foreign
27
+ content, never `rm -rf`'s `~/.claude`:
28
+
29
+ ```bash
30
+ ./harnesses/claude/uninstall.sh
31
+ ```
32
+
33
+ Health check for the whole harness (binaries, symlinks, configs, MCP):
34
+
35
+ ```bash
36
+ ./doctor.sh
37
+ ```
38
+
39
+ Launch with the isolated profile:
40
+
41
+ ```bash
42
+ CLAUDE_CONFIG_DIR=~/.config/impulso-profile/claude claude
43
+ ```
44
+
45
+ To install into `~/.claude/` directly (shared with your normal Claude Code
46
+ config):
47
+
48
+ ```bash
49
+ IMPULSO_CLAUDE_PROFILE="$HOME/.claude" ./harnesses/claude/install.sh
50
+ ```
51
+
52
+ ## Plannotator
53
+
54
+ Plannotator slash commands work directly after install (vendored in `skills/`).
55
+ Requires the `plannotator` binary:
56
+
57
+ ```bash
58
+ curl -fsSL https://plannotator.ai/install.sh | bash
59
+ ```
60
+
61
+ ## RTK (token savings)
62
+
63
+ `install.sh` hard-requires the `rtk` binary:
64
+
65
+ ```bash
66
+ brew install rtk
67
+ ```
68
+
69
+ PreToolUse hook rewrites Bash commands through `rtk rewrite` (only when rtk
70
+ exits 0). Enforcement is runtime, not just install time:
71
+
72
+ - `preflight.cjs` (UserPromptSubmit) blocks every prompt while `rtk` or
73
+ `tokensave` is missing from PATH, with install hints.
74
+ - `rtk.cjs` (PreToolUse) blocks Bash calls when `rtk` is missing.
75
+ - A failed rewrite with rtk present passes the command through unchanged.
76
+
77
+ ## DirectSpeech
78
+
79
+ Always active — enforced by the harness, no toggle, no flag files.
80
+
81
+ - Ruleset: Impulso mode block in `shared/AGENTS.md`, loaded via `CLAUDE.md` →
82
+ `@AGENTS.md`.
83
+ - Per-turn reinforcement: `directspeech-context.cjs` (UserPromptSubmit) injects
84
+ one line unconditionally. The line lives in `shared/directspeech-config.cjs`,
85
+ shared with the opencode harness.
86
+ - Escape hatches are model-level: Auto-Clarity for security warnings and
87
+ irreversible actions; code/commits/PRs always written normal.
88
+
89
+ ## Tests
90
+
91
+ ```bash
92
+ node harnesses/claude/tests.cjs # hook contracts + config assertions
93
+ node shared/tests.cjs # reinforcement line unit tests
94
+ ```
95
+
96
+ ## Structure
97
+
98
+ ```
99
+ impulso/harnesses/claude/
100
+ ├── README.md
101
+ ├── install.sh
102
+ ├── uninstall.sh
103
+ ├── merge-settings.cjs
104
+ ├── tests.cjs
105
+ └── hooks/
106
+ ├── preflight.cjs
107
+ ├── rtk.cjs
108
+ └── directspeech-context.cjs
109
+ ```
@@ -0,0 +1,84 @@
1
+ // impulso bash-state-guard hook — Claude Code
2
+ //
3
+ // PreToolUse handler (matcher: Bash).
4
+ // Blocks any Bash command that touches gate-owned state paths:
5
+ // .impulso/work/<slug>/state.json or .impulso/active
6
+ // Gate invocations (impulso-hub / scripts/impulso-hub.cjs) are always allowed.
7
+ //
8
+ // Exit codes:
9
+ // 0 — allow
10
+ // 2 — blocked (canary phrase on stderr)
11
+ //
12
+ // Reads like `cat state.json` are blocked too (fail-closed). Stderr includes
13
+ // guidance for status queries.
14
+ //
15
+ // Hook: PreToolUse (matcher: Bash)
16
+ //
17
+ // Exported for use by pretooluse-bash.cjs dispatcher:
18
+ // check(input) -> { block: boolean, stderr?: string }
19
+
20
+ 'use strict';
21
+
22
+ const path = require('node:path');
23
+ const { isStateGuardHit, isGateInvocation } = require(
24
+ path.join(__dirname, '..', '..', '..', 'shared', 'state-guard.cjs'),
25
+ );
26
+
27
+ // Pure function: check whether a hook input payload should be blocked.
28
+ // Returns { block: true, stderr: '...' } when blocked, { block: false } otherwise.
29
+ function check(input) {
30
+ const toolName = (input.tool_name || '').toLowerCase();
31
+ if (toolName !== 'bash') return { block: false };
32
+ const command = (input.tool_input && input.tool_input.command) || '';
33
+ if (!command) return { block: false };
34
+ if (isGateInvocation(command)) return { block: false };
35
+ if (isStateGuardHit(command)) {
36
+ return {
37
+ block: true,
38
+ stderr:
39
+ 'state file is gate-owned; use impulso-hub\nread state via: impulso-hub status --json\n',
40
+ };
41
+ }
42
+ return { block: false };
43
+ }
44
+
45
+ module.exports = { check };
46
+
47
+ function main() {
48
+ let done = false;
49
+ const chunks = [];
50
+
51
+ process.stdin.setEncoding('utf8');
52
+ process.stdin.on('readable', () => {
53
+ let c;
54
+ while ((c = process.stdin.read()) !== null) chunks.push(c);
55
+ });
56
+
57
+ function finish(raw) {
58
+ if (done) return;
59
+ done = true;
60
+ clearTimeout(timer); // safe: timer defined below before finish can fire
61
+
62
+ let input;
63
+ try {
64
+ input = JSON.parse(raw || '{}');
65
+ } catch (_e) {
66
+ process.exit(0);
67
+ return;
68
+ }
69
+
70
+ const result = check(input);
71
+ if (result.block) {
72
+ if (result.stderr) process.stderr.write(result.stderr);
73
+ process.exit(2);
74
+ return;
75
+ }
76
+
77
+ process.exit(0);
78
+ }
79
+
80
+ process.stdin.on('end', () => finish(chunks.join('')));
81
+ const timer = setTimeout(() => finish(chunks.join('')), 1000);
82
+ }
83
+
84
+ if (require.main === module) main();
@@ -0,0 +1,29 @@
1
+ // impulso-directspeech context hook — Claude Code
2
+ //
3
+ // UserPromptSubmit handler. DirectSpeech is always on — inject the
4
+ // reinforcement line as additionalContext every turn, unconditionally.
5
+ // No flags, no toggles, no modes.
6
+ //
7
+ // Hook: UserPromptSubmit
8
+ //
9
+ // Exported for use by userpromptsubmit.cjs dispatcher:
10
+ // contextObject() -> { hookSpecificOutput: { hookEventName, additionalContext } }
11
+
12
+ const path = require('node:path');
13
+ const config = require(path.join(__dirname, '../../../shared/directspeech-config.cjs'));
14
+
15
+ // Pure function: build the hook output object.
16
+ function contextObject() {
17
+ return {
18
+ hookSpecificOutput: {
19
+ hookEventName: 'UserPromptSubmit',
20
+ additionalContext: config.reinforcementLine(),
21
+ },
22
+ };
23
+ }
24
+
25
+ module.exports = { contextObject };
26
+
27
+ if (require.main === module) {
28
+ process.stdout.write(JSON.stringify(contextObject()) + '\n');
29
+ }
@@ -0,0 +1,106 @@
1
+ // impulso path-guard hook — Claude Code
2
+ //
3
+ // PreToolUse handler (matcher: Edit|Write). Enforces two deny rules:
4
+ //
5
+ // Deny 1 — gate-owned state files (fail-CLOSED; never depends on role):
6
+ // Normalized file_path matches /(^|\/)\.impulso\/(work\/[^/]+\/state\.json|active)$/
7
+ // → exit 2 "state file is gate-owned; use impulso-hub"
8
+ //
9
+ // Deny 2 — read-only agent writing to source tree:
10
+ // Role file for session_id exists AND role ∈ {scout, scout-writer, planner, reviewer}
11
+ // AND normalized path does NOT contain '/.impulso/'
12
+ // → exit 2 "read-only agent (<role>): source tree writes denied; workspace (.impulso/) only"
13
+ //
14
+ // Everything else → exit 0 (allow). Missing or unparseable role file → treat
15
+ // as no role (exit 0), EXCEPT Deny 1 which is unconditional.
16
+ //
17
+ // Sessions dir: IMPULSO_SESSIONS_DIR env (default: os.tmpdir()/impulso-sessions).
18
+ //
19
+ // Hook: PreToolUse (matcher: Edit|Write)
20
+
21
+ const os = require('node:os');
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+
25
+ const READ_ONLY_ROLES = new Set(['scout', 'scout-writer', 'planner', 'reviewer']);
26
+
27
+ // Matches gate-owned state paths:
28
+ // .impulso/work/<slug>/state.json or .impulso/active
29
+ // at any position in the path (absolute or relative).
30
+ const STATE_FILE_RE = /(^|\/)\.impulso\/(work\/[^/]+\/state\.json|active)$/;
31
+
32
+ function sessionsDir() {
33
+ return process.env.IMPULSO_SESSIONS_DIR || path.join(os.tmpdir(), 'impulso-sessions');
34
+ }
35
+
36
+ function getRole(sessionId) {
37
+ if (!sessionId) return null;
38
+ try {
39
+ const p = path.join(sessionsDir(), sessionId + '.json');
40
+ const data = JSON.parse(fs.readFileSync(p, 'utf8'));
41
+ return data.role || null;
42
+ } catch (_e) {
43
+ return null; // Missing or unparseable → no role
44
+ }
45
+ }
46
+
47
+ function main() {
48
+ let done = false;
49
+ const chunks = [];
50
+
51
+ process.stdin.setEncoding('utf8');
52
+ process.stdin.on('readable', () => {
53
+ let c;
54
+ while ((c = process.stdin.read()) !== null) chunks.push(c);
55
+ });
56
+
57
+ function finish(raw) {
58
+ if (done) return;
59
+ done = true;
60
+ clearTimeout(timer); // safe: finish() is only called after const timer = ... executes
61
+
62
+ let input;
63
+ try {
64
+ input = JSON.parse(raw || '{}');
65
+ } catch (_e) {
66
+ process.exit(0); // Fail-open on parse error
67
+ return;
68
+ }
69
+
70
+ const filePath = (input.tool_input && input.tool_input.file_path) || '';
71
+ // Normalize: replace backslashes first, then resolve dot/dot-dot segments.
72
+ const normalized = path.posix.normalize(filePath.replace(/\\/g, '/'));
73
+ // Lowercase copy for case-insensitive checks (macOS FS is case-insensitive).
74
+ // Never alter `normalized` — it is used in stderr messages.
75
+ const normalizedLower = normalized.toLowerCase();
76
+
77
+ // Deny 1: gate-owned state files — unconditional, no role check needed
78
+ if (STATE_FILE_RE.test(normalizedLower)) {
79
+ process.stderr.write('state file is gate-owned; use impulso-hub\n');
80
+ process.exit(2);
81
+ return;
82
+ }
83
+
84
+ // Deny 2: read-only role writing to source tree
85
+ const sessionId = input.session_id || null;
86
+ const role = getRole(sessionId);
87
+
88
+ // Workspace = path contains /.impulso/ (absolute) OR starts with .impulso/ (relative).
89
+ const isWorkspace =
90
+ normalizedLower.includes('/.impulso/') || normalizedLower.startsWith('.impulso/');
91
+ if (role && READ_ONLY_ROLES.has(role) && !isWorkspace) {
92
+ process.stderr.write(
93
+ `read-only agent (${role}): source tree writes denied; workspace (.impulso/) only\n`,
94
+ );
95
+ process.exit(2);
96
+ return;
97
+ }
98
+
99
+ process.exit(0);
100
+ }
101
+
102
+ process.stdin.on('end', () => finish(chunks.join('')));
103
+ const timer = setTimeout(() => finish(chunks.join('')), 1000);
104
+ }
105
+
106
+ main();
@@ -0,0 +1,39 @@
1
+ // impulso preflight hook — Claude Code
2
+ //
3
+ // UserPromptSubmit handler. Enforces the hard-required binaries (rtk,
4
+ // tokensave): if either is missing from PATH, exit 2 blocks the prompt and
5
+ // surfaces install instructions. Impulso is opinionated — no degraded mode.
6
+ //
7
+ // Hook: UserPromptSubmit
8
+ //
9
+ // Exported for use by userpromptsubmit.cjs dispatcher:
10
+ // missingBinaries() -> [{bin, hint}]
11
+
12
+ const { spawnSync } = require('node:child_process');
13
+
14
+ const REQUIRED = [
15
+ { bin: 'rtk', hint: 'brew install rtk-ai/tap/rtk' },
16
+ { bin: 'tokensave', hint: 'brew install aovestdipaperino/tap/tokensave' },
17
+ { bin: 'plannotator', hint: 'npm i -g plannotator' },
18
+ ];
19
+
20
+ // Pure function: returns required binaries missing from PATH.
21
+ function missingBinaries() {
22
+ return REQUIRED.filter(({ bin }) => {
23
+ const r = spawnSync('which', [bin], { stdio: ['ignore', 'pipe', 'ignore'] });
24
+ return r.error || r.status !== 0;
25
+ });
26
+ }
27
+
28
+ module.exports = { missingBinaries };
29
+
30
+ if (require.main === module) {
31
+ const missing = missingBinaries();
32
+ if (missing.length > 0) {
33
+ const lines = missing.map(({ bin, hint }) => ` ${bin} — install: ${hint}`);
34
+ process.stderr.write(
35
+ 'impulso: hard-required binaries missing from PATH:\n' + lines.join('\n') + '\n',
36
+ );
37
+ process.exit(2);
38
+ }
39
+ }
@@ -0,0 +1,69 @@
1
+ // impulso pretooluse-bash dispatcher — Claude Code
2
+ //
3
+ // PreToolUse(Bash) dispatcher. Reads stdin once, runs rtk rewrite then
4
+ // bash-state-guard. rtk's exit-2 (missing binary) short-circuits before the
5
+ // guard runs.
6
+ //
7
+ // Hook: PreToolUse (matcher: Bash)
8
+
9
+ 'use strict';
10
+
11
+ const path = require('node:path');
12
+ const rtk = require(path.join(__dirname, 'rtk.cjs'));
13
+ const bashStateGuard = require(path.join(__dirname, 'bash-state-guard.cjs'));
14
+
15
+ const chunks = [];
16
+ let done = false;
17
+ process.stdin.setEncoding('utf8');
18
+ process.stdin.on('readable', () => {
19
+ let c;
20
+ while ((c = process.stdin.read()) !== null) chunks.push(c);
21
+ });
22
+
23
+ function finish(raw) {
24
+ if (done) return;
25
+ done = true;
26
+ clearTimeout(timer); // safe: timer defined below before finish can fire
27
+
28
+ let input;
29
+ try {
30
+ input = JSON.parse(raw);
31
+ } catch (_e) {
32
+ process.exit(0);
33
+ return;
34
+ }
35
+
36
+ // Stage 1: rtk rewrite (may process.exit(2) if rtk missing)
37
+ const rtkResult = rtk.rewriteResult(input);
38
+
39
+ // Stage 2: bash-state-guard on the effective (possibly rewritten) input
40
+ const effectiveInput = rtkResult
41
+ ? { ...input, tool_input: { ...input.tool_input, command: rtkResult.updatedInput.command } }
42
+ : input;
43
+
44
+ const guard = bashStateGuard.check(effectiveInput);
45
+ if (guard.block) {
46
+ if (guard.stderr) process.stderr.write(guard.stderr);
47
+ process.exit(2);
48
+ return;
49
+ }
50
+
51
+ // Emit rewrite result if rtk changed the command
52
+ if (rtkResult) {
53
+ process.stdout.write(
54
+ JSON.stringify({
55
+ hookSpecificOutput: {
56
+ hookEventName: 'PreToolUse',
57
+ updatedInput: rtkResult.updatedInput,
58
+ },
59
+ }) + '\n',
60
+ );
61
+ }
62
+
63
+ process.exit(0);
64
+ }
65
+
66
+ process.stdin.on('end', () => finish(chunks.join('')));
67
+ // 1-second fallback: if Claude Code's hook runner delays closing stdin,
68
+ // finish on whatever data arrived. Mirrors bash-state-guard.cjs defensive design.
69
+ const timer = setTimeout(() => finish(chunks.join('')), 1000);
@@ -0,0 +1,87 @@
1
+ // impulso rtk hook — Claude Code
2
+ //
3
+ // PreToolUse Bash handler. Rewrites bash commands through `rtk rewrite`
4
+ // for token savings. Mirrors the opencode rtk.ts plugin behavior.
5
+ //
6
+ // Hook: PreToolUse (matcher: Bash)
7
+ //
8
+ // Exported for use by pretooluse-bash.cjs dispatcher:
9
+ // rewriteResult(input) -> { updatedInput: {command} } | null
10
+ // Returns null when tool is not Bash, command is empty, or rtk exits
11
+ // non-zero. May process.exit(2) if rtk is missing from PATH.
12
+
13
+ const { spawnSync } = require('node:child_process');
14
+
15
+ // Returns the rewritten command, or null to pass through unchanged.
16
+ //
17
+ // rtk is hard-required: missing from PATH (spawn ENOENT) blocks the Bash
18
+ // call — exit 2 makes Claude Code reject the tool use and feeds stderr back
19
+ // to the model. Do not fall back to raw commands; fix the install.
20
+ //
21
+ // A non-zero exit with rtk PRESENT means this particular rewrite failed —
22
+ // pass through unchanged (rtk's partial stdout must not be trusted).
23
+ function rewrite(command) {
24
+ const r = spawnSync('rtk', ['rewrite', command], {
25
+ encoding: 'utf8',
26
+ stdio: ['ignore', 'pipe', 'pipe'],
27
+ timeout: 5000,
28
+ });
29
+ if (r.error && r.error.code === 'ENOENT') {
30
+ process.stderr.write(
31
+ 'impulso: rtk is hard-required but missing from PATH. ' +
32
+ 'Install: brew install rtk-ai/tap/rtk\n',
33
+ );
34
+ process.exit(2);
35
+ }
36
+ if (r.error || r.status !== 0) return null;
37
+ return r.stdout.trim();
38
+ }
39
+
40
+ // Pure function: evaluate rtk rewrite for a hook input payload.
41
+ // Returns { updatedInput: { command } } when rtk rewrites the command,
42
+ // null otherwise. May process.exit(2) if rtk is missing from PATH.
43
+ function rewriteResult(input) {
44
+ const toolName = (input.tool_name || '').toLowerCase();
45
+ if (toolName !== 'bash' && toolName !== 'shell') return null;
46
+ const command = input.tool_input && input.tool_input.command;
47
+ if (typeof command !== 'string' || !command) return null;
48
+ const rewritten = rewrite(command);
49
+ if (rewritten && rewritten !== command) {
50
+ return { updatedInput: { command: rewritten } };
51
+ }
52
+ return null;
53
+ }
54
+
55
+ module.exports = { rewriteResult };
56
+
57
+ function main() {
58
+ const chunks = [];
59
+ process.stdin.setEncoding('utf8');
60
+ process.stdin.on('readable', () => {
61
+ let chunk;
62
+ while ((chunk = process.stdin.read()) !== null) {
63
+ chunks.push(chunk);
64
+ }
65
+ });
66
+
67
+ process.stdin.on('end', () => {
68
+ try {
69
+ const input = JSON.parse(chunks.join(''));
70
+ const result = rewriteResult(input);
71
+ if (result) {
72
+ process.stdout.write(
73
+ JSON.stringify({
74
+ hookSpecificOutput: {
75
+ hookEventName: 'PreToolUse',
76
+ updatedInput: result.updatedInput,
77
+ },
78
+ }) + '\n',
79
+ );
80
+ }
81
+ } catch (e) {
82
+ // Parse / filesystem errors — pass through unchanged
83
+ }
84
+ });
85
+ }
86
+
87
+ if (require.main === module) main();