impulso 0.23.0 → 0.25.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;
@@ -29,10 +29,15 @@ function defaultCreateTelematics(ctx) {
29
29
  // Record the raw sessionId before sanitization for sessionIdSource
30
30
  const rawSessionId = ctx.sessionID || null;
31
31
 
32
+ // Use process.pid as stable fallback when ctx.sessionID is absent.
33
+ // Prevents per-init UUID generation that creates a new log directory
34
+ // every time the plugin initializes without a native session ID.
35
+ const stableSessionId = ctx.sessionID || String(process.pid);
36
+
32
37
  return {
33
38
  telematics: createTelematics({
34
39
  harness: 'opencode',
35
- sessionId: ctx.sessionID || null,
40
+ sessionId: stableSessionId,
36
41
  processId: process.pid,
37
42
  cwd: ctx.directory || process.cwd(),
38
43
  home,
@@ -130,10 +135,18 @@ export async function ImpulsoTelematicsPlugin(ctx, _createTelematics) {
130
135
  status = output.metadata.exitCode === 0 ? 'success' : 'error';
131
136
  }
132
137
 
138
+ const outputSummary = output
139
+ ? {
140
+ title: output.title,
141
+ responseSize: typeof output.output === 'string' ? output.output.length : undefined,
142
+ preview: typeof output.output === 'string' ? output.output.slice(0, 200) : undefined,
143
+ }
144
+ : null;
145
+
133
146
  t.emit('tool.completed', {
134
147
  toolName: input.tool,
135
148
  toolUseId: input.callID,
136
- output,
149
+ output: outputSummary,
137
150
  durationMs,
138
151
  status,
139
152
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impulso",
3
- "version": "0.23.0",
3
+ "version": "0.25.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",
@@ -65,7 +65,8 @@
65
65
  "package.json",
66
66
  "README.md",
67
67
  "harnesses/opencode/telematics/",
68
- "shared/telematics.cjs"
68
+ "shared/telematics.cjs",
69
+ "shared/scout-dispatch.cjs"
69
70
  ],
70
71
  "dependencies": {
71
72
  "@opencode-ai/plugin": "^1.17.14"
@@ -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();