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.
- package/harnesses/claude/hooks/task-exit.cjs +106 -1
- package/harnesses/claude/hooks/tdd-observer.cjs +56 -11
- 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/telematics/plugin.js +15 -2
- package/package.json +3 -2
- package/scripts/install-hub.js +44 -3
- package/shared/scout-dispatch.cjs +499 -0
|
@@ -36,6 +36,105 @@ const IMPLEMENTER_ROLES = new Set(['implementer', 'implementer-frontend', 'imple
|
|
|
36
36
|
|
|
37
37
|
const STATUS_BLOCK_LIMIT = 15;
|
|
38
38
|
|
|
39
|
+
// --- Test command resolution ---
|
|
40
|
+
//
|
|
41
|
+
// Resolution ladder (consistent with hub ResolveTestCommand):
|
|
42
|
+
// local .impulso/config.json > IMPULSO_HOME/projects/<sha256>/config.json >
|
|
43
|
+
// IMPULSO_HOME/.impulso/ > HOME/.impulso/ >
|
|
44
|
+
// project heuristics (package.json scripts.test, Makefile test target,
|
|
45
|
+
// go.mod, Cargo.toml, pyproject.toml)
|
|
46
|
+
//
|
|
47
|
+
// Returns ("", "") when no test command can be resolved.
|
|
48
|
+
// Never writes config/state files.
|
|
49
|
+
|
|
50
|
+
const crypto = require('node:crypto');
|
|
51
|
+
|
|
52
|
+
function userImpulsoDir() {
|
|
53
|
+
const home = process.env.IMPULSO_HOME || process.env.HOME;
|
|
54
|
+
if (!home) return null;
|
|
55
|
+
return path.join(home, '.impulso');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function projectConfigDir(projectDir) {
|
|
59
|
+
const userDir = userImpulsoDir();
|
|
60
|
+
if (!userDir) return null;
|
|
61
|
+
const abs = path.resolve(projectDir);
|
|
62
|
+
const hash = crypto.createHash('sha256').update(abs).digest('hex');
|
|
63
|
+
return path.join(userDir, 'projects', hash);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readImpulsoConfig(dir) {
|
|
67
|
+
if (!dir) return null;
|
|
68
|
+
try {
|
|
69
|
+
const raw = fs.readFileSync(path.join(dir, 'config.json'), 'utf8');
|
|
70
|
+
const cfg = JSON.parse(raw);
|
|
71
|
+
return cfg && typeof cfg === 'object' && !Array.isArray(cfg) ? cfg : null;
|
|
72
|
+
} catch (_e) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function detectTestCommand(projectDir) {
|
|
78
|
+
// 1. package.json with scripts.test
|
|
79
|
+
try {
|
|
80
|
+
const raw = fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8');
|
|
81
|
+
const pkg = JSON.parse(raw);
|
|
82
|
+
if (pkg && pkg.scripts && typeof pkg.scripts.test === 'string') {
|
|
83
|
+
return 'npm test';
|
|
84
|
+
}
|
|
85
|
+
} catch (_e) {
|
|
86
|
+
/* continue */
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 2. Makefile with test: target
|
|
90
|
+
try {
|
|
91
|
+
const raw = fs.readFileSync(path.join(projectDir, 'Makefile'), 'utf8');
|
|
92
|
+
if (/^test:/m.test(raw)) return 'make test';
|
|
93
|
+
} catch (_e) {
|
|
94
|
+
/* continue */
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 3. go.mod
|
|
98
|
+
if (fs.existsSync(path.join(projectDir, 'go.mod'))) return 'go test ./...';
|
|
99
|
+
|
|
100
|
+
// 4. Cargo.toml
|
|
101
|
+
if (fs.existsSync(path.join(projectDir, 'Cargo.toml'))) return 'cargo test';
|
|
102
|
+
|
|
103
|
+
// 5. pyproject.toml
|
|
104
|
+
if (fs.existsSync(path.join(projectDir, 'pyproject.toml'))) return 'pytest';
|
|
105
|
+
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveTestCommand(projectDir) {
|
|
110
|
+
// 1. Local config (.impulso/config.json)
|
|
111
|
+
const localCfg = readImpulsoConfig(path.join(projectDir, '.impulso'));
|
|
112
|
+
if (localCfg && typeof localCfg.test_cmd === 'string' && localCfg.test_cmd) {
|
|
113
|
+
return localCfg.test_cmd;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 2. Project-specific external config (~/.impulso/projects/<hash>/config.json)
|
|
117
|
+
const projCfgDir = projectConfigDir(projectDir);
|
|
118
|
+
if (projCfgDir) {
|
|
119
|
+
const projCfg = readImpulsoConfig(projCfgDir);
|
|
120
|
+
if (projCfg && typeof projCfg.test_cmd === 'string' && projCfg.test_cmd) {
|
|
121
|
+
return projCfg.test_cmd;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 3. User config (IMPULSO_HOME or HOME .impulso/config.json)
|
|
126
|
+
const userDir = userImpulsoDir();
|
|
127
|
+
if (userDir) {
|
|
128
|
+
const userCfg = readImpulsoConfig(userDir);
|
|
129
|
+
if (userCfg && typeof userCfg.test_cmd === 'string' && userCfg.test_cmd) {
|
|
130
|
+
return userCfg.test_cmd;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 4. Project heuristics
|
|
135
|
+
return detectTestCommand(projectDir);
|
|
136
|
+
}
|
|
137
|
+
|
|
39
138
|
function reportLineCount(reportText) {
|
|
40
139
|
if (!reportText || typeof reportText !== 'string') return 0;
|
|
41
140
|
const lines = reportText.split('\n');
|
|
@@ -116,6 +215,12 @@ function main() {
|
|
|
116
215
|
return;
|
|
117
216
|
}
|
|
118
217
|
|
|
218
|
+
// Resolve test command — skip TDD gate if none is resolvable
|
|
219
|
+
if (!resolveTestCommand(cwd)) {
|
|
220
|
+
process.exit(0);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
119
224
|
// Read state non-mutatively
|
|
120
225
|
let slug;
|
|
121
226
|
try {
|
|
@@ -186,4 +291,4 @@ function main() {
|
|
|
186
291
|
|
|
187
292
|
if (require.main === module) main();
|
|
188
293
|
|
|
189
|
-
module.exports = { reportLineCount, violatesLimit };
|
|
294
|
+
module.exports = { reportLineCount, violatesLimit, resolveTestCommand, detectTestCommand };
|
|
@@ -19,10 +19,64 @@
|
|
|
19
19
|
|
|
20
20
|
const fs = require('node:fs');
|
|
21
21
|
const path = require('node:path');
|
|
22
|
+
const crypto = require('node:crypto');
|
|
22
23
|
const { spawnSync } = require('node:child_process');
|
|
23
24
|
|
|
24
25
|
const GATE_REL = path.resolve(__dirname, '..', '..', '..', 'scripts', 'impulso-hub.cjs');
|
|
25
26
|
|
|
27
|
+
function userImpulsoDir() {
|
|
28
|
+
const home = process.env.IMPULSO_HOME || process.env.HOME;
|
|
29
|
+
if (!home) return null;
|
|
30
|
+
return path.join(home, '.impulso');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function projectConfigDir(projectDir) {
|
|
34
|
+
const userDir = userImpulsoDir();
|
|
35
|
+
if (!userDir) return null;
|
|
36
|
+
const abs = path.resolve(projectDir);
|
|
37
|
+
const hash = crypto.createHash('sha256').update(abs).digest('hex');
|
|
38
|
+
return path.join(userDir, 'projects', hash);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readImpulsoConfig(dir) {
|
|
42
|
+
if (!dir) return null;
|
|
43
|
+
try {
|
|
44
|
+
const raw = fs.readFileSync(path.join(dir, 'config.json'), 'utf8');
|
|
45
|
+
const cfg = JSON.parse(raw);
|
|
46
|
+
return cfg && typeof cfg === 'object' && !Array.isArray(cfg) ? cfg : null;
|
|
47
|
+
} catch (_e) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveTestCommand(projectDir) {
|
|
53
|
+
// 1. Local config (.impulso/config.json)
|
|
54
|
+
const localCfg = readImpulsoConfig(path.join(projectDir, '.impulso'));
|
|
55
|
+
if (localCfg && typeof localCfg.test_cmd === 'string' && localCfg.test_cmd) {
|
|
56
|
+
return localCfg.test_cmd;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. Project-specific external config
|
|
60
|
+
const projCfgDir = projectConfigDir(projectDir);
|
|
61
|
+
if (projCfgDir) {
|
|
62
|
+
const projCfg = readImpulsoConfig(projCfgDir);
|
|
63
|
+
if (projCfg && typeof projCfg.test_cmd === 'string' && projCfg.test_cmd) {
|
|
64
|
+
return projCfg.test_cmd;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 3. User config
|
|
69
|
+
const userDir = userImpulsoDir();
|
|
70
|
+
if (userDir) {
|
|
71
|
+
const userCfg = readImpulsoConfig(userDir);
|
|
72
|
+
if (userCfg && typeof userCfg.test_cmd === 'string' && userCfg.test_cmd) {
|
|
73
|
+
return userCfg.test_cmd;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
26
80
|
function main() {
|
|
27
81
|
let done = false;
|
|
28
82
|
const chunks = [];
|
|
@@ -50,17 +104,8 @@ function main() {
|
|
|
50
104
|
return;
|
|
51
105
|
}
|
|
52
106
|
|
|
53
|
-
//
|
|
54
|
-
const
|
|
55
|
-
let config;
|
|
56
|
-
try {
|
|
57
|
-
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
58
|
-
} catch (_e) {
|
|
59
|
-
process.exit(0);
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const testCmd = config.test_cmd;
|
|
107
|
+
// Resolve test_cmd from config ladder
|
|
108
|
+
const testCmd = resolveTestCommand(cwd);
|
|
64
109
|
if (!testCmd || !command.includes(testCmd)) {
|
|
65
110
|
process.exit(0);
|
|
66
111
|
return;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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:
|
|
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.
|
|
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"
|
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();
|