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.
@@ -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
- // 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;
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 };
@@ -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.22.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"