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,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
+ }