@asobacloud/nehanda 0.0.0-stage → 0.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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.md +57 -0
  3. package/README.md +444 -2
  4. package/bin/agent.mjs +1260 -0
  5. package/bin/nehanda-ui.mjs +824 -0
  6. package/lib/anthropicHeaders.mjs +8 -0
  7. package/lib/app.mjs +313 -0
  8. package/lib/auth.mjs +255 -0
  9. package/lib/bashguard.mjs +159 -0
  10. package/lib/compact.mjs +306 -0
  11. package/lib/energy-middleware.mjs +318 -0
  12. package/lib/hookShapeOrder.mjs +30 -0
  13. package/lib/hookplane.mjs +228 -0
  14. package/lib/instructions.mjs +23 -0
  15. package/lib/jev_read.mjs +216 -0
  16. package/lib/jev_write.mjs +253 -0
  17. package/lib/laya.mjs +296 -0
  18. package/lib/laya_sidecar.mjs +198 -0
  19. package/lib/mcp-config.mjs +189 -0
  20. package/lib/mcp.mjs +169 -0
  21. package/lib/modelConfig.mjs +152 -0
  22. package/lib/modelDiscovery.mjs +205 -0
  23. package/lib/openaiCompat.mjs +147 -0
  24. package/lib/orchestrate.mjs +1163 -0
  25. package/lib/paths.mjs +20 -0
  26. package/lib/permissions.mjs +44 -0
  27. package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
  28. package/lib/scripts/audit-code-integrity.py +437 -0
  29. package/lib/scripts/ensure-laya-env.sh +55 -0
  30. package/lib/scripts/js-safety-checker.cjs +308 -0
  31. package/lib/scripts/laya_worker.py +171 -0
  32. package/lib/scripts/odse-transform.py +97 -0
  33. package/lib/scripts/python-safety-checker.py +197 -0
  34. package/lib/scripts/shell-safety-checker.sh +306 -0
  35. package/lib/settings.mjs +147 -0
  36. package/lib/store.mjs +278 -0
  37. package/lib/team.mjs +69 -0
  38. package/lib/testgen.mjs +146 -0
  39. package/lib/tools/AuditCodeIntegrity.json +30 -0
  40. package/lib/tools/JsSafetyChecker.json +29 -0
  41. package/lib/tools/PythonSafetyChecker.json +29 -0
  42. package/lib/tools/ShellSafetyChecker.json +29 -0
  43. package/lib/tools.mjs +907 -0
  44. package/lib/transcript.mjs +223 -0
  45. package/lib/ui.mjs +286 -0
  46. package/lib/workflow.mjs +91 -0
  47. package/lib/worktree.mjs +56 -0
  48. package/package.json +50 -4
  49. package/schema.sql +181 -0
  50. package/scripts/postinstall.mjs +62 -0
  51. package/scripts/sdlc-acceptance.sh +477 -0
  52. package/scripts/verify-sdlc-hook-order.mjs +26 -0
  53. package/templates/test_e2e.sh +32 -0
  54. package/templates/test_file.sh +14 -0
  55. package/templates/test_hook.sh +27 -0
  56. package/templates/test_http.sh +17 -0
  57. package/templates/test_phase.sh +23 -0
  58. package/templates/test_process.sh +14 -0
  59. package/templates/test_tool.sh +24 -0
  60. package/templates/validate_test.sh +55 -0
