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
package/scripts/install-hub.js
CHANGED
|
@@ -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
|
|
78
|
-
const
|
|
114
|
+
const hubResult = await installHub();
|
|
115
|
+
const hubAction = hubResult.updated ? 'Installed' : 'Already installed';
|
|
79
116
|
console.log(
|
|
80
|
-
` ${
|
|
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();
|