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,83 @@
|
|
|
1
|
+
// impulso subagent-start hook — Claude Code
|
|
2
|
+
//
|
|
3
|
+
// SubagentStart handler. Writes a role file to the sessions dir and injects
|
|
4
|
+
// the DirectSpeech reinforcement line + a static role reminder as
|
|
5
|
+
// additionalContext. Fail-open throughout — losing persona context (exit 2) is
|
|
6
|
+
// worse than a missing role file.
|
|
7
|
+
//
|
|
8
|
+
// Role file: ${IMPULSO_SESSIONS_DIR}/<session_id>.json
|
|
9
|
+
// = { role: agent_type|null, at: ISO }
|
|
10
|
+
// IMPULSO_SESSIONS_DIR env overrides the default (os.tmpdir()/impulso-sessions).
|
|
11
|
+
//
|
|
12
|
+
// additionalContext is byte-stable per agent_type — no timestamps in the
|
|
13
|
+
// emitted text, so prompt caching is not broken.
|
|
14
|
+
//
|
|
15
|
+
// Hook: SubagentStart
|
|
16
|
+
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
const dsConfig = require(path.join(__dirname, '../../../shared/directspeech-config.cjs'));
|
|
22
|
+
|
|
23
|
+
function sessionsDir() {
|
|
24
|
+
return process.env.IMPULSO_SESSIONS_DIR || path.join(os.tmpdir(), 'impulso-sessions');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function main() {
|
|
28
|
+
let done = false;
|
|
29
|
+
const chunks = [];
|
|
30
|
+
|
|
31
|
+
process.stdin.setEncoding('utf8');
|
|
32
|
+
process.stdin.on('readable', () => {
|
|
33
|
+
let c;
|
|
34
|
+
while ((c = process.stdin.read()) !== null) chunks.push(c);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function finish(raw) {
|
|
38
|
+
if (done) return;
|
|
39
|
+
done = true;
|
|
40
|
+
clearTimeout(timer); // safe: finish() is only called after const timer = ... executes
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const input = JSON.parse(raw || '{}');
|
|
44
|
+
const sessionId = input.session_id || null;
|
|
45
|
+
const role = input.agent_type || null;
|
|
46
|
+
|
|
47
|
+
// Write role file — best-effort; never block on failure
|
|
48
|
+
if (sessionId) {
|
|
49
|
+
try {
|
|
50
|
+
const dir = sessionsDir();
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
52
|
+
const entry = { role, at: new Date().toISOString() };
|
|
53
|
+
fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify(entry), 'utf8');
|
|
54
|
+
} catch (_e) {
|
|
55
|
+
// Intentionally swallowed — fail-open
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Emit additionalContext — byte-stable per role (no timestamps here)
|
|
60
|
+
const dsLine = dsConfig.reinforcementLine();
|
|
61
|
+
const roleLabel = role || 'generalist';
|
|
62
|
+
const roleLine = `IMPULSO ROLE: ${roleLabel}. Gates enforced by impulso-hub.`;
|
|
63
|
+
|
|
64
|
+
process.stdout.write(
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
hookSpecificOutput: {
|
|
67
|
+
hookEventName: 'SubagentStart',
|
|
68
|
+
additionalContext: dsLine + '\n' + roleLine,
|
|
69
|
+
},
|
|
70
|
+
}) + '\n',
|
|
71
|
+
);
|
|
72
|
+
} catch (_e) {
|
|
73
|
+
// Fail-open — emit nothing rather than block the subagent
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
process.stdin.on('end', () => finish(chunks.join('')));
|
|
80
|
+
const timer = setTimeout(() => finish(chunks.join('')), 1000);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
main();
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// impulso task-exit hook — Claude Code
|
|
2
|
+
//
|
|
3
|
+
// SubagentStop handler. Two additive checks:
|
|
4
|
+
//
|
|
5
|
+
// 1. Status-block length check (all roles, fail-open): reads last_assistant_message
|
|
6
|
+
// from the SubagentStop payload (available since Claude Code added the field:
|
|
7
|
+
// "Added last_assistant_message field to Stop and SubagentStop hook inputs,
|
|
8
|
+
// providing the final assistant response text so hooks can access it without
|
|
9
|
+
// parsing transcript files."). If the report exceeds 15 lines, emits a
|
|
10
|
+
// visible violation via hookSpecificOutput.additionalContext on stdout —
|
|
11
|
+
// SubagentStop's non-blocking visible channel (source:
|
|
12
|
+
// https://code.claude.com/docs/en/hooks, SubagentStop hookSpecificOutput table:
|
|
13
|
+
// additionalContext "injected into Claude's conversation at the end of the turn").
|
|
14
|
+
// Stderr at exit-0 is discarded by Claude Code; only exit-2 stderr is relayed.
|
|
15
|
+
// Never truncates; never blocks (always continues after this check).
|
|
16
|
+
//
|
|
17
|
+
// 2. TDD gate (implementer roles only): for implementer roles with an active
|
|
18
|
+
// workspace and a current task in_progress, checks the TDD gate non-mutatively
|
|
19
|
+
// by reading state.json directly. If the red→green test cycle is not complete,
|
|
20
|
+
// exits 2 with a reason (blocking the subagent stop and feeding stderr back to
|
|
21
|
+
// the orchestrator).
|
|
22
|
+
//
|
|
23
|
+
// Fail-open on everything except the clear TDD gate-unmet case: missing role file,
|
|
24
|
+
// no active workspace, no in-progress task, gate unclear → exit 0.
|
|
25
|
+
//
|
|
26
|
+
// Gate path: resolved relative to hook location (../../..) — same as tdd-observer.
|
|
27
|
+
// Sessions dir: IMPULSO_SESSIONS_DIR env (default: os.tmpdir()/impulso-sessions).
|
|
28
|
+
//
|
|
29
|
+
// Hook: SubagentStop
|
|
30
|
+
|
|
31
|
+
const os = require('node:os');
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
|
|
35
|
+
const IMPLEMENTER_ROLES = new Set(['implementer', 'implementer-frontend', 'implementer-infra']);
|
|
36
|
+
|
|
37
|
+
const STATUS_BLOCK_LIMIT = 15;
|
|
38
|
+
|
|
39
|
+
function reportLineCount(reportText) {
|
|
40
|
+
if (!reportText || typeof reportText !== 'string') return 0;
|
|
41
|
+
const lines = reportText.split('\n');
|
|
42
|
+
// Every \n-delimited segment counts — blank lines and code-fence markers (```) included.
|
|
43
|
+
// A lone trailing newline is NOT counted as an extra line.
|
|
44
|
+
return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function violatesLimit(reportText, limit = STATUS_BLOCK_LIMIT) {
|
|
48
|
+
return reportLineCount(reportText) > limit;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sessionsDir() {
|
|
52
|
+
return process.env.IMPULSO_SESSIONS_DIR || path.join(os.tmpdir(), 'impulso-sessions');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getRole(sessionId) {
|
|
56
|
+
if (!sessionId) return null;
|
|
57
|
+
try {
|
|
58
|
+
const p = path.join(sessionsDir(), sessionId + '.json');
|
|
59
|
+
const data = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
60
|
+
return data.role || null;
|
|
61
|
+
} catch (_e) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function main() {
|
|
67
|
+
let done = false;
|
|
68
|
+
const chunks = [];
|
|
69
|
+
|
|
70
|
+
process.stdin.setEncoding('utf8');
|
|
71
|
+
process.stdin.on('readable', () => {
|
|
72
|
+
let c;
|
|
73
|
+
while ((c = process.stdin.read()) !== null) chunks.push(c);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function finish(raw) {
|
|
77
|
+
if (done) return;
|
|
78
|
+
done = true;
|
|
79
|
+
clearTimeout(timer); // safe: finish() is only called after const timer = ... executes
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const input = JSON.parse(raw || '{}');
|
|
83
|
+
const sessionId = input.session_id || null;
|
|
84
|
+
const role = getRole(sessionId);
|
|
85
|
+
|
|
86
|
+
// Status-block length check — additive, all roles, fail-open
|
|
87
|
+
try {
|
|
88
|
+
const report = input.last_assistant_message;
|
|
89
|
+
if (report && violatesLimit(report)) {
|
|
90
|
+
process.stdout.write(
|
|
91
|
+
JSON.stringify({
|
|
92
|
+
hookSpecificOutput: {
|
|
93
|
+
hookEventName: 'SubagentStop',
|
|
94
|
+
additionalContext:
|
|
95
|
+
`[impulso] subagent report exceeds ${STATUS_BLOCK_LIMIT}-line limit: ` +
|
|
96
|
+
`${reportLineCount(report)} lines. Trim the status block to ≤${STATUS_BLOCK_LIMIT} lines.`,
|
|
97
|
+
},
|
|
98
|
+
}) + '\n',
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
} catch (_e) {
|
|
102
|
+
// fail-open
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Only check for implementer roles
|
|
106
|
+
if (!role || !IMPLEMENTER_ROLES.has(role)) {
|
|
107
|
+
process.exit(0);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check for active workspace
|
|
112
|
+
const cwd = process.cwd();
|
|
113
|
+
const activePath = path.join(cwd, '.impulso', 'active');
|
|
114
|
+
if (!fs.existsSync(activePath)) {
|
|
115
|
+
process.exit(0);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Read state non-mutatively
|
|
120
|
+
let slug;
|
|
121
|
+
try {
|
|
122
|
+
slug = fs.readFileSync(activePath, 'utf8').trim();
|
|
123
|
+
} catch (_e) {
|
|
124
|
+
process.exit(0);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const statePath = path.join(cwd, '.impulso', 'work', slug, 'state.json');
|
|
129
|
+
let state;
|
|
130
|
+
try {
|
|
131
|
+
state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
132
|
+
} catch (_e) {
|
|
133
|
+
process.exit(0); // Fail-open if state unreadable
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Find current in-progress task
|
|
138
|
+
const currentTask = state.currentTask;
|
|
139
|
+
if (!currentTask) {
|
|
140
|
+
process.exit(0);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const taskInfo = state.tasks && state.tasks[String(currentTask)];
|
|
145
|
+
if (!taskInfo || taskInfo.status !== 'in_progress') {
|
|
146
|
+
process.exit(0);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check TDD gate: must have at least one red, then a green, for this task
|
|
151
|
+
const taskTests = (state.tests || []).filter((t) => String(t.task) === String(currentTask));
|
|
152
|
+
|
|
153
|
+
let lastRedIdx = -1;
|
|
154
|
+
for (let i = 0; i < taskTests.length; i++) {
|
|
155
|
+
if (taskTests[i].exitCode !== 0) lastRedIdx = i;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (lastRedIdx === -1) {
|
|
159
|
+
process.stderr.write(
|
|
160
|
+
`task ${currentTask}: TDD gate unmet — no failing test recorded. ` +
|
|
161
|
+
`Run failing tests before stopping: impulso-hub run-test\n`,
|
|
162
|
+
);
|
|
163
|
+
process.exit(2);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const greenAfterRed = taskTests.slice(lastRedIdx + 1).some((t) => t.exitCode === 0);
|
|
168
|
+
if (!greenAfterRed) {
|
|
169
|
+
process.stderr.write(
|
|
170
|
+
`task ${currentTask}: TDD gate unmet — no passing test after the last failing test. ` +
|
|
171
|
+
`Make tests green before stopping: impulso-hub run-test\n`,
|
|
172
|
+
);
|
|
173
|
+
process.exit(2);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
} catch (_e) {
|
|
177
|
+
// Fail-open — never block on unclear state
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
process.stdin.on('end', () => finish(chunks.join('')));
|
|
184
|
+
const timer = setTimeout(() => finish(chunks.join('')), 1000);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (require.main === module) main();
|
|
188
|
+
|
|
189
|
+
module.exports = { reportLineCount, violatesLimit };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// impulso tdd-observer hook — Claude Code
|
|
2
|
+
//
|
|
3
|
+
// PostToolUse handler (matcher: Bash). If an active workspace exists and the
|
|
4
|
+
// command matches the project's test_cmd, records the test exit code via
|
|
5
|
+
// impulso-hub record-test.
|
|
6
|
+
//
|
|
7
|
+
// Exit code extraction: checks tool_response fields in order:
|
|
8
|
+
// exitCode → exit_code → code
|
|
9
|
+
// If none is a number, skips recording — impulso-hub run-test is the primary
|
|
10
|
+
// capture path and provides reliable exit codes; this hook is a best-effort
|
|
11
|
+
// secondary observer.
|
|
12
|
+
//
|
|
13
|
+
// Gate path: resolved relative to hook location (../../..); if not found
|
|
14
|
+
// (plugin installed standalone), falls back to 'impulso-hub' on PATH.
|
|
15
|
+
//
|
|
16
|
+
// Always exits 0 — never blocks tool use.
|
|
17
|
+
//
|
|
18
|
+
// Hook: PostToolUse (matcher: Bash)
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { spawnSync } = require('node:child_process');
|
|
23
|
+
|
|
24
|
+
const GATE_REL = path.resolve(__dirname, '..', '..', '..', 'scripts', 'impulso-hub.cjs');
|
|
25
|
+
|
|
26
|
+
function main() {
|
|
27
|
+
let done = false;
|
|
28
|
+
const chunks = [];
|
|
29
|
+
|
|
30
|
+
process.stdin.setEncoding('utf8');
|
|
31
|
+
process.stdin.on('readable', () => {
|
|
32
|
+
let c;
|
|
33
|
+
while ((c = process.stdin.read()) !== null) chunks.push(c);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function finish(raw) {
|
|
37
|
+
if (done) return;
|
|
38
|
+
done = true;
|
|
39
|
+
clearTimeout(timer); // safe: finish() is only called after const timer = ... executes
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const input = JSON.parse(raw || '{}');
|
|
43
|
+
const command = (input.tool_input && input.tool_input.command) || '';
|
|
44
|
+
|
|
45
|
+
// Check for active workspace from cwd
|
|
46
|
+
const cwd = process.cwd();
|
|
47
|
+
const activePath = path.join(cwd, '.impulso', 'active');
|
|
48
|
+
if (!fs.existsSync(activePath)) {
|
|
49
|
+
process.exit(0);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Read config to get test_cmd
|
|
54
|
+
const configPath = path.join(cwd, '.impulso', 'config.json');
|
|
55
|
+
let config;
|
|
56
|
+
try {
|
|
57
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
58
|
+
} catch (_e) {
|
|
59
|
+
process.exit(0);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const testCmd = config.test_cmd;
|
|
64
|
+
if (!testCmd || !command.includes(testCmd)) {
|
|
65
|
+
process.exit(0);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Extract exit code from payload — check candidate fields in order.
|
|
70
|
+
// If none is a number, skip: run-test is the primary capture path.
|
|
71
|
+
const resp = input.tool_response || {};
|
|
72
|
+
let exitCode = null;
|
|
73
|
+
for (const field of ['exitCode', 'exit_code', 'code']) {
|
|
74
|
+
if (typeof resp[field] === 'number') {
|
|
75
|
+
exitCode = resp[field];
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (exitCode === null) {
|
|
81
|
+
process.exit(0);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Invoke impulso-hub record-test (non-blocking best-effort)
|
|
86
|
+
const gateArg = fs.existsSync(GATE_REL) ? GATE_REL : null;
|
|
87
|
+
if (gateArg) {
|
|
88
|
+
spawnSync(
|
|
89
|
+
process.execPath,
|
|
90
|
+
[gateArg, 'record-test', '--cmd', command, '--exit-code', String(exitCode)],
|
|
91
|
+
{
|
|
92
|
+
stdio: 'ignore',
|
|
93
|
+
timeout: 5000,
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
// Plugin installed standalone — fall back to impulso-hub on PATH
|
|
98
|
+
spawnSync(
|
|
99
|
+
'impulso-hub',
|
|
100
|
+
['record-test', '--cmd', command, '--exit-code', String(exitCode)],
|
|
101
|
+
{
|
|
102
|
+
stdio: 'ignore',
|
|
103
|
+
timeout: 5000,
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
} catch (_e) {
|
|
108
|
+
// Fail-open — never block tool use
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
process.stdin.on('end', () => finish(chunks.join('')));
|
|
115
|
+
const timer = setTimeout(() => finish(chunks.join('')), 1000);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
main();
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// impulso tokensave-stale hook — Claude Code
|
|
2
|
+
//
|
|
3
|
+
// PostToolUse(Edit|Write) handler. On a successful Edit/Write, writes
|
|
4
|
+
// .tokensave/stale containing a timestamp + the edited/written path so the
|
|
5
|
+
// userpromptsubmit dispatcher can warn when the tokensave index is stale.
|
|
6
|
+
//
|
|
7
|
+
// Fail-open: always exits 0. Never blocks or throws. If .tokensave/ does not
|
|
8
|
+
// exist, skips silently (no marker created).
|
|
9
|
+
//
|
|
10
|
+
// Tokensave dir resolution:
|
|
11
|
+
// 1. TOKENSAVE_DIR env var — override for tests and CI
|
|
12
|
+
// 2. process.cwd()/.tokensave — runtime default
|
|
13
|
+
//
|
|
14
|
+
// Hook: PostToolUse (matcher: Edit|Write)
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
const chunks = [];
|
|
22
|
+
let done = false;
|
|
23
|
+
|
|
24
|
+
process.stdin.setEncoding('utf8');
|
|
25
|
+
process.stdin.on('readable', () => {
|
|
26
|
+
let c;
|
|
27
|
+
while ((c = process.stdin.read()) !== null) chunks.push(c);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function finish(raw) {
|
|
31
|
+
if (done) return;
|
|
32
|
+
done = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const input = JSON.parse(raw || '{}');
|
|
37
|
+
const toolName = input.tool_name || '';
|
|
38
|
+
if (toolName !== 'Edit' && toolName !== 'Write') {
|
|
39
|
+
process.exit(0);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const filePath = (input.tool_input && input.tool_input.file_path) || '';
|
|
44
|
+
const tsDir = process.env.TOKENSAVE_DIR || path.join(process.cwd(), '.tokensave');
|
|
45
|
+
|
|
46
|
+
// Fail-open: skip if the dir does not exist
|
|
47
|
+
if (!fs.existsSync(tsDir)) {
|
|
48
|
+
process.exit(0);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ts = new Date().toISOString();
|
|
53
|
+
fs.writeFileSync(path.join(tsDir, 'stale'), ts + '\n' + filePath + '\n');
|
|
54
|
+
} catch (_) {
|
|
55
|
+
// Fail-open: never throw, never block the write
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
process.stdin.on('end', () => finish(chunks.join('')));
|
|
62
|
+
// 1-second fallback: in case Claude Code's hook runner delays closing stdin
|
|
63
|
+
const timer = setTimeout(() => finish(chunks.join('')), 1000);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// impulso userpromptsubmit dispatcher — Claude Code
|
|
2
|
+
//
|
|
3
|
+
// UserPromptSubmit dispatcher. Runs preflight binary check, then checks
|
|
4
|
+
// tokensave freshness and injects directspeech context (with stale warning
|
|
5
|
+
// appended when the index is stale). preflight's exit-2 (missing binary)
|
|
6
|
+
// short-circuits before anything else.
|
|
7
|
+
//
|
|
8
|
+
// Hook: UserPromptSubmit
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const preflight = require(path.join(__dirname, 'preflight.cjs'));
|
|
14
|
+
const directspeechContext = require(path.join(__dirname, 'directspeech-context.cjs'));
|
|
15
|
+
const { reconcile } = require(path.join(__dirname, '../../../shared/tokensave-freshness.cjs'));
|
|
16
|
+
|
|
17
|
+
// Stage 1: preflight — exit 2 if any required binary is missing
|
|
18
|
+
const missing = preflight.missingBinaries();
|
|
19
|
+
if (missing.length > 0) {
|
|
20
|
+
const lines = missing.map(({ bin, hint }) => ` ${bin} — install: ${hint}`);
|
|
21
|
+
process.stderr.write(
|
|
22
|
+
'impulso: hard-required binaries missing from PATH:\n' + lines.join('\n') + '\n',
|
|
23
|
+
);
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Stage 2: reconcile tokensave freshness (fail-open — never block prompt)
|
|
28
|
+
const tsDir = process.env.TOKENSAVE_DIR || path.join(process.cwd(), '.tokensave');
|
|
29
|
+
let stalenessLine = null;
|
|
30
|
+
try {
|
|
31
|
+
const result = reconcile(tsDir);
|
|
32
|
+
if (result.stale) {
|
|
33
|
+
stalenessLine = `tokensave index stale since ${result.since}`;
|
|
34
|
+
}
|
|
35
|
+
} catch (_) {}
|
|
36
|
+
|
|
37
|
+
// Stage 3: directspeech context injection (stale warning appended when needed)
|
|
38
|
+
const ctx = directspeechContext.contextObject();
|
|
39
|
+
if (stalenessLine) {
|
|
40
|
+
ctx.hookSpecificOutput.additionalContext += '\n' + stalenessLine;
|
|
41
|
+
}
|
|
42
|
+
process.stdout.write(JSON.stringify(ctx) + '\n');
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Impulso claude install — creates an isolated profile dir, symlinks
|
|
5
|
+
# skills/commands from this repo, copies AGENTS.md + CLAUDE.md, generates
|
|
6
|
+
# settings.json from the self-boot config, and registers the tokensave MCP.
|
|
7
|
+
# Idempotent — safe to rerun.
|
|
8
|
+
#
|
|
9
|
+
# Default profile dir: ~/.config/impulso-profile/claude
|
|
10
|
+
# Override with: IMPULSO_CLAUDE_PROFILE=/some/dir ./install.sh
|
|
11
|
+
#
|
|
12
|
+
# To install into ~/.claude/ directly (shared with your normal Claude
|
|
13
|
+
# Code config): IMPULSO_CLAUDE_PROFILE="$HOME/.claude" ./install.sh
|
|
14
|
+
#
|
|
15
|
+
# Launch with the isolated profile:
|
|
16
|
+
# CLAUDE_CONFIG_DIR=~/.config/impulso-profile/claude claude
|
|
17
|
+
|
|
18
|
+
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
19
|
+
PROFILE_DIR="${IMPULSO_CLAUDE_PROFILE:-${HOME}/.config/impulso-profile/claude}"
|
|
20
|
+
ISOLATED=1
|
|
21
|
+
if [[ "${PROFILE_DIR}" == "${HOME}/.claude" ]]; then
|
|
22
|
+
ISOLATED=0
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
echo "==> Impulso install (claude)"
|
|
26
|
+
echo " Repo: ${REPO}"
|
|
27
|
+
echo " Profile: ${PROFILE_DIR}"
|
|
28
|
+
|
|
29
|
+
# --- Preflight: hard-required binaries (parity with opencode installer) -----
|
|
30
|
+
|
|
31
|
+
require_bin() {
|
|
32
|
+
local bin="$1"
|
|
33
|
+
local hint="$2"
|
|
34
|
+
if ! command -v "$bin" >/dev/null 2>&1; then
|
|
35
|
+
echo "ERROR: '$bin' not found on PATH. Impulso requires it." >&2
|
|
36
|
+
echo " $hint" >&2
|
|
37
|
+
exit 1
|
|
38
|
+
fi
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
require_bin rtk "Install: brew install rtk-ai/tap/rtk (see https://www.rtk-ai.app)"
|
|
42
|
+
require_bin tokensave "Install: brew install aovestdipaperino/tap/tokensave (or: cargo install tokensave)"
|
|
43
|
+
require_bin plannotator "Install: curl -fsSL https://plannotator.ai/install.sh | bash"
|
|
44
|
+
|
|
45
|
+
mkdir -p "${PROFILE_DIR}"
|
|
46
|
+
|
|
47
|
+
# Symlink skills/, commands/, and agents/ into the profile — without clobbering.
|
|
48
|
+
# Missing or already-a-symlink target: whole-dir symlink. Existing real
|
|
49
|
+
# directory (shared profiles like ~/.claude have their own): link each
|
|
50
|
+
# bundled entry individually inside it, never touching foreign entries.
|
|
51
|
+
for sub in skills commands agents; do
|
|
52
|
+
link_path="${PROFILE_DIR}/${sub}"
|
|
53
|
+
target="${REPO}/${sub}"
|
|
54
|
+
if [[ -L "${link_path}" ]] || [[ ! -e "${link_path}" ]]; then
|
|
55
|
+
ln -sfn "${target}" "${link_path}"
|
|
56
|
+
echo " Linked ${sub} -> ${target}"
|
|
57
|
+
else
|
|
58
|
+
for entry in "${target}"/*; do
|
|
59
|
+
ln -sfn "${entry}" "${link_path}/$(basename "${entry}")"
|
|
60
|
+
done
|
|
61
|
+
echo " Linked ${sub}/* entries into existing ${link_path}"
|
|
62
|
+
fi
|
|
63
|
+
done
|
|
64
|
+
|
|
65
|
+
# Ruleset: copy as IMPULSO-AGENTS.md (never clobbers a user AGENTS.md) and
|
|
66
|
+
# make sure CLAUDE.md imports it — created if missing, appended otherwise.
|
|
67
|
+
# Claude Code reads CLAUDE.md, not AGENTS.md.
|
|
68
|
+
cp -f "${REPO}/shared/AGENTS.md" "${PROFILE_DIR}/IMPULSO-AGENTS.md"
|
|
69
|
+
if [[ ! -f "${PROFILE_DIR}/CLAUDE.md" ]]; then
|
|
70
|
+
printf '@IMPULSO-AGENTS.md\n' > "${PROFILE_DIR}/CLAUDE.md"
|
|
71
|
+
echo " Wrote CLAUDE.md (@IMPULSO-AGENTS.md import)"
|
|
72
|
+
elif ! grep -qF '@IMPULSO-AGENTS.md' "${PROFILE_DIR}/CLAUDE.md"; then
|
|
73
|
+
printf '\n@IMPULSO-AGENTS.md\n' >> "${PROFILE_DIR}/CLAUDE.md"
|
|
74
|
+
echo " Appended @IMPULSO-AGENTS.md import to existing CLAUDE.md"
|
|
75
|
+
else
|
|
76
|
+
echo " CLAUDE.md already imports IMPULSO-AGENTS.md"
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
# settings.json: generate from the self-boot config (single source of truth,
|
|
80
|
+
# hooks pinned to this repo's absolute path) and MERGE into any existing
|
|
81
|
+
# settings — foreign hooks and keys survive; stale impulso entries replaced.
|
|
82
|
+
TMP_SETTINGS="$(mktemp)"
|
|
83
|
+
sed "s|\$CLAUDE_CONFIG_DIR/\.\.|${REPO}|g" "${REPO}/claude/settings.json" > "${TMP_SETTINGS}"
|
|
84
|
+
node "${REPO}/harnesses/claude/merge-settings.cjs" "${PROFILE_DIR}/settings.json" "${TMP_SETTINGS}"
|
|
85
|
+
rm -f "${TMP_SETTINGS}"
|
|
86
|
+
echo " Merged settings.json (hooks -> ${REPO}/harnesses/claude/hooks)"
|
|
87
|
+
|
|
88
|
+
# impulso-hub binary — copy matching bundled package binary
|
|
89
|
+
node "${REPO}/scripts/install-hub.js"
|
|
90
|
+
|
|
91
|
+
# Remove old impulso-gate binary if present (renamed to impulso-hub)
|
|
92
|
+
OLD_GATE="${HOME}/.local/bin/impulso-gate"
|
|
93
|
+
if [ -f "${OLD_GATE}" ] || [ -L "${OLD_GATE}" ]; then
|
|
94
|
+
rm -f "${OLD_GATE}" && echo " Removed old impulso-gate binary"
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
# Register the tokensave MCP server (user scope, idempotent).
|
|
98
|
+
if command -v claude >/dev/null 2>&1; then
|
|
99
|
+
if CLAUDE_CONFIG_DIR="${PROFILE_DIR}" claude mcp get tokensave >/dev/null 2>&1; then
|
|
100
|
+
echo " tokensave MCP already registered"
|
|
101
|
+
else
|
|
102
|
+
CLAUDE_CONFIG_DIR="${PROFILE_DIR}" claude mcp add --scope user tokensave -- tokensave serve
|
|
103
|
+
echo " Registered tokensave MCP (user scope)"
|
|
104
|
+
fi
|
|
105
|
+
else
|
|
106
|
+
echo " WARN: claude CLI not found — register tokensave manually:"
|
|
107
|
+
echo " CLAUDE_CONFIG_DIR=${PROFILE_DIR} claude mcp add --scope user tokensave -- tokensave serve"
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
echo "==> Done."
|
|
111
|
+
echo ""
|
|
112
|
+
if [[ "${ISOLATED}" == "1" ]]; then
|
|
113
|
+
echo " Launch with the isolated profile:"
|
|
114
|
+
echo " CLAUDE_CONFIG_DIR=${PROFILE_DIR} claude"
|
|
115
|
+
else
|
|
116
|
+
echo " Launch Claude Code normally — hooks, skills, and commands are active."
|
|
117
|
+
fi
|
|
118
|
+
echo ""
|
|
119
|
+
echo " Plannotator requires the plannotator binary:"
|
|
120
|
+
echo " curl -fsSL https://plannotator.ai/install.sh | bash"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// impulso — settings.json merge for shared-profile installs
|
|
2
|
+
//
|
|
3
|
+
// Installing into an existing profile (e.g. ~/.claude) must not clobber the
|
|
4
|
+
// user's settings.json. mergeSettings() strips any previous impulso hook
|
|
5
|
+
// entries (recognized by their harnesses/claude/hooks/ path) and appends the
|
|
6
|
+
// current ones, leaving every foreign key and foreign hook untouched.
|
|
7
|
+
// Idempotent: re-running the install replaces impulso entries in place.
|
|
8
|
+
//
|
|
9
|
+
// Also usable as a CLI (used by install.sh/uninstall.sh):
|
|
10
|
+
// node merge-settings.cjs <target-settings.json> <impulso-settings.json>
|
|
11
|
+
// node merge-settings.cjs --remove <target-settings.json>
|
|
12
|
+
// Writes the merged result back to <target-settings.json>.
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
|
|
16
|
+
const IMPULSO_MARKER = 'harnesses/claude/hooks/';
|
|
17
|
+
|
|
18
|
+
function isImpulsoHook(h) {
|
|
19
|
+
return h && typeof h.command === 'string' && h.command.includes(IMPULSO_MARKER);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Remove impulso hook entries from a settings object (in a copy).
|
|
23
|
+
// Drops matchers and events that end up empty.
|
|
24
|
+
function stripImpulsoHooks(settings) {
|
|
25
|
+
const out = JSON.parse(JSON.stringify(settings || {}));
|
|
26
|
+
if (!out.hooks) return out;
|
|
27
|
+
for (const event of Object.keys(out.hooks)) {
|
|
28
|
+
const matchers = (out.hooks[event] || [])
|
|
29
|
+
.map((m) => ({ ...m, hooks: (m.hooks || []).filter((h) => !isImpulsoHook(h)) }))
|
|
30
|
+
.filter((m) => m.hooks.length > 0);
|
|
31
|
+
if (matchers.length > 0) {
|
|
32
|
+
out.hooks[event] = matchers;
|
|
33
|
+
} else {
|
|
34
|
+
delete out.hooks[event];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (Object.keys(out.hooks).length === 0) delete out.hooks;
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mergeSettings(existing, impulso) {
|
|
42
|
+
const out = stripImpulsoHooks(existing);
|
|
43
|
+
out.hooks = out.hooks || {};
|
|
44
|
+
for (const [event, matchers] of Object.entries(impulso.hooks || {})) {
|
|
45
|
+
out.hooks[event] = [...(out.hooks[event] || []), ...JSON.parse(JSON.stringify(matchers))];
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { mergeSettings, stripImpulsoHooks };
|
|
51
|
+
|
|
52
|
+
if (require.main === module) {
|
|
53
|
+
const args = process.argv.slice(2);
|
|
54
|
+
const remove = args[0] === '--remove';
|
|
55
|
+
const targetPath = remove ? args[1] : args[0];
|
|
56
|
+
const impulsoPath = remove ? null : args[1];
|
|
57
|
+
if (!targetPath || (!remove && !impulsoPath)) {
|
|
58
|
+
console.error('usage: merge-settings.cjs <target> <impulso> | --remove <target>');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
let existing = {};
|
|
62
|
+
try {
|
|
63
|
+
existing = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
// Missing or empty target — start fresh.
|
|
66
|
+
}
|
|
67
|
+
const result = remove
|
|
68
|
+
? stripImpulsoHooks(existing)
|
|
69
|
+
: mergeSettings(existing, JSON.parse(fs.readFileSync(impulsoPath, 'utf8')));
|
|
70
|
+
fs.writeFileSync(targetPath, JSON.stringify(result, null, 2) + '\n');
|
|
71
|
+
}
|