package/lib/tools.mjs ADDED
@@ -0,0 +1,907 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { spawn } from 'node:child_process'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { setPhase, getPhase } from './workflow.mjs'
6
+ import { withTransaction } from './store.mjs'
7
+ import { getMcpServer, listMcpServers, mcpListResources, mcpReadResource } from './mcp.mjs'
8
+ import { applyEnergyNormalization } from './energy-middleware.mjs'
9
+ import { runHooks } from './hookplane.mjs'
10
+ import { createWorktree, removeWorktree, getCurrentWorktree } from './worktree.mjs'
11
+ import { addTeammate, setTeammateIdle } from './team.mjs'
12
+ import { validateBashCommand } from './bashguard.mjs'
13
+
14
+ const BASH_CAP = 1048576
15
+ const TRUNC = '[SDLC_TRUNCATED]'
16
+ const READ_CAP = 1048576
17
+
18
+ // Dynamically load declarative tools from the 'tools' directory
19
+ const TOOLS_CONFIG_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), 'tools')
20
+ export const dynamicTools = new Map()
21
+
22
+ export const EXPLORE_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep'])
23
+ const PLANNING_BLOCKED_TOOLS = new Set(['Write', 'Edit', 'Bash', 'NotebookEdit'])
24
+
25
+ try {
26
+ if (fs.existsSync(TOOLS_CONFIG_DIR)) {
27
+ const files = fs.readdirSync(TOOLS_CONFIG_DIR)
28
+ for (const file of files) {
29
+ if (file.endsWith('.json')) {
30
+ const filePath = path.join(TOOLS_CONFIG_DIR, file)
31
+ const config = JSON.parse(fs.readFileSync(filePath, 'utf8'))
32
+ dynamicTools.set(config.name, config)
33
+
34
+ if (config.phases?.explore_only) {
35
+ EXPLORE_ONLY_TOOLS.add(config.name)
36
+ }
37
+ if (config.phases?.planning_blocked) {
38
+ PLANNING_BLOCKED_TOOLS.add(config.name)
39
+ }
40
+ }
41
+ }
42
+ }
43
+ } catch (err) {
44
+ console.error('Failed to load declarative tools:', err)
45
+ }
46
+
47
+ // ─── Tool dispatch ────────────────────────────────────────────
48
+
49
+ export async function executeBuiltinTool(db, ctx, toolName, toolInput, io) {
50
+ const { conversationId, cwd } = ctx
51
+
52
+ // The harness owns phase-based tool filtering. If a model calls a blocked tool
53
+ // anyway (e.g. hallucination), enforce it here as a hard stop.
54
+ const phase = getPhase(db, conversationId)
55
+ if (phase === 'planning' && PLANNING_BLOCKED_TOOLS.has(toolName)) {
56
+ return { content: `Tool ${toolName} is not available in planning phase. Use Read, Glob, or Grep to explore the codebase, then write your plan as text.`, is_error: true }
57
+ }
58
+
59
+ // §1 Bug fix: surface JSON parse failures from streaming tool call arguments
60
+ if (toolInput?._parseError) {
61
+ const raw = String(toolInput._raw || '').slice(0, 200)
62
+ return { content: `Tool call arguments could not be parsed (truncated JSON): ${raw}`, is_error: true }
63
+ }
64
+
65
+ try {
66
+ switch (toolName) {
67
+ case 'Read': return await toolRead(cwd, toolInput)
68
+ case 'Write': return toolWrite(cwd, toolInput, db, ctx)
69
+ case 'Edit': return toolEdit(cwd, toolInput, db, ctx)
70
+ case 'Glob': return toolGlob(cwd, toolInput)
71
+ case 'Grep': return toolGrep(cwd, toolInput)
72
+ case 'Bash': return await toolBash(cwd, toolInput, db, ctx)
73
+ case 'NotebookEdit': return toolNotebookEdit(cwd, toolInput)
74
+ case 'WebFetch': return await toolWebFetch(toolInput)
75
+ case 'WebSearch': return await toolWebSearch(toolInput)
76
+ case 'AskUserQuestion': return await toolAskUser(io, toolInput)
77
+ case 'Brief': return toolBrief(io, toolInput)
78
+ case 'TodoWrite': return toolTodoWrite(db, ctx, toolInput)
79
+ case 'TaskOutput': return toolTaskOutput(db, ctx, toolInput)
80
+ case 'TaskStop': return toolTaskStop(toolInput)
81
+ // Phase transitions are owned by the orchestrator harness, not the model.
82
+ // If the model somehow calls these, return a clear error.
83
+ case 'EnterPlanMode':
84
+ case 'ExitPlanMode':
85
+ case 'SubmitImplementation':
86
+ case 'SubmitTest':
87
+ return { content: `[SDLC] ${toolName} is not a model tool. Phase transitions are managed by the harness.`, is_error: true }
88
+ case 'Agent': return await toolAgent(db, ctx, toolInput, io)
89
+ case 'Skill': return toolSkill(cwd, toolInput)
90
+ case 'ToolSearch': return toolSearchFn(toolInput)
91
+ case 'EnterWorktree': return await toolEnterWorktree(db, ctx, toolInput)
92
+ case 'ExitWorktree': return await toolExitWorktree(db, ctx, toolInput)
93
+ case 'ListMcpResources': return await toolListMcpResources(ctx, toolInput)
94
+ case 'ReadMcpResource': return await toolReadMcpResource(ctx, toolInput)
95
+ default:
96
+ if (dynamicTools.has(toolName)) {
97
+ return await executeDynamicScriptTool(cwd, toolName, toolInput)
98
+ }
99
+ if (toolName.startsWith('mcp__')) return await toolMcpInvoke(ctx, toolName, toolInput)
100
+ return { content: `Unknown tool: ${toolName}`, is_error: true }
101
+ }
102
+ } catch (e) {
103
+ return { content: `[SDLC_INTERNAL] ${String(e?.message || e)}`, is_error: true }
104
+ }
105
+ }
106
+
107
+ // ─── Read ─────────────────────────────────────────────────────
108
+
109
+ async function toolRead(cwd, input) {
110
+ const rel = input?.file_path
111
+ if (!rel || typeof rel !== 'string') return { content: 'Read: missing file_path', is_error: true }
112
+ const abs = path.isAbsolute(rel) ? path.resolve(rel) : path.resolve(cwd, rel)
113
+ if (!fs.existsSync(abs)) return { content: `Read: not found: ${rel}`, is_error: true }
114
+ const stat = fs.statSync(abs)
115
+ if (stat.isDirectory()) return { content: `Read: ${rel} is a directory`, is_error: true }
116
+ const buf = fs.readFileSync(abs)
117
+ if (buf.length > READ_CAP) {
118
+ return { content: `Read ${rel}: ${buf.length} bytes (truncated to ${READ_CAP})\n---\n${buf.subarray(0, READ_CAP).toString('utf8')}\n${TRUNC}`, is_error: false }
119
+ }
120
+ const text = buf.toString('utf8')
121
+ const lines = text.split('\n')
122
+ const offset = input?.offset || 0
123
+ const limit = input?.limit || lines.length
124
+ const slice = lines.slice(offset, offset + limit)
125
+ const numbered = slice.map((l, i) => `${offset + i + 1}\t${l}`).join('\n')
126
+ return { content: `Read ${rel}: ${lines.length} lines.\n---\n${numbered}`, is_error: false }
127
+ }
128
+
129
+ // ─── Write ────────────────────────────────────────────────────
130
+
131
+ function toolWrite(cwd, input, db, ctx) {
132
+ const rel = input?.file_path
133
+ const content = input?.content
134
+ if (!rel || typeof rel !== 'string') return { content: 'Write: missing file_path', is_error: true }
135
+ if (typeof content !== 'string') return { content: 'Write: missing content', is_error: true }
136
+ const abs = path.isAbsolute(rel) ? path.resolve(rel) : path.resolve(cwd, rel)
137
+ if (abs !== path.resolve(cwd) && !abs.startsWith(path.resolve(cwd) + path.sep)) {
138
+ return { content: 'Write: path outside working directory', is_error: true }
139
+ }
140
+ const existed = fs.existsSync(abs)
141
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
142
+ fs.writeFileSync(abs, content, 'utf8')
143
+ const lines = content.split('\n').length
144
+ // §3 FileChanged hook
145
+ if (process.env.SDLC_DISABLE_ALL_HOOKS !== '1' && db && ctx) {
146
+ const hookRt = { sessionId: ctx.sessionId, conversationId: ctx.conversationId, runtimeDbPath: process.env.AGENT_SDLC_DB || '', cwd, settings: ctx.settings }
147
+ runHooks(db, hookRt, 'FileChanged', { file_path: abs, event: existed ? 'change' : 'add' }).catch(() => {})
148
+ }
149
+ return { content: `${existed ? 'Updated' : 'Created'} ${rel} (${lines} lines)`, is_error: false }
150
+ }
151
+
152
+ // ─── Edit ─────────────────────────────────────────────────────
153
+
154
+ function toolEdit(cwd, input, db, ctx) {
155
+ const rel = input?.file_path
156
+ const oldStr = input?.old_string
157
+ const newStr = input?.new_string
158
+ if (!rel) return { content: 'Edit: missing file_path', is_error: true }
159
+ if (typeof oldStr !== 'string') return { content: 'Edit: missing old_string', is_error: true }
160
+ if (typeof newStr !== 'string') return { content: 'Edit: missing new_string', is_error: true }
161
+ const abs = path.isAbsolute(rel) ? path.resolve(rel) : path.resolve(cwd, rel)
162
+ if (abs !== path.resolve(cwd) && !abs.startsWith(path.resolve(cwd) + path.sep)) {
163
+ return { content: 'Edit: path outside working directory', is_error: true }
164
+ }
165
+ if (!fs.existsSync(abs)) return { content: `Edit: not found: ${rel}`, is_error: true }
166
+ let text = fs.readFileSync(abs, 'utf8')
167
+ if (input?.replace_all) {
168
+ if (!text.includes(oldStr)) return { content: `Edit: old_string not found in ${rel}`, is_error: true }
169
+ text = text.replaceAll(oldStr, newStr)
170
+ } else {
171
+ const idx = text.indexOf(oldStr)
172
+ if (idx === -1) return { content: `Edit: old_string not found in ${rel}`, is_error: true }
173
+ const count = text.split(oldStr).length - 1
174
+ if (count > 1) return { content: `Edit: old_string matches ${count} locations in ${rel} — provide more context to make it unique, or use replace_all`, is_error: true }
175
+ text = text.slice(0, idx) + newStr + text.slice(idx + oldStr.length)
176
+ }
177
+ fs.writeFileSync(abs, text, 'utf8')
178
+ if (process.env.SDLC_DISABLE_ALL_HOOKS !== '1' && db && ctx) {
179
+ const hookRt = { sessionId: ctx.sessionId, conversationId: ctx.conversationId, runtimeDbPath: process.env.AGENT_SDLC_DB || '', cwd, settings: ctx.settings }
180
+ runHooks(db, hookRt, 'FileChanged', { file_path: abs, event: 'change' }).catch(() => {})
181
+ }
182
+ return { content: `Edited ${rel}`, is_error: false }
183
+ }
184
+
185
+ // ─── Glob ─────────────────────────────────────────────────────
186
+
187
+ function toolGlob(cwd, input) {
188
+ const pattern = input?.pattern
189
+ if (!pattern) return { content: 'Glob: missing pattern', is_error: true }
190
+ const searchDir = input?.path ? path.resolve(cwd, input.path) : cwd
191
+ if (!fs.existsSync(searchDir)) return { content: `Glob: directory not found: ${searchDir}`, is_error: true }
192
+ const results = []
193
+ const maxResults = 100
194
+ globWalk(searchDir, pattern, results, maxResults, searchDir)
195
+ return { content: results.length ? results.join('\n') : '(no matches)', is_error: false }
196
+ }
197
+
198
+ function globWalk(dir, pattern, results, max, root) {
199
+ if (results.length >= max) return
200
+ let entries
201
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
202
+ for (const e of entries) {
203
+ if (results.length >= max) return
204
+ if (e.name.startsWith('.') && !pattern.startsWith('.')) continue
205
+ const full = path.join(dir, e.name)
206
+ const rel = path.relative(root, full)
207
+ if (e.isDirectory()) {
208
+ if (e.name === 'node_modules' || e.name === '.git') continue
209
+ globWalk(full, pattern, results, max, root)
210
+ } else {
211
+ if (globMatch(pattern, rel) || globMatch(pattern, e.name)) results.push(rel)
212
+ }
213
+ }
214
+ }
215
+
216
+ function globMatch(pattern, str) {
217
+ const regex = pattern
218
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
219
+ .replace(/\*\*/g, '<<<GLOBSTAR>>>')
220
+ .replace(/\*/g, '[^/]*')
221
+ .replace(/<<<GLOBSTAR>>>/g, '.*')
222
+ .replace(/\?/g, '[^/]')
223
+ return new RegExp(`^${regex}$`).test(str)
224
+ }
225
+
226
+ // ─── Grep ─────────────────────────────────────────────────────
227
+
228
+ function toolGrep(cwd, input) {
229
+ const pattern = input?.pattern
230
+ if (!pattern) return { content: 'Grep: missing pattern', is_error: true }
231
+ const searchPath = input?.path ? path.resolve(cwd, input.path) : cwd
232
+ const mode = input?.output_mode || 'files_with_matches'
233
+ const caseI = input?.['-i'] || false
234
+ const headLimit = input?.head_limit ?? 250
235
+ const contextLines = input?.['-C'] || input?.context || 0
236
+ let re
237
+ try { re = new RegExp(pattern, caseI ? 'i' : '') } catch (e) { return { content: `Grep: invalid regex: ${e.message}`, is_error: true } }
238
+
239
+ const stat = fs.existsSync(searchPath) ? fs.statSync(searchPath) : null
240
+ if (!stat) return { content: `Grep: path not found: ${searchPath}`, is_error: true }
241
+
242
+ const matchingFiles = []
243
+ const contentLines = []
244
+ let matchCount = 0
245
+
246
+ function searchFile(filePath) {
247
+ if (headLimit > 0 && matchingFiles.length >= headLimit && mode === 'files_with_matches') return
248
+ let text
249
+ try { text = fs.readFileSync(filePath, 'utf8') } catch { return }
250
+ const lines = text.split('\n')
251
+ let fileMatched = false
252
+ for (let i = 0; i < lines.length; i++) {
253
+ if (re.test(lines[i])) {
254
+ matchCount++
255
+ if (!fileMatched) { matchingFiles.push(path.relative(cwd, filePath)); fileMatched = true }
256
+ if (mode === 'content') {
257
+ if (headLimit > 0 && contentLines.length >= headLimit) return
258
+ const start = Math.max(0, i - contextLines)
259
+ const end = Math.min(lines.length - 1, i + contextLines)
260
+ for (let j = start; j <= end; j++) {
261
+ const prefix = j === i ? `${j + 1}:` : `${j + 1}-`
262
+ contentLines.push(`${path.relative(cwd, filePath)}:${prefix}${lines[j]}`)
263
+ }
264
+ if (contextLines > 0) contentLines.push('--')
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ function walk(dir) {
271
+ let entries
272
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
273
+ for (const e of entries) {
274
+ if (e.name === 'node_modules' || e.name === '.git' || e.name.startsWith('.')) continue
275
+ const full = path.join(dir, e.name)
276
+ if (e.isDirectory()) walk(full)
277
+ else {
278
+ if (input?.glob && !globMatch(input.glob, e.name)) continue
279
+ searchFile(full)
280
+ }
281
+ }
282
+ }
283
+
284
+ if (stat.isFile()) searchFile(searchPath)
285
+ else walk(searchPath)
286
+
287
+ if (mode === 'files_with_matches') return { content: matchingFiles.join('\n') || '(no matches)', is_error: false }
288
+ if (mode === 'count') return { content: `${matchCount} matches in ${matchingFiles.length} files`, is_error: false }
289
+ return { content: contentLines.join('\n') || '(no matches)', is_error: false }
290
+ }
291
+
292
+ // ─── Bash ─────────────────────────────────────────────────────
293
+
294
+ const CWD_MARKER = '___ONA_CWD___'
295
+
296
+ async function toolBash(cwd, input, db, ctx) {
297
+ const cmd = input?.command
298
+ if (!cmd || typeof cmd !== 'string') return { content: 'Bash: missing command', is_error: true }
299
+ // Validate command safety before execution
300
+ const guard = validateBashCommand(cmd, cwd)
301
+ if (!guard.ok) return { content: `[SDLC] Bash command blocked: ${guard.reason}`, is_error: true }
302
+ const timeoutMs = Math.min(input?.timeout || 120_000, 600_000)
303
+ // Append cwd detection marker
304
+ const wrappedCmd = `${cmd} ; echo "${CWD_MARKER}$(pwd)"`
305
+ return new Promise(resolve => {
306
+ const child = spawn(resolveBash(), ['-c', wrappedCmd], { cwd, env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'] })
307
+ let out = Buffer.alloc(0), err = Buffer.alloc(0)
308
+ const push = (b, chunk) => {
309
+ const n = Buffer.concat([b, chunk])
310
+ return n.length <= BASH_CAP ? n : Buffer.concat([n.subarray(0, BASH_CAP), Buffer.from(TRUNC)])
311
+ }
312
+ child.stdout.on('data', d => { out = push(out, d) })
313
+ child.stderr.on('data', d => { err = push(err, d) })
314
+ const t = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
315
+ child.on('close', code => {
316
+ clearTimeout(t)
317
+ let content = out.toString('utf8')
318
+ // Extract and strip cwd marker
319
+ const markerIdx = content.lastIndexOf(CWD_MARKER)
320
+ let newCwd = null
321
+ if (markerIdx !== -1) {
322
+ newCwd = content.slice(markerIdx + CWD_MARKER.length).trim()
323
+ content = content.slice(0, markerIdx).trimEnd()
324
+ }
325
+ const se = err.toString('utf8')
326
+ if (se) content += `\n--- stderr ---\n${se}`
327
+ // Fire CwdChanged hook if directory changed
328
+ if (newCwd && newCwd !== cwd && db && ctx && process.env.SDLC_DISABLE_ALL_HOOKS !== '1') {
329
+ const hookRt = { sessionId: ctx.sessionId, conversationId: ctx.conversationId, runtimeDbPath: process.env.AGENT_SDLC_DB || '', cwd, settings: ctx.settings }
330
+ process.chdir(newCwd)
331
+ runHooks(db, hookRt, 'CwdChanged', { old_cwd: cwd, new_cwd: newCwd }).catch(() => {})
332
+ }
333
+ resolve({ content, is_error: code !== 0, newCwd: (newCwd && newCwd !== cwd) ? newCwd : null })
334
+ })
335
+ child.on('error', e => { clearTimeout(t); resolve({ content: String(e?.message || e), is_error: true }) })
336
+ })
337
+ }
338
+
339
+ function resolveBash() {
340
+ if (fs.existsSync('/bin/bash')) return '/bin/bash'
341
+ if (fs.existsSync('/usr/bin/bash')) return '/usr/bin/bash'
342
+ return 'bash'
343
+ }
344
+
345
+ // ─── Dynamic Script Tool Executor ───────────────────────────────
346
+
347
+ async function executeDynamicScriptTool(cwd, toolName, input) {
348
+ const config = dynamicTools.get(toolName)
349
+ if (!config) return { content: `Tool ${toolName} config not found`, is_error: true }
350
+
351
+ let scriptPath = config.execution.script
352
+ if (!path.isAbsolute(scriptPath)) {
353
+ scriptPath = path.join(path.dirname(new URL(import.meta.url).pathname), '..', scriptPath)
354
+ }
355
+
356
+ // Override dynamically using environment variable (e.g. AUDIT_CODE_INTEGRITY_SCRIPT for AuditCodeIntegrity)
357
+ const envKey = `${toolName.replace(/([A-Z])/g, '_$1').toUpperCase()}_SCRIPT`.replace(/^_/, '')
358
+ const resolvedScriptPath = process.env[envKey] || scriptPath
359
+
360
+ if (!fs.existsSync(resolvedScriptPath)) {
361
+ return { content: `${toolName}: script not found at ${resolvedScriptPath}.`, is_error: true }
362
+ }
363
+
364
+ // Parse arguments template
365
+ const spawnArgs = []
366
+ const templateArgs = config.execution.args || []
367
+ for (let i = 0; i < templateArgs.length; i++) {
368
+ const arg = templateArgs[i]
369
+ if (arg.startsWith('{{') && arg.endsWith('}}')) {
370
+ const key = arg.slice(2, -2)
371
+ const val = input[key]
372
+ if (val !== undefined) {
373
+ spawnArgs.push(String(val))
374
+ }
375
+ } else {
376
+ // Check if next arg is a placeholder, and if its value is undefined, skip both the flag and the placeholder
377
+ const nextArg = templateArgs[i + 1]
378
+ if (nextArg && nextArg.startsWith('{{') && nextArg.endsWith('}}')) {
379
+ const key = nextArg.slice(2, -2)
380
+ if (input[key] === undefined) {
381
+ i++ // skip next arg
382
+ continue
383
+ }
384
+ }
385
+ spawnArgs.push(arg)
386
+ }
387
+ }
388
+
389
+ const cmd = config.execution.runtime || 'python3'
390
+ const args = [resolvedScriptPath, ...spawnArgs]
391
+ const timeoutMs = Math.min(
392
+ input?.timeout || config.execution.default_timeout || 120_000,
393
+ config.execution.max_timeout || 600_000
394
+ )
395
+
396
+ return new Promise(resolve => {
397
+ const child = spawn(cmd, args, { cwd, env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'] })
398
+ let out = Buffer.alloc(0), err = Buffer.alloc(0)
399
+ const push = (b, chunk) => {
400
+ const n = Buffer.concat([b, chunk])
401
+ return n.length <= BASH_CAP ? n : Buffer.concat([n.subarray(0, BASH_CAP), Buffer.from(TRUNC)])
402
+ }
403
+ child.stdout.on('data', d => { out = push(out, d) })
404
+ child.stderr.on('data', d => { err = push(err, d) })
405
+ const t = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
406
+ child.on('close', code => {
407
+ clearTimeout(t)
408
+ const stdout = out.toString('utf8')
409
+ const stderr = err.toString('utf8')
410
+ if (code !== 0 && code !== 1) {
411
+ resolve({ content: stderr || `${toolName} exited with code ${code}`, is_error: true })
412
+ return
413
+ }
414
+ if (code === 1 && !stdout.trim()) {
415
+ resolve({ content: stderr || `${toolName} execution error (exited with code 1 and empty output)`, is_error: true })
416
+ return
417
+ }
418
+ resolve({ content: stdout, is_error: code === 1 })
419
+ })
420
+ child.on('error', e => { clearTimeout(t); resolve({ content: String(e?.message || e), is_error: true }) })
421
+ })
422
+ }
423
+
424
+ // ─── NotebookEdit ─────────────────────────────────────────────
425
+
426
+ function toolNotebookEdit(cwd, input) {
427
+ const rel = input?.notebook_path
428
+ if (!rel) return { content: 'NotebookEdit: missing notebook_path', is_error: true }
429
+ const abs = path.isAbsolute(rel) ? path.resolve(rel) : path.resolve(cwd, rel)
430
+ if (!fs.existsSync(abs)) return { content: `NotebookEdit: not found: ${rel}`, is_error: true }
431
+ let nb
432
+ try { nb = JSON.parse(fs.readFileSync(abs, 'utf8')) } catch (e) { return { content: `NotebookEdit: parse error: ${e.message}`, is_error: true } }
433
+ const cells = nb.cells || []
434
+ const cellId = input?.cell_id
435
+ const newSource = input?.new_source
436
+ const cellType = input?.cell_type || 'code'
437
+ if (typeof newSource !== 'string') return { content: 'NotebookEdit: missing new_source', is_error: true }
438
+
439
+ const sourceLines = newSource.split('\n').map((l, i, a) => i < a.length - 1 ? l + '\n' : l)
440
+
441
+ if (cellId) {
442
+ const idx = cells.findIndex(c => c.id === cellId || c.metadata?.id === cellId)
443
+ if (idx >= 0) {
444
+ cells[idx].source = sourceLines
445
+ cells[idx].cell_type = cellType
446
+ } else {
447
+ cells.push({ id: cellId, cell_type: cellType, source: sourceLines, metadata: { id: cellId }, outputs: [] })
448
+ }
449
+ } else {
450
+ const id = randomUUID().slice(0, 8)
451
+ cells.unshift({ id, cell_type: cellType, source: sourceLines, metadata: { id }, outputs: [] })
452
+ }
453
+
454
+ nb.cells = cells
455
+ fs.writeFileSync(abs, JSON.stringify(nb, null, 1), 'utf8')
456
+ return { content: `NotebookEdit: updated ${rel} (${cells.length} cells)`, is_error: false }
457
+ }
458
+
459
+ // ─── WebFetch ─────────────────────────────────────────────────
460
+
461
+ async function toolWebFetch(input) {
462
+ const url = input?.url
463
+ if (!url) return { content: 'WebFetch: missing url', is_error: true }
464
+ try { new URL(url) } catch { return { content: `WebFetch: invalid url: ${url}`, is_error: true } }
465
+ const start = Date.now()
466
+ try {
467
+ const resp = await fetch(url, { headers: { 'User-Agent': 'ona-sdlc-repl/0.2' }, signal: AbortSignal.timeout(30_000) })
468
+ const text = await resp.text()
469
+ const capped = text.length > BASH_CAP ? text.slice(0, BASH_CAP) + `\n${TRUNC}` : text
470
+ const duration = Date.now() - start
471
+ if (!resp.ok) return { content: `WebFetch ${resp.status} ${resp.statusText}: ${capped.slice(0, 2000)}`, is_error: true }
472
+ return { content: `WebFetch ${url} (${resp.status}, ${text.length} bytes, ${duration}ms):\n${capped}`, is_error: false }
473
+ } catch (e) {
474
+ return { content: `WebFetch error: ${String(e?.message || e)}`, is_error: true }
475
+ }
476
+ }
477
+
478
+ // ─── WebSearch ────────────────────────────────────────────────
479
+
480
+ async function toolWebSearch(input) {
481
+ const query = input?.query
482
+ if (!query || query.length < 2) return { content: 'WebSearch: query must be ≥2 chars', is_error: true }
483
+ try {
484
+ const url = `https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`
485
+ const resp = await fetch(url, { headers: { 'User-Agent': 'ona-sdlc-repl/0.2' }, signal: AbortSignal.timeout(15_000) })
486
+ const html = await resp.text()
487
+ const results = parseDDGLite(html).slice(0, 8)
488
+ if (!results.length) return { content: `WebSearch: no results for "${query}"`, is_error: false }
489
+ const text = results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet}`).join('\n\n')
490
+ return { content: `WebSearch results for "${query}":\n\n${text}`, is_error: false }
491
+ } catch (e) {
492
+ return { content: `WebSearch error: ${String(e?.message || e)}`, is_error: true }
493
+ }
494
+ }
495
+
496
+ function parseDDGLite(html) {
497
+ const results = []
498
+ const linkRe = /<a[^>]+class="result-link"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi
499
+ const snippetRe = /<td[^>]+class="result-snippet"[^>]*>([\s\S]*?)<\/td>/gi
500
+ const links = [...html.matchAll(linkRe)]
501
+ const snippets = [...html.matchAll(snippetRe)]
502
+ for (let i = 0; i < links.length; i++) {
503
+ const url = links[i][1].replace(/&amp;/g, '&')
504
+ const title = links[i][2].replace(/<[^>]+>/g, '').trim()
505
+ const snippet = (snippets[i]?.[1] || '').replace(/<[^>]+>/g, '').trim()
506
+ if (url && title) results.push({ url, title, snippet })
507
+ }
508
+ if (!results.length) {
509
+ const simpleLinks = [...html.matchAll(/<a[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/gi)]
510
+ for (const m of simpleLinks.slice(0, 8)) {
511
+ const u = m[1]; const t = m[2].replace(/<[^>]+>/g, '').trim()
512
+ if (t.length > 5 && !u.includes('duckduckgo.com')) results.push({ url: u, title: t, snippet: '' })
513
+ }
514
+ }
515
+ return results
516
+ }
517
+
518
+ // ─── AskUserQuestion ──────────────────────────────────────────
519
+
520
+ async function toolAskUser(io, input) {
521
+ const question = input?.question || input?.prompt || 'Please provide input:'
522
+ if (!io?.ask) return { content: 'AskUserQuestion: no interactive input available', is_error: true }
523
+ const answer = await io.ask(`${question}\n> `)
524
+ return { content: String(answer || '').trim() || '(no response)', is_error: false }
525
+ }
526
+
527
+ // ─── Brief ────────────────────────────────────────────────────
528
+
529
+ // Strip BOS/EOS tokens that some models (DeepSeek, Qwen) leak into tool call arguments
530
+ function sanitizeBriefMessage(msg) {
531
+ return msg
532
+ .replace(/<[||]begin[▁_]of[▁_]sentence[||]>/g, '')
533
+ .replace(/<[||]end[▁_]of[▁_]sentence[||]>/g, '')
534
+ .replace(/<\|im_start\|>/g, '')
535
+ .replace(/<\|im_end\|>/g, '')
536
+ .trim()
537
+ }
538
+
539
+ function toolBrief(io, input) {
540
+ const raw = input?.message || input?.content || ''
541
+ const msg = sanitizeBriefMessage(raw)
542
+ if (io?.println) io.println(msg)
543
+ return { content: 'Message displayed.', is_error: false }
544
+ }
545
+
546
+ // ─── TodoWrite ────────────────────────────────────────────────
547
+
548
+ async function toolTodoWrite(db, ctx, input) {
549
+ const todos = input?.todos
550
+ if (!Array.isArray(todos)) return { content: 'TodoWrite: missing todos array', is_error: true }
551
+ const hookRt = { sessionId: ctx.sessionId, conversationId: ctx.conversationId, runtimeDbPath: process.env.AGENT_SDLC_DB || '', cwd: ctx.cwd || process.cwd(), settings: ctx.settings }
552
+ withTransaction(db, () => {
553
+ for (const t of todos) {
554
+ const id = t.id || randomUUID().slice(0, 8)
555
+ db.prepare(`INSERT OR REPLACE INTO state(conversation_id, key, value) VALUES (?, ?, ?)`).run(ctx.conversationId, `todo:${id}`, JSON.stringify(t))
556
+ }
557
+ })
558
+ for (const t of todos) {
559
+ const id = t.id || ''
560
+ if (t.status === 'completed') {
561
+ await runHooks(db, hookRt, 'TaskCompleted', { task_id: id, task_subject: t.content || t.title || '' })
562
+ } else {
563
+ await runHooks(db, hookRt, 'TaskCreated', { task_id: id, task_subject: t.content || t.title || '' })
564
+ }
565
+ }
566
+ return { content: `TodoWrite: ${todos.length} item(s) saved.`, is_error: false }
567
+ }
568
+
569
+ // ─── TaskOutput ───────────────────────────────────────────────
570
+
571
+ function toolTaskOutput(db, ctx, input) {
572
+ const output = input?.output || input?.content || ''
573
+ withTransaction(db, () => {
574
+ db.prepare(`INSERT INTO events(conversation_id, session_id, event_type, detail) VALUES (?, ?, 'task_output', ?)`).run(ctx.conversationId, ctx.sessionId, typeof output === 'string' ? output : JSON.stringify(output))
575
+ })
576
+ return { content: 'Task output recorded.', is_error: false }
577
+ }
578
+
579
+ // ─── TaskStop ─────────────────────────────────────────────────
580
+
581
+ function toolTaskStop(input) {
582
+ const taskId = input?.task_id || input?.shell_id
583
+ if (!taskId) return { content: 'TaskStop: missing task_id', is_error: true }
584
+ // Background tasks tracked by the orchestrator; signal via process group
585
+ try { process.kill(Number(taskId), 'SIGTERM') } catch {}
586
+ return { content: `TaskStop: sent SIGTERM to ${taskId}`, is_error: false }
587
+ }
588
+
589
+ // ─── EnterWorktree ───────────────────────────────────────────
590
+
591
+ async function toolEnterWorktree(db, ctx, input) {
592
+ const name = input?.name
593
+ if (!name) return { content: 'EnterWorktree: missing name', is_error: true }
594
+ const result = createWorktree(ctx.cwd, name)
595
+ if (!result.ok) return { content: `EnterWorktree: ${result.error}`, is_error: true }
596
+ process.chdir(result.worktreePath)
597
+ const hookRt = { sessionId: ctx.sessionId, conversationId: ctx.conversationId, runtimeDbPath: process.env.AGENT_SDLC_DB || '', cwd: ctx.cwd, settings: ctx.settings }
598
+ await runHooks(db, hookRt, 'WorktreeCreate', { name })
599
+ await runHooks(db, hookRt, 'CwdChanged', { old_cwd: ctx.cwd, new_cwd: result.worktreePath })
600
+ return { content: `Entered worktree: ${result.worktreePath} (branch: ${result.worktreeBranch})`, is_error: false }
601
+ }
602
+
603
+ // ─── ExitWorktree ────────────────────────────────────────────
604
+
605
+ async function toolExitWorktree(db, ctx, input) {
606
+ const wt = getCurrentWorktree()
607
+ if (!wt) return { content: 'ExitWorktree: not in a worktree', is_error: true }
608
+ const action = input?.action || 'remove'
609
+ const hookRt = { sessionId: ctx.sessionId, conversationId: ctx.conversationId, runtimeDbPath: process.env.AGENT_SDLC_DB || '', cwd: ctx.cwd, settings: ctx.settings }
610
+ if (action === 'remove') {
611
+ const result = removeWorktree()
612
+ if (!result.ok) return { content: `ExitWorktree: ${result.error}`, is_error: true }
613
+ process.chdir(result.originalCwd)
614
+ await runHooks(db, hookRt, 'WorktreeRemove', { worktree_path: result.removedPath })
615
+ await runHooks(db, hookRt, 'CwdChanged', { old_cwd: ctx.cwd, new_cwd: result.originalCwd })
616
+ return { content: `Exited and removed worktree. Restored to ${result.originalCwd}`, is_error: false }
617
+ }
618
+ // keep — just chdir back without removing
619
+ process.chdir(wt.originalCwd)
620
+ await runHooks(db, hookRt, 'CwdChanged', { old_cwd: ctx.cwd, new_cwd: wt.originalCwd })
621
+ return { content: `Exited worktree (kept at ${wt.worktreePath}). Restored to ${wt.originalCwd}`, is_error: false }
622
+ }
623
+
624
+ // ─── Agent ────────────────────────────────────────────────────
625
+
626
+ async function toolAgent(db, ctx, input, io) {
627
+ const prompt = input?.prompt
628
+ if (!prompt) return { content: 'Agent: missing prompt', is_error: true }
629
+ const desc = input?.description || 'subagent'
630
+ const subSessionId = randomUUID()
631
+ const subConvId = ctx.conversationId // same conversation
632
+
633
+ db.prepare(`INSERT INTO sessions(session_id, conversation_id) VALUES (?,?)`).run(subSessionId, subConvId)
634
+ db.prepare(`INSERT INTO events(conversation_id, session_id, event_type, detail) VALUES (?, ?, 'subagent_start', ?)`).run(subConvId, subSessionId, JSON.stringify({ description: desc }))
635
+ const hookRt = { sessionId: ctx.sessionId, conversationId: ctx.conversationId, runtimeDbPath: process.env.AGENT_SDLC_DB || '', cwd: ctx.cwd, settings: ctx.settings }
636
+ await runHooks(db, hookRt, 'SubagentStart', { agent_id: subSessionId, agent_type: desc })
637
+
638
+ // Run one turn in the sub-session using the same orchestration
639
+ const { runUserTurn } = await import('./orchestrate.mjs')
640
+ const subRt = { ...ctx, sessionId: subSessionId }
641
+ const collected = []
642
+ const subIo = {
643
+ write: s => collected.push(s),
644
+ println: s => collected.push(s + '\n'),
645
+ ask: io?.ask,
646
+ }
647
+
648
+ await runUserTurn(db, subRt, prompt, subIo)
649
+
650
+ const output = collected.join('')
651
+ db.prepare(`INSERT INTO events(conversation_id, session_id, event_type, detail) VALUES (?, ?, 'subagent_stop', ?)`).run(subConvId, subSessionId, JSON.stringify({ description: desc }))
652
+ await runHooks(db, hookRt, 'SubagentStop', { stop_hook_active: false, agent_id: subSessionId, agent_transcript_path: '', agent_type: desc, last_assistant_message: output.slice(0, 500) })
653
+
654
+ // If this is a team member, fire TeammateIdle
655
+ const teamName = input?.team_name || ctx.teamName
656
+ if (teamName) {
657
+ const agentId = `${desc}@${teamName}`
658
+ setTeammateIdle(teamName, agentId)
659
+ await runHooks(db, hookRt, 'TeammateIdle', { teammate_name: desc, team_name: teamName })
660
+ }
661
+
662
+ return { content: output || '(agent produced no output)', is_error: false }
663
+ }
664
+
665
+ // ─── Skill ────────────────────────────────────────────────────
666
+
667
+ function toolSkill(cwd, input) {
668
+ const name = input?.skill
669
+ if (!name) return { content: 'Skill: missing skill name', is_error: true }
670
+
671
+ // Search for skill markdown file in standard locations:
672
+ // .ona/skills/<name>/SKILL.md, .claude/skills/<name>/SKILL.md,
673
+ // .ona/skills/<name>.md, .claude/skills/<name>.md
674
+ const searchDirs = [
675
+ path.join(cwd, '.ona', 'skills'),
676
+ path.join(cwd, '.claude', 'skills'),
677
+ path.join(process.env.HOME || '', '.ona', 'skills'),
678
+ path.join(process.env.HOME || '', '.claude', 'skills'),
679
+ ]
680
+
681
+ for (const dir of searchDirs) {
682
+ if (!fs.existsSync(dir)) continue
683
+ // Directory format: <name>/SKILL.md
684
+ const dirSkill = path.join(dir, name, 'SKILL.md')
685
+ if (fs.existsSync(dirSkill)) {
686
+ try {
687
+ const content = fs.readFileSync(dirSkill, 'utf8')
688
+ return { content: `Skill '${name}':\n\n${content}`, is_error: false }
689
+ } catch (e) {
690
+ return { content: `Skill: failed to read ${dirSkill}: ${e.message}`, is_error: true }
691
+ }
692
+ }
693
+ // Flat format: <name>.md
694
+ const flatSkill = path.join(dir, `${name}.md`)
695
+ if (fs.existsSync(flatSkill)) {
696
+ try {
697
+ const content = fs.readFileSync(flatSkill, 'utf8')
698
+ return { content: `Skill '${name}':\n\n${content}`, is_error: false }
699
+ } catch (e) {
700
+ return { content: `Skill: failed to read ${flatSkill}: ${e.message}`, is_error: true }
701
+ }
702
+ }
703
+ }
704
+
705
+ // No skill file found — return available skills list
706
+ const available = []
707
+ for (const dir of searchDirs) {
708
+ if (!fs.existsSync(dir)) continue
709
+ try {
710
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
711
+ for (const e of entries) {
712
+ if (e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md'))) {
713
+ available.push(e.name)
714
+ } else if (e.isFile() && e.name.endsWith('.md')) {
715
+ available.push(e.name.replace(/\.md$/, ''))
716
+ }
717
+ }
718
+ } catch { /* skip unreadable dirs */ }
719
+ }
720
+
721
+ if (available.length) {
722
+ return { content: `Skill '${name}' not found. Available skills: ${[...new Set(available)].join(', ')}`, is_error: true }
723
+ }
724
+ return { content: `Skill '${name}' not found. No skills directory configured. Create .ona/skills/${name}/SKILL.md to define a skill.`, is_error: true }
725
+ }
726
+
727
+ // ─── ToolSearch ───────────────────────────────────────────────
728
+
729
+ function toolSearchFn(input) {
730
+ const query = (input?.query || '').toLowerCase()
731
+ const max = input?.max_results || 5
732
+ const defs = anthropicToolDefinitions()
733
+ const matches = defs.filter(t => t.name.toLowerCase().includes(query) || t.description.toLowerCase().includes(query)).slice(0, max)
734
+ if (!matches.length) return { content: `ToolSearch: no matches for "${query}"`, is_error: false }
735
+ const text = matches.map(t => `${t.name}: ${t.description}`).join('\n')
736
+ return { content: text, is_error: false }
737
+ }
738
+
739
+ // ─── MCP tools ────────────────────────────────────────────────
740
+
741
+ async function toolListMcpResources(ctx, input) {
742
+ const serverName = input?.server
743
+ const settings = ctx.settings || {}
744
+ const serverConfigs = settings.mcp_servers || {}
745
+ if (serverName) {
746
+ if (!serverConfigs[serverName]) return { content: `MCP server '${serverName}' not configured`, is_error: true }
747
+ const srv = getMcpServer(serverName, serverConfigs[serverName])
748
+ return mcpListResources(srv)
749
+ }
750
+ const names = Object.keys(serverConfigs)
751
+ if (!names.length) return { content: 'No MCP servers configured.', is_error: false }
752
+ const all = []
753
+ for (const n of names) {
754
+ const srv = getMcpServer(n, serverConfigs[n])
755
+ const r = await mcpListResources(srv)
756
+ all.push(`[${n}]\n${r.content}`)
757
+ }
758
+ return { content: all.join('\n\n'), is_error: false }
759
+ }
760
+
761
+ async function toolReadMcpResource(ctx, input) {
762
+ const serverName = input?.server
763
+ const uri = input?.uri
764
+ if (!serverName || !uri) return { content: 'ReadMcpResource: missing server or uri', is_error: true }
765
+ const settings = ctx.settings || {}
766
+ const serverConfigs = settings.mcp_servers || {}
767
+ if (!serverConfigs[serverName]) return { content: `MCP server '${serverName}' not configured`, is_error: true }
768
+ const srv = getMcpServer(serverName, serverConfigs[serverName])
769
+ return mcpReadResource(srv, uri)
770
+ }
771
+
772
+ async function toolMcpInvoke(ctx, toolName, toolInput) {
773
+ // mcp__<server>__<tool>
774
+ const parts = toolName.split('__')
775
+ if (parts.length < 3) return { content: `Invalid MCP tool name: ${toolName}`, is_error: true }
776
+ const serverName = parts[1]
777
+ const mcpToolName = parts.slice(2).join('__')
778
+ const settings = ctx.settings || {}
779
+ const serverConfigs = settings.mcp_servers || {}
780
+ if (!serverConfigs[serverName]) return { content: `MCP server '${serverName}' not configured`, is_error: true }
781
+ const srv = getMcpServer(serverName, serverConfigs[serverName])
782
+ const { mcpCallTool: callTool } = await import('./mcp.mjs')
783
+ const result = await callTool(srv, mcpToolName, toolInput || {})
784
+
785
+ // Pass energy payloads through ODSE normalization if OEM is recognized.
786
+ // On any failure applyEnergyNormalization returns the original content unchanged.
787
+ if (!result.is_error && result.content) {
788
+ const { content } = await applyEnergyNormalization(serverName, result.content)
789
+ return { ...result, content }
790
+ }
791
+
792
+ return result
793
+ }
794
+
795
+ // ─── Tool definitions ─────────────────────────────────────────
796
+
797
+ export function anthropicToolDefinitions() {
798
+ const tools = [
799
+ { name: 'Read', description: 'Read a file from the filesystem.', input_schema: { type: 'object', properties: { file_path: { type: 'string', description: 'Path to file (absolute or relative to cwd)' }, offset: { type: 'integer', description: 'Start line (0-based)' }, limit: { type: 'integer', description: 'Max lines to read' } }, required: ['file_path'] } },
800
+ { name: 'Write', description: 'Create or overwrite a file.', input_schema: { type: 'object', properties: { file_path: { type: 'string' }, content: { type: 'string' } }, required: ['file_path', 'content'] } },
801
+ { name: 'Edit', description: 'Find and replace text in a file.', input_schema: { type: 'object', properties: { file_path: { type: 'string' }, old_string: { type: 'string' }, new_string: { type: 'string' }, replace_all: { type: 'boolean' } }, required: ['file_path', 'old_string', 'new_string'] } },
802
+ { name: 'Glob', description: 'Find files matching a glob pattern.', input_schema: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string', description: 'Directory to search (default: cwd)' } }, required: ['pattern'] } },
803
+ { name: 'Grep', description: 'Search file contents with regex.', input_schema: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' }, output_mode: { type: 'string', enum: ['content', 'files_with_matches', 'count'] }, glob: { type: 'string' }, '-i': { type: 'boolean' }, '-C': { type: 'integer' }, head_limit: { type: 'integer' } }, required: ['pattern'] } },
804
+ { name: 'Bash', description: 'Run a shell command.', input_schema: { type: 'object', properties: { command: { type: 'string' }, timeout: { type: 'integer', description: 'Timeout in ms (max 600000)' } }, required: ['command'] } },
805
+ { name: 'NotebookEdit', description: 'Edit a Jupyter notebook cell.', input_schema: { type: 'object', properties: { notebook_path: { type: 'string' }, cell_id: { type: 'string' }, new_source: { type: 'string' }, cell_type: { type: 'string', enum: ['code', 'markdown'] } }, required: ['notebook_path', 'new_source', 'cell_type'] } },
806
+ { name: 'WebFetch', description: 'Fetch content from a URL.', input_schema: { type: 'object', properties: { url: { type: 'string' }, prompt: { type: 'string' } }, required: ['url'] } },
807
+ { name: 'WebSearch', description: 'Search the web.', input_schema: { type: 'object', properties: { query: { type: 'string' }, allowed_domains: { type: 'array', items: { type: 'string' } }, blocked_domains: { type: 'array', items: { type: 'string' } } }, required: ['query'] } },
808
+ { name: 'AskUserQuestion', description: 'Ask the user a question and wait for response.', input_schema: { type: 'object', properties: { question: { type: 'string' } }, required: ['question'] } },
809
+ { name: 'Brief', description: 'Display a message to the user.', input_schema: { type: 'object', properties: { message: { type: 'string' } }, required: ['message'] } },
810
+ { name: 'TodoWrite', description: 'Create or update todo items.', input_schema: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object' } } }, required: ['todos'] } },
811
+ { name: 'TaskOutput', description: 'Record output from a task.', input_schema: { type: 'object', properties: { output: { type: 'string' } } } },
812
+ { name: 'TaskStop', description: 'Stop a background task.', input_schema: { type: 'object', properties: { task_id: { type: 'string' } } } },
813
+ { name: 'Agent', description: 'Run a subagent with an isolated session.', input_schema: { type: 'object', properties: { description: { type: 'string' }, prompt: { type: 'string' } }, required: ['prompt'] } },
814
+ { name: 'Skill', description: 'Invoke a skill or slash command.', input_schema: { type: 'object', properties: { skill: { type: 'string' }, args: { type: 'string' } }, required: ['skill'] } },
815
+ { name: 'ToolSearch', description: 'Search for available tools.', input_schema: { type: 'object', properties: { query: { type: 'string' }, max_results: { type: 'integer' } }, required: ['query'] } },
816
+ { name: 'EnterWorktree', description: 'Create a git worktree for isolated work.', input_schema: { type: 'object', properties: { name: { type: 'string', description: 'Worktree name (alphanumeric, max 64 chars)' } }, required: ['name'] } },
817
+ { name: 'ExitWorktree', description: 'Exit the current worktree.', input_schema: { type: 'object', properties: { action: { type: 'string', enum: ['keep', 'remove'], description: 'Keep or remove the worktree (default: remove)' } } } },
818
+ { name: 'ListMcpResources', description: 'List MCP server resources.', input_schema: { type: 'object', properties: { server: { type: 'string' } } } },
819
+ { name: 'ReadMcpResource', description: 'Read a resource from an MCP server.', input_schema: { type: 'object', properties: { server: { type: 'string' }, uri: { type: 'string' } }, required: ['server', 'uri'] } },
820
+ ]
821
+ for (const [name, config] of dynamicTools) {
822
+ tools.push({
823
+ name: config.name,
824
+ description: config.description,
825
+ input_schema: config.input_schema
826
+ })
827
+ }
828
+ return tools
829
+ }
830
+
831
+ export function openAICompatToolDefinitions() {
832
+ return anthropicToolDefinitions().map(t => ({
833
+ type: 'function',
834
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
835
+ }))
836
+ }
837
+
838
+ /** Core tools for local models with limited context (§2.1 lm_studio_local). */
839
+ export const LOCAL_CORE_TOOLS = new Set(['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'])
840
+
841
+ // ─── MCP tool discovery ───────────────────────────────────────
842
+
843
+ /** In-memory cache: serverName → { tools, ts } */
844
+ const _mcpToolCache = new Map()
845
+ const MCP_CACHE_TTL_MS = 60_000 // re-discover after 1 minute
846
+
847
+ /**
848
+ * Connect to all configured MCP servers, call tools/list on each, and return
849
+ * tool definitions in Anthropic format. Results are cached per-server for
850
+ * MCP_CACHE_TTL_MS to avoid spawning child processes on every turn.
851
+ *
852
+ * Tool names are namespaced as mcp__<server>__<tool> so they route through
853
+ * toolMcpInvoke() in executeBuiltinTool() without any extra plumbing.
854
+ *
855
+ * @param {object} settings - Effective settings object (settings.mcp_servers used)
856
+ * @returns {Promise<Array>} Anthropic-format tool definitions
857
+ */
858
+ export async function getMcpToolDefinitions(settings) {
859
+ const serverConfigs = settings?.mcp_servers
860
+ if (!serverConfigs || typeof serverConfigs !== 'object') return []
861
+
862
+ const names = Object.keys(serverConfigs)
863
+ if (!names.length) return []
864
+
865
+ const allTools = []
866
+ const now = Date.now()
867
+
868
+ for (const name of names) {
869
+ const config = serverConfigs[name]
870
+ if (!config?.command) continue
871
+
872
+ // Return cached entry if still fresh
873
+ const cached = _mcpToolCache.get(name)
874
+ if (cached && now - cached.ts < MCP_CACHE_TTL_MS) {
875
+ allTools.push(...cached.tools)
876
+ continue
877
+ }
878
+
879
+ try {
880
+ const srv = getMcpServer(name, config)
881
+ const resp = await srv.request('tools/list', {})
882
+ if (resp.error) continue
883
+
884
+ const serverTools = (resp.result?.tools || []).map(t => ({
885
+ name: `mcp__${name}__${t.name}`,
886
+ description: `[MCP:${name}] ${t.description || t.name}`,
887
+ input_schema: t.inputSchema || { type: 'object', properties: {} },
888
+ }))
889
+
890
+ _mcpToolCache.set(name, { tools: serverTools, ts: now })
891
+ allTools.push(...serverTools)
892
+ } catch {
893
+ // Server unreachable — skip silently, don't break the turn
894
+ }
895
+ }
896
+
897
+ return allTools
898
+ }
899
+
900
+ /**
901
+ * Invalidate the MCP tool cache for one server (or all if no name given).
902
+ * Called by /mcp reload.
903
+ */
904
+ export function invalidateMcpToolCache(serverName) {
905
+ if (serverName) _mcpToolCache.delete(serverName)
906
+ else _mcpToolCache.clear()
907
+ }