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.
@@ -72,13 +72,54 @@ export async function installHub({
72
72
  return { target, updated: true, destination, version: packageVersion };
73
73
  }
74
74
 
75
+ /**
76
+ * Installs the scout dispatch bridge (shared/scout-dispatch.cjs) as an
77
+ * executable on PATH at ~/.local/bin/impulso-scout-dispatch. Uses the same
78
+ * atomic-temp-rename pattern as installHub. Idempotent: skips if the
79
+ * existing file is identical (checked by size + mtime heuristic).
80
+ */
81
+ export async function installScoutDispatch({
82
+ packageRoot = PACKAGE_ROOT,
83
+ destination = path.join(os.homedir(), '.local', 'bin', 'impulso-scout-dispatch'),
84
+ } = {}) {
85
+ const source = path.join(packageRoot, 'shared', 'scout-dispatch.cjs');
86
+ await stat(source);
87
+
88
+ // Simple idempotency check: if destination exists and has same size as source, skip
89
+ try {
90
+ const srcStat = await stat(source);
91
+ const dstStat = await stat(destination);
92
+ if (dstStat.size === srcStat.size) {
93
+ return { updated: false, destination };
94
+ }
95
+ } catch (_e) {
96
+ // destination absent — fall through to copy
97
+ }
98
+
99
+ await mkdir(path.dirname(destination), { recursive: true });
100
+ const tempDir = await mkdtemp(path.join(path.dirname(destination), '.impulso-scout-'));
101
+ const temporary = path.join(tempDir, 'impulso-scout-dispatch');
102
+ try {
103
+ await copyFile(source, temporary);
104
+ await chmod(temporary, 0o755);
105
+ await rename(temporary, destination);
106
+ } finally {
107
+ await rm(tempDir, { recursive: true, force: true });
108
+ }
109
+ return { updated: true, destination };
110
+ }
111
+
75
112
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
76
113
  try {
77
- const result = await installHub();
78
- const action = result.updated ? 'Installed' : 'Already installed';
114
+ const hubResult = await installHub();
115
+ const hubAction = hubResult.updated ? 'Installed' : 'Already installed';
79
116
  console.log(
80
- ` ${action} impulso-hub ${result.version} (${result.target}) -> ${result.destination}`,
117
+ ` ${hubAction} impulso-hub ${hubResult.version} (${hubResult.target}) -> ${hubResult.destination}`,
81
118
  );
119
+
120
+ const scoutResult = await installScoutDispatch();
121
+ const scoutAction = scoutResult.updated ? 'Installed' : 'Already installed';
122
+ console.log(` ${scoutAction} impulso-scout-dispatch -> ${scoutResult.destination}`);
82
123
  } catch (error) {
83
124
  console.error(`ERROR: ${error.message}`);
84
125
  process.exitCode = 1;
@@ -0,0 +1,499 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * impulso-scout-dispatch — harness-owned synchronous read-only scout bridge.
4
+ *
5
+ * Launched by impulso-hub init when static test detection fails. Uses the
6
+ * installed harness (opencode or claude) to dispatch the scout subagent
7
+ * synchronously, producing a validated ScoutReport.
8
+ *
9
+ * Usage:
10
+ * impulso-scout-dispatch --slug <slug> --workspace <abs-path> --timeout-ms <n>
11
+ * stdin: {"schemaVersion":1,"slug":"...","workspace":"..."}
12
+ * stdout: ScoutReport JSON
13
+ * exit 0 on success, nonzero on failure
14
+ *
15
+ * Report path: <workspace>/reports/scout.json
16
+ */
17
+
18
+ const { spawnSync } = require('node:child_process');
19
+ const { existsSync, mkdirSync, readFileSync, writeFileSync } = require('node:fs');
20
+ const { resolve, join, dirname } = require('node:path');
21
+
22
+ // --- CLI argument parsing ---
23
+
24
+ function usage() {
25
+ process.stderr.write(
26
+ 'Usage: impulso-scout-dispatch --slug <slug> --workspace <abs-path> [--timeout-ms <n>]\n',
27
+ );
28
+ process.exit(2);
29
+ }
30
+
31
+ let slug = '';
32
+ let workspace = '';
33
+ let timeoutMs = 180_000; // default 3 min
34
+
35
+ // Manual arg parsing (node:util parseArgs needs node 18.3+)
36
+ const args = process.argv.slice(2);
37
+ for (let i = 0; i < args.length; i++) {
38
+ switch (args[i]) {
39
+ case '--slug':
40
+ slug = args[++i] || '';
41
+ break;
42
+ case '--workspace':
43
+ workspace = args[++i] || '';
44
+ break;
45
+ case '--timeout-ms':
46
+ timeoutMs = parseInt(args[++i] || '180000', 10);
47
+ break;
48
+ case '-h':
49
+ case '--help':
50
+ usage();
51
+ break;
52
+ default:
53
+ process.stderr.write(`Unknown flag: ${args[i]}\n`);
54
+ usage();
55
+ }
56
+ }
57
+
58
+ if (!slug || !workspace) {
59
+ usage();
60
+ }
61
+
62
+ const absWorkspace = resolve(workspace);
63
+ const reportPath = join(absWorkspace, 'reports', 'scout.json');
64
+
65
+ // --- Stdin payload (optional validation) ---
66
+
67
+ let stdinPayload = null;
68
+ try {
69
+ // Read synchronously from stdin if data is available
70
+ // For piped input, readFileSync('/dev/stdin') works on Linux
71
+ const stdinData = readFileSync('/dev/stdin', 'utf8').trim();
72
+ if (stdinData) {
73
+ stdinPayload = JSON.parse(stdinData);
74
+ }
75
+ } catch {
76
+ // stdin may not be available — that's OK
77
+ }
78
+ void stdinPayload; // may be used in future for payload validation
79
+
80
+ // --- Detect harness ---
81
+
82
+ function detectHarness() {
83
+ // Prefer opencode (primary harness), fall back to claude
84
+ const opencodeCheck = spawnSync('which', ['opencode'], {
85
+ stdio: 'pipe',
86
+ timeout: 5000,
87
+ });
88
+ if (opencodeCheck.status === 0 && opencodeCheck.stdout.toString().trim()) {
89
+ return 'opencode';
90
+ }
91
+ const claudeCheck = spawnSync('which', ['claude'], {
92
+ stdio: 'pipe',
93
+ timeout: 5000,
94
+ });
95
+ if (claudeCheck.status === 0 && claudeCheck.stdout.toString().trim()) {
96
+ return 'claude';
97
+ }
98
+ return null;
99
+ }
100
+
101
+ // --- Dispatch via opencode ---
102
+
103
+ function dispatchOpenCode() {
104
+ // Ensure reports dir exists
105
+ mkdirSync(dirname(reportPath), { recursive: true });
106
+
107
+ // Write the initial context file that scout will read
108
+ const contextPath = join(absWorkspace, 'context.md');
109
+ const contextContent = [
110
+ `# Scout reconnaissance context`,
111
+ `slug: ${slug}`,
112
+ `workspace: ${absWorkspace}`,
113
+ `task: Determine the test framework used in this project. Look for test files, test configuration (package.json scripts.test, Makefile test target, go.mod, Cargo.toml, pyproject.toml, etc.). Report each finding with path:line refs.`,
114
+ ``,
115
+ ].join('\n');
116
+ writeFileSync(contextPath, contextContent, 'utf8');
117
+
118
+ // Build scout prompt
119
+ const prompt = [
120
+ 'You are the scout subagent (read-only).',
121
+ 'Explore the project at the current directory.',
122
+ 'Inspect manifests (package.json, Makefile, go.mod, Cargo.toml, pyproject.toml),',
123
+ 'package scripts, .github/workflows, test configs, targeted source/test directories,',
124
+ 'and monorepo boundaries.',
125
+ 'Determine what test command to use (or conclude no tests are present).',
126
+ 'Write a JSON scout report to ' + reportPath + ' with this exact v2 schema:',
127
+ '{ "schemaVersion": 2, "slug": "' +
128
+ slug +
129
+ '", "reportPath": "' +
130
+ reportPath +
131
+ '", "command": "the test command", "noTest": false, "noTestJustification": "", "workingDirectory": "relative/path", "confidence": "high|medium|low", "evidence": [ { "ref": "path/to/file:lineNumber", "finding": "description" } ], "concerns": [], "findings": [ { "ref": "path/to/file:lineNumber", "finding": "description" } ] }',
132
+ '- "command": exact test command string (e.g. "npm test", "go test ./..."). Empty ONLY if noTest is true.',
133
+ '- "noTest": true ONLY when no test framework can be detected. When true, "command" must be empty.',
134
+ '- "noTestJustification": required when noTest is true. Explain why no tests were found.',
135
+ '- "workingDirectory": relative path from project root where the command should run (e.g. "packages/web/", "."). Must be relative, no ".." traversal.',
136
+ '- "confidence": high, medium, or low.',
137
+ '- "evidence": array of path:line refs pointing to manifests/configs that support your conclusion.',
138
+ '- "concerns": array of strings noting any caveats or limitations.',
139
+ '- "findings": general path:line observations about the project structure.',
140
+ 'The "ref" field MUST use path:line format (e.g. main.go:42).',
141
+ 'Do NOT modify any source files. Only read and write the report.',
142
+ ].join('\n');
143
+
144
+ const result = spawnSync(
145
+ 'opencode',
146
+ ['run', '--agent', 'scout', '--format', 'json', '--dir', dirname(absWorkspace), prompt],
147
+ {
148
+ stdio: ['pipe', 'pipe', 'pipe'],
149
+ timeout: timeoutMs,
150
+ env: { ...process.env },
151
+ maxBuffer: 10 * 1024 * 1024, // 10MB
152
+ },
153
+ );
154
+
155
+ return {
156
+ exitCode: result.status ?? 1,
157
+ stderr: result.stderr?.toString() || '',
158
+ error: result.error,
159
+ };
160
+ }
161
+
162
+ // --- Dispatch via claude ---
163
+
164
+ function dispatchClaude() {
165
+ mkdirSync(dirname(reportPath), { recursive: true });
166
+
167
+ const contextPath = join(absWorkspace, 'context.md');
168
+ const contextContent = [
169
+ `# Scout reconnaissance context`,
170
+ `slug: ${slug}`,
171
+ `workspace: ${absWorkspace}`,
172
+ `task: Determine the test framework used in this project. Look for test files, test configuration. Report each finding with path:line refs.`,
173
+ ``,
174
+ ].join('\n');
175
+ writeFileSync(contextPath, contextContent, 'utf8');
176
+
177
+ const prompt = [
178
+ 'You are the scout subagent (read-only).',
179
+ 'Explore the project at the current directory.',
180
+ 'Inspect manifests (package.json, Makefile, go.mod, Cargo.toml, pyproject.toml),',
181
+ 'package scripts, .github/workflows, test configs, targeted source/test directories,',
182
+ 'and monorepo boundaries.',
183
+ 'Determine what test command to use (or conclude no tests are present).',
184
+ 'Write a JSON scout report to ' + reportPath + ' with this exact v2 schema:',
185
+ '{ "schemaVersion": 2, "slug": "' +
186
+ slug +
187
+ '", "reportPath": "' +
188
+ reportPath +
189
+ '", "command": "the test command", "noTest": false, "noTestJustification": "", "workingDirectory": "relative/path", "confidence": "high|medium|low", "evidence": [ { "ref": "path/to/file:lineNumber", "finding": "description" } ], "concerns": [], "findings": [ { "ref": "path/to/file:lineNumber", "finding": "description" } ] }',
190
+ '- "command": exact test command string (e.g. "npm test", "go test ./..."). Empty ONLY if noTest is true.',
191
+ '- "noTest": true ONLY when no test framework can be detected. When true, "command" must be empty.',
192
+ '- "noTestJustification": required when noTest is true.',
193
+ '- "workingDirectory": relative path from project root where command should run.',
194
+ '- "confidence": high, medium, or low.',
195
+ '- "evidence": array of path:line refs to manifests/configs.',
196
+ '- "concerns": array of caveats or limitations.',
197
+ '- "findings": general path:line observations.',
198
+ 'The "ref" field MUST use path:line format (e.g. main.go:42).',
199
+ 'Do NOT modify any source files. Only read and write the report.',
200
+ ].join('\n');
201
+
202
+ const result = spawnSync(
203
+ 'claude',
204
+ ['--agent', 'scout', '-p', '--output-format', 'json', prompt],
205
+ {
206
+ cwd: dirname(absWorkspace),
207
+ stdio: ['pipe', 'pipe', 'pipe'],
208
+ timeout: timeoutMs,
209
+ env: { ...process.env },
210
+ maxBuffer: 10 * 1024 * 1024,
211
+ },
212
+ );
213
+
214
+ return {
215
+ exitCode: result.status ?? 1,
216
+ stderr: result.stderr?.toString() || '',
217
+ error: result.error,
218
+ };
219
+ }
220
+
221
+ // --- Output helpers ---
222
+
223
+ function fail(message) {
224
+ process.stderr.write(`impulso-scout-dispatch: ${message}\n`);
225
+ process.exit(1);
226
+ }
227
+
228
+ function outputReport(report) {
229
+ process.stdout.write(JSON.stringify(report));
230
+ process.exit(0);
231
+ }
232
+
233
+ // --- Validate scout report ---
234
+
235
+ function validateReport(raw) {
236
+ try {
237
+ const report = JSON.parse(raw);
238
+
239
+ if (!report || typeof report !== 'object') {
240
+ return { valid: false, error: 'report is not a JSON object' };
241
+ }
242
+
243
+ if (report.schemaVersion !== 2) {
244
+ return {
245
+ valid: false,
246
+ error: `invalid schemaVersion: ${report.schemaVersion} (must be 2)`,
247
+ };
248
+ }
249
+
250
+ if (!report.slug || report.slug !== slug) {
251
+ return {
252
+ valid: false,
253
+ error: `slug mismatch: ${report.slug || '(empty)'} vs ${slug}`,
254
+ };
255
+ }
256
+
257
+ if (!report.reportPath) {
258
+ return { valid: false, error: 'missing reportPath' };
259
+ }
260
+
261
+ // Validate that reportPath matches the expected workspace path
262
+ const resolvedReturned = resolve(report.reportPath);
263
+ const resolvedExpected = resolve(reportPath);
264
+ if (resolvedReturned !== resolvedExpected) {
265
+ return {
266
+ valid: false,
267
+ error: `reportPath mismatch: returned ${report.reportPath}, expected ${reportPath}`,
268
+ };
269
+ }
270
+
271
+ // v2: command/noTest exclusivity
272
+ if (report.noTest === true) {
273
+ if (report.command && report.command !== '') {
274
+ return { valid: false, error: 'noTest=true and command are mutually exclusive' };
275
+ }
276
+ if (!report.noTestJustification || report.noTestJustification.trim() === '') {
277
+ return { valid: false, error: 'noTestJustification is required when noTest=true' };
278
+ }
279
+ } else {
280
+ if (!report.command || report.command.trim() === '') {
281
+ return { valid: false, error: 'command is required when noTest is false or absent' };
282
+ }
283
+ }
284
+
285
+ // v2: workingDirectory
286
+ if (!report.workingDirectory || report.workingDirectory.trim() === '') {
287
+ return { valid: false, error: 'workingDirectory is required' };
288
+ }
289
+ const wd = report.workingDirectory;
290
+ if (require('node:path').isAbsolute(wd)) {
291
+ return { valid: false, error: `workingDirectory must be relative, got absolute: ${wd}` };
292
+ }
293
+ if (wd.includes('..')) {
294
+ return { valid: false, error: `workingDirectory contains path traversal: ${wd}` };
295
+ }
296
+
297
+ // v2: confidence enum
298
+ if (!['low', 'medium', 'high'].includes(report.confidence)) {
299
+ return {
300
+ valid: false,
301
+ error: `invalid confidence: ${report.confidence} (must be low, medium, or high)`,
302
+ };
303
+ }
304
+
305
+ // v2: evidence array (can be empty)
306
+ if (!Array.isArray(report.evidence)) {
307
+ return { valid: false, error: 'evidence must be an array' };
308
+ }
309
+ for (let i = 0; i < report.evidence.length; i++) {
310
+ const f = report.evidence[i];
311
+ if (!f.ref || !/^[^/].+:\d+$/.test(f.ref)) {
312
+ return {
313
+ valid: false,
314
+ error: `evidence[${i}] has invalid ref: ${JSON.stringify(f.ref)} — must be path:line format`,
315
+ };
316
+ }
317
+ if (!f.finding || typeof f.finding !== 'string' || f.finding.trim() === '') {
318
+ return {
319
+ valid: false,
320
+ error: `evidence[${i}] has empty finding text`,
321
+ };
322
+ }
323
+ }
324
+
325
+ // v2: concerns is optional (can be absent or array)
326
+ if (report.concerns !== undefined && !Array.isArray(report.concerns)) {
327
+ return { valid: false, error: 'concerns must be an array if present' };
328
+ }
329
+
330
+ // findings: non-empty array
331
+ if (!Array.isArray(report.findings) || report.findings.length === 0) {
332
+ return {
333
+ valid: false,
334
+ error: 'findings must be a non-empty array',
335
+ };
336
+ }
337
+
338
+ for (let i = 0; i < report.findings.length; i++) {
339
+ const f = report.findings[i];
340
+ if (!f.ref || !/^[^/].+:\d+$/.test(f.ref)) {
341
+ return {
342
+ valid: false,
343
+ error: `finding[${i}] has invalid ref: ${JSON.stringify(f.ref)} — must be path:line format`,
344
+ };
345
+ }
346
+ if (!f.finding || typeof f.finding !== 'string' || f.finding.trim() === '') {
347
+ return {
348
+ valid: false,
349
+ error: `finding[${i}] has empty finding text`,
350
+ };
351
+ }
352
+ }
353
+
354
+ return { valid: true, report };
355
+ } catch (e) {
356
+ return { valid: false, error: `invalid JSON: ${e.message}` };
357
+ }
358
+ }
359
+
360
+ // --- Main ---
361
+
362
+ function main() {
363
+ // 1. Check for existing validated report
364
+ if (existsSync(reportPath)) {
365
+ try {
366
+ const raw = readFileSync(reportPath, 'utf8');
367
+ const { valid, report } = validateReport(raw);
368
+ if (valid) {
369
+ outputReport(report);
370
+ }
371
+ // Invalid existing report — fall through to dispatch
372
+ } catch {
373
+ // Unreadable — fall through
374
+ }
375
+ }
376
+
377
+ // 2. Detect and dispatch via harness
378
+ const harness = detectHarness();
379
+ if (!harness) {
380
+ fail('no harness found on PATH (opencode or claude required for scout dispatch)');
381
+ }
382
+
383
+ const maxAttempts = 2;
384
+
385
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
386
+ // emit telematics if enabled
387
+ emitLifecycle('scout.dispatch', 'started', { attempt });
388
+
389
+ let result;
390
+ if (harness === 'opencode') {
391
+ result = dispatchOpenCode();
392
+ } else {
393
+ result = dispatchClaude();
394
+ }
395
+
396
+ if (result.error) {
397
+ const reason = result.error.message || String(result.error);
398
+ emitLifecycle('scout.dispatch', 'failed', { attempt, reason });
399
+ if (attempt < maxAttempts) {
400
+ emitLifecycle('scout.dispatch', 'retry', {
401
+ attempt,
402
+ nextAttempt: attempt + 1,
403
+ });
404
+ }
405
+ continue;
406
+ }
407
+
408
+ // Check if report was produced
409
+ if (existsSync(reportPath)) {
410
+ try {
411
+ const raw = readFileSync(reportPath, 'utf8');
412
+ const { valid, report, error } = validateReport(raw);
413
+ if (valid) {
414
+ emitLifecycle('scout.dispatch', 'completed', {
415
+ attempt,
416
+ findingCount: report.findings.length,
417
+ });
418
+ outputReport(report);
419
+ }
420
+ if (attempt < maxAttempts) {
421
+ emitLifecycle('scout.dispatch', 'retry', {
422
+ attempt,
423
+ reason: `invalid report: ${error}`,
424
+ });
425
+ }
426
+ } catch (e) {
427
+ if (attempt < maxAttempts) {
428
+ emitLifecycle('scout.dispatch', 'retry', {
429
+ attempt,
430
+ reason: `report read error: ${e.message}`,
431
+ });
432
+ }
433
+ }
434
+ } else {
435
+ if (attempt < maxAttempts) {
436
+ emitLifecycle('scout.dispatch', 'retry', {
437
+ attempt,
438
+ reason: 'no report produced',
439
+ });
440
+ }
441
+ }
442
+
443
+ // continue loop for retry
444
+ }
445
+
446
+ // All attempts exhausted
447
+ emitLifecycle('scout.dispatch', 'failed', {
448
+ attempt: maxAttempts,
449
+ reason: 'all dispatch attempts exhausted',
450
+ harness,
451
+ });
452
+ fail(`scout dispatch failed after ${maxAttempts} attempts (${harness})`);
453
+ }
454
+
455
+ // --- Telematics ---
456
+ // Uses existing shared/telematics.cjs module — config-gated: only emits
457
+ // when $HOME/.impulso/config.json has telematics.enabled = true.
458
+ // Redacts sensitive keys and patterns. Fails silently — telematics must
459
+ // never disrupt the scout dispatch.
460
+
461
+ let _telemetry = null;
462
+
463
+ function getTelemetry() {
464
+ if (_telemetry) return _telemetry;
465
+ try {
466
+ const { createTelematics } = require('./telematics.cjs');
467
+ const os = require('node:os');
468
+ const path = require('node:path');
469
+ const crypto = require('node:crypto');
470
+ const home = process.env.IMPULSO_HOME || path.join(os.homedir(), '.impulso');
471
+ _telemetry = createTelematics({
472
+ harness: 'scout-dispatch',
473
+ sessionId: 'scout-' + slug + '-' + Date.now(),
474
+ processId: process.pid,
475
+ cwd: absWorkspace,
476
+ home: home,
477
+ now: () => new Date().toISOString(),
478
+ uuid: () => crypto.randomUUID(),
479
+ fs: require('node:fs'),
480
+ });
481
+ } catch (_) {
482
+ // Module unavailable or config load failed — create no-op fallback
483
+ _telemetry = {
484
+ enabled: false,
485
+ sessionId: null,
486
+ emit: () => {},
487
+ close: () => {},
488
+ };
489
+ }
490
+ return _telemetry;
491
+ }
492
+
493
+ function emitLifecycle(eventType, phase, meta) {
494
+ const tm = getTelemetry();
495
+ if (!tm.enabled) return;
496
+ tm.emit(eventType + '.' + phase, meta || {});
497
+ }
498
+
499
+ main();