nubos-pilot 1.2.3 → 1.3.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +18 -1
  3. package/SECURITY.md +3 -4
  4. package/bin/np-tools/_commands.cjs +1 -0
  5. package/bin/np-tools/learnings.cjs +5 -1
  6. package/bin/np-tools/resolve-model.cjs +55 -1
  7. package/bin/np-tools/resolve-model.test.cjs +139 -0
  8. package/bin/np-tools/security.cjs +4 -1
  9. package/bin/np-tools/spawn-headless.cjs +135 -2
  10. package/bin/np-tools/spawn-headless.test.cjs +225 -40
  11. package/bin/np-tools/spawn-offhost.cjs +93 -0
  12. package/bin/np-tools/spawn-offhost.test.cjs +38 -0
  13. package/lib/agents.cjs +16 -2
  14. package/lib/config-schema.cjs +5 -1
  15. package/lib/headless-guard.cjs +127 -0
  16. package/lib/headless-guard.test.cjs +119 -0
  17. package/lib/learnings/extract.cjs +4 -4
  18. package/lib/learnings/extract.test.cjs +8 -8
  19. package/lib/model-providers.cjs +118 -0
  20. package/lib/model-providers.test.cjs +85 -0
  21. package/lib/runtime/agent-loop.cjs +64 -0
  22. package/lib/runtime/agent-loop.test.cjs +135 -0
  23. package/lib/runtime/dispatch.cjs +174 -0
  24. package/lib/runtime/dispatch.test.cjs +193 -0
  25. package/lib/runtime/preflight.cjs +68 -0
  26. package/lib/runtime/preflight.test.cjs +62 -0
  27. package/lib/runtime/providers/openai-compat.cjs +102 -0
  28. package/lib/runtime/providers/openai-compat.test.cjs +103 -0
  29. package/lib/runtime/tools/index.cjs +415 -0
  30. package/lib/runtime/tools/index.test.cjs +230 -0
  31. package/lib/security/review.cjs +4 -4
  32. package/lib/security/review.test.cjs +6 -6
  33. package/np-tools.cjs +1 -0
  34. package/package.json +1 -1
  35. package/templates/claude/payload/hooks/np-learnings-hook.cjs +1 -0
  36. package/templates/claude/payload/hooks/np-security-hook.cjs +1 -0
  37. package/workflows/add-tests.md +41 -0
  38. package/workflows/architect-phase.md +19 -0
  39. package/workflows/discuss-phase.md +29 -10
  40. package/workflows/execute-phase.md +93 -4
  41. package/workflows/plan-phase.md +57 -16
  42. package/workflows/research-phase.md +45 -0
  43. package/workflows/scan-codebase.md +21 -3
  44. package/workflows/validate-phase.md +30 -13
  45. package/workflows/verify-work.md +17 -0
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const { NubosPilotError } = require('../../core.cjs');
4
+
5
+ const DEFAULT_TIMEOUT_MS = 120000;
6
+
7
+ function _hostOf(url) {
8
+ try { return new URL(url).host; } catch { return 'provider'; }
9
+ }
10
+
11
+ function _parse(json) {
12
+ const choice = json && Array.isArray(json.choices) ? json.choices[0] : null;
13
+ const msg = (choice && choice.message) ? choice.message : {};
14
+ const toolCalls = Array.isArray(msg.tool_calls)
15
+ ? msg.tool_calls.map((tc, i) => ({
16
+ id: (tc && tc.id) || ('call_' + i),
17
+ name: tc && tc.function && tc.function.name,
18
+ arguments: tc && tc.function && tc.function.arguments,
19
+ }))
20
+ : [];
21
+ const usage = (json && json.usage) ? {
22
+ tokens_in: typeof json.usage.prompt_tokens === 'number' ? json.usage.prompt_tokens : null,
23
+ tokens_out: typeof json.usage.completion_tokens === 'number' ? json.usage.completion_tokens : null,
24
+ } : null;
25
+ return {
26
+ content: typeof msg.content === 'string' ? msg.content : '',
27
+ toolCalls,
28
+ finishReason: (choice && choice.finish_reason) || null,
29
+ usage,
30
+ raw: msg,
31
+ };
32
+ }
33
+
34
+ async function chat({ baseUrl, apiKeyEnv, model, messages, tools, timeoutMs, fetchImpl, env }) {
35
+ if (typeof baseUrl !== 'string' || !baseUrl) {
36
+ throw new NubosPilotError('provider-no-base-url', 'openai-compat chat requires a base_url', {});
37
+ }
38
+ if (typeof model !== 'string' || !model) {
39
+ throw new NubosPilotError('provider-no-model', 'openai-compat chat requires a model', {});
40
+ }
41
+ const f = fetchImpl || globalThis.fetch;
42
+ if (typeof f !== 'function') {
43
+ throw new NubosPilotError('provider-no-fetch', 'global fetch unavailable (node >=22 required)', {});
44
+ }
45
+ const e = env || process.env;
46
+ const headers = { 'content-type': 'application/json' };
47
+ if (apiKeyEnv) {
48
+ const key = e[apiKeyEnv];
49
+ if (!key) {
50
+ throw new NubosPilotError(
51
+ 'provider-missing-api-key',
52
+ 'env var ' + apiKeyEnv + ' is empty or unset',
53
+ { apiKeyEnv },
54
+ );
55
+ }
56
+ headers.authorization = 'Bearer ' + key;
57
+ }
58
+
59
+ const body = { model, messages, stream: false };
60
+ if (Array.isArray(tools) && tools.length) {
61
+ body.tools = tools;
62
+ body.tool_choice = 'auto';
63
+ }
64
+
65
+ const url = baseUrl.replace(/\/+$/, '') + '/chat/completions';
66
+ const host = _hostOf(url);
67
+
68
+ let res;
69
+ try {
70
+ res = await f(url, {
71
+ method: 'POST',
72
+ headers,
73
+ body: JSON.stringify(body),
74
+ signal: AbortSignal.timeout(timeoutMs || DEFAULT_TIMEOUT_MS),
75
+ });
76
+ } catch (err) {
77
+ throw new NubosPilotError(
78
+ 'provider-request-failed',
79
+ 'request to ' + host + ' failed (' + ((err && (err.code || err.name)) || 'error') + ')',
80
+ { host, cause: (err && (err.code || err.name)) || 'unknown' },
81
+ );
82
+ }
83
+
84
+ if (!res.ok) {
85
+ let snippet = '';
86
+ try { snippet = (await res.text()).slice(0, 300); } catch {}
87
+ throw new NubosPilotError(
88
+ 'provider-http-error',
89
+ host + ' returned HTTP ' + res.status,
90
+ { host, status: res.status, body: snippet },
91
+ );
92
+ }
93
+
94
+ let json;
95
+ try { json = await res.json(); }
96
+ catch {
97
+ throw new NubosPilotError('provider-bad-json', host + ' returned a non-JSON body', { host });
98
+ }
99
+ return _parse(json);
100
+ }
101
+
102
+ module.exports = { chat, _parse, _hostOf, DEFAULT_TIMEOUT_MS };
@@ -0,0 +1,103 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const { chat, _parse } = require('./openai-compat.cjs');
5
+
6
+ function _res({ ok = true, status = 200, json, text }) {
7
+ return {
8
+ ok, status,
9
+ json: async () => json,
10
+ text: async () => (text != null ? text : JSON.stringify(json || {})),
11
+ };
12
+ }
13
+
14
+ test('OAC-1: _parse extracts content + tool_calls from an OpenAI-shaped response', () => {
15
+ const out = _parse({
16
+ choices: [{
17
+ finish_reason: 'tool_calls',
18
+ message: {
19
+ role: 'assistant', content: 'thinking',
20
+ tool_calls: [{ id: 'c1', function: { name: 'Read', arguments: '{"path":"a.txt"}' } }],
21
+ },
22
+ }],
23
+ });
24
+ assert.equal(out.content, 'thinking');
25
+ assert.equal(out.finishReason, 'tool_calls');
26
+ assert.deepEqual(out.toolCalls, [{ id: 'c1', name: 'Read', arguments: '{"path":"a.txt"}' }]);
27
+ });
28
+
29
+ test('OAC-2: _parse on a content-only response yields empty toolCalls', () => {
30
+ const out = _parse({ choices: [{ finish_reason: 'stop', message: { content: 'done' } }] });
31
+ assert.equal(out.content, 'done');
32
+ assert.deepEqual(out.toolCalls, []);
33
+ });
34
+
35
+ test('OAC-3: chat POSTs to <base>/chat/completions with model + tools and parses the reply', async () => {
36
+ let captured = null;
37
+ const fetchImpl = async (url, opts) => {
38
+ captured = { url, opts };
39
+ return _res({ json: { choices: [{ message: { content: 'hi' } }] } });
40
+ };
41
+ const out = await chat({
42
+ baseUrl: 'http://localhost:11434/v1', model: 'qwen', messages: [{ role: 'user', content: 'x' }],
43
+ tools: [{ type: 'function', function: { name: 'Read' } }], fetchImpl,
44
+ });
45
+ assert.equal(captured.url, 'http://localhost:11434/v1/chat/completions');
46
+ const body = JSON.parse(captured.opts.body);
47
+ assert.equal(body.model, 'qwen');
48
+ assert.equal(body.tool_choice, 'auto');
49
+ assert.equal(out.content, 'hi');
50
+ });
51
+
52
+ test('OAC-4: api_key_env adds a bearer header; missing key throws provider-missing-api-key', async () => {
53
+ let auth = null;
54
+ const fetchImpl = async (_url, opts) => { auth = opts.headers.authorization; return _res({ json: { choices: [{ message: { content: 'ok' } }] } }); };
55
+ await chat({ baseUrl: 'https://api.x.ai/v1', model: 'grok-2', messages: [], apiKeyEnv: 'XAI_KEY', env: { XAI_KEY: 'sk-123' }, fetchImpl });
56
+ assert.equal(auth, 'Bearer sk-123');
57
+
58
+ let thrown = null;
59
+ try { await chat({ baseUrl: 'https://api.x.ai/v1', model: 'grok-2', messages: [], apiKeyEnv: 'XAI_KEY', env: {}, fetchImpl }); }
60
+ catch (e) { thrown = e; }
61
+ assert.equal(thrown && thrown.code, 'provider-missing-api-key');
62
+ });
63
+
64
+ test('OAC-5: non-2xx throws provider-http-error carrying status + host (not full url)', async () => {
65
+ const fetchImpl = async () => _res({ ok: false, status: 500, text: 'boom' });
66
+ let thrown = null;
67
+ try { await chat({ baseUrl: 'http://localhost:11434/v1', model: 'qwen', messages: [], fetchImpl }); }
68
+ catch (e) { thrown = e; }
69
+ assert.equal(thrown.code, 'provider-http-error');
70
+ assert.equal(thrown.details.status, 500);
71
+ assert.equal(thrown.details.host, 'localhost:11434');
72
+ });
73
+
74
+ test('OAC-6: network failure throws provider-request-failed with host only', async () => {
75
+ const fetchImpl = async () => { const e = new Error('refused'); e.code = 'ECONNREFUSED'; throw e; };
76
+ let thrown = null;
77
+ try { await chat({ baseUrl: 'http://localhost:11434/v1', model: 'qwen', messages: [], fetchImpl }); }
78
+ catch (e) { thrown = e; }
79
+ assert.equal(thrown.code, 'provider-request-failed');
80
+ assert.equal(thrown.details.host, 'localhost:11434');
81
+ });
82
+
83
+ test('OAC-7: missing base_url / model throw before any fetch', async () => {
84
+ let a = null; try { await chat({ model: 'm', messages: [] }); } catch (e) { a = e; }
85
+ assert.equal(a.code, 'provider-no-base-url');
86
+ let b = null; try { await chat({ baseUrl: 'http://x/v1', messages: [] }); } catch (e) { b = e; }
87
+ assert.equal(b.code, 'provider-no-model');
88
+ });
89
+
90
+ test('OAC-8: _parse synthesizes a stable id when the provider omits tool_calls[].id', () => {
91
+ const out = _parse({ choices: [{ message: { tool_calls: [
92
+ { function: { name: 'Read', arguments: '{}' } },
93
+ { function: { name: 'Grep', arguments: '{}' } },
94
+ ] } }] });
95
+ assert.deepEqual(out.toolCalls.map((t) => t.id), ['call_0', 'call_1']);
96
+ });
97
+
98
+ test('OAC-9: _parse captures token usage when present', () => {
99
+ const out = _parse({ choices: [{ message: { content: 'x' } }], usage: { prompt_tokens: 12, completion_tokens: 5 } });
100
+ assert.deepEqual(out.usage, { tokens_in: 12, tokens_out: 5 });
101
+ const none = _parse({ choices: [{ message: { content: 'x' } }] });
102
+ assert.equal(none.usage, null);
103
+ });
@@ -0,0 +1,415 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { spawnSync } = require('node:child_process');
6
+ const { NubosPilotError } = require('../../core.cjs');
7
+ const { assertInsideBase } = require('../../safe-path.cjs');
8
+ const { scanContent, _looksCatastrophic } = require('../../security/scan.cjs');
9
+ const { search: knowledgeSearch } = require('../../knowledge.cjs');
10
+ const { recordSearchEvidence } = require('../../nubosloop-audit.cjs');
11
+
12
+ const MAX_FILE_BYTES = 1024 * 1024;
13
+ const MAX_READ_LINES = 2000;
14
+ const MAX_GREP_MATCHES = 200;
15
+ const MAX_GLOB_RESULTS = 500;
16
+ const MAX_WALK_ENTRIES = 20000;
17
+ const IGNORE_DIRS = new Set(['node_modules', '.git', 'coverage', '.next', 'dist', 'build', 'vendor']);
18
+
19
+ const DEFAULT_BASH_TIMEOUT_MS = 120000;
20
+ const MAX_BASH_OUTPUT = 30000;
21
+
22
+ const BASH_DENYLIST = [
23
+ { re: /\brm\s+(-[a-z]*\s+)*-[a-z]*[rf][a-z]*\s+(-[a-z]+\s+)*(\/|~|\/\*|\$HOME)(\s|$)/, why: 'recursive delete of / or $HOME' },
24
+ { re: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, why: 'fork bomb' },
25
+ { re: /\bmkfs(\.\w+)?\b/, why: 'filesystem format' },
26
+ { re: /\bdd\b[^\n]*\bof=\/dev\//, why: 'raw write to a device' },
27
+ { re: />\s*\/dev\/(sd|nvme|disk)/, why: 'redirect into a block device' },
28
+ { re: /\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(sh|bash|zsh)\b/, why: 'pipe-from-network into a shell' },
29
+ { re: /\bsudo\b/, why: 'privilege escalation' },
30
+ { re: /\bchmod\s+(-R\s+)?0?777\s+\//, why: 'world-writable on /' },
31
+ { re: /\bgit\b[^\n]*\bpush\b/, why: 'git push (publishing is out of scope for an executor)' },
32
+ ];
33
+
34
+ function _looksBinary(buf) {
35
+ const n = Math.min(buf.length, 8000);
36
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
37
+ return false;
38
+ }
39
+
40
+ function _walk(root, onFile) {
41
+ let count = 0;
42
+ const stack = [root];
43
+ while (stack.length) {
44
+ const dir = stack.pop();
45
+ let entries;
46
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
47
+ for (const ent of entries) {
48
+ if (++count > MAX_WALK_ENTRIES) return;
49
+ const full = path.join(dir, ent.name);
50
+ if (ent.isDirectory()) {
51
+ if (!IGNORE_DIRS.has(ent.name) && !ent.name.startsWith('.')) stack.push(full);
52
+ } else if (ent.isFile()) {
53
+ onFile(full);
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ function _globToRegex(glob) {
60
+ let re = '';
61
+ for (let i = 0; i < glob.length; i++) {
62
+ const c = glob[i];
63
+ if (c === '*') {
64
+ if (glob[i + 1] === '*') { re += '.*'; i++; if (glob[i + 1] === '/') i++; }
65
+ else re += '[^/]*';
66
+ } else if (c === '?') {
67
+ re += '[^/]';
68
+ } else {
69
+ re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
70
+ }
71
+ }
72
+ return new RegExp('^' + re + '$');
73
+ }
74
+
75
+ function _read(args, ctx) {
76
+ const rel = args && args.path;
77
+ if (typeof rel !== 'string' || !rel) throw new NubosPilotError('tool-bad-args', 'Read requires a "path"', {});
78
+ const abs = assertInsideBase(ctx.cwd, rel, 'Read path');
79
+ const stat = fs.statSync(abs);
80
+ if (stat.size > MAX_FILE_BYTES) throw new NubosPilotError('tool-file-too-large', 'file exceeds 1MB: ' + path.basename(abs), {});
81
+ const buf = fs.readFileSync(abs);
82
+ if (_looksBinary(buf)) return '[binary file omitted: ' + path.basename(abs) + ']';
83
+ const lines = buf.toString('utf-8').split('\n');
84
+ const offset = Math.max(0, (args && Number(args.offset)) || 0);
85
+ const limit = Math.min(MAX_READ_LINES, (args && Number(args.limit)) || MAX_READ_LINES);
86
+ const slice = lines.slice(offset, offset + limit);
87
+ return slice.map((l, i) => (offset + i + 1) + '\t' + l).join('\n');
88
+ }
89
+
90
+ function _glob(args, ctx) {
91
+ const pattern = (args && args.pattern) || '**/*';
92
+ const re = _globToRegex(pattern);
93
+ const matches = [];
94
+ _walk(ctx.cwd, (full) => {
95
+ if (matches.length >= MAX_GLOB_RESULTS) return;
96
+ const relPath = path.relative(ctx.cwd, full);
97
+ if (re.test(relPath)) matches.push(relPath);
98
+ });
99
+ matches.sort();
100
+ if (!matches.length) return 'no files match: ' + pattern;
101
+ return matches.join('\n');
102
+ }
103
+
104
+ function _grep(args, ctx) {
105
+ const pattern = args && args.pattern;
106
+ if (typeof pattern !== 'string' || !pattern) throw new NubosPilotError('tool-bad-args', 'Grep requires a "pattern"', {});
107
+ if (_looksCatastrophic(pattern)) {
108
+ throw new NubosPilotError('tool-bad-args', 'Grep pattern rejected as potentially catastrophic (ReDoS)', {});
109
+ }
110
+ let re;
111
+ try { re = new RegExp(pattern, (args && args.ignore_case) ? 'i' : ''); }
112
+ catch { throw new NubosPilotError('tool-bad-args', 'Grep pattern is not a valid regex', {}); }
113
+ const globRe = (args && args.glob) ? _globToRegex(args.glob) : null;
114
+ const out = [];
115
+ _walk(ctx.cwd, (full) => {
116
+ if (out.length >= MAX_GREP_MATCHES) return;
117
+ const relPath = path.relative(ctx.cwd, full);
118
+ if (globRe && !globRe.test(relPath)) return;
119
+ let buf;
120
+ try { buf = fs.readFileSync(full); } catch { return; }
121
+ if (buf.length > MAX_FILE_BYTES || _looksBinary(buf)) return;
122
+ const lines = buf.toString('utf-8').split('\n');
123
+ for (let i = 0; i < lines.length; i++) {
124
+ if (out.length >= MAX_GREP_MATCHES) break;
125
+ if (re.test(lines[i])) out.push(relPath + ':' + (i + 1) + ':' + lines[i].slice(0, 300));
126
+ }
127
+ });
128
+ if (!out.length) return 'no matches for /' + pattern + '/';
129
+ return out.join('\n');
130
+ }
131
+
132
+ function _scanNote(relPath, content, ctx) {
133
+ let res;
134
+ try { res = scanContent({ filePath: relPath, content, customRulesPath: ctx && ctx.customRulesPath }); }
135
+ catch { return '\n[security] scan unavailable for this write (scanner error) — content NOT verified'; }
136
+ const findings = (res && res.findings) || [];
137
+ if (!findings.length) return '';
138
+ const lines = findings.slice(0, 10).map((f) => ' - ' + f.severity + ' ' + f.category + ' @ line ' + f.line + ': ' + f.rule_name);
139
+ return '\n[security] ' + findings.length + ' finding(s) in the written content — review before relying on it:\n' + lines.join('\n');
140
+ }
141
+
142
+ function _assertNotSymlinkLeaf(abs, label) {
143
+ let lst;
144
+ try { lst = fs.lstatSync(abs); } catch { return; }
145
+ if (lst.isSymbolicLink()) {
146
+ throw new NubosPilotError('tool-path-symlink', label + ' resolves to a symlink — refused (workspace confinement)', { file: path.basename(abs) });
147
+ }
148
+ }
149
+
150
+ function _write(args, ctx) {
151
+ const rel = args && args.path;
152
+ if (typeof rel !== 'string' || !rel) throw new NubosPilotError('tool-bad-args', 'Write requires a "path"', {});
153
+ if (typeof (args && args.content) !== 'string') throw new NubosPilotError('tool-bad-args', 'Write requires string "content"', {});
154
+ const abs = assertInsideBase(ctx.cwd, rel, 'Write path');
155
+ _assertNotSymlinkLeaf(abs, 'Write path');
156
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
157
+ fs.writeFileSync(abs, args.content, 'utf-8');
158
+ return 'wrote ' + rel + ' (' + Buffer.byteLength(args.content, 'utf-8') + ' bytes)' + _scanNote(rel, args.content, ctx);
159
+ }
160
+
161
+ function _edit(args, ctx) {
162
+ const rel = args && args.path;
163
+ const oldStr = args && args.old_string;
164
+ const newStr = args && args.new_string;
165
+ if (typeof rel !== 'string' || !rel) throw new NubosPilotError('tool-bad-args', 'Edit requires a "path"', {});
166
+ if (typeof oldStr !== 'string' || typeof newStr !== 'string') {
167
+ throw new NubosPilotError('tool-bad-args', 'Edit requires string "old_string" and "new_string"', {});
168
+ }
169
+ const abs = assertInsideBase(ctx.cwd, rel, 'Edit path');
170
+ _assertNotSymlinkLeaf(abs, 'Edit path');
171
+ const stat = fs.statSync(abs);
172
+ if (stat.size > MAX_FILE_BYTES) throw new NubosPilotError('tool-file-too-large', 'file exceeds 1MB: ' + path.basename(abs), {});
173
+ const before = fs.readFileSync(abs, 'utf-8');
174
+ const replaceAll = !!(args && args.replace_all);
175
+ const occurrences = before.split(oldStr).length - 1;
176
+ if (occurrences === 0) throw new NubosPilotError('tool-edit-no-match', 'old_string not found in ' + rel, {});
177
+ if (occurrences > 1 && !replaceAll) {
178
+ throw new NubosPilotError('tool-edit-ambiguous', 'old_string occurs ' + occurrences + 'x in ' + rel + ' — pass replace_all or make it unique', {});
179
+ }
180
+ const after = replaceAll
181
+ ? before.split(oldStr).join(newStr)
182
+ : (() => { const idx = before.indexOf(oldStr); return before.slice(0, idx) + newStr + before.slice(idx + oldStr.length); })();
183
+ fs.writeFileSync(abs, after, 'utf-8');
184
+ return 'edited ' + rel + ' (' + (replaceAll ? occurrences + ' replacements' : '1 replacement') + ')' + _scanNote(rel, after, ctx);
185
+ }
186
+
187
+ function _bash(args, ctx) {
188
+ const cmd = args && args.command;
189
+ if (typeof cmd !== 'string' || !cmd.trim()) throw new NubosPilotError('tool-bad-args', 'Bash requires a "command"', {});
190
+ for (const entry of BASH_DENYLIST) {
191
+ if (entry.re.test(cmd)) return 'Error: bash-command-blocked: ' + entry.why;
192
+ }
193
+ const timeout = Math.min(600000, Math.max(1000, (ctx && Number(ctx.bashTimeoutMs)) || (args && Number(args.timeout_ms)) || DEFAULT_BASH_TIMEOUT_MS));
194
+ const res = spawnSync('/bin/sh', ['-c', cmd], {
195
+ cwd: ctx.cwd,
196
+ timeout,
197
+ encoding: 'utf-8',
198
+ maxBuffer: MAX_BASH_OUTPUT * 4,
199
+ stdio: ['ignore', 'pipe', 'pipe'],
200
+ });
201
+ if (res.error) {
202
+ if (res.error.code === 'ETIMEDOUT') return 'Error: bash-timeout: command exceeded ' + timeout + 'ms';
203
+ return 'Error: bash-spawn-failed: ' + (res.error.code || res.error.message);
204
+ }
205
+ let out = (res.stdout || '') + (res.stderr || '');
206
+ let truncated = '';
207
+ if (out.length > MAX_BASH_OUTPUT) { out = out.slice(0, MAX_BASH_OUTPUT); truncated = '\n[output truncated at ' + MAX_BASH_OUTPUT + ' chars]'; }
208
+ const code = res.status == null ? '?' : res.status;
209
+ return '[exit ' + code + ']\n' + out + truncated;
210
+ }
211
+
212
+ function _knowledgeSearch(args, ctx) {
213
+ const query = args && (args.query || args.q);
214
+ if (typeof query !== 'string' || !query.trim()) {
215
+ throw new NubosPilotError('tool-bad-args', 'knowledge-search requires a "query"', {});
216
+ }
217
+ const limit = Math.min(50, Math.max(1, (args && Number(args.limit)) || 10));
218
+ if (ctx && ctx.taskId) {
219
+ try { recordSearchEvidence(ctx.taskId, query, ctx.cwd); } catch {}
220
+ }
221
+ let res;
222
+ try { res = knowledgeSearch(query, ctx.cwd, { limit }); }
223
+ catch (err) { return 'knowledge-search: index unavailable (' + ((err && err.code) || 'error') + ')'; }
224
+ const hits = (res && res.hits) || [];
225
+ if (!hits.length) return 'knowledge-search: no results for "' + query + '"';
226
+ return hits.map((h) => h.rel_path + ':' + h.line_start + ' (score ' + h.score + ')\n ' + String(h.preview || '').slice(0, 200)).join('\n');
227
+ }
228
+
229
+ const TOOLS = {
230
+ 'knowledge-search': {
231
+ run: _knowledgeSearch,
232
+ schema: {
233
+ type: 'function',
234
+ function: {
235
+ name: 'knowledge-search',
236
+ description: 'Search the project knowledge base (codebase docs, prior learnings) before writing code. Returns ranked "path:line (score)" hits. Call this first — it satisfies the Rule-9 search bar.',
237
+ parameters: {
238
+ type: 'object',
239
+ properties: {
240
+ query: { type: 'string', description: 'Free-text search query.' },
241
+ limit: { type: 'integer', description: 'Max hits (default 10).' },
242
+ },
243
+ required: ['query'],
244
+ },
245
+ },
246
+ },
247
+ },
248
+ Read: {
249
+ run: _read,
250
+ schema: {
251
+ type: 'function',
252
+ function: {
253
+ name: 'Read',
254
+ description: 'Read a file from the workspace. Returns up to 2000 lines, each prefixed with its 1-based line number.',
255
+ parameters: {
256
+ type: 'object',
257
+ properties: {
258
+ path: { type: 'string', description: 'Workspace-relative file path.' },
259
+ offset: { type: 'integer', description: 'Zero-based line to start from.' },
260
+ limit: { type: 'integer', description: 'Maximum number of lines to return.' },
261
+ },
262
+ required: ['path'],
263
+ },
264
+ },
265
+ },
266
+ },
267
+ Glob: {
268
+ run: _glob,
269
+ schema: {
270
+ type: 'function',
271
+ function: {
272
+ name: 'Glob',
273
+ description: 'List workspace files matching a glob (supports *, **, ?). Ignores node_modules/.git/vendor and dotdirs.',
274
+ parameters: {
275
+ type: 'object',
276
+ properties: { pattern: { type: 'string', description: 'Glob pattern, e.g. "src/**/*.ts".' } },
277
+ required: ['pattern'],
278
+ },
279
+ },
280
+ },
281
+ },
282
+ Grep: {
283
+ run: _grep,
284
+ schema: {
285
+ type: 'function',
286
+ function: {
287
+ name: 'Grep',
288
+ description: 'Search workspace file contents by regex. Returns up to 200 "relpath:line:text" matches.',
289
+ parameters: {
290
+ type: 'object',
291
+ properties: {
292
+ pattern: { type: 'string', description: 'JavaScript regular expression.' },
293
+ glob: { type: 'string', description: 'Optional glob to restrict which files are scanned.' },
294
+ ignore_case: { type: 'boolean', description: 'Case-insensitive match.' },
295
+ },
296
+ required: ['pattern'],
297
+ },
298
+ },
299
+ },
300
+ },
301
+ Write: {
302
+ run: _write,
303
+ schema: {
304
+ type: 'function',
305
+ function: {
306
+ name: 'Write',
307
+ description: 'Create or overwrite a workspace file with the given content. Confined to the workspace; content is security-scanned.',
308
+ parameters: {
309
+ type: 'object',
310
+ properties: {
311
+ path: { type: 'string', description: 'Workspace-relative file path.' },
312
+ content: { type: 'string', description: 'Full file content to write.' },
313
+ },
314
+ required: ['path', 'content'],
315
+ },
316
+ },
317
+ },
318
+ },
319
+ Edit: {
320
+ run: _edit,
321
+ schema: {
322
+ type: 'function',
323
+ function: {
324
+ name: 'Edit',
325
+ description: 'Replace an exact string in a workspace file. old_string must be unique unless replace_all is set.',
326
+ parameters: {
327
+ type: 'object',
328
+ properties: {
329
+ path: { type: 'string', description: 'Workspace-relative file path.' },
330
+ old_string: { type: 'string', description: 'Exact text to replace.' },
331
+ new_string: { type: 'string', description: 'Replacement text.' },
332
+ replace_all: { type: 'boolean', description: 'Replace every occurrence instead of requiring uniqueness.' },
333
+ },
334
+ required: ['path', 'old_string', 'new_string'],
335
+ },
336
+ },
337
+ },
338
+ },
339
+ Bash: {
340
+ run: _bash,
341
+ schema: {
342
+ type: 'function',
343
+ function: {
344
+ name: 'Bash',
345
+ description: 'Run a shell command in the workspace. Timed out and output-capped; catastrophic commands are blocked. Returns "[exit N]\\n<output>".',
346
+ parameters: {
347
+ type: 'object',
348
+ properties: {
349
+ command: { type: 'string', description: 'Shell command to execute.' },
350
+ timeout_ms: { type: 'integer', description: 'Optional timeout (ms), capped at 600000.' },
351
+ },
352
+ required: ['command'],
353
+ },
354
+ },
355
+ },
356
+ },
357
+ };
358
+
359
+ const READ_ONLY_TOOL_NAMES = Object.freeze(['Read', 'Glob', 'Grep']);
360
+ const MUTATING_TOOL_NAMES = Object.freeze(['Write', 'Edit']);
361
+ const SEARCH_TOOL_NAME = 'knowledge-search';
362
+ const IMPLEMENTED_TOOL_NAMES = Object.freeze([...READ_ONLY_TOOL_NAMES, ...MUTATING_TOOL_NAMES, 'Bash', SEARCH_TOOL_NAME]);
363
+
364
+ function toolsetFor(declaredNames, opts) {
365
+ const o = opts || {};
366
+ const declared = Array.isArray(declaredNames) ? declaredNames : [];
367
+ let allowed = READ_ONLY_TOOL_NAMES.slice();
368
+ if (!o.readOnly) {
369
+ allowed = allowed.concat(MUTATING_TOOL_NAMES);
370
+ if (o.allowBash === true) allowed.push('Bash');
371
+ }
372
+ const names = declared.filter((n) => allowed.includes(n));
373
+ if (o.withSearch && !names.includes(SEARCH_TOOL_NAME)) names.push(SEARCH_TOOL_NAME);
374
+ const extraCtx = o.ctx || {};
375
+ return {
376
+ names,
377
+ schemas: names.map((n) => TOOLS[n].schema),
378
+ execute(name, argsJson, ctx) {
379
+ return execute(name, argsJson, Object.assign({}, extraCtx, ctx), names);
380
+ },
381
+ };
382
+ }
383
+
384
+ function execute(name, argsJson, ctx, allowed) {
385
+ if (allowed && !allowed.includes(name)) {
386
+ return 'Error: tool "' + name + '" is not available to this agent';
387
+ }
388
+ const tool = TOOLS[name];
389
+ if (!tool) return 'Error: unknown tool "' + name + '"';
390
+ let args = {};
391
+ if (typeof argsJson === 'string' && argsJson.trim()) {
392
+ try { args = JSON.parse(argsJson); }
393
+ catch { return 'Error: tool "' + name + '" received arguments that are not valid JSON'; }
394
+ } else if (argsJson && typeof argsJson === 'object') {
395
+ args = argsJson;
396
+ }
397
+ try {
398
+ return String(tool.run(args, ctx || { cwd: process.cwd() }));
399
+ } catch (err) {
400
+ if (err && err.name === 'NubosPilotError') return 'Error: ' + err.code + ': ' + err.message;
401
+ return 'Error: ' + ((err && err.message) || 'tool execution failed');
402
+ }
403
+ }
404
+
405
+ module.exports = {
406
+ TOOLS,
407
+ READ_ONLY_TOOL_NAMES,
408
+ MUTATING_TOOL_NAMES,
409
+ SEARCH_TOOL_NAME,
410
+ IMPLEMENTED_TOOL_NAMES,
411
+ BASH_DENYLIST,
412
+ toolsetFor,
413
+ execute,
414
+ _globToRegex,
415
+ };