impulso 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harnesses/claude/hooks/task-exit.cjs +106 -1
- package/harnesses/claude/hooks/tdd-observer.cjs +56 -11
- package/harnesses/claude/hooks/telematics.cjs +216 -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/linux-amd64/impulso-hub +0 -0
- package/harnesses/opencode/bin/linux-arm64/impulso-hub +0 -0
- package/harnesses/opencode/plugin.js +4 -0
- package/harnesses/opencode/telematics/plugin.js +175 -0
- package/package.json +5 -2
- package/scripts/install-hub.js +44 -3
- package/shared/scout-dispatch.cjs +499 -0
- package/shared/telematics.cjs +444 -0
|
@@ -36,6 +36,105 @@ const IMPLEMENTER_ROLES = new Set(['implementer', 'implementer-frontend', 'imple
|
|
|
36
36
|
|
|
37
37
|
const STATUS_BLOCK_LIMIT = 15;
|
|
38
38
|
|
|
39
|
+
// --- Test command resolution ---
|
|
40
|
+
//
|
|
41
|
+
// Resolution ladder (consistent with hub ResolveTestCommand):
|
|
42
|
+
// local .impulso/config.json > IMPULSO_HOME/projects/<sha256>/config.json >
|
|
43
|
+
// IMPULSO_HOME/.impulso/ > HOME/.impulso/ >
|
|
44
|
+
// project heuristics (package.json scripts.test, Makefile test target,
|
|
45
|
+
// go.mod, Cargo.toml, pyproject.toml)
|
|
46
|
+
//
|
|
47
|
+
// Returns ("", "") when no test command can be resolved.
|
|
48
|
+
// Never writes config/state files.
|
|
49
|
+
|
|
50
|
+
const crypto = require('node:crypto');
|
|
51
|
+
|
|
52
|
+
function userImpulsoDir() {
|
|
53
|
+
const home = process.env.IMPULSO_HOME || process.env.HOME;
|
|
54
|
+
if (!home) return null;
|
|
55
|
+
return path.join(home, '.impulso');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function projectConfigDir(projectDir) {
|
|
59
|
+
const userDir = userImpulsoDir();
|
|
60
|
+
if (!userDir) return null;
|
|
61
|
+
const abs = path.resolve(projectDir);
|
|
62
|
+
const hash = crypto.createHash('sha256').update(abs).digest('hex');
|
|
63
|
+
return path.join(userDir, 'projects', hash);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readImpulsoConfig(dir) {
|
|
67
|
+
if (!dir) return null;
|
|
68
|
+
try {
|
|
69
|
+
const raw = fs.readFileSync(path.join(dir, 'config.json'), 'utf8');
|
|
70
|
+
const cfg = JSON.parse(raw);
|
|
71
|
+
return cfg && typeof cfg === 'object' && !Array.isArray(cfg) ? cfg : null;
|
|
72
|
+
} catch (_e) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function detectTestCommand(projectDir) {
|
|
78
|
+
// 1. package.json with scripts.test
|
|
79
|
+
try {
|
|
80
|
+
const raw = fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8');
|
|
81
|
+
const pkg = JSON.parse(raw);
|
|
82
|
+
if (pkg && pkg.scripts && typeof pkg.scripts.test === 'string') {
|
|
83
|
+
return 'npm test';
|
|
84
|
+
}
|
|
85
|
+
} catch (_e) {
|
|
86
|
+
/* continue */
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 2. Makefile with test: target
|
|
90
|
+
try {
|
|
91
|
+
const raw = fs.readFileSync(path.join(projectDir, 'Makefile'), 'utf8');
|
|
92
|
+
if (/^test:/m.test(raw)) return 'make test';
|
|
93
|
+
} catch (_e) {
|
|
94
|
+
/* continue */
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 3. go.mod
|
|
98
|
+
if (fs.existsSync(path.join(projectDir, 'go.mod'))) return 'go test ./...';
|
|
99
|
+
|
|
100
|
+
// 4. Cargo.toml
|
|
101
|
+
if (fs.existsSync(path.join(projectDir, 'Cargo.toml'))) return 'cargo test';
|
|
102
|
+
|
|
103
|
+
// 5. pyproject.toml
|
|
104
|
+
if (fs.existsSync(path.join(projectDir, 'pyproject.toml'))) return 'pytest';
|
|
105
|
+
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveTestCommand(projectDir) {
|
|
110
|
+
// 1. Local config (.impulso/config.json)
|
|
111
|
+
const localCfg = readImpulsoConfig(path.join(projectDir, '.impulso'));
|
|
112
|
+
if (localCfg && typeof localCfg.test_cmd === 'string' && localCfg.test_cmd) {
|
|
113
|
+
return localCfg.test_cmd;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 2. Project-specific external config (~/.impulso/projects/<hash>/config.json)
|
|
117
|
+
const projCfgDir = projectConfigDir(projectDir);
|
|
118
|
+
if (projCfgDir) {
|
|
119
|
+
const projCfg = readImpulsoConfig(projCfgDir);
|
|
120
|
+
if (projCfg && typeof projCfg.test_cmd === 'string' && projCfg.test_cmd) {
|
|
121
|
+
return projCfg.test_cmd;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 3. User config (IMPULSO_HOME or HOME .impulso/config.json)
|
|
126
|
+
const userDir = userImpulsoDir();
|
|
127
|
+
if (userDir) {
|
|
128
|
+
const userCfg = readImpulsoConfig(userDir);
|
|
129
|
+
if (userCfg && typeof userCfg.test_cmd === 'string' && userCfg.test_cmd) {
|
|
130
|
+
return userCfg.test_cmd;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 4. Project heuristics
|
|
135
|
+
return detectTestCommand(projectDir);
|
|
136
|
+
}
|
|
137
|
+
|
|
39
138
|
function reportLineCount(reportText) {
|
|
40
139
|
if (!reportText || typeof reportText !== 'string') return 0;
|
|
41
140
|
const lines = reportText.split('\n');
|
|
@@ -116,6 +215,12 @@ function main() {
|
|
|
116
215
|
return;
|
|
117
216
|
}
|
|
118
217
|
|
|
218
|
+
// Resolve test command — skip TDD gate if none is resolvable
|
|
219
|
+
if (!resolveTestCommand(cwd)) {
|
|
220
|
+
process.exit(0);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
119
224
|
// Read state non-mutatively
|
|
120
225
|
let slug;
|
|
121
226
|
try {
|
|
@@ -186,4 +291,4 @@ function main() {
|
|
|
186
291
|
|
|
187
292
|
if (require.main === module) main();
|
|
188
293
|
|
|
189
|
-
module.exports = { reportLineCount, violatesLimit };
|
|
294
|
+
module.exports = { reportLineCount, violatesLimit, resolveTestCommand, detectTestCommand };
|
|
@@ -19,10 +19,64 @@
|
|
|
19
19
|
|
|
20
20
|
const fs = require('node:fs');
|
|
21
21
|
const path = require('node:path');
|
|
22
|
+
const crypto = require('node:crypto');
|
|
22
23
|
const { spawnSync } = require('node:child_process');
|
|
23
24
|
|
|
24
25
|
const GATE_REL = path.resolve(__dirname, '..', '..', '..', 'scripts', 'impulso-hub.cjs');
|
|
25
26
|
|
|
27
|
+
function userImpulsoDir() {
|
|
28
|
+
const home = process.env.IMPULSO_HOME || process.env.HOME;
|
|
29
|
+
if (!home) return null;
|
|
30
|
+
return path.join(home, '.impulso');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function projectConfigDir(projectDir) {
|
|
34
|
+
const userDir = userImpulsoDir();
|
|
35
|
+
if (!userDir) return null;
|
|
36
|
+
const abs = path.resolve(projectDir);
|
|
37
|
+
const hash = crypto.createHash('sha256').update(abs).digest('hex');
|
|
38
|
+
return path.join(userDir, 'projects', hash);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readImpulsoConfig(dir) {
|
|
42
|
+
if (!dir) return null;
|
|
43
|
+
try {
|
|
44
|
+
const raw = fs.readFileSync(path.join(dir, 'config.json'), 'utf8');
|
|
45
|
+
const cfg = JSON.parse(raw);
|
|
46
|
+
return cfg && typeof cfg === 'object' && !Array.isArray(cfg) ? cfg : null;
|
|
47
|
+
} catch (_e) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveTestCommand(projectDir) {
|
|
53
|
+
// 1. Local config (.impulso/config.json)
|
|
54
|
+
const localCfg = readImpulsoConfig(path.join(projectDir, '.impulso'));
|
|
55
|
+
if (localCfg && typeof localCfg.test_cmd === 'string' && localCfg.test_cmd) {
|
|
56
|
+
return localCfg.test_cmd;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. Project-specific external config
|
|
60
|
+
const projCfgDir = projectConfigDir(projectDir);
|
|
61
|
+
if (projCfgDir) {
|
|
62
|
+
const projCfg = readImpulsoConfig(projCfgDir);
|
|
63
|
+
if (projCfg && typeof projCfg.test_cmd === 'string' && projCfg.test_cmd) {
|
|
64
|
+
return projCfg.test_cmd;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 3. User config
|
|
69
|
+
const userDir = userImpulsoDir();
|
|
70
|
+
if (userDir) {
|
|
71
|
+
const userCfg = readImpulsoConfig(userDir);
|
|
72
|
+
if (userCfg && typeof userCfg.test_cmd === 'string' && userCfg.test_cmd) {
|
|
73
|
+
return userCfg.test_cmd;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
26
80
|
function main() {
|
|
27
81
|
let done = false;
|
|
28
82
|
const chunks = [];
|
|
@@ -50,17 +104,8 @@ function main() {
|
|
|
50
104
|
return;
|
|
51
105
|
}
|
|
52
106
|
|
|
53
|
-
//
|
|
54
|
-
const
|
|
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;
|
|
107
|
+
// Resolve test_cmd from config ladder
|
|
108
|
+
const testCmd = resolveTestCommand(cwd);
|
|
64
109
|
if (!testCmd || !command.includes(testCmd)) {
|
|
65
110
|
process.exit(0);
|
|
66
111
|
return;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// impulso passive telematics adapter — Claude Code
|
|
2
|
+
//
|
|
3
|
+
// Reads Claude hook JSON from stdin, maps it to telematics events, and emits
|
|
4
|
+
// to the local NDJSON log. Never writes stdout, never blocks, never throws.
|
|
5
|
+
// Telemetry is disabled by default — requires { telematics: { enabled: true } }
|
|
6
|
+
// in <IMPULSO_HOME>/config.json (IMPULSO_HOME or ~/.impulso).
|
|
7
|
+
// See shared/telematics.cjs for config format.
|
|
8
|
+
//
|
|
9
|
+
// Hook event type passed via IMPULSO_HOOK_EVENT env var. See
|
|
10
|
+
// claude/settings.json and hooks/hooks.json for dual-registration entries.
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const crypto = require('node:crypto');
|
|
18
|
+
const { createTelematics } = require(path.join(__dirname, '../../../shared/telematics.cjs'));
|
|
19
|
+
|
|
20
|
+
const hookEvent = process.env.IMPULSO_HOOK_EVENT || 'unknown';
|
|
21
|
+
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let done = false;
|
|
24
|
+
process.stdin.setEncoding('utf8');
|
|
25
|
+
process.stdin.on('readable', function () {
|
|
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
|
+
let input;
|
|
36
|
+
try {
|
|
37
|
+
input = JSON.parse(raw || '{}');
|
|
38
|
+
} catch (_) {
|
|
39
|
+
process.exit(0);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Resolve impulso home: IMPULSO_HOME env, else ~/.impulso
|
|
44
|
+
let impulsoHome = process.env.IMPULSO_HOME;
|
|
45
|
+
if (!impulsoHome || impulsoHome.length === 0) {
|
|
46
|
+
impulsoHome = path.join(os.homedir(), '.impulso');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let t;
|
|
50
|
+
try {
|
|
51
|
+
t = createTelematics({
|
|
52
|
+
harness: 'claude',
|
|
53
|
+
sessionId: input.session_id || null,
|
|
54
|
+
processId: process.pid,
|
|
55
|
+
cwd: input.cwd || process.cwd(),
|
|
56
|
+
home: impulsoHome,
|
|
57
|
+
now: function () {
|
|
58
|
+
return new Date().toISOString();
|
|
59
|
+
},
|
|
60
|
+
uuid: function () {
|
|
61
|
+
return crypto.randomUUID();
|
|
62
|
+
},
|
|
63
|
+
fs: fs,
|
|
64
|
+
});
|
|
65
|
+
} catch (_) {
|
|
66
|
+
process.exit(0);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!t.enabled) {
|
|
71
|
+
process.exit(0);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Emit session.started once per native session across separate hook
|
|
76
|
+
// processes. Uses a lock file (<IMPULSO_HOME>/telematics-locks/<sid>.started)
|
|
77
|
+
// with atomic O_CREAT|O_EXCL to coordinate. If another process already
|
|
78
|
+
// locked this session, skip. If lock creation fails for any reason, skip
|
|
79
|
+
// (best-effort, passive — session.started is supplementary telemetry).
|
|
80
|
+
let lockFd = null;
|
|
81
|
+
try {
|
|
82
|
+
const locksDir = path.join(impulsoHome, 'telematics-locks');
|
|
83
|
+
const lockFile = path.join(locksDir, t.sessionId + '.started');
|
|
84
|
+
fs.mkdirSync(locksDir, { recursive: true, mode: 0o700 });
|
|
85
|
+
try {
|
|
86
|
+
fs.chmodSync(locksDir, 0o700);
|
|
87
|
+
} catch (_) {
|
|
88
|
+
/* best effort */
|
|
89
|
+
}
|
|
90
|
+
// wx = write + exclusive — fails with EEXIST if lock already held
|
|
91
|
+
lockFd = fs.openSync(lockFile, 'wx', 0o600);
|
|
92
|
+
fs.writeSync(lockFd, String(process.pid));
|
|
93
|
+
// Lock acquired — emit session.started
|
|
94
|
+
try {
|
|
95
|
+
t.emit('session.started', {
|
|
96
|
+
nativeSessionId: input.session_id || null,
|
|
97
|
+
sessionIdSource: input.session_id ? 'native' : 'generated',
|
|
98
|
+
});
|
|
99
|
+
} catch (_) {
|
|
100
|
+
/* silent */
|
|
101
|
+
}
|
|
102
|
+
} catch (_) {
|
|
103
|
+
// EEXIST or any fs failure → lock not acquired, skip session.started
|
|
104
|
+
} finally {
|
|
105
|
+
if (lockFd !== null) {
|
|
106
|
+
try {
|
|
107
|
+
fs.closeSync(lockFd);
|
|
108
|
+
} catch (_) {
|
|
109
|
+
/* silent */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const event = mapEvent(hookEvent, input, t);
|
|
116
|
+
if (event) {
|
|
117
|
+
t.emit(event.eventType, event.data);
|
|
118
|
+
}
|
|
119
|
+
} catch (_) {
|
|
120
|
+
/* silent */
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
t.close();
|
|
125
|
+
} catch (_) {
|
|
126
|
+
/* silent */
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
process.exit(0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
process.stdin.on('end', function () {
|
|
133
|
+
finish(chunks.join(''));
|
|
134
|
+
});
|
|
135
|
+
const timer = setTimeout(function () {
|
|
136
|
+
finish(chunks.join(''));
|
|
137
|
+
}, 1000);
|
|
138
|
+
|
|
139
|
+
// ----------------------------------------------------------------
|
|
140
|
+
// event mapping — direct host-payload translation per spec
|
|
141
|
+
// ----------------------------------------------------------------
|
|
142
|
+
function mapEvent(hook, input) {
|
|
143
|
+
switch (hook) {
|
|
144
|
+
case 'UserPromptSubmit':
|
|
145
|
+
return {
|
|
146
|
+
eventType: 'message.submitted',
|
|
147
|
+
data: {
|
|
148
|
+
message: typeof input.prompt === 'string' ? input.prompt : '',
|
|
149
|
+
messageId: input.session_id || undefined,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
case 'PreToolUse':
|
|
153
|
+
return {
|
|
154
|
+
eventType: 'tool.requested',
|
|
155
|
+
data: {
|
|
156
|
+
toolName: input.tool_name || '',
|
|
157
|
+
toolUseId: input.tool_use_id || undefined,
|
|
158
|
+
input: input.tool_input || {},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
case 'PostToolUse':
|
|
162
|
+
return {
|
|
163
|
+
eventType: 'tool.completed',
|
|
164
|
+
data: {
|
|
165
|
+
toolName: input.tool_name || '',
|
|
166
|
+
toolUseId: input.tool_use_id || undefined,
|
|
167
|
+
output: input.tool_response || {},
|
|
168
|
+
status: getToolStatus(input.tool_response),
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
case 'SubagentStart':
|
|
172
|
+
return {
|
|
173
|
+
eventType: 'lifecycle',
|
|
174
|
+
data: {
|
|
175
|
+
name: 'subagent',
|
|
176
|
+
phase: 'started',
|
|
177
|
+
details: { agent_type: input.agent_type || null },
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
case 'SubagentStop':
|
|
181
|
+
// Best-effort session.ended from SubagentStop
|
|
182
|
+
return {
|
|
183
|
+
eventType: 'session.ended',
|
|
184
|
+
data: {
|
|
185
|
+
reason: 'subagent_stopped',
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
case 'Notification':
|
|
189
|
+
// Error/notification events from host
|
|
190
|
+
return {
|
|
191
|
+
eventType: 'error',
|
|
192
|
+
data: {
|
|
193
|
+
operation: input.operation || undefined,
|
|
194
|
+
name: input.name || undefined,
|
|
195
|
+
message: input.message || undefined,
|
|
196
|
+
stack: input.stack || undefined,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
default:
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getToolStatus(response) {
|
|
205
|
+
if (!response) return 'unknown';
|
|
206
|
+
if (typeof response.exitCode === 'number') {
|
|
207
|
+
return response.exitCode === 0 ? 'success' : 'error';
|
|
208
|
+
}
|
|
209
|
+
if (typeof response.exit_code === 'number') {
|
|
210
|
+
return response.exit_code === 0 ? 'success' : 'error';
|
|
211
|
+
}
|
|
212
|
+
return 'completed';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// export for testing
|
|
216
|
+
module.exports = { mapEvent, getToolStatus };
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -17,6 +17,7 @@ import { registerCommands } from './commands-loader.js';
|
|
|
17
17
|
import { ImpulsoDirectSpeechPlugin } from './directspeech/plugin.js';
|
|
18
18
|
import { ImpulsoOrchestrationPlugin } from './orchestration/index.js';
|
|
19
19
|
import { ImpulsoSessionNamePlugin } from './session-name/plugin.js';
|
|
20
|
+
import { ImpulsoTelematicsPlugin } from './telematics/plugin.js';
|
|
20
21
|
import { installHub } from '../../scripts/install-hub.js';
|
|
21
22
|
|
|
22
23
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -56,6 +57,9 @@ export const ImpulsoPlugin = async (ctx) => {
|
|
|
56
57
|
// Session-name plugin (X-Session-Name header for proxy cache locality)
|
|
57
58
|
sub.push(await ImpulsoSessionNamePlugin(ctx));
|
|
58
59
|
|
|
60
|
+
// Telematics plugin — passive observer, must be last to observe final permission state
|
|
61
|
+
sub.push(await ImpulsoTelematicsPlugin(ctx));
|
|
62
|
+
|
|
59
63
|
const hooks = composeHooks(sub);
|
|
60
64
|
|
|
61
65
|
// Wrap the composed config hook with our own skills/commands registration.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// impulso — opencode passive telematics observer plugin
|
|
2
|
+
//
|
|
3
|
+
// Observes runtime hooks and emits NDJSON telemetry events via the shared
|
|
4
|
+
// writer. Passive only: never calls a model, never injects chat/system content,
|
|
5
|
+
// never modifies request/response or hook output, never invokes impulso-hub,
|
|
6
|
+
// never spawns tools. Zero model-token impact.
|
|
7
|
+
//
|
|
8
|
+
// Hooks:
|
|
9
|
+
// chat.message → message.submitted / message.received (by role)
|
|
10
|
+
// tool.execute.before → tool.requested
|
|
11
|
+
// tool.execute.after → tool.completed (with paired durationMs)
|
|
12
|
+
// permission.ask → permission.requested + permission.resolved
|
|
13
|
+
// event → lifecycle
|
|
14
|
+
//
|
|
15
|
+
// Initialize once for plugin lifetime; config read exactly once.
|
|
16
|
+
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
|
|
20
|
+
const require_ = createRequire(import.meta.url);
|
|
21
|
+
|
|
22
|
+
function defaultCreateTelematics(ctx) {
|
|
23
|
+
const { createTelematics } = require_('../../../shared/telematics.cjs');
|
|
24
|
+
const fs = require_('node:fs');
|
|
25
|
+
const crypto = require_('node:crypto');
|
|
26
|
+
|
|
27
|
+
const home = process.env.IMPULSO_HOME || require_('node:path').join(os.homedir(), '.impulso');
|
|
28
|
+
|
|
29
|
+
// Record the raw sessionId before sanitization for sessionIdSource
|
|
30
|
+
const rawSessionId = ctx.sessionID || null;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
telematics: createTelematics({
|
|
34
|
+
harness: 'opencode',
|
|
35
|
+
sessionId: ctx.sessionID || null,
|
|
36
|
+
processId: process.pid,
|
|
37
|
+
cwd: ctx.directory || process.cwd(),
|
|
38
|
+
home,
|
|
39
|
+
now: () => new Date().toISOString(),
|
|
40
|
+
uuid: () => crypto.randomUUID(),
|
|
41
|
+
fs,
|
|
42
|
+
}),
|
|
43
|
+
rawSessionId,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function ImpulsoTelematicsPlugin(ctx, _createTelematics) {
|
|
48
|
+
const createTel = _createTelematics || defaultCreateTelematics;
|
|
49
|
+
|
|
50
|
+
// One-time construct: create the telematics instance
|
|
51
|
+
const { telematics: t, rawSessionId } = createTel(ctx);
|
|
52
|
+
|
|
53
|
+
// Duration trackers — keyed by callID for tools, session-permission pair for perms
|
|
54
|
+
const toolStarts = new Map(); // callID → timestamp ms
|
|
55
|
+
const permStarts = new Map(); // `${sessionID}::${permission}` → timestamp ms
|
|
56
|
+
|
|
57
|
+
// Emit session.started on init
|
|
58
|
+
if (t.enabled) {
|
|
59
|
+
t.emit('session.started', {
|
|
60
|
+
nativeSessionId: rawSessionId || null,
|
|
61
|
+
sessionIdSource: rawSessionId ? 'native' : 'generated',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const hooks = {};
|
|
66
|
+
|
|
67
|
+
// ---- event ---------------------------------------------------
|
|
68
|
+
hooks.event = async (input) => {
|
|
69
|
+
if (!t.enabled) return;
|
|
70
|
+
t.emit('lifecycle', {
|
|
71
|
+
name: (input && input.type) || 'unknown',
|
|
72
|
+
phase: input && input.phase,
|
|
73
|
+
details: input,
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ---- chat.message --------------------------------------------
|
|
78
|
+
hooks['chat.message'] = async (input, _output) => {
|
|
79
|
+
if (!t.enabled) return;
|
|
80
|
+
if (!input) return;
|
|
81
|
+
|
|
82
|
+
// Determine direction from input.role
|
|
83
|
+
// user → submitted, assistant/system → received
|
|
84
|
+
const role = input.role || 'unknown';
|
|
85
|
+
if (role === 'user') {
|
|
86
|
+
t.emit('message.submitted', {
|
|
87
|
+
message: input,
|
|
88
|
+
messageId: input.id,
|
|
89
|
+
});
|
|
90
|
+
} else {
|
|
91
|
+
t.emit('message.received', {
|
|
92
|
+
message: input,
|
|
93
|
+
messageId: input.id,
|
|
94
|
+
role: input.role,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// ---- tool.execute.before -------------------------------------
|
|
100
|
+
hooks['tool.execute.before'] = async (input, _output) => {
|
|
101
|
+
if (!t.enabled) return;
|
|
102
|
+
if (!input) return;
|
|
103
|
+
|
|
104
|
+
// Record start time for duration pairing
|
|
105
|
+
if (input.callID) {
|
|
106
|
+
toolStarts.set(input.callID, Date.now());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
t.emit('tool.requested', {
|
|
110
|
+
toolName: input.tool,
|
|
111
|
+
toolUseId: input.callID,
|
|
112
|
+
input: input.args,
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ---- tool.execute.after --------------------------------------
|
|
117
|
+
hooks['tool.execute.after'] = async (input, output) => {
|
|
118
|
+
if (!t.enabled) return;
|
|
119
|
+
if (!input) return;
|
|
120
|
+
|
|
121
|
+
const start = input.callID ? toolStarts.get(input.callID) : undefined;
|
|
122
|
+
if (input.callID) {
|
|
123
|
+
toolStarts.delete(input.callID);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const durationMs = start != null ? Date.now() - start : undefined;
|
|
127
|
+
|
|
128
|
+
let status;
|
|
129
|
+
if (output && output.metadata && output.metadata.exitCode !== undefined) {
|
|
130
|
+
status = output.metadata.exitCode === 0 ? 'success' : 'error';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
t.emit('tool.completed', {
|
|
134
|
+
toolName: input.tool,
|
|
135
|
+
toolUseId: input.callID,
|
|
136
|
+
output,
|
|
137
|
+
durationMs,
|
|
138
|
+
status,
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// ---- permission.ask ------------------------------------------
|
|
143
|
+
hooks['permission.ask'] = async (input, output) => {
|
|
144
|
+
if (!t.enabled) return;
|
|
145
|
+
if (!input) return;
|
|
146
|
+
|
|
147
|
+
const start = Date.now();
|
|
148
|
+
|
|
149
|
+
// Record start so we can compute duration
|
|
150
|
+
const permKey = `${input.sessionID || ''}::${input.permission || ''}`;
|
|
151
|
+
permStarts.set(permKey, start);
|
|
152
|
+
|
|
153
|
+
t.emit('permission.requested', {
|
|
154
|
+
permission: input.permission,
|
|
155
|
+
request: input,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Observe output.decision synchronously after our handler runs
|
|
159
|
+
// (telematics plugin is composed last, so output is in final state)
|
|
160
|
+
const decision = output && output.decision;
|
|
161
|
+
|
|
162
|
+
const durationMs = Date.now() - start;
|
|
163
|
+
permStarts.delete(permKey);
|
|
164
|
+
|
|
165
|
+
t.emit('permission.resolved', {
|
|
166
|
+
permission: input.permission,
|
|
167
|
+
decision,
|
|
168
|
+
durationMs,
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return hooks;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export default ImpulsoTelematicsPlugin;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "impulso",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Impulso — plugin bundle for opencode and Claude Code: Plannotator skills + Impulso-DirectSpeech (rethemed ex-Caveman). Harness-neutral repo; per-harness glue under harnesses/.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "harnesses/opencode/plugin.js",
|
|
@@ -63,7 +63,10 @@
|
|
|
63
63
|
"shared/tokensave-freshness.cjs",
|
|
64
64
|
"skills",
|
|
65
65
|
"package.json",
|
|
66
|
-
"README.md"
|
|
66
|
+
"README.md",
|
|
67
|
+
"harnesses/opencode/telematics/",
|
|
68
|
+
"shared/telematics.cjs",
|
|
69
|
+
"shared/scout-dispatch.cjs"
|
|
67
70
|
],
|
|
68
71
|
"dependencies": {
|
|
69
72
|
"@opencode-ai/plugin": "^1.17.14"
|