nubos-pilot 1.2.4 → 1.3.1
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/CHANGELOG.md +17 -1
- package/README.md +2 -1
- package/SECURITY.md +3 -4
- package/bin/np-tools/_commands.cjs +3 -0
- package/bin/np-tools/_elision-proxy-entry.cjs +13 -0
- package/bin/np-tools/elision-bench.cjs +67 -0
- package/bin/np-tools/elision-get.cjs +48 -0
- package/bin/np-tools/elision-get.test.cjs +66 -0
- package/bin/np-tools/learnings.cjs +1 -1
- package/bin/np-tools/loop-run-round.cjs +25 -11
- package/bin/np-tools/plan-milestone.cjs +1 -0
- package/bin/np-tools/research-phase.cjs +1 -1
- package/bin/np-tools/resolve-model.cjs +55 -1
- package/bin/np-tools/resolve-model.test.cjs +139 -0
- package/bin/np-tools/security.cjs +1 -1
- package/bin/np-tools/spawn-headless.cjs +155 -3
- package/bin/np-tools/spawn-headless.test.cjs +108 -58
- package/bin/np-tools/spawn-offhost.cjs +93 -0
- package/bin/np-tools/spawn-offhost.test.cjs +38 -0
- package/lib/agents.cjs +16 -2
- package/lib/cache-align.cjs +78 -0
- package/lib/cache-align.test.cjs +69 -0
- package/lib/compress.cjs +495 -0
- package/lib/compress.test.cjs +267 -0
- package/lib/config-defaults.cjs +39 -0
- package/lib/config-schema.cjs +45 -5
- package/lib/elision-bench.cjs +409 -0
- package/lib/elision-bench.test.cjs +89 -0
- package/lib/elision-proxy.cjs +158 -0
- package/lib/elision-proxy.test.cjs +243 -0
- package/lib/elision.cjs +163 -0
- package/lib/elision.test.cjs +143 -0
- package/lib/learnings/extract.cjs +4 -4
- package/lib/learnings/extract.test.cjs +8 -8
- package/lib/model-providers.cjs +118 -0
- package/lib/model-providers.test.cjs +85 -0
- package/lib/nubosloop.cjs +1 -1
- package/lib/output-steering.cjs +68 -0
- package/lib/output-steering.test.cjs +74 -0
- package/lib/researcher-swarm.cjs +14 -3
- package/lib/runtime/agent-loop.cjs +94 -0
- package/lib/runtime/agent-loop.test.cjs +240 -0
- package/lib/runtime/dispatch.cjs +174 -0
- package/lib/runtime/dispatch.test.cjs +207 -0
- package/lib/runtime/preflight.cjs +68 -0
- package/lib/runtime/preflight.test.cjs +62 -0
- package/lib/runtime/providers/openai-compat.cjs +103 -0
- package/lib/runtime/providers/openai-compat.test.cjs +112 -0
- package/lib/runtime/tools/index.cjs +447 -0
- package/lib/runtime/tools/index.test.cjs +254 -0
- package/lib/schemas/data/elision-entry.v1.json +16 -0
- package/lib/security/review.cjs +4 -4
- package/lib/security/review.test.cjs +6 -6
- package/lib/token-cost.cjs +46 -0
- package/lib/token-cost.test.cjs +42 -0
- package/np-tools.cjs +3 -0
- package/package.json +1 -1
- package/workflows/add-tests.md +41 -0
- package/workflows/architect-phase.md +19 -0
- package/workflows/discuss-phase.md +29 -10
- package/workflows/execute-phase.md +93 -4
- package/workflows/plan-phase.md +57 -16
- package/workflows/research-phase.md +45 -0
- package/workflows/scan-codebase.md +21 -3
- package/workflows/validate-phase.md +30 -13
- package/workflows/verify-work.md +17 -0
|
@@ -9,8 +9,35 @@ const { NubosPilotError, atomicWriteFileSync, appendJsonl, findProjectRoot } = r
|
|
|
9
9
|
const runContext = require('../../lib/run-context.cjs');
|
|
10
10
|
const safePath = require('../../lib/safe-path.cjs');
|
|
11
11
|
const headlessGuard = require('../../lib/headless-guard.cjs');
|
|
12
|
+
const compress = require('../../lib/compress.cjs');
|
|
13
|
+
const elision = require('../../lib/elision.cjs');
|
|
14
|
+
const elisionProxy = require('../../lib/elision-proxy.cjs');
|
|
15
|
+
const steering = require('../../lib/output-steering.cjs');
|
|
16
|
+
const logger = require('../../lib/logger.cjs').child('spawn-headless');
|
|
12
17
|
const args = require('./_args.cjs');
|
|
13
18
|
|
|
19
|
+
const PROXY_START_TIMEOUT_MS = 5000;
|
|
20
|
+
|
|
21
|
+
function _startCompressionProxy(cwd) {
|
|
22
|
+
const upstream = process.env.ANTHROPIC_BASE_URL || '';
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const proc = child_process.fork(path.join(__dirname, '_elision-proxy-entry.cjs'), [], {
|
|
25
|
+
env: Object.assign({}, process.env, { ELISION_PROXY_CWD: cwd, ELISION_PROXY_UPSTREAM: upstream }),
|
|
26
|
+
stdio: ['ignore', 'ignore', 'inherit', 'ipc'],
|
|
27
|
+
});
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
try { proc.kill(); } catch { /* already gone */ }
|
|
30
|
+
reject(new NubosPilotError('elision-proxy-start-timeout', 'compression proxy did not report ready in time', {}));
|
|
31
|
+
}, PROXY_START_TIMEOUT_MS);
|
|
32
|
+
proc.once('message', (m) => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
if (m && m.ready) resolve({ proc, baseUrl: m.baseUrl });
|
|
35
|
+
else { try { proc.kill(); } catch { /* already gone */ } reject(new NubosPilotError('elision-proxy-start-failed', String(m && m.error), {})); }
|
|
36
|
+
});
|
|
37
|
+
proc.once('error', (err) => { clearTimeout(timer); reject(err); });
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
14
41
|
function _sha256(s) {
|
|
15
42
|
return crypto.createHash('sha256').update(s == null ? '' : String(s)).digest('hex');
|
|
16
43
|
}
|
|
@@ -159,6 +186,20 @@ function _composePrompt(agentBody, userPrompt) {
|
|
|
159
186
|
return agentBody.trimEnd() + '\n\n---\n\n' + userPrompt.trimEnd() + '\n';
|
|
160
187
|
}
|
|
161
188
|
|
|
189
|
+
function _maybeCompressPrompt(prompt, cwd) {
|
|
190
|
+
const none = { prompt, stats: null };
|
|
191
|
+
const cx = elision.compressionContext(cwd);
|
|
192
|
+
if (!cx.enabled) return none;
|
|
193
|
+
try {
|
|
194
|
+
const out = compress.compressPrompt(prompt, { minBlockBytes: cx.minBlockBytes, store: cx.store });
|
|
195
|
+
if (!out || out.stats.blocks_compressed === 0) return none;
|
|
196
|
+
return { prompt: out.text, stats: out.stats };
|
|
197
|
+
} catch (err) {
|
|
198
|
+
logger.warn('compression skipped', { cause: err && err.message });
|
|
199
|
+
return none;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
162
203
|
function _stripFrontmatter(md) {
|
|
163
204
|
if (!md.startsWith('---\n')) return md;
|
|
164
205
|
const end = md.indexOf('\n---\n', 4);
|
|
@@ -166,7 +207,88 @@ function _stripFrontmatter(md) {
|
|
|
166
207
|
return md.slice(end + 5);
|
|
167
208
|
}
|
|
168
209
|
|
|
169
|
-
function
|
|
210
|
+
function _defaultResolveKind(agent, cwd) {
|
|
211
|
+
const { resolveFromConfig } = require('./resolve-model.cjs');
|
|
212
|
+
return resolveFromConfig({ agentOrTier: agent, cwd });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function _runOffHost(o) {
|
|
216
|
+
const dispatch = o.dispatchImpl || require('../../lib/runtime/dispatch.cjs').dispatchOffHost;
|
|
217
|
+
const startedAt = o.startedAt;
|
|
218
|
+
let result = null;
|
|
219
|
+
let errObj = null;
|
|
220
|
+
try {
|
|
221
|
+
result = await dispatch({ agent: o.agent, task: o.userPrompt, cwd: o.cwd });
|
|
222
|
+
} catch (err) {
|
|
223
|
+
errObj = {
|
|
224
|
+
code: (err && err.code) || 'spawn-headless-offhost-failed',
|
|
225
|
+
message: (err && err.message) || 'off-host dispatch failed',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const endedAt = new Date().toISOString();
|
|
229
|
+
const content = result ? (result.content || '') : '';
|
|
230
|
+
|
|
231
|
+
const envelope = JSON.stringify({
|
|
232
|
+
type: 'result',
|
|
233
|
+
result: content,
|
|
234
|
+
model: result ? result.model : null,
|
|
235
|
+
provider: result ? result.provider : null,
|
|
236
|
+
is_error: !!errObj,
|
|
237
|
+
});
|
|
238
|
+
atomicWriteFileSync(o.resolvedOutput, envelope, 'utf-8', 0o600);
|
|
239
|
+
|
|
240
|
+
const exitCode = errObj ? 2 : 0;
|
|
241
|
+
const spawnTrailPath = _spawnTrailPath(o.cwd);
|
|
242
|
+
const spawnRecord = {
|
|
243
|
+
run_id: o.runId,
|
|
244
|
+
started_at: startedAt,
|
|
245
|
+
ended_at: endedAt,
|
|
246
|
+
duration_ms: Math.max(0, Date.parse(endedAt) - Date.parse(startedAt)),
|
|
247
|
+
agent: o.agent,
|
|
248
|
+
bin: 'off-host:' + (result ? result.provider : 'unknown'),
|
|
249
|
+
exit_code: exitCode,
|
|
250
|
+
timed_out: false,
|
|
251
|
+
prompt_sha256: _sha256(o.userPrompt),
|
|
252
|
+
prompt_bytes: Buffer.byteLength(o.userPrompt || '', 'utf-8'),
|
|
253
|
+
response_sha256: _sha256(content),
|
|
254
|
+
response_bytes: Buffer.byteLength(content, 'utf-8'),
|
|
255
|
+
stderr_excerpt: errObj ? _redactSecrets(String(errObj.message)).slice(-STDERR_TAIL_BYTES) : '',
|
|
256
|
+
model_actual: result ? result.model : null,
|
|
257
|
+
tokens_in: null,
|
|
258
|
+
tokens_out: null,
|
|
259
|
+
payload_parse_ok: !errObj,
|
|
260
|
+
runtime: result ? result.provider : 'off-host',
|
|
261
|
+
};
|
|
262
|
+
try { appendJsonl(spawnTrailPath, spawnRecord, { maxLineBytes: 16 * 1024, mode: 0o600 }); }
|
|
263
|
+
catch (err) {
|
|
264
|
+
throw new NubosPilotError(
|
|
265
|
+
'spawn-headless-audit-persist-failed',
|
|
266
|
+
'spawn-trail append failed; refusing to commit response without audit record',
|
|
267
|
+
{ file: 'spawns.jsonl', cause: (err && err.code) || 'unknown' },
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const payload = {
|
|
272
|
+
agent: o.agent,
|
|
273
|
+
output_path: o.outputPath,
|
|
274
|
+
output_path_resolved: o.resolvedOutput,
|
|
275
|
+
exit_code: exitCode,
|
|
276
|
+
stderr_excerpt: spawnRecord.stderr_excerpt,
|
|
277
|
+
bin: spawnRecord.bin,
|
|
278
|
+
timed_out: false,
|
|
279
|
+
run_id: o.runId,
|
|
280
|
+
spawn_trail_path: spawnTrailPath,
|
|
281
|
+
model_actual: spawnRecord.model_actual,
|
|
282
|
+
tokens_in: null,
|
|
283
|
+
tokens_out: null,
|
|
284
|
+
payload_parse_ok: spawnRecord.payload_parse_ok,
|
|
285
|
+
off_host: true,
|
|
286
|
+
};
|
|
287
|
+
o.stdout.write(JSON.stringify(payload) + '\n');
|
|
288
|
+
return exitCode;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function run(argv, ctx) {
|
|
170
292
|
const context = ctx || {};
|
|
171
293
|
const cwd = context.cwd || process.cwd();
|
|
172
294
|
const stdout = context.stdout || process.stdout;
|
|
@@ -223,13 +345,29 @@ function run(argv, ctx) {
|
|
|
223
345
|
}
|
|
224
346
|
|
|
225
347
|
const agentPath = _resolveAgentPath(agent, cwd);
|
|
226
|
-
|
|
348
|
+
let agentBody = _stripFrontmatter(fs.readFileSync(agentPath, 'utf-8'));
|
|
349
|
+
const _os = elision.compressionContext(cwd).outputSteering;
|
|
350
|
+
if (_os && _os.enabled) agentBody = steering.enrichSystemPrompt(agentBody, _os.profile);
|
|
227
351
|
const userPrompt = _readPromptFile(promptPath, cwd);
|
|
228
|
-
|
|
352
|
+
let composedPrompt = _composePrompt(agentBody, userPrompt);
|
|
229
353
|
const resolvedOutput = _ensureOutputDir(outputPath, cwd);
|
|
230
354
|
|
|
231
355
|
const runId = runContext.getRunId();
|
|
232
356
|
|
|
357
|
+
const resolveKind = context.resolveImpl || _defaultResolveKind;
|
|
358
|
+
let routed;
|
|
359
|
+
try { routed = resolveKind(agent, cwd); } catch { routed = { kind: 'native' }; }
|
|
360
|
+
if (routed && routed.kind === 'openai-compat') {
|
|
361
|
+
return _runOffHost({
|
|
362
|
+
agent, userPrompt, resolvedOutput, outputPath, cwd, runId, stdout,
|
|
363
|
+
startedAt: new Date().toISOString(),
|
|
364
|
+
dispatchImpl: context.dispatchImpl,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const compression = _maybeCompressPrompt(composedPrompt, cwd);
|
|
369
|
+
composedPrompt = compression.prompt;
|
|
370
|
+
|
|
233
371
|
let lockRoot;
|
|
234
372
|
try { lockRoot = findProjectRoot(cwd); }
|
|
235
373
|
catch { lockRoot = cwd; }
|
|
@@ -245,6 +383,17 @@ function run(argv, ctx) {
|
|
|
245
383
|
const childEnv = _filterSpawnEnv(process.env);
|
|
246
384
|
Object.assign(childEnv, headlessGuard.childSpawnEnv(process.env));
|
|
247
385
|
|
|
386
|
+
let proxyProc = null;
|
|
387
|
+
if (elisionProxy.proxyEnabled(cwd)) {
|
|
388
|
+
try {
|
|
389
|
+
const started = await _startCompressionProxy(cwd);
|
|
390
|
+
proxyProc = started.proc;
|
|
391
|
+
childEnv.ANTHROPIC_BASE_URL = started.baseUrl;
|
|
392
|
+
} catch (err) {
|
|
393
|
+
logger.warn('compression proxy unavailable; spawning without it', { cause: err && err.message });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
248
397
|
const bin = _claudeBinary();
|
|
249
398
|
const claudeArgs = ['-p', '--output-format', 'json'];
|
|
250
399
|
const startedAt = new Date().toISOString();
|
|
@@ -266,6 +415,7 @@ function run(argv, ctx) {
|
|
|
266
415
|
{ bin, cause: err && err.code },
|
|
267
416
|
);
|
|
268
417
|
} finally {
|
|
418
|
+
if (proxyProc) { try { proxyProc.kill(); } catch { /* already exited */ } }
|
|
269
419
|
lock.release();
|
|
270
420
|
}
|
|
271
421
|
if (result.error && result.error.code === 'ENOENT') {
|
|
@@ -307,6 +457,7 @@ function run(argv, ctx) {
|
|
|
307
457
|
tokens_in: claudeMeta.tokens_in == null ? null : claudeMeta.tokens_in,
|
|
308
458
|
tokens_out: claudeMeta.tokens_out == null ? null : claudeMeta.tokens_out,
|
|
309
459
|
payload_parse_ok: claudeMeta.parse_ok,
|
|
460
|
+
compression: compression.stats || null,
|
|
310
461
|
};
|
|
311
462
|
try { appendJsonl(spawnTrailPath, spawnRecord, { maxLineBytes: 16 * 1024, mode: 0o600 }); }
|
|
312
463
|
catch (err) {
|
|
@@ -332,6 +483,7 @@ function run(argv, ctx) {
|
|
|
332
483
|
tokens_in: spawnRecord.tokens_in,
|
|
333
484
|
tokens_out: spawnRecord.tokens_out,
|
|
334
485
|
payload_parse_ok: spawnRecord.payload_parse_ok,
|
|
486
|
+
compression: compression.stats || null,
|
|
335
487
|
};
|
|
336
488
|
stdout.write(JSON.stringify(payload) + '\n');
|
|
337
489
|
if (exitCode !== 0) return 2;
|
|
@@ -56,30 +56,30 @@ function _setEnv(k, v) {
|
|
|
56
56
|
else process.env[k] = v;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
test('SH-1: spawn-headless requires --agent', () => {
|
|
59
|
+
test('SH-1: spawn-headless requires --agent', async () => {
|
|
60
60
|
const r = _mkRoot();
|
|
61
61
|
const cap = _cap();
|
|
62
|
-
assert.
|
|
63
|
-
() => spawnHeadless.run([], { cwd: r, stdout: cap.stub }),
|
|
62
|
+
await assert.rejects(
|
|
63
|
+
async () => spawnHeadless.run([], { cwd: r, stdout: cap.stub }),
|
|
64
64
|
(err) => err && err.code === 'spawn-headless-missing-agent',
|
|
65
65
|
);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
test('SH-2: spawn-headless requires --prompt-path', () => {
|
|
68
|
+
test('SH-2: spawn-headless requires --prompt-path', async () => {
|
|
69
69
|
const r = _mkRoot();
|
|
70
70
|
const cap = _cap();
|
|
71
|
-
assert.
|
|
72
|
-
() => spawnHeadless.run(['--agent', 'np-test-critic'], { cwd: r, stdout: cap.stub }),
|
|
71
|
+
await assert.rejects(
|
|
72
|
+
async () => spawnHeadless.run(['--agent', 'np-test-critic'], { cwd: r, stdout: cap.stub }),
|
|
73
73
|
(err) => err && err.code === 'spawn-headless-missing-prompt-path',
|
|
74
74
|
);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
test('SH-3: spawn-headless requires --output-path', () => {
|
|
77
|
+
test('SH-3: spawn-headless requires --output-path', async () => {
|
|
78
78
|
const r = _mkRoot();
|
|
79
79
|
fs.writeFileSync(path.join(r, 'p.md'), 'do the audit', 'utf-8');
|
|
80
80
|
const cap = _cap();
|
|
81
|
-
assert.
|
|
82
|
-
() => spawnHeadless.run(
|
|
81
|
+
await assert.rejects(
|
|
82
|
+
async () => spawnHeadless.run(
|
|
83
83
|
['--agent', 'np-test-critic', '--prompt-path', 'p.md'],
|
|
84
84
|
{ cwd: r, stdout: cap.stub },
|
|
85
85
|
),
|
|
@@ -87,11 +87,11 @@ test('SH-3: spawn-headless requires --output-path', () => {
|
|
|
87
87
|
);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
test('SH-4: spawn-headless rejects path traversal on prompt-path', () => {
|
|
90
|
+
test('SH-4: spawn-headless rejects path traversal on prompt-path', async () => {
|
|
91
91
|
const r = _mkRoot();
|
|
92
92
|
const cap = _cap();
|
|
93
|
-
assert.
|
|
94
|
-
() => spawnHeadless.run(
|
|
93
|
+
await assert.rejects(
|
|
94
|
+
async () => spawnHeadless.run(
|
|
95
95
|
['--agent', 'np-test-critic',
|
|
96
96
|
'--prompt-path', '/etc/passwd',
|
|
97
97
|
'--output-path', 'out.json'],
|
|
@@ -101,12 +101,12 @@ test('SH-4: spawn-headless rejects path traversal on prompt-path', () => {
|
|
|
101
101
|
);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
test('SH-5: spawn-headless rejects unknown agent', () => {
|
|
104
|
+
test('SH-5: spawn-headless rejects unknown agent', async () => {
|
|
105
105
|
const r = _mkRoot();
|
|
106
106
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
107
107
|
const cap = _cap();
|
|
108
|
-
assert.
|
|
109
|
-
() => spawnHeadless.run(
|
|
108
|
+
await assert.rejects(
|
|
109
|
+
async () => spawnHeadless.run(
|
|
110
110
|
['--agent', 'np-does-not-exist',
|
|
111
111
|
'--prompt-path', 'p.md',
|
|
112
112
|
'--output-path', 'out.json'],
|
|
@@ -116,12 +116,12 @@ test('SH-5: spawn-headless rejects unknown agent', () => {
|
|
|
116
116
|
);
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
-
test('SH-6: spawn-headless rejects invalid agent name (path-injection guard)', () => {
|
|
119
|
+
test('SH-6: spawn-headless rejects invalid agent name (path-injection guard)', async () => {
|
|
120
120
|
const r = _mkRoot();
|
|
121
121
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
122
122
|
const cap = _cap();
|
|
123
|
-
assert.
|
|
124
|
-
() => spawnHeadless.run(
|
|
123
|
+
await assert.rejects(
|
|
124
|
+
async () => spawnHeadless.run(
|
|
125
125
|
['--agent', '../../etc/passwd',
|
|
126
126
|
'--prompt-path', 'p.md',
|
|
127
127
|
'--output-path', 'out.json'],
|
|
@@ -131,13 +131,13 @@ test('SH-6: spawn-headless rejects invalid agent name (path-injection guard)', (
|
|
|
131
131
|
);
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
-
test('SH-7: spawn-headless reports claude-not-found when binary missing', () => {
|
|
134
|
+
test('SH-7: spawn-headless reports claude-not-found when binary missing', async () => {
|
|
135
135
|
const r = _mkRoot();
|
|
136
136
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
137
137
|
_setEnv('NUBOS_PILOT_CLAUDE_BIN', path.join(r, 'no-such-binary'));
|
|
138
138
|
const cap = _cap();
|
|
139
|
-
assert.
|
|
140
|
-
() => spawnHeadless.run(
|
|
139
|
+
await assert.rejects(
|
|
140
|
+
async () => spawnHeadless.run(
|
|
141
141
|
['--agent', 'np-test-critic',
|
|
142
142
|
'--prompt-path', 'p.md',
|
|
143
143
|
'--output-path', 'out.json'],
|
|
@@ -147,7 +147,7 @@ test('SH-7: spawn-headless reports claude-not-found when binary missing', () =>
|
|
|
147
147
|
);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
-
test('SH-8: spawn-headless captures stdout to output-path on success (mock binary)', () => {
|
|
150
|
+
test('SH-8: spawn-headless captures stdout to output-path on success (mock binary)', async () => {
|
|
151
151
|
const r = _mkRoot();
|
|
152
152
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
153
153
|
const mockBin = path.join(r, 'mock-claude.sh');
|
|
@@ -155,7 +155,7 @@ test('SH-8: spawn-headless captures stdout to output-path on success (mock binar
|
|
|
155
155
|
fs.chmodSync(mockBin, 0o755);
|
|
156
156
|
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
157
157
|
const cap = _cap();
|
|
158
|
-
const rc = spawnHeadless.run(
|
|
158
|
+
const rc = await spawnHeadless.run(
|
|
159
159
|
['--agent', 'np-test-critic',
|
|
160
160
|
'--prompt-path', 'p.md',
|
|
161
161
|
'--output-path', 'out.json'],
|
|
@@ -169,7 +169,7 @@ test('SH-8: spawn-headless captures stdout to output-path on success (mock binar
|
|
|
169
169
|
assert.match(written, /"verdict":"passed"/);
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
-
test('SH-9: spawn-headless surfaces non-zero subprocess exit (mock failure)', () => {
|
|
172
|
+
test('SH-9: spawn-headless surfaces non-zero subprocess exit (mock failure)', async () => {
|
|
173
173
|
const r = _mkRoot();
|
|
174
174
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
175
175
|
const mockBin = path.join(r, 'mock-fail.sh');
|
|
@@ -177,7 +177,7 @@ test('SH-9: spawn-headless surfaces non-zero subprocess exit (mock failure)', ()
|
|
|
177
177
|
fs.chmodSync(mockBin, 0o755);
|
|
178
178
|
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
179
179
|
const cap = _cap();
|
|
180
|
-
const rc = spawnHeadless.run(
|
|
180
|
+
const rc = await spawnHeadless.run(
|
|
181
181
|
['--agent', 'np-test-critic',
|
|
182
182
|
'--prompt-path', 'p.md',
|
|
183
183
|
'--output-path', 'out.json'],
|
|
@@ -189,12 +189,12 @@ test('SH-9: spawn-headless surfaces non-zero subprocess exit (mock failure)', ()
|
|
|
189
189
|
assert.match(payload.stderr_excerpt, /boom/);
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
-
test('SH-10: spawn-headless rejects --timeout-ms below 1000', () => {
|
|
192
|
+
test('SH-10: spawn-headless rejects --timeout-ms below 1000', async () => {
|
|
193
193
|
const r = _mkRoot();
|
|
194
194
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
195
195
|
const cap = _cap();
|
|
196
|
-
assert.
|
|
197
|
-
() => spawnHeadless.run(
|
|
196
|
+
await assert.rejects(
|
|
197
|
+
async () => spawnHeadless.run(
|
|
198
198
|
['--agent', 'np-test-critic',
|
|
199
199
|
'--prompt-path', 'p.md',
|
|
200
200
|
'--output-path', 'out.json',
|
|
@@ -205,7 +205,7 @@ test('SH-10: spawn-headless rejects --timeout-ms below 1000', () => {
|
|
|
205
205
|
);
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
-
test('SH-11: spawn-headless writes output atomically (no .tmp residue)', () => {
|
|
208
|
+
test('SH-11: spawn-headless writes output atomically (no .tmp residue)', async () => {
|
|
209
209
|
const r = _mkRoot();
|
|
210
210
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
211
211
|
const mockBin = path.join(r, 'mock-claude.sh');
|
|
@@ -213,7 +213,7 @@ test('SH-11: spawn-headless writes output atomically (no .tmp residue)', () => {
|
|
|
213
213
|
fs.chmodSync(mockBin, 0o755);
|
|
214
214
|
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
215
215
|
const cap = _cap();
|
|
216
|
-
const rc = spawnHeadless.run(
|
|
216
|
+
const rc = await spawnHeadless.run(
|
|
217
217
|
['--agent', 'np-test-critic',
|
|
218
218
|
'--prompt-path', 'p.md',
|
|
219
219
|
'--output-path', 'out.json'],
|
|
@@ -321,7 +321,7 @@ test('SH-REDACT-2 _redactSecrets is a no-op on safe text', () => {
|
|
|
321
321
|
assert.equal(spawnHeadless._redactSecrets(safe), safe);
|
|
322
322
|
});
|
|
323
323
|
|
|
324
|
-
test('SH-AUDIT-FIRST spawn-trail is written BEFORE caller-visible output (audit-first)', () => {
|
|
324
|
+
test('SH-AUDIT-FIRST spawn-trail is written BEFORE caller-visible output (audit-first)', async () => {
|
|
325
325
|
const r = _mkRoot();
|
|
326
326
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
327
327
|
const mockBin = path.join(r, 'mock.sh');
|
|
@@ -335,7 +335,7 @@ test('SH-AUDIT-FIRST spawn-trail is written BEFORE caller-visible output (audit-
|
|
|
335
335
|
const cap = _cap();
|
|
336
336
|
let thrown = null;
|
|
337
337
|
try {
|
|
338
|
-
spawnHeadless.run(
|
|
338
|
+
await spawnHeadless.run(
|
|
339
339
|
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
340
340
|
{ cwd: r, stdout: cap.stub },
|
|
341
341
|
);
|
|
@@ -348,7 +348,7 @@ test('SH-AUDIT-FIRST spawn-trail is written BEFORE caller-visible output (audit-
|
|
|
348
348
|
'output must NOT exist if audit append failed (audit-first invariant)');
|
|
349
349
|
});
|
|
350
350
|
|
|
351
|
-
test('SH-PARSE-OK payload_parse_ok=false when claude returns non-JSON output', () => {
|
|
351
|
+
test('SH-PARSE-OK payload_parse_ok=false when claude returns non-JSON output', async () => {
|
|
352
352
|
const r = _mkRoot();
|
|
353
353
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
354
354
|
const mockBin = path.join(r, 'mock-plain.sh');
|
|
@@ -357,7 +357,7 @@ test('SH-PARSE-OK payload_parse_ok=false when claude returns non-JSON output', (
|
|
|
357
357
|
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
358
358
|
_setEnv('NUBOS_PILOT_RUN_ID', 'r-parse-test');
|
|
359
359
|
const cap = _cap();
|
|
360
|
-
spawnHeadless.run(
|
|
360
|
+
await spawnHeadless.run(
|
|
361
361
|
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
362
362
|
{ cwd: r, stdout: cap.stub },
|
|
363
363
|
);
|
|
@@ -415,7 +415,7 @@ test('SH-ENV-3 NUBOS_PILOT_SPAWN_ENV_PASSTHROUGH allow-lists by exact key name',
|
|
|
415
415
|
assert.equal(filtered.NOT_LISTED, undefined);
|
|
416
416
|
});
|
|
417
417
|
|
|
418
|
-
test('SH-TRAIL-1 spawn writes append-only spawn-trail record with run_id + prompt/response sha256 + timing', () => {
|
|
418
|
+
test('SH-TRAIL-1 spawn writes append-only spawn-trail record with run_id + prompt/response sha256 + timing', async () => {
|
|
419
419
|
const r = _mkRoot();
|
|
420
420
|
fs.writeFileSync(path.join(r, 'p.md'), 'do the audit', 'utf-8');
|
|
421
421
|
const mockBin = path.join(r, 'mock-claude.sh');
|
|
@@ -424,7 +424,7 @@ test('SH-TRAIL-1 spawn writes append-only spawn-trail record with run_id + promp
|
|
|
424
424
|
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
425
425
|
_setEnv('NUBOS_PILOT_RUN_ID', 'r-traceme-deadbeef');
|
|
426
426
|
const cap = _cap();
|
|
427
|
-
const rc = spawnHeadless.run(
|
|
427
|
+
const rc = await spawnHeadless.run(
|
|
428
428
|
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
429
429
|
{ cwd: r, stdout: cap.stub },
|
|
430
430
|
);
|
|
@@ -452,7 +452,7 @@ test('SH-TRAIL-1 spawn writes append-only spawn-trail record with run_id + promp
|
|
|
452
452
|
assert.ok(Number.isFinite(rec.duration_ms) && rec.duration_ms >= 0);
|
|
453
453
|
});
|
|
454
454
|
|
|
455
|
-
test('SH-TRAIL-1b run_id is seeded BEFORE spawn so the child env inherits NUBOS_PILOT_RUN_ID', () => {
|
|
455
|
+
test('SH-TRAIL-1b run_id is seeded BEFORE spawn so the child env inherits NUBOS_PILOT_RUN_ID', async () => {
|
|
456
456
|
const r = _mkRoot();
|
|
457
457
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
458
458
|
// Mock-claude echoes its own ENV var so we can prove the child saw it.
|
|
@@ -465,7 +465,7 @@ test('SH-TRAIL-1b run_id is seeded BEFORE spawn so the child env inherits NUBOS_
|
|
|
465
465
|
// Crucially: do NOT set NUBOS_PILOT_RUN_ID; the lazy-seed must happen.
|
|
466
466
|
runContext._resetForTests();
|
|
467
467
|
const cap = _cap();
|
|
468
|
-
spawnHeadless.run(
|
|
468
|
+
await spawnHeadless.run(
|
|
469
469
|
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
470
470
|
{ cwd: r, stdout: cap.stub },
|
|
471
471
|
);
|
|
@@ -475,7 +475,7 @@ test('SH-TRAIL-1b run_id is seeded BEFORE spawn so the child env inherits NUBOS_
|
|
|
475
475
|
assert.equal(childRunId, payload.run_id, 'child must inherit parent NUBOS_PILOT_RUN_ID via filtered env');
|
|
476
476
|
});
|
|
477
477
|
|
|
478
|
-
test('SH-TRAIL-2 two sequential spawns append two parseable trail lines (jsonl integrity)', () => {
|
|
478
|
+
test('SH-TRAIL-2 two sequential spawns append two parseable trail lines (jsonl integrity)', async () => {
|
|
479
479
|
const r = _mkRoot();
|
|
480
480
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit X', 'utf-8');
|
|
481
481
|
const mockBin = path.join(r, 'mock.sh');
|
|
@@ -485,7 +485,7 @@ test('SH-TRAIL-2 two sequential spawns append two parseable trail lines (jsonl i
|
|
|
485
485
|
_setEnv('NUBOS_PILOT_RUN_ID', 'r-test-multi-aaa1');
|
|
486
486
|
const cap = _cap();
|
|
487
487
|
for (let i = 0; i < 2; i++) {
|
|
488
|
-
spawnHeadless.run(
|
|
488
|
+
await spawnHeadless.run(
|
|
489
489
|
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out' + i + '.json'],
|
|
490
490
|
{ cwd: r, stdout: cap.stub },
|
|
491
491
|
);
|
|
@@ -496,15 +496,15 @@ test('SH-TRAIL-2 two sequential spawns append two parseable trail lines (jsonl i
|
|
|
496
496
|
for (const l of lines) JSON.parse(l);
|
|
497
497
|
});
|
|
498
498
|
|
|
499
|
-
test('SH-GUARD-1 refuses to spawn when NUBOS_PILOT_HEADLESS=1 (reentrancy guard)', () => {
|
|
499
|
+
test('SH-GUARD-1 refuses to spawn when NUBOS_PILOT_HEADLESS=1 (reentrancy guard)', async () => {
|
|
500
500
|
const r = _mkRoot();
|
|
501
501
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
502
502
|
const mockBin = _mockClaude(r, 'mock.sh', '#!/bin/sh\ncat > /dev/null\necho "{}"\n');
|
|
503
503
|
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
504
504
|
_setEnv('NUBOS_PILOT_HEADLESS', '1');
|
|
505
505
|
const cap = _cap();
|
|
506
|
-
assert.
|
|
507
|
-
|
|
506
|
+
await assert.rejects(
|
|
507
|
+
spawnHeadless.run(
|
|
508
508
|
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
509
509
|
{ cwd: r, stdout: cap.stub },
|
|
510
510
|
),
|
|
@@ -513,15 +513,15 @@ test('SH-GUARD-1 refuses to spawn when NUBOS_PILOT_HEADLESS=1 (reentrancy guard)
|
|
|
513
513
|
assert.equal(fs.existsSync(path.join(r, 'out.json')), false, 'no claude must be spawned inside a headless run');
|
|
514
514
|
});
|
|
515
515
|
|
|
516
|
-
test('SH-GUARD-2 refuses to spawn when hook depth has reached the cap (depth guard)', () => {
|
|
516
|
+
test('SH-GUARD-2 refuses to spawn when hook depth has reached the cap (depth guard)', async () => {
|
|
517
517
|
const r = _mkRoot();
|
|
518
518
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
519
519
|
const mockBin = _mockClaude(r, 'mock.sh', '#!/bin/sh\ncat > /dev/null\necho "{}"\n');
|
|
520
520
|
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
521
521
|
_setEnv('NUBOS_PILOT_HOOK_DEPTH', '1');
|
|
522
522
|
const cap = _cap();
|
|
523
|
-
assert.
|
|
524
|
-
|
|
523
|
+
await assert.rejects(
|
|
524
|
+
spawnHeadless.run(
|
|
525
525
|
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
526
526
|
{ cwd: r, stdout: cap.stub },
|
|
527
527
|
),
|
|
@@ -529,14 +529,14 @@ test('SH-GUARD-2 refuses to spawn when hook depth has reached the cap (depth gua
|
|
|
529
529
|
);
|
|
530
530
|
});
|
|
531
531
|
|
|
532
|
-
test('SH-GUARD-3 child env carries NUBOS_PILOT_HEADLESS=1 and depth=1 (one level deep only)', () => {
|
|
532
|
+
test('SH-GUARD-3 child env carries NUBOS_PILOT_HEADLESS=1 and depth=1 (one level deep only)', async () => {
|
|
533
533
|
const r = _mkRoot();
|
|
534
534
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
535
535
|
const mockBin = _mockClaude(r, 'mock.sh',
|
|
536
536
|
'#!/bin/sh\ncat > /dev/null\nprintf \'{"hl":"\'$NUBOS_PILOT_HEADLESS\'","depth":"\'$NUBOS_PILOT_HOOK_DEPTH\'"}\\n\'\n');
|
|
537
537
|
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
538
538
|
const cap = _cap();
|
|
539
|
-
const rc = spawnHeadless.run(
|
|
539
|
+
const rc = await spawnHeadless.run(
|
|
540
540
|
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
541
541
|
{ cwd: r, stdout: cap.stub },
|
|
542
542
|
);
|
|
@@ -546,7 +546,7 @@ test('SH-GUARD-3 child env carries NUBOS_PILOT_HEADLESS=1 and depth=1 (one level
|
|
|
546
546
|
assert.equal(child.depth, '1', 'child claude must run at hook depth 1');
|
|
547
547
|
});
|
|
548
548
|
|
|
549
|
-
test('SH-GUARD-4 refuses to spawn while a live lock for the same agent is held (concurrency guard)', () => {
|
|
549
|
+
test('SH-GUARD-4 refuses to spawn while a live lock for the same agent is held (concurrency guard)', async () => {
|
|
550
550
|
const r = _mkRoot();
|
|
551
551
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
552
552
|
const mockBin = _mockClaude(r, 'mock.sh', '#!/bin/sh\ncat > /dev/null\necho "{}"\n');
|
|
@@ -555,8 +555,8 @@ test('SH-GUARD-4 refuses to spawn while a live lock for the same agent is held (
|
|
|
555
555
|
assert.equal(held.acquired, true);
|
|
556
556
|
const cap = _cap();
|
|
557
557
|
try {
|
|
558
|
-
assert.
|
|
559
|
-
|
|
558
|
+
await assert.rejects(
|
|
559
|
+
spawnHeadless.run(
|
|
560
560
|
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
561
561
|
{ cwd: r, stdout: cap.stub },
|
|
562
562
|
),
|
|
@@ -567,14 +567,14 @@ test('SH-GUARD-4 refuses to spawn while a live lock for the same agent is held (
|
|
|
567
567
|
}
|
|
568
568
|
});
|
|
569
569
|
|
|
570
|
-
test('SH-GUARD-5 lock is released after a successful spawn (re-spawnable)', () => {
|
|
570
|
+
test('SH-GUARD-5 lock is released after a successful spawn (re-spawnable)', async () => {
|
|
571
571
|
const r = _mkRoot();
|
|
572
572
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
573
573
|
const mockBin = _mockClaude(r, 'mock.sh', '#!/bin/sh\ncat > /dev/null\necho "{}"\n');
|
|
574
574
|
_setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
|
|
575
575
|
const cap = _cap();
|
|
576
576
|
for (let i = 0; i < 2; i++) {
|
|
577
|
-
const rc = spawnHeadless.run(
|
|
577
|
+
const rc = await spawnHeadless.run(
|
|
578
578
|
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out' + i + '.json'],
|
|
579
579
|
{ cwd: r, stdout: cap.stub },
|
|
580
580
|
);
|
|
@@ -583,7 +583,7 @@ test('SH-GUARD-5 lock is released after a successful spawn (re-spawnable)', () =
|
|
|
583
583
|
assert.equal(fs.existsSync(headlessGuard._lockPath(r, 'np-test-critic')), false, 'no lock residue after spawns');
|
|
584
584
|
});
|
|
585
585
|
|
|
586
|
-
test('SH-GUARD-6 a held lock for one agent does NOT block a different agent (per-agent scope)', () => {
|
|
586
|
+
test('SH-GUARD-6 a held lock for one agent does NOT block a different agent (per-agent scope)', async () => {
|
|
587
587
|
const r = _mkRoot();
|
|
588
588
|
fs.writeFileSync(
|
|
589
589
|
path.join(r, '.nubos-pilot', 'agents', 'np-other-critic.md'),
|
|
@@ -597,7 +597,7 @@ test('SH-GUARD-6 a held lock for one agent does NOT block a different agent (per
|
|
|
597
597
|
assert.equal(held.acquired, true);
|
|
598
598
|
const cap = _cap();
|
|
599
599
|
try {
|
|
600
|
-
const rc = spawnHeadless.run(
|
|
600
|
+
const rc = await spawnHeadless.run(
|
|
601
601
|
['--agent', 'np-other-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
602
602
|
{ cwd: r, stdout: cap.stub },
|
|
603
603
|
);
|
|
@@ -607,13 +607,13 @@ test('SH-GUARD-6 a held lock for one agent does NOT block a different agent (per
|
|
|
607
607
|
}
|
|
608
608
|
});
|
|
609
609
|
|
|
610
|
-
test('SH-GUARD-7 lock is released even when the spawn errors (claude-not-found)', () => {
|
|
610
|
+
test('SH-GUARD-7 lock is released even when the spawn errors (claude-not-found)', async () => {
|
|
611
611
|
const r = _mkRoot();
|
|
612
612
|
fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
|
|
613
613
|
_setEnv('NUBOS_PILOT_CLAUDE_BIN', path.join(r, 'no-such-binary'));
|
|
614
614
|
const cap = _cap();
|
|
615
|
-
assert.
|
|
616
|
-
|
|
615
|
+
await assert.rejects(
|
|
616
|
+
spawnHeadless.run(
|
|
617
617
|
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
618
618
|
{ cwd: r, stdout: cap.stub },
|
|
619
619
|
),
|
|
@@ -639,3 +639,53 @@ test('SH-ENV-4 NUBOS_PILOT_/CLAUDE_/ANTHROPIC_ prefixed vars pass through (white
|
|
|
639
639
|
assert.equal(filtered.ANTHROPIC_BASE_URL, 'https://api.anthropic.com');
|
|
640
640
|
assert.equal(filtered.UNRELATED_FOO, undefined);
|
|
641
641
|
});
|
|
642
|
+
|
|
643
|
+
test('SH-OFFHOST-1: openai-compat routing runs dispatchOffHost and writes a {result} envelope (no claude -p)', async () => {
|
|
644
|
+
const r = _mkRoot();
|
|
645
|
+
fs.writeFileSync(path.join(r, 'p.md'), 'review this diff', 'utf-8');
|
|
646
|
+
const cap = _cap();
|
|
647
|
+
let claudeWasCalled = false;
|
|
648
|
+
_setEnv('NUBOS_PILOT_CLAUDE_BIN', path.join(r, 'nonexistent-claude-should-not-run'));
|
|
649
|
+
let dispatchArgs = null;
|
|
650
|
+
const code = await spawnHeadless.run(
|
|
651
|
+
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
652
|
+
{
|
|
653
|
+
cwd: r,
|
|
654
|
+
stdout: cap.stub,
|
|
655
|
+
resolveImpl: () => ({ kind: 'openai-compat', provider: 'ollama', model: 'qwen2.5-coder:32b' }),
|
|
656
|
+
dispatchImpl: async (o) => { dispatchArgs = o; return { content: 'REVIEW: 0 risks', model: 'qwen2.5-coder:32b', provider: 'ollama' }; },
|
|
657
|
+
},
|
|
658
|
+
);
|
|
659
|
+
assert.equal(code, 0);
|
|
660
|
+
// dispatch received the agent + the prompt body as the task
|
|
661
|
+
assert.equal(dispatchArgs.agent, 'np-test-critic');
|
|
662
|
+
assert.match(dispatchArgs.task, /review this diff/);
|
|
663
|
+
// output is the claude-compatible {result} envelope so review/extract parse it unchanged
|
|
664
|
+
const out = JSON.parse(fs.readFileSync(path.join(r, 'out.json'), 'utf-8'));
|
|
665
|
+
assert.equal(out.result, 'REVIEW: 0 risks');
|
|
666
|
+
assert.equal(out.provider, 'ollama');
|
|
667
|
+
// caller-visible payload marks it off-host and the native claude bin was never invoked
|
|
668
|
+
const payload = JSON.parse(cap.get().trim());
|
|
669
|
+
assert.equal(payload.off_host, true);
|
|
670
|
+
assert.equal(payload.exit_code, 0);
|
|
671
|
+
assert.equal(claudeWasCalled, false);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test('SH-OFFHOST-2: a failing off-host dispatch returns exit 2 with an empty result (soft-fail parity)', async () => {
|
|
675
|
+
const r = _mkRoot();
|
|
676
|
+
fs.writeFileSync(path.join(r, 'p.md'), 'review', 'utf-8');
|
|
677
|
+
const cap = _cap();
|
|
678
|
+
const code = await spawnHeadless.run(
|
|
679
|
+
['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
|
|
680
|
+
{
|
|
681
|
+
cwd: r,
|
|
682
|
+
stdout: cap.stub,
|
|
683
|
+
resolveImpl: () => ({ kind: 'openai-compat', provider: 'ollama', model: 'x' }),
|
|
684
|
+
dispatchImpl: async () => { const e = new Error('provider unreachable'); e.code = 'preflight-failed'; throw e; },
|
|
685
|
+
},
|
|
686
|
+
);
|
|
687
|
+
assert.equal(code, 2);
|
|
688
|
+
const out = JSON.parse(fs.readFileSync(path.join(r, 'out.json'), 'utf-8'));
|
|
689
|
+
assert.equal(out.result, '');
|
|
690
|
+
assert.equal(out.is_error, true);
|
|
691
|
+
});
|