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.
- package/README.md +425 -0
- package/agents/ImpulsoOrchestrator.md +112 -0
- package/agents/debugger.md +38 -0
- package/agents/generalist.md +26 -0
- package/agents/implementer-frontend.md +41 -0
- package/agents/implementer-infra.md +41 -0
- package/agents/implementer.md +35 -0
- package/agents/planner.md +52 -0
- package/agents/reviewer.md +40 -0
- package/agents/scout-writer.md +24 -0
- package/agents/scout.md +22 -0
- package/agents/shipper.md +64 -0
- package/commands/impulso-address-pr-comments.md +162 -0
- package/commands/impulso-brainstorm.md +54 -0
- package/commands/impulso-context-pack.md +55 -0
- package/commands/impulso-debug.md +50 -0
- package/commands/impulso-directspeech-compress.md +137 -0
- package/commands/impulso-directspeech-help.md +33 -0
- package/commands/impulso-directspeech-stats.md +16 -0
- package/commands/impulso-implement.md +86 -0
- package/commands/impulso-plan.md +92 -0
- package/commands/impulso-ship.md +46 -0
- package/commands/impulso-spec.md +106 -0
- package/commands/impulso-verify.md +99 -0
- package/commands/plannotator-annotate.md +47 -0
- package/commands/plannotator-last.md +45 -0
- package/commands/plannotator-review.md +46 -0
- package/harnesses/claude/README.md +109 -0
- package/harnesses/claude/hooks/bash-state-guard.cjs +84 -0
- package/harnesses/claude/hooks/directspeech-context.cjs +29 -0
- package/harnesses/claude/hooks/path-guard.cjs +106 -0
- package/harnesses/claude/hooks/preflight.cjs +39 -0
- package/harnesses/claude/hooks/pretooluse-bash.cjs +69 -0
- package/harnesses/claude/hooks/rtk.cjs +87 -0
- package/harnesses/claude/hooks/subagent-start.cjs +83 -0
- package/harnesses/claude/hooks/task-exit.cjs +189 -0
- package/harnesses/claude/hooks/tdd-observer.cjs +118 -0
- package/harnesses/claude/hooks/tokensave-stale.cjs +63 -0
- package/harnesses/claude/hooks/userpromptsubmit.cjs +42 -0
- package/harnesses/claude/install.sh +120 -0
- package/harnesses/claude/merge-settings.cjs +71 -0
- package/harnesses/claude/uninstall.sh +89 -0
- package/harnesses/opencode/bin/darwin-amd64/impulso-hub +0 -0
- package/harnesses/opencode/bin/darwin-arm64/impulso-hub +0 -0
- package/harnesses/opencode/bin/impulso-hub +0 -0
- package/harnesses/opencode/bin/linux-amd64/impulso-hub +0 -0
- package/harnesses/opencode/bin/linux-arm64/impulso-hub +0 -0
- package/harnesses/opencode/commands-loader.js +101 -0
- package/harnesses/opencode/compose.js +111 -0
- package/harnesses/opencode/directspeech/README.md +43 -0
- package/harnesses/opencode/directspeech/plugin.js +47 -0
- package/harnesses/opencode/frontmatter.js +60 -0
- package/harnesses/opencode/install.sh +121 -0
- package/harnesses/opencode/orchestration/index.js +362 -0
- package/harnesses/opencode/plugin.js +84 -0
- package/harnesses/opencode/rtk/README.md +100 -0
- package/harnesses/opencode/rtk/rtk.ts +41 -0
- package/harnesses/opencode/session-name/plugin.js +27 -0
- package/harnesses/opencode/sync-commands.js +98 -0
- package/harnesses/opencode/uninstall.sh +36 -0
- package/opencode/opencode.jsonc +35 -0
- package/package.json +95 -0
- package/scripts/bump-plugin-version.cjs +28 -0
- package/scripts/install-hub.js +78 -0
- package/scripts/sync-agents.cjs +167 -0
- package/shared/directspeech-config.cjs +18 -0
- package/shared/orchestration-config.cjs +281 -0
- package/shared/orchestration-data.json +48 -0
- package/shared/state-guard.cjs +56 -0
- package/shared/tokensave-freshness.cjs +62 -0
- package/skills/impulso-address-pr-comments/SKILL.md +159 -0
- package/skills/impulso-brainstorm/SKILL.md +51 -0
- package/skills/impulso-context-pack/SKILL.md +52 -0
- package/skills/impulso-debug/SKILL.md +47 -0
- package/skills/impulso-directspeech-compress/SKILL.md +134 -0
- package/skills/impulso-directspeech-help/SKILL.md +30 -0
- package/skills/impulso-directspeech-stats/SKILL.md +13 -0
- package/skills/impulso-implement/SKILL.md +83 -0
- package/skills/impulso-plan/SKILL.md +89 -0
- package/skills/impulso-ship/SKILL.md +43 -0
- package/skills/impulso-spec/SKILL.md +103 -0
- package/skills/impulso-verify/SKILL.md +96 -0
- package/skills/plannotator-annotate/SKILL.md +45 -0
- package/skills/plannotator-last/SKILL.md +43 -0
- 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();
|