@asobacloud/nehanda 0.0.0-stage → 0.2.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 +404 -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/bin/agent.mjs ADDED
@@ -0,0 +1,1260 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { randomUUID } from 'node:crypto'
6
+ import { fileURLToPath } from 'node:url'
7
+ import readline from 'node:readline/promises'
8
+ import { stdin as input, stdout as output } from 'node:process'
9
+
10
+ import { openStore } from '../lib/store.mjs'
11
+ import { defaultDbPath } from '../lib/paths.mjs'
12
+ import { resolveAnthropicCredentials, authStatusSummary, saveSecureCredentials, clearSecureCredentials, interactiveOAuthLogin } from '../lib/auth.mjs'
13
+ import { bootstrapSettings, getEffectiveSettings, updateEffectiveSettings } from '../lib/settings.mjs'
14
+ import { resolveWireModel, allModelIds, supportsCustomModels, supportsDiscovery } from '../lib/modelConfig.mjs'
15
+ import { runHooks } from '../lib/hookplane.mjs'
16
+ import { runUserTurn } from '../lib/orchestrate.mjs'
17
+ import { closeAllMcpServers, getMcpServer } from '../lib/mcp.mjs'
18
+ import { applyMcpConfig, loadMcpConfig, writeGlobalMcpConfig, readGlobalMcpConfig, globalMcpConfigPath, getMcpServerSource, updateMcpServerEnv } from '../lib/mcp-config.mjs'
19
+ import { getPhase, canTransition, setPhase } from '../lib/workflow.mjs'
20
+ import { executeBuiltinTool, invalidateMcpToolCache } from '../lib/tools.mjs'
21
+ import { appendEntry, makeUserPayload, makeToolResultPayload } from '../lib/transcript.mjs'
22
+ import { loadInstructions } from '../lib/instructions.mjs'
23
+ import { discoverOllamaModels, formatModelList } from '../lib/modelDiscovery.mjs'
24
+ import { compactConversation } from '../lib/compact.mjs'
25
+ import { createTeam, getTeam, listTeams, deleteTeam } from '../lib/team.mjs'
26
+ import { ensureLayaReady } from '../lib/laya_sidecar.mjs'
27
+
28
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
29
+ const PKG_ROOT = path.resolve(__dirname, '..')
30
+
31
+ function parseArgs(argv) {
32
+ const args = { bare: false, cwd: process.cwd(), help: false, eval: null, transition: null, conversation: null, initDb: false, compact: false }
33
+ for (let i = 2; i < argv.length; i++) {
34
+ if (argv[i] === '--help' || argv[i] === '-h') args.help = true
35
+ else if (argv[i] === '--bare') args.bare = true
36
+ else if (argv[i] === '--cwd' && argv[i + 1]) args.cwd = path.resolve(argv[++i])
37
+ else if (argv[i] === '--eval' && argv[i + 1]) args.eval = argv[++i]
38
+ else if (argv[i] === '--transition' && argv[i + 1]) args.transition = argv[++i]
39
+ else if (argv[i] === '--conversation' && argv[i + 1]) args.conversation = argv[++i]
40
+ else if (argv[i] === '--init-db') args.initDb = true
41
+ else if (argv[i] === '--compact') args.compact = true
42
+ }
43
+ return args
44
+ }
45
+
46
+ // ── Shared bootstrap ─────────────────────────────────────────
47
+ async function autoDetectLmStudioModel() {
48
+ if (process.env.LM_STUDIO_MODEL) return
49
+ try {
50
+ const base = (process.env.LM_STUDIO_BASE_URL || 'http://127.0.0.1:1234/v1').replace(/\/$/, '')
51
+ const resp = await fetch(`${base}/models`, { signal: AbortSignal.timeout(3000) })
52
+ if (!resp.ok) return
53
+ const data = await resp.json()
54
+ const models = data?.data?.filter(m => m.id && !m.id.includes('embedding'))
55
+ if (models?.length) process.env.LM_STUDIO_MODEL = models[0].id
56
+ } catch { /* LM Studio not running — that's fine */ }
57
+ }
58
+
59
+ function bootstrap(opts) {
60
+ const cwd = opts.cwd
61
+ process.chdir(cwd)
62
+
63
+ let dbPath = process.env.AGENT_SDLC_DB
64
+ if (dbPath && !path.isAbsolute(dbPath)) dbPath = path.resolve(cwd, dbPath)
65
+ if (!dbPath) dbPath = defaultDbPath()
66
+ process.env.AGENT_SDLC_DB = dbPath
67
+
68
+ const db = openStore(dbPath)
69
+ let settings = bootstrapSettings(db, cwd)
70
+ applyMcpConfig(settings, cwd) // merge mcp.json → settings.mcp_servers
71
+ const pkg = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8'))
72
+
73
+ let conversationId = randomUUID()
74
+ let sessionId = randomUUID()
75
+ db.prepare(`INSERT INTO conversations(id, project_dir, phase) VALUES (?,?, 'idle')`).run(conversationId, cwd)
76
+ db.prepare(`INSERT INTO sessions(session_id, conversation_id) VALUES (?,?)`).run(sessionId, conversationId)
77
+
78
+ return { db, dbPath, settings, pkg, conversationId, sessionId, cwd }
79
+ }
80
+
81
+ // ── Interactive mode (TTY) — Ink-based UI ────────────────────
82
+ async function mainInteractive(opts) {
83
+ const ctx = bootstrap(opts)
84
+ let { db, dbPath, settings, pkg, conversationId, sessionId, cwd } = ctx
85
+
86
+ const makeHookRt = () => ({
87
+ sessionId, conversationId, runtimeDbPath: dbPath, cwd,
88
+ permissionMode: settings.permissions?.defaultMode ?? 'default', settings,
89
+ })
90
+
91
+ // Load Ona.md instructions
92
+ const { content: onaInstructions, path: onaPath } = loadInstructions(cwd)
93
+
94
+ if (process.env.SDLC_DISABLE_ALL_HOOKS !== '1') {
95
+ await runHooks(db, makeHookRt(), 'Setup', { trigger: 'init' })
96
+ await runHooks(db, makeHookRt(), 'SessionStart', { source: 'startup', model: (() => { try { return resolveWireModel(settings.model_config) } catch { return '' } })() })
97
+ if (onaPath) {
98
+ await runHooks(db, makeHookRt(), 'InstructionsLoaded', { file_path: onaPath, memory_type: 'Project', load_reason: 'session_start' })
99
+ }
100
+ }
101
+
102
+ // Resolve model info for banner
103
+ let wireModel = '(not set)', endpoint = '(not configured)'
104
+ const provider = settings.model_config?.provider || 'lm_studio_local'
105
+ const savedBaseUrl = settings.model_config?.base_url // Use persistent config
106
+
107
+ try {
108
+ wireModel = resolveWireModel(settings.model_config)
109
+ const base = savedBaseUrl || (process.env.LM_STUDIO_BASE_URL || 'http://127.0.0.1:1234/v1').replace(/\/$/, '')
110
+ endpoint = provider === 'lm_studio_local' ? `${base}/chat/completions`
111
+ : provider === 'zhipu' ? 'https://open.bigmodel.cn/api/paas/v4/chat/completions'
112
+ : provider === 'ollama' ? `${savedBaseUrl || 'http://localhost:11434/v1'}/chat/completions` // No more hardcoded IP
113
+ : provider === 'openai_compatible' ? `${savedBaseUrl || process.env.OPENAI_BASE_URL || '(unset)'}/chat/completions`
114
+ : `${process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com'}/v1/messages`
115
+ } catch (e) { wireModel = `(${e.message})` }
116
+
117
+ // Start Ink app
118
+ const { startApp } = await import('../lib/app.mjs')
119
+ const app = startApp({
120
+ version: pkg.version,
121
+ provider,
122
+ wireModel,
123
+ endpoint,
124
+ dbPath,
125
+ cwd,
126
+ bare: opts.bare,
127
+ permissionMode: settings.permissions?.defaultMode ?? 'default',
128
+ onUserInput: async (text) => {
129
+ await handleInput(text)
130
+ },
131
+ onExit: async () => { await shutdown() },
132
+ })
133
+
134
+ async function handleInput(line) {
135
+ const trimmed = line.trim()
136
+ if (!trimmed) return
137
+
138
+ // Slash commands
139
+ if (trimmed === '/exit' || trimmed === '/quit') {
140
+ await shutdown()
141
+ return
142
+ }
143
+
144
+ if (trimmed === '/help') {
145
+ app.addSystemMessage(`SDLC Status (read-only):
146
+ /phase Current phase
147
+ /plan Plan content
148
+ /test Test results
149
+ /verify Coverage report
150
+ /code Implementation status
151
+ /done Workflow status
152
+
153
+ Workflow transitions are automatic:
154
+ Plan → approve → implement → SubmitImplementation → approve → tests → approve → done
155
+
156
+ Tools:
157
+ /init Create Ona.md
158
+ /diff Uncommitted changes
159
+ /cost Token usage
160
+ /doctor Diagnostics
161
+ /permissions Permission rules
162
+ /pr-comments PR comments (requires gh)
163
+ /issue Create GitHub issue
164
+ /compact Compact conversation
165
+ /team Manage teams
166
+
167
+ Session:
168
+ /model [name] Change model
169
+ /models Discover available models
170
+ /login Authenticate
171
+ /logout Clear credentials
172
+ /status Auth status
173
+ /config Settings (try /config set <key> <value>)
174
+ /mcp MCP server management & guide
175
+ /mcp list List all discovered tools & descriptions
176
+ /mcp status Show configured MCP servers
177
+ /mcp env Set MCP env vars (API keys)
178
+ /clear New conversation
179
+ /exit Quit`)
180
+ return
181
+ }
182
+
183
+ if (trimmed === '/status') {
184
+ const s = authStatusSummary({ bareMode: opts.bare, apiKeyHelper: settings.apiKeyHelper })
185
+ const lines = [`Auth: ${s.ok ? 'active' : 'none'} kind: ${s.kind} source: ${s.source} preference: ${s.preference}`]
186
+ if (s.alsoConfigured?.ignoredHints?.length) {
187
+ for (const h of s.alsoConfigured.ignoredHints) lines.push(` ${h}`)
188
+ }
189
+ app.addSystemMessage(lines.join('\n'))
190
+ return
191
+ }
192
+
193
+ if (trimmed === '/logout') {
194
+ clearSecureCredentials()
195
+ app.addSystemMessage('✓ Credentials cleared.')
196
+ return
197
+ }
198
+
199
+ if (trimmed === '/login') {
200
+ if (opts.bare) {
201
+ app.addSystemMessage('Bare mode: only ANTHROPIC_API_KEY / apiKeyHelper allowed (§2.7 A7).')
202
+ return
203
+ }
204
+ app.addSystemMessage('Login: secrets stored in ~/.ona/secure/ (never in SQLite).')
205
+ const kind = await app.askUser('Type (1) API key (2) Bearer token (3) Browser OAuth (4) Zhipu API key:\n')
206
+ if (kind === '1') {
207
+ const key = await app.askUser('ANTHROPIC_API_KEY: ')
208
+ if (!key?.trim()) { app.addSystemMessage('Aborted.'); return }
209
+ saveSecureCredentials({ apiKey: key.trim() }); app.addSystemMessage('Saved API key.')
210
+ } else if (kind === '2') {
211
+ const tok = await app.askUser('Bearer token: ')
212
+ if (!tok?.trim()) { app.addSystemMessage('Aborted.'); return }
213
+ saveSecureCredentials({ bearerToken: tok.trim() }); app.addSystemMessage('Saved bearer token.')
214
+ } else if (kind === '3') {
215
+ try {
216
+ await interactiveOAuthLogin({ println: s => app.addSystemMessage(s) })
217
+ } catch (e) { app.addSystemMessage(`OAuth failed: ${e.message}`) }
218
+ } else if (kind === '4') {
219
+ const key = await app.askUser('ZAI API key: ')
220
+ if (!key?.trim()) { app.addSystemMessage('Aborted.'); return }
221
+ saveSecureCredentials({ zhipuApiKey: key.trim() }); app.addSystemMessage('Saved Zhipu API key.')
222
+ } else { app.addSystemMessage('Invalid choice.') }
223
+ return
224
+ }
225
+
226
+ if (trimmed.startsWith('/model')) {
227
+ const arg = trimmed.slice(6).trim()
228
+ if (!arg) {
229
+ let info = ''
230
+ try {
231
+ const wire = resolveWireModel(settings.model_config)
232
+ info = `Provider: ${settings.model_config.provider}\nModel: ${wire}\n\nAvailable:\n`
233
+ } catch (e) { info = `Error: ${e.message}\n\nAvailable:\n` }
234
+ for (const m of allModelIds()) info += ` ${m.provider} / ${m.model_id}\n`
235
+ info += '\nUsage: /model <name>'
236
+ app.addSystemMessage(info)
237
+ return
238
+ }
239
+ const resolved = resolveModelArg(arg)
240
+ if (!resolved || resolved.error) {
241
+ app.addSystemMessage(resolved?.error || `Unknown model: ${arg}`)
242
+ return
243
+ }
244
+ settings = updateEffectiveSettings(db, { model_config: { ...resolved, custom_model_name: resolved.custom_model_name || null } })
245
+ try {
246
+ const wire = resolveWireModel(settings.model_config)
247
+ const isCustom = !!resolved.custom_model_name
248
+ app.addSystemMessage(`✓ Model: ${resolved.provider} / ${wire}${isCustom ? ' (custom)' : ''}`)
249
+ } catch (e) { app.addSystemMessage(`Model set but: ${e.message}`) }
250
+ if (process.env.SDLC_DISABLE_ALL_HOOKS !== '1') {
251
+ await runHooks(db, makeHookRt(), 'ConfigChange', { source: 'user_settings' })
252
+ }
253
+ return
254
+ }
255
+
256
+ if (trimmed === '/models') {
257
+ const provider = settings.model_config?.provider
258
+ if (!provider) {
259
+ app.addSystemMessage('No provider configured. Use /model to set a provider first.')
260
+ return
261
+ }
262
+
263
+ if (!supportsDiscovery(provider)) {
264
+ app.addSystemMessage(`Model discovery is not supported for provider: ${provider}\n\nProviders with discovery support:\n ollama\n\nFor other providers, use /model <provider>/<model-name> directly.`)
265
+ return
266
+ }
267
+
268
+ // Get base URL for the provider
269
+ const baseUrl = getBaseUrlForProvider(provider, settings)
270
+
271
+ app.addSystemMessage(`Discovering models from ${baseUrl}...`)
272
+
273
+ try {
274
+ const result = await discoverOllamaModels(baseUrl, { timeout: 5000 })
275
+
276
+ if (!result.success) {
277
+ app.addSystemMessage(`\nDiscovery failed: ${result.error}\n\nTroubleshooting:\n • Make sure Ollama is running: ollama serve\n • Check the base URL in settings: /config\n • Verify network connectivity to ${baseUrl}`)
278
+ return
279
+ }
280
+
281
+ const formatted = formatModelList(result.models)
282
+ app.addSystemMessage(`\n${formatted}`)
283
+ } catch (error) {
284
+ app.addSystemMessage(`\nUnexpected error: ${error.message}`)
285
+ }
286
+ return
287
+ }
288
+
289
+ if (trimmed === '/init') {
290
+ const onaPath = path.join(cwd, 'Ona.md')
291
+ if (fs.existsSync(onaPath)) { app.addSystemMessage('Ona.md already exists.'); return }
292
+ fs.writeFileSync(onaPath, '# Project Instructions\n\nDescribe your project and how ona should work with it.\n\n## Rules\n\n## Context\n', 'utf8')
293
+ app.addSystemMessage(`✓ Created ${onaPath}`)
294
+ return
295
+ }
296
+
297
+ if (trimmed === '/cost') {
298
+ const { getSessionTokens } = await import('../lib/orchestrate.mjs')
299
+ const t = getSessionTokens(sessionId)
300
+ const cost = ((t.input * 15 + t.output * 75) / 1_000_000).toFixed(4) // rough opus pricing
301
+ app.addSystemMessage(`Session tokens: ${t.input.toLocaleString()} in / ${t.output.toLocaleString()} out (${t.calls} calls)\nEstimated cost: $${cost}`)
302
+ return
303
+ }
304
+
305
+ if (trimmed === '/diff') {
306
+ const { spawnSync } = await import('node:child_process')
307
+ const r = spawnSync('git', ['diff'], { cwd, encoding: 'utf8', timeout: 10_000 })
308
+ app.addSystemMessage(r.stdout?.trim() || '(no uncommitted changes)')
309
+ return
310
+ }
311
+
312
+ if (trimmed === '/doctor') {
313
+ const lines = []
314
+ lines.push(`DB: ${dbPath} ✓`)
315
+ try { const w = resolveWireModel(settings.model_config); lines.push(`Model: ${settings.model_config.provider} / ${w} ✓`) }
316
+ catch (e) { lines.push(`Model: ✗ ${e.message}`) }
317
+ const s = authStatusSummary({ bareMode: opts.bare, apiKeyHelper: settings.apiKeyHelper })
318
+ lines.push(`Auth: ${s.ok ? `${s.kind} (${s.source}) ✓` : '✗ not configured'}`)
319
+ lines.push(`Hooks: ${settings.hooks?.length || 0} configured`)
320
+ lines.push(`Ona.md: ${fs.existsSync(path.join(cwd, 'Ona.md')) ? '✓ found' : '✗ not found (run /init)'}`)
321
+ lines.push(`Node: ${process.version}`)
322
+ const { spawnSync: sp } = await import('node:child_process')
323
+ const git = sp('git', ['--version'], { encoding: 'utf8', timeout: 5000 })
324
+ lines.push(`Git: ${git.stdout?.trim() || '✗ not found'}`)
325
+ lines.push(`Phase: ${getPhase(db, conversationId)}`)
326
+ app.addSystemMessage(lines.join('\n'))
327
+ return
328
+ }
329
+
330
+ if (trimmed === '/permissions') {
331
+ const p = settings.permissions || {}
332
+ const lines = [`Default mode: ${p.defaultMode || 'default'}`]
333
+ if (p.allow?.length) lines.push(`Allow: ${p.allow.join(', ')}`)
334
+ if (p.deny?.length) lines.push(`Deny: ${p.deny.join(', ')}`)
335
+ if (p.ask?.length) lines.push(`Ask: ${p.ask.join(', ')}`)
336
+ if (!p.allow?.length && !p.deny?.length && !p.ask?.length) lines.push('No custom rules configured.')
337
+ app.addSystemMessage(lines.join('\n'))
338
+ return
339
+ }
340
+
341
+ if (trimmed === '/pr-comments') {
342
+ const { spawnSync: sp } = await import('node:child_process')
343
+ // Get current branch and find PR
344
+ const branch = sp('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, encoding: 'utf8', timeout: 5000 })
345
+ const branchName = branch.stdout?.trim()
346
+ if (!branchName) { app.addSystemMessage('Not in a git repository.'); return }
347
+ const pr = sp('gh', ['pr', 'view', branchName, '--json', 'number,title,comments', '--jq', '.'], { cwd, encoding: 'utf8', timeout: 15_000 })
348
+ if (pr.status !== 0) { app.addSystemMessage(`No PR found for branch ${branchName}. (Requires gh CLI)`); return }
349
+ try {
350
+ const data = JSON.parse(pr.stdout)
351
+ const lines = [`PR #${data.number}: ${data.title}`]
352
+ for (const c of (data.comments || [])) {
353
+ lines.push(` ${c.author?.login || 'unknown'}: ${c.body?.slice(0, 200) || ''}`)
354
+ }
355
+ if (!data.comments?.length) lines.push(' No comments.')
356
+ app.addSystemMessage(lines.join('\n'))
357
+ } catch { app.addSystemMessage(pr.stdout?.trim() || 'Failed to parse PR data.') }
358
+ return
359
+ }
360
+
361
+ if (trimmed === '/issue') {
362
+ app.addSystemMessage('Create a new issue (SEP format)')
363
+ const title = await app.askUser('Title: ')
364
+ if (!title?.trim()) { app.addSystemMessage('Aborted.'); return }
365
+ const summary = await app.askUser('Summary: ')
366
+ const motivation = await app.askUser('Motivation: ')
367
+ const change = await app.askUser('Proposed change: ')
368
+ const criteria = await app.askUser('Acceptance criteria: ')
369
+ const priority = await app.askUser('Priority (P0/P1/P2): ')
370
+ const size = await app.askUser('Size (XS/S/M/L/XL): ')
371
+ const pLabel = ['P0', 'P1', 'P2'].includes(priority?.trim().toUpperCase()) ? priority.trim().toUpperCase() : 'P2'
372
+ const sLabel = ['XS', 'S', 'M', 'L', 'XL'].includes(size?.trim().toUpperCase()) ? size.trim().toUpperCase() : 'M'
373
+ const body = `## Summary\n${summary || ''}\n\n## Motivation\n${motivation || ''}\n\n## Proposed Change\n${change || ''}\n\n## Acceptance Criteria\n${criteria || ''}`
374
+ const { spawnSync: sp } = await import('node:child_process')
375
+ const labels = `${pLabel},${sLabel},Backlog`
376
+ const r = sp('gh', ['issue', 'create', '--title', title.trim(), '--body', body, '--label', labels], { cwd, encoding: 'utf8', timeout: 30_000 })
377
+ if (r.status === 0) {
378
+ app.addSystemMessage(`✓ Issue created: ${r.stdout.trim()}`)
379
+ } else {
380
+ // Labels might not exist yet — try creating them, then retry
381
+ for (const l of [pLabel, sLabel, 'Backlog']) {
382
+ sp('gh', ['label', 'create', l, '--force'], { cwd, encoding: 'utf8', timeout: 10_000 })
383
+ }
384
+ const r2 = sp('gh', ['issue', 'create', '--title', title.trim(), '--body', body, '--label', labels], { cwd, encoding: 'utf8', timeout: 30_000 })
385
+ if (r2.status === 0) {
386
+ app.addSystemMessage(`✓ Issue created: ${r2.stdout.trim()}`)
387
+ } else {
388
+ app.addSystemMessage(`✗ Failed: ${(r2.stderr || r.stderr || '').trim()}`)
389
+ }
390
+ }
391
+ return
392
+ }
393
+
394
+ if (trimmed.startsWith('/config')) {
395
+ const parts = trimmed.split(' ')
396
+ if (parts[1] === 'base_url' && parts[2]) {
397
+ settings = updateEffectiveSettings(db, { model_config: { base_url: parts[2] } })
398
+ app.addSystemMessage(`✓ Saved base_url: ${parts[2]}`)
399
+ } else if (parts[1] === 'num_ctx' && parts[2]) {
400
+ const n = parseInt(parts[2], 10)
401
+ if (isNaN(n) || n < 512) { app.addSystemMessage('num_ctx must be a number ≥ 512'); return }
402
+ settings = updateEffectiveSettings(db, { model_config: { num_ctx: n } })
403
+ app.addSystemMessage(`✓ Saved num_ctx: ${n}`)
404
+ } else if (parts[1] === 'set' && parts[2] && parts.length >= 4) {
405
+ const dotPath = parts[2]
406
+ const value = parts.slice(3).join(' ')
407
+ // Build a nested patch from the dot path
408
+ const keys = dotPath.split('.')
409
+ let patch = {}
410
+ let node = patch
411
+ for (let i = 0; i < keys.length - 1; i++) {
412
+ node[keys[i]] = {}
413
+ node = node[keys[i]]
414
+ }
415
+ // Auto-convert numeric values
416
+ const leafKey = keys[keys.length - 1]
417
+ node[leafKey] = /^\d+$/.test(value) ? parseInt(value, 10) : value
418
+ settings = updateEffectiveSettings(db, patch)
419
+ app.addSystemMessage(`✓ Set ${dotPath}`)
420
+ } else {
421
+ app.addSystemMessage(JSON.stringify(settings, null, 2))
422
+ }
423
+ return
424
+ }
425
+
426
+ if (trimmed === '/clear' || trimmed === '/reset' || trimmed === '/new') {
427
+ if (process.env.SDLC_DISABLE_ALL_HOOKS !== '1') {
428
+ await runHooks(db, makeHookRt(), 'SessionEnd', { reason: 'clear' })
429
+ }
430
+ conversationId = randomUUID()
431
+ sessionId = randomUUID()
432
+ db.prepare(`INSERT INTO conversations(id, project_dir, phase) VALUES (?,?, 'idle')`).run(conversationId, cwd)
433
+ db.prepare(`INSERT INTO sessions(session_id, conversation_id) VALUES (?,?)`).run(sessionId, conversationId)
434
+ if (process.env.SDLC_DISABLE_ALL_HOOKS !== '1') {
435
+ await runHooks(db, makeHookRt(), 'SessionStart', { source: 'clear', model: (() => { try { return resolveWireModel(settings.model_config) } catch { return '' } })() })
436
+ }
437
+ app.addSystemMessage('✓ Conversation cleared.')
438
+ return
439
+ }
440
+
441
+ if (trimmed === '/phase') {
442
+ const phase = getPhase(db, conversationId)
443
+ app.addSystemMessage(`Phase: ${phase}`)
444
+ return
445
+ }
446
+
447
+ if (trimmed === '/plan') {
448
+ const plan = db.prepare(`SELECT status, content FROM plans WHERE conversation_id = ? ORDER BY id DESC LIMIT 1`).get(conversationId)
449
+ if (!plan) { app.addSystemMessage('No plan. The model will use EnterPlanMode to start planning.'); return }
450
+ const preview = plan.content.length > 500 ? plan.content.slice(0, 500) + '\n...' : plan.content
451
+ app.addSystemMessage(`Plan status: ${plan.status}\n${preview}`)
452
+ return
453
+ }
454
+
455
+ if (trimmed === '/test') {
456
+ const results = db.prepare(`SELECT detail FROM events WHERE conversation_id = ? AND event_type = 'test_result' ORDER BY id`).all(conversationId)
457
+ if (!results.length) { app.addSystemMessage('No test results yet. Tests run automatically when the model calls SubmitImplementation and you approve.'); return }
458
+ const passed = results.filter(r => JSON.parse(r.detail).passed).length
459
+ app.addSystemMessage(`Test results: ${passed}/${results.length} passed`)
460
+ for (const r of results) { const d = JSON.parse(r.detail); app.addSystemMessage(` ${d.passed ? '✓' : '✗'} ${d.criterion}`) }
461
+ return
462
+ }
463
+
464
+ if (trimmed === '/verify') {
465
+ const results = db.prepare(`SELECT detail FROM events WHERE conversation_id = ? AND event_type = 'test_result' ORDER BY id`).all(conversationId)
466
+ if (!results.length) { app.addSystemMessage('No coverage data. Tests run automatically on implementation approval.'); return }
467
+ app.addSystemMessage('Coverage Report:')
468
+ for (const r of results) { const d = JSON.parse(r.detail); app.addSystemMessage(` ${d.passed ? '✓' : '✗'} ${d.criterion}`) }
469
+ const passed = results.filter(r => JSON.parse(r.detail).passed).length
470
+ app.addSystemMessage(`Aggregate: ${passed}/${results.length} passed`)
471
+ return
472
+ }
473
+
474
+ if (trimmed === '/code') {
475
+ const phase = getPhase(db, conversationId)
476
+ if (phase === 'implement') app.addSystemMessage('Implementation in progress. The model will call SubmitImplementation when ready for review.')
477
+ else app.addSystemMessage(`Phase: ${phase}. Implementation happens automatically after plan approval.`)
478
+ return
479
+ }
480
+
481
+ if (trimmed === '/compact') {
482
+ settings = getEffectiveSettings(db)
483
+ const rt = { sessionId, conversationId, runtimeDbPath: dbPath, cwd, bareMode: opts.bare, settings }
484
+ const summary = await compactConversation(db, rt, async (text) => {
485
+ // Use model to summarize
486
+ const { runUserTurn: turn } = await import('../lib/orchestrate.mjs')
487
+ // Simple summarization — send text to model and capture response
488
+ return `[Conversation compacted: ${text.split('\n').length} messages summarized]`
489
+ }, s => app.addSystemMessage(s))
490
+ if (summary) app.addSystemMessage(`Summary:\n${summary}`)
491
+ return
492
+ }
493
+
494
+ if (trimmed.startsWith('/team')) {
495
+ const arg = trimmed.slice(5).trim()
496
+ if (arg.startsWith('create ')) {
497
+ const name = arg.slice(7).trim()
498
+ if (!name) { app.addSystemMessage('Usage: /team create <name>'); return }
499
+ createTeam(name, sessionId)
500
+ app.addSystemMessage(`✓ Team created: ${name}`)
501
+ } else if (arg === 'list') {
502
+ const teams = listTeams()
503
+ if (!teams.length) { app.addSystemMessage('No teams.'); return }
504
+ for (const t of teams) {
505
+ const members = t.members?.map(m => `${m.name}${m.isActive ? '' : ' (idle)'}`).join(', ') || 'none'
506
+ app.addSystemMessage(`${t.name}: ${members}`)
507
+ }
508
+ } else if (arg.startsWith('delete ')) {
509
+ const name = arg.slice(7).trim()
510
+ deleteTeam(name)
511
+ app.addSystemMessage(`✓ Team deleted: ${name}`)
512
+ } else {
513
+ app.addSystemMessage('Usage: /team create <name> | /team list | /team delete <name>')
514
+ }
515
+ return
516
+ }
517
+
518
+ if (trimmed === '/done') {
519
+ const phase = getPhase(db, conversationId)
520
+ app.addSystemMessage(`Phase: ${phase}. Workflow completes automatically when all tests pass and you approve the coverage report.`)
521
+ return
522
+ }
523
+
524
+ if (trimmed.startsWith('/mcp')) {
525
+ const parts = trimmed.split(/\s+/)
526
+ const sub = parts[1] || ''
527
+ const mcpServers = settings?.mcp_servers || {}
528
+ const serverNames = Object.keys(mcpServers)
529
+
530
+ if (!sub || sub === 'help') {
531
+ const lines = [
532
+ 'MCP Server & Tool Commands:',
533
+ ' /mcp list Discover and list all available MCP tools',
534
+ ' /mcp status Show configured servers and command paths',
535
+ ' /mcp reload [server] Reload mcp.json configs into session',
536
+ ' /mcp add <name> <cmd> […] Register a new MCP server',
537
+ ' /mcp env [server] [K] [V] View or set server environment variables',
538
+ '',
539
+ `Configured servers: ${serverNames.length ? serverNames.join(', ') : 'none'}`,
540
+ 'Tip: Run /mcp list to inspect tools injected into the assistant.',
541
+ ]
542
+ app.addSystemMessage(lines.join('\n'))
543
+ return
544
+ }
545
+
546
+ if (sub === 'status') {
547
+ if (!serverNames.length) {
548
+ app.addSystemMessage('No MCP servers configured.\nAdd servers to mcp.json in your project root or ~/.config/nehanda/mcp.json')
549
+ return
550
+ }
551
+ const lines = ['MCP Servers:']
552
+ for (const n of serverNames) {
553
+ const cfg = mcpServers[n]
554
+ lines.push(` ${n.padEnd(22)} ${cfg.command} ${(cfg.args || []).join(' ')}`)
555
+ }
556
+ lines.push('')
557
+ lines.push('Tip: Run /mcp list to view tools provided by these servers.')
558
+ app.addSystemMessage(lines.join('\n'))
559
+ return
560
+ }
561
+
562
+ if (sub === 'list') {
563
+ if (!serverNames.length) {
564
+ app.addSystemMessage('No MCP servers configured.')
565
+ return
566
+ }
567
+ app.addSystemMessage('Discovering MCP tools…')
568
+ const lines = []
569
+ for (const n of serverNames) {
570
+ try {
571
+ const srv = getMcpServer(n, mcpServers[n])
572
+ const resp = await srv.request('tools/list', {})
573
+ const tools = resp.result?.tools || []
574
+ if (resp.error) {
575
+ lines.push(`✗ ${n}: ${resp.error.message}`)
576
+ } else if (!tools.length) {
577
+ lines.push(`${n}: (no tools)`)
578
+ } else {
579
+ lines.push(`${n} (${tools.length} tool${tools.length === 1 ? '' : 's'}):`)
580
+ for (const t of tools) {
581
+ lines.push(` ${t.name.padEnd(32)} ${t.description || ''}`)
582
+ }
583
+ }
584
+ } catch (err) {
585
+ lines.push(`✗ ${n}: ${err.message}`)
586
+ }
587
+ }
588
+ app.addSystemMessage(lines.join('\n'))
589
+ return
590
+ }
591
+
592
+ if (sub === 'reload') {
593
+ const targetServer = parts[2] || null
594
+ settings.mcp_servers = {}
595
+ applyMcpConfig(settings, cwd)
596
+ invalidateMcpToolCache(targetServer || undefined)
597
+ const count = Object.keys(settings.mcp_servers || {}).length
598
+ app.addSystemMessage(`✓ MCP config reloaded. ${count} server${count === 1 ? '' : 's'} configured.`)
599
+ return
600
+ }
601
+
602
+ if (sub === 'add') {
603
+ const srvName = parts[2]
604
+ const command = parts[3]
605
+ const args = parts.slice(4)
606
+ if (!srvName || !command) {
607
+ app.addSystemMessage('Usage: /mcp add <name> <command> [args…]\nExample: /mcp add filesystem npx -y @modelcontextprotocol/server-filesystem /path')
608
+ return
609
+ }
610
+ const existing = readGlobalMcpConfig()
611
+ existing[srvName] = { command, args, env: {} }
612
+ writeGlobalMcpConfig(existing)
613
+ settings.mcp_servers = {}
614
+ applyMcpConfig(settings, cwd)
615
+ invalidateMcpToolCache()
616
+ app.addSystemMessage(`✓ Added ${srvName} → ${command} ${args.join(' ')}\nSaved to ${globalMcpConfigPath()}`)
617
+ return
618
+ }
619
+
620
+ if (sub === 'env') {
621
+ const srvName = parts[2]
622
+ const envKey = parts[3]
623
+ if (!srvName) {
624
+ // Show env for all servers
625
+ const mcpServers = settings?.mcp_servers || {}
626
+ const names = Object.keys(mcpServers)
627
+ if (!names.length) { app.addSystemMessage('No MCP servers configured.'); return }
628
+ const lines = ['MCP server environment variables:']
629
+ for (const n of names) {
630
+ const src = getMcpServerSource(n, cwd)
631
+ const envKeys = Object.keys(mcpServers[n].env || {})
632
+ const srcLabel = src ? path.relative(os.homedir(), src.path) : 'programmatic'
633
+ if (!envKeys.length) {
634
+ lines.push(` ${n} (${srcLabel}) — no env vars set`)
635
+ } else {
636
+ lines.push(` ${n} (${srcLabel})`)
637
+ for (const k of envKeys) {
638
+ const v = mcpServers[n].env[k]
639
+ const masked = v ? v.slice(0, 6) + '…' + v.slice(-4) : '(empty)'
640
+ lines.push(` ${k}=${masked}`)
641
+ }
642
+ }
643
+ }
644
+ app.addSystemMessage(lines.join('\n'))
645
+ return
646
+ }
647
+ if (!envKey) {
648
+ // Show env for a specific server
649
+ const cfg = (settings?.mcp_servers || {})[srvName]
650
+ if (!cfg) { app.addSystemMessage(`Server "${srvName}" not found. Use /mcp status to see configured servers.`); return }
651
+ const envKeys = Object.keys(cfg.env || {})
652
+ if (!envKeys.length) { app.addSystemMessage(`${srvName}: no env vars set\nUse /mcp env ${srvName} <KEY> <value> to set one.`); return }
653
+ const lines = [`${srvName} environment variables:`]
654
+ for (const k of envKeys) {
655
+ const v = cfg.env[k]
656
+ const masked = v ? v.slice(0, 6) + '…' + v.slice(-4) : '(empty)'
657
+ lines.push(` ${k}=${masked}`)
658
+ }
659
+ app.addSystemMessage(lines.join('\n'))
660
+ return
661
+ }
662
+ // Set or clear an env var
663
+ const value = parts.slice(4).join(' ')
664
+ const result = updateMcpServerEnv(srvName, envKey, value, cwd)
665
+ if (!result.ok) {
666
+ app.addSystemMessage(`✗ ${result.error}`)
667
+ return
668
+ }
669
+ // Reload to pick up the change
670
+ settings.mcp_servers = {}
671
+ applyMcpConfig(settings, cwd)
672
+ invalidateMcpToolCache()
673
+ const action = result.set ? 'Set' : 'Cleared'
674
+ const display = result.set ? ' = ********' : ''
675
+ const relPath = path.relative(os.homedir(), result.path).startsWith('..') ? result.path : '~/' + path.relative(os.homedir(), result.path)
676
+ app.addSystemMessage(`✓ ${action} ${envKey}${display} on ${srvName}\n Config file: ${relPath}`)
677
+ return
678
+ }
679
+
680
+ app.addSystemMessage('/mcp sub-commands: status · list · reload [server] · add <name> <command> [args…] · env [server] [KEY] [value]')
681
+ return
682
+ }
683
+
684
+ if (trimmed.startsWith('/')) {
685
+ app.addSystemMessage(`Unknown command: ${trimmed}. Type /help for commands.`)
686
+ return
687
+ }
688
+
689
+ // User turn
690
+ settings = getEffectiveSettings(db)
691
+ app.startLoading()
692
+ const rt = {
693
+ sessionId, conversationId, runtimeDbPath: dbPath, cwd,
694
+ bareMode: opts.bare, settings, onaInstructions: onaInstructions.content,
695
+ }
696
+ let streamBuf = ''
697
+ const io = {
698
+ write: s => {
699
+ // Do not stop the spinner here — the orchestrator calls io.spinner.stop()
700
+ // explicitly at the right moment. Stopping on every write causes flicker.
701
+ streamBuf += s
702
+ app.updateStream(streamBuf)
703
+ },
704
+ println: s => {
705
+ // Same — don't stop spinner on intermediate messages (tool results, phase notices).
706
+ // The spinner is stopped explicitly by the orchestrator before user-facing gates.
707
+ if (s) app.addSystemMessage(String(s))
708
+ },
709
+ ask: async (q) => {
710
+ // Asking the user requires stopping — they need to see the prompt and type.
711
+ app.stopLoading()
712
+ return app.askUser(String(q || ''))
713
+ },
714
+ spinner: {
715
+ start: (_label) => { app.startLoading() },
716
+ stop: () => { app.stopLoading() },
717
+ },
718
+ // Structured tool events for Ink UI
719
+ onToolStart: (name) => { app.addToolStart(name) },
720
+ onToolResult: (name, content, isError) => { app.addToolResult(name, content, isError) },
721
+ }
722
+ await runUserTurn(db, rt, line, io)
723
+ app.stopLoading()
724
+ if (streamBuf) {
725
+ app.clearStream()
726
+ app.addAssistantMessage(streamBuf)
727
+ streamBuf = ''
728
+ }
729
+ }
730
+
731
+ async function shutdown() {
732
+ if (process.env.SDLC_DISABLE_ALL_HOOKS !== '1') {
733
+ await runHooks(db, makeHookRt(), 'SessionEnd', { reason: 'prompt_input_exit' })
734
+ }
735
+ // Print session summary before exit
736
+ try {
737
+ const { getSessionTokens } = await import('../lib/orchestrate.mjs')
738
+ const { formatSessionSummary } = await import('../lib/ui.mjs')
739
+ const tokens = getSessionTokens(sessionId)
740
+ let model = '(unknown)', provider = settings?.model_config?.provider || 'unknown'
741
+ try { model = resolveWireModel(settings.model_config) } catch {}
742
+ process.stdout.write(formatSessionSummary(sessionId, tokens, model, provider))
743
+ } catch {}
744
+ closeAllMcpServers()
745
+ app.instance?.unmount()
746
+ process.exit(0)
747
+ }
748
+ }
749
+
750
+ // ── Pipe mode (non-TTY) — readline for acceptance tests ──────
751
+ async function mainPipe(opts) {
752
+ const ctx = bootstrap(opts)
753
+ let { db, dbPath, settings, pkg, conversationId, sessionId, cwd } = ctx
754
+
755
+ const makeHookRt = () => ({
756
+ sessionId, conversationId, runtimeDbPath: dbPath, cwd,
757
+ permissionMode: settings.permissions?.defaultMode ?? 'default', settings,
758
+ })
759
+
760
+ if (process.env.SDLC_DISABLE_ALL_HOOKS !== '1') {
761
+ await runHooks(db, makeHookRt(), 'SessionStart', { source: 'startup', model: (() => { try { return resolveWireModel(settings.model_config) } catch { return '' } })() })
762
+ }
763
+
764
+ // Import UI for pipe-mode formatting (legacy)
765
+ const ui = await import('../lib/ui.mjs')
766
+
767
+ const rl = readline.createInterface({ input, output })
768
+ const spinner = new ui.Spinner({ write: s => output.write(s) })
769
+ const io = {
770
+ write: s => { spinner.stop(); output.write(s) },
771
+ println: s => { spinner.stop(); console.log(s) },
772
+ ask: async (q) => { spinner.stop(); if (!process.stdin.isTTY) return 'y'; return rl.question(q) },
773
+ spinner: { start: (label) => spinner.start(label), stop: () => spinner.stop() },
774
+ }
775
+
776
+ // Pipe-mode banner
777
+ const provider = settings.model_config?.provider || 'lm_studio_local'
778
+ const savedBaseUrl = settings.model_config?.base_url // Use persistent config
779
+ let wireModel = '(not set)'
780
+ try { wireModel = resolveWireModel(settings.model_config) } catch {}
781
+ output.write(ui.printBanner(pkg.version, dbPath, opts.bare))
782
+ const base = savedBaseUrl || (process.env.LM_STUDIO_BASE_URL || 'http://127.0.0.1:1234/v1').replace(/\/$/, '')
783
+ const endpoint = provider === 'lm_studio_local' ? `${base}/chat/completions`
784
+ : provider === 'zhipu' ? 'https://open.bigmodel.cn/api/paas/v4/chat/completions'
785
+ : provider === 'ollama' ? `${savedBaseUrl || 'http://localhost:11434/v1'}/chat/completions` // No more hardcoded IP
786
+ : provider === 'openai_compatible' ? `${savedBaseUrl || process.env.OPENAI_BASE_URL || '(unset)'}/chat/completions`
787
+ : `${process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com'}/v1/messages`
788
+ output.write(ui.printProviderBanner(provider, wireModel, endpoint))
789
+ output.write(ui.colors.dim(' Type /help for commands\n\n'))
790
+
791
+ while (true) {
792
+ let line
793
+ try { line = (await rl.question(ui.formatPrompt())).trim() } catch (e) {
794
+ if (e?.code === 'ERR_USE_AFTER_CLOSE') break; throw e
795
+ }
796
+ if (!line) continue
797
+ if (line === '/exit' || line === '/quit') break
798
+
799
+ if (line === '/help') {
800
+ io.println(ui.formatHelp(provider, dbPath)); continue
801
+ }
802
+ if (line === '/status') {
803
+ const s = authStatusSummary({ bareMode: opts.bare, apiKeyHelper: settings.apiKeyHelper })
804
+ io.println(ui.formatStatus(s)); continue
805
+ }
806
+ if (line === '/logout') {
807
+ clearSecureCredentials(); io.println(ui.colors.success(' ✓ Credentials cleared.')); continue
808
+ }
809
+ if (line === '/login') {
810
+ await interactiveLogin(rl, io, opts.bare); settings = getEffectiveSettings(db); continue
811
+ }
812
+ if (line.startsWith('/model')) {
813
+ const arg = line.slice(6).trim()
814
+ if (!arg) {
815
+ try {
816
+ const wire = resolveWireModel(settings.model_config)
817
+ io.println(`\n ${ui.colors.key('Provider:')} ${ui.colors.provider(settings.model_config.provider)}`)
818
+ io.println(` ${ui.colors.key('Model:')} ${ui.colors.model(wire)}`)
819
+ } catch (e) { io.println(` ${ui.colors.error(e.message)}`) }
820
+ io.println(`\n${ui.colors.header('Available models')}`)
821
+ for (const m of allModelIds()) io.println(` ${ui.colors.provider(m.provider)} ${ui.colors.dim('/')} ${ui.colors.model(m.model_id)}`)
822
+ io.println(`\n ${ui.colors.dim('Usage: /model <name>')}\n`)
823
+ continue
824
+ }
825
+ const resolved = resolveModelArg(arg)
826
+ if (!resolved || resolved.error) {
827
+ io.println(ui.colors.error(` ${resolved?.error || `Unknown model: ${arg}`}`))
828
+ continue
829
+ }
830
+ settings = updateEffectiveSettings(db, { model_config: { ...resolved, custom_model_name: resolved.custom_model_name || null } })
831
+ try {
832
+ const wire = resolveWireModel(settings.model_config)
833
+ const isCustom = !!resolved.custom_model_name
834
+ io.println(ui.formatModelChange(resolved.provider, resolved.model_id, wire, isCustom))
835
+ }
836
+ catch (e) { io.println(` ${ui.colors.warning('Model set but: ' + e.message)}`) }
837
+ continue
838
+ }
839
+ if (line === '/models') {
840
+ const provider = settings.model_config?.provider
841
+ if (!provider) {
842
+ io.println(ui.colors.error(' No provider configured. Use /model to set a provider first.'))
843
+ continue
844
+ }
845
+
846
+ if (!supportsDiscovery(provider)) {
847
+ io.println(ui.colors.error(` Model discovery is not supported for provider: ${provider}`))
848
+ io.println(`\n ${ui.colors.header('Providers with discovery support:')}`)
849
+ io.println(' ollama')
850
+ io.println(`\n ${ui.colors.dim('For other providers, use /model <provider>/<model-name> directly.')}`)
851
+ continue
852
+ }
853
+
854
+ // Get base URL for the provider
855
+ const baseUrl = getBaseUrlForProvider(provider, settings)
856
+
857
+ io.println(`\n Discovering models from ${baseUrl}...\n`)
858
+
859
+ try {
860
+ const result = await discoverOllamaModels(baseUrl, { timeout: 5000 })
861
+
862
+ if (!result.success) {
863
+ io.println(ui.colors.error(` Discovery failed: ${result.error}`))
864
+ io.println(`\n ${ui.colors.header('Troubleshooting:')}`)
865
+ io.println(' • Make sure Ollama is running: ollama serve')
866
+ io.println(' • Check the base URL in settings: /config')
867
+ io.println(` • Verify network connectivity to ${baseUrl}\n`)
868
+ continue
869
+ }
870
+
871
+ const formatted = formatModelList(result.models)
872
+ io.println(formatted)
873
+ } catch (error) {
874
+ io.println(ui.colors.error(` Unexpected error: ${error.message}`))
875
+ }
876
+ continue
877
+ }
878
+ if (line === '/config' || line === '/settings' || line.startsWith('/config ')) {
879
+ const parts = line.split(' ')
880
+ if (parts[1] === 'set' && parts[2] && parts.length >= 4) {
881
+ const dotPath = parts[2]
882
+ const value = parts.slice(3).join(' ')
883
+ const keys = dotPath.split('.')
884
+ let patch = {}
885
+ let node = patch
886
+ for (let i = 0; i < keys.length - 1; i++) { node[keys[i]] = {}; node = node[keys[i]] }
887
+ const leafKey = keys[keys.length - 1]
888
+ node[leafKey] = /^\d+$/.test(value) ? parseInt(value, 10) : value
889
+ settings = updateEffectiveSettings(db, patch)
890
+ io.println(ui.colors.success(` ✓ Set ${dotPath}`))
891
+ } else {
892
+ io.println(ui.formatConfig(settings))
893
+ }
894
+ continue
895
+ }
896
+ if (line === '/clear' || line === '/reset' || line === '/new') {
897
+ if (process.env.SDLC_DISABLE_ALL_HOOKS !== '1') await runHooks(db, makeHookRt(), 'SessionEnd', { reason: 'clear' })
898
+ conversationId = randomUUID(); sessionId = randomUUID()
899
+ db.prepare(`INSERT INTO conversations(id, project_dir, phase) VALUES (?,?, 'idle')`).run(conversationId, cwd)
900
+ db.prepare(`INSERT INTO sessions(session_id, conversation_id) VALUES (?,?)`).run(sessionId, conversationId)
901
+ if (process.env.SDLC_DISABLE_ALL_HOOKS !== '1') await runHooks(db, makeHookRt(), 'SessionStart', { source: 'clear', model: (() => { try { return resolveWireModel(settings.model_config) } catch { return '' } })() })
902
+ io.println(ui.colors.success(' ✓ Conversation cleared.')); continue
903
+ }
904
+ if (line === '/phase') {
905
+ io.println(` Phase: ${getPhase(db, conversationId)}`); continue
906
+ }
907
+ if (line === '/plan') {
908
+ const plan = db.prepare(`SELECT status, content FROM plans WHERE conversation_id = ? ORDER BY id DESC LIMIT 1`).get(conversationId)
909
+ if (!plan) { io.println(' No plan.'); continue }
910
+ io.println(` Plan: ${plan.status}\n${plan.content.slice(0, 500)}`); continue
911
+ }
912
+ if (line === '/code') {
913
+ const phase = getPhase(db, conversationId)
914
+ if (phase !== 'implement') { io.println(` Cannot /code in phase: ${phase}`); continue }
915
+ const plan = db.prepare(`SELECT content FROM plans WHERE conversation_id = ? AND status = 'approved' ORDER BY id DESC LIMIT 1`).get(conversationId)
916
+ if (!plan) { io.println(' No approved plan.'); continue }
917
+ io.println(' Implementing...')
918
+ settings = getEffectiveSettings(db)
919
+ const crt = { sessionId, conversationId, runtimeDbPath: dbPath, cwd, bareMode: opts.bare, settings, onaInstructions: onaInstructions?.content }
920
+ await runUserTurn(db, crt, `Implement the following approved plan:\n\n${plan.content}`, io)
921
+ continue
922
+ }
923
+ if (line === '/test') {
924
+ const phase = getPhase(db, conversationId)
925
+ if (phase !== 'implement' && phase !== 'test') { io.println(` Cannot test in phase: ${phase}`); continue }
926
+ const plan = db.prepare(`SELECT content FROM plans WHERE conversation_id = ? AND status = 'approved' ORDER BY id DESC LIMIT 1`).get(conversationId)
927
+ if (!plan) { io.println(' No approved plan.'); continue }
928
+ const { generateAndRunTests } = await import('../lib/testgen.mjs')
929
+ settings = getEffectiveSettings(db)
930
+ const trt = { sessionId, conversationId, runtimeDbPath: dbPath, cwd, bareMode: opts.bare, settings }
931
+ const results = await generateAndRunTests(db, trt, plan.content, s => io.println(` ${s}`))
932
+ if (phase === 'implement') setPhase(db, conversationId, 'test', 'cli_admin')
933
+ const passed = results.filter(r => r.passed).length
934
+ io.println(` Tests: ${passed}/${results.length} passed`)
935
+ continue
936
+ }
937
+ if (line === '/verify') {
938
+ if (getPhase(db, conversationId) !== 'test') { io.println(' Must be in test phase.'); continue }
939
+ const results = db.prepare(`SELECT detail FROM events WHERE conversation_id = ? AND event_type = 'test_result' ORDER BY id`).all(conversationId)
940
+ if (!results.length) { io.println(' No test results. Run /test.'); continue }
941
+ for (const r of results) { const d = JSON.parse(r.detail); io.println(` ${d.passed ? '✓' : '✗'} ${d.criterion}`) }
942
+ continue
943
+ }
944
+ if (line === '/issue') {
945
+ io.println(' /issue requires interactive mode (run ona in TTY).'); continue
946
+ }
947
+ if (line === '/done') {
948
+ if (getPhase(db, conversationId) !== 'verify') { io.println(' Must be in verify phase.'); continue }
949
+ setPhase(db, conversationId, 'done', 'cli_admin')
950
+ const { spawnSync: sp } = await import('node:child_process')
951
+ const plan = db.prepare(`SELECT content FROM plans WHERE conversation_id = ? AND status = 'approved' ORDER BY id DESC LIMIT 1`).get(conversationId)
952
+ const planTitle = plan?.content?.split('\n').find(l => l.startsWith('#'))?.replace(/^#+\s*/, '') || 'SDLC workflow complete'
953
+ sp('git', ['add', '-A'], { cwd, encoding: 'utf8' })
954
+ const commit = sp('git', ['commit', '-m', `${planTitle}\n\nPlan approved and verified via ona SDLC workflow.`], { cwd, encoding: 'utf8', timeout: 30_000 })
955
+ const hash = commit.status === 0 ? sp('git', ['rev-parse', '--short', 'HEAD'], { cwd, encoding: 'utf8' }).stdout?.trim() : null
956
+ io.println(hash ? ` Phase: done ✓ Committed: ${hash}` : ` Phase: done ✓ (commit failed)`); continue
957
+ }
958
+ if (line === '/compact') {
959
+ settings = getEffectiveSettings(db)
960
+ const crt = { sessionId, conversationId, runtimeDbPath: dbPath, cwd, bareMode: opts.bare, settings }
961
+ await compactConversation(db, crt, async (text) => `[Compacted: ${text.split('\n').length} messages]`, s => io.println(` ${s}`))
962
+ continue
963
+ }
964
+ if (line === '/init') {
965
+ const onaPath = path.join(cwd, 'Ona.md')
966
+ if (fs.existsSync(onaPath)) { io.println(' Ona.md already exists.'); continue }
967
+ fs.writeFileSync(onaPath, '# Project Instructions\n\nDescribe your project and how ona should work with it.\n\n## Rules\n\n## Context\n', 'utf8')
968
+ io.println(` ✓ Created ${onaPath}`); continue
969
+ }
970
+ if (line === '/cost') {
971
+ const { getSessionTokens } = await import('../lib/orchestrate.mjs')
972
+ const t = getSessionTokens(sessionId)
973
+ const cost = ((t.input * 15 + t.output * 75) / 1_000_000).toFixed(4)
974
+ io.println(` Tokens: ${t.input.toLocaleString()} in / ${t.output.toLocaleString()} out (${t.calls} calls) ≈ $${cost}`); continue
975
+ }
976
+ if (line === '/diff') {
977
+ const { spawnSync: sp } = await import('node:child_process')
978
+ const r = sp('git', ['diff'], { cwd, encoding: 'utf8', timeout: 10_000 })
979
+ io.println(r.stdout?.trim() || ' (no changes)'); continue
980
+ }
981
+ if (line === '/doctor') {
982
+ io.println(` DB: ${dbPath} ✓`)
983
+ try { const w = resolveWireModel(settings.model_config); io.println(` Model: ${settings.model_config.provider} / ${w} ✓`) }
984
+ catch (e) { io.println(` Model: ✗ ${e.message}`) }
985
+ const s = authStatusSummary({ bareMode: opts.bare, apiKeyHelper: settings.apiKeyHelper })
986
+ io.println(` Auth: ${s.ok ? s.kind + ' ✓' : '✗ not configured'}`)
987
+ io.println(` Hooks: ${settings.hooks?.length || 0}`)
988
+ io.println(` Ona.md: ${fs.existsSync(path.join(cwd, 'Ona.md')) ? '✓' : '✗ (run /init)'}`)
989
+ io.println(` Phase: ${getPhase(db, conversationId)}`); continue
990
+ }
991
+ if (line === '/permissions') {
992
+ const p = settings.permissions || {}
993
+ io.println(` Default mode: ${p.defaultMode || 'default'}`)
994
+ if (p.allow?.length) io.println(` Allow: ${p.allow.join(', ')}`)
995
+ if (p.deny?.length) io.println(` Deny: ${p.deny.join(', ')}`)
996
+ if (p.ask?.length) io.println(` Ask: ${p.ask.join(', ')}`)
997
+ continue
998
+ }
999
+ if (line === '/pr-comments') {
1000
+ const { spawnSync: sp } = await import('node:child_process')
1001
+ const branch = sp('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, encoding: 'utf8', timeout: 5000 })
1002
+ const branchName = branch.stdout?.trim()
1003
+ if (!branchName) { io.println(' Not in a git repo.'); continue }
1004
+ const pr = sp('gh', ['pr', 'view', branchName, '--json', 'number,title,comments'], { cwd, encoding: 'utf8', timeout: 15_000 })
1005
+ if (pr.status !== 0) { io.println(` No PR for ${branchName}. (Requires gh)`); continue }
1006
+ io.println(pr.stdout?.trim() || ' No data.'); continue
1007
+ }
1008
+ if (line.startsWith('/team')) {
1009
+ const arg = line.slice(5).trim()
1010
+ if (arg.startsWith('create ')) { createTeam(arg.slice(7).trim(), sessionId); io.println(' ✓ Team created.') }
1011
+ else if (arg === 'list') { for (const t of listTeams()) io.println(` ${t.name}: ${t.members?.map(m => m.name).join(', ') || 'none'}`) }
1012
+ else if (arg.startsWith('delete ')) { deleteTeam(arg.slice(7).trim()); io.println(' ✓ Deleted.') }
1013
+ else io.println(' Usage: /team create|list|delete <name>')
1014
+ continue
1015
+ }
1016
+ if (line.startsWith('/')) { io.println(`Unknown command: ${line}. Type /help.`); continue }
1017
+
1018
+ settings = getEffectiveSettings(db)
1019
+ const onaInst = loadInstructions(cwd)
1020
+ const rt = { sessionId, conversationId, runtimeDbPath: dbPath, cwd, bareMode: opts.bare, settings, onaInstructions: onaInst.content }
1021
+ await runUserTurn(db, rt, line, io)
1022
+ io.println('')
1023
+ }
1024
+
1025
+ if (process.env.SDLC_DISABLE_ALL_HOOKS !== '1') await runHooks(db, makeHookRt(), 'SessionEnd', { reason: 'prompt_input_exit' })
1026
+ // Print session summary before exit
1027
+ try {
1028
+ const { getSessionTokens } = await import('../lib/orchestrate.mjs')
1029
+ const tokens = getSessionTokens(sessionId)
1030
+ let model = '(unknown)', provider = settings?.model_config?.provider || 'unknown'
1031
+ try { model = resolveWireModel(settings.model_config) } catch {}
1032
+ output.write(ui.formatSessionSummary(sessionId, tokens, model, provider))
1033
+ } catch {}
1034
+ closeAllMcpServers()
1035
+ rl.close()
1036
+ }
1037
+
1038
+ async function interactiveLogin(rl, io, bareMode) {
1039
+ if (bareMode) { io.println('Bare mode: only ANTHROPIC_API_KEY / apiKeyHelper allowed (§2.7 A7).'); return }
1040
+ io.println('Login: secrets stored in ~/.ona/secure/ (never in SQLite).')
1041
+ const kind = (await rl.question('Type (1) API key (2) Bearer token (3) Browser OAuth (4) Zhipu API key:\n')).trim()
1042
+ if (kind === '1') {
1043
+ const key = (await rl.question('ANTHROPIC_API_KEY: ')).trim()
1044
+ if (!key) { io.println('Aborted.'); return }
1045
+ saveSecureCredentials({ apiKey: key }); io.println('Saved API key.')
1046
+ } else if (kind === '2') {
1047
+ const tok = (await rl.question('Bearer token: ')).trim()
1048
+ if (!tok) { io.println('Aborted.'); return }
1049
+ saveSecureCredentials({ bearerToken: tok }); io.println('Saved bearer token.')
1050
+ } else if (kind === '3') {
1051
+ try { await interactiveOAuthLogin(io) } catch (e) { io.println(`OAuth failed: ${e.message}`) }
1052
+ } else if (kind === '4') {
1053
+ const key = (await rl.question('ZAI API key: ')).trim()
1054
+ if (!key) { io.println('Aborted.'); return }
1055
+ saveSecureCredentials({ zhipuApiKey: key }); io.println('Saved Zhipu API key.')
1056
+ } else { io.println('Invalid choice.') }
1057
+ }
1058
+
1059
+ function resolveModelArg(arg) {
1060
+ // Validate input
1061
+ if (!arg || typeof arg !== 'string' || !arg.trim()) {
1062
+ return { error: 'Model name cannot be empty' }
1063
+ }
1064
+
1065
+ const all = allModelIds()
1066
+
1067
+ // First, try exact match in hardcoded WIRE map (backward compatibility)
1068
+ if (arg.includes('/')) {
1069
+ const [p, m] = arg.split('/', 2)
1070
+
1071
+ // Validate provider and model_id are non-empty
1072
+ if (!p || !p.trim()) {
1073
+ return { error: 'Provider name cannot be empty' }
1074
+ }
1075
+ if (!m || !m.trim()) {
1076
+ return { error: 'Model name cannot be empty' }
1077
+ }
1078
+
1079
+ const match = all.find(x => x.provider === p && x.model_id === m)
1080
+ if (match) return match
1081
+
1082
+ // Check if provider exists
1083
+ const knownProviders = ['claude_code_subscription', 'openai_compatible', 'zhipu', 'ollama', 'lm_studio_local']
1084
+ if (!knownProviders.includes(p)) {
1085
+ return {
1086
+ error: `Unknown provider: ${p}\n\nValid providers:\n ${knownProviders.join('\n ')}`
1087
+ }
1088
+ }
1089
+
1090
+ // No WIRE match - check if provider supports custom models
1091
+ if (supportsCustomModels(p)) {
1092
+ // Create config with custom_model_name
1093
+ return {
1094
+ provider: p,
1095
+ model_id: m,
1096
+ custom_model_name: m
1097
+ }
1098
+ }
1099
+
1100
+ // Provider doesn't support custom models - list valid models
1101
+ const validModels = all.filter(x => x.provider === p).map(x => x.model_id)
1102
+ return {
1103
+ error: `Provider ${p} does not support custom models.\n\nValid models for ${p}:\n ${validModels.join('\n ')}\n\nTo use custom models, try: ollama, openai_compatible, or lm_studio_local`
1104
+ }
1105
+ }
1106
+
1107
+ // Freeform name (no provider prefix)
1108
+ // First, search WIRE map
1109
+ const match = all.find(x => x.model_id === arg)
1110
+ if (match) return match
1111
+
1112
+ // No WIRE match - default to openai_compatible with custom_model_name
1113
+ return {
1114
+ provider: 'openai_compatible',
1115
+ model_id: arg,
1116
+ custom_model_name: arg
1117
+ }
1118
+ }
1119
+
1120
+ function getBaseUrlForProvider(provider, settings) {
1121
+ // Get base URL from settings or use defaults
1122
+ if (settings.model_config?.base_url) {
1123
+ return settings.model_config.base_url
1124
+ }
1125
+
1126
+ // Provider-specific defaults
1127
+ if (provider === 'ollama') {
1128
+ return process.env.OLLAMA_BASE_URL || 'http://localhost:11434/v1'
1129
+ }
1130
+
1131
+ if (provider === 'lm_studio_local') {
1132
+ return (process.env.LM_STUDIO_BASE_URL || 'http://127.0.0.1:1234/v1').replace(/\/$/, '')
1133
+ }
1134
+
1135
+ if (provider === 'openai_compatible') {
1136
+ return process.env.OPENAI_BASE_URL || 'http://localhost:8080/v1'
1137
+ }
1138
+
1139
+ // Generic fallback
1140
+ return 'http://localhost:11434/v1'
1141
+ }
1142
+
1143
+ // ── CLI: --eval ──────────────────────────────────────────────
1144
+ async function runEval(opts) {
1145
+ const ctx = bootstrap(opts)
1146
+ let parsed
1147
+ try { parsed = JSON.parse(opts.eval) } catch (e) { console.error(`Invalid JSON: \${e.message}`); process.exit(1) }
1148
+ const toolName = parsed.tool
1149
+ const toolInput = parsed.input || {}
1150
+ if (!toolName) { console.error('Missing "tool" in eval JSON'); process.exit(1) }
1151
+
1152
+ // If an existing conversation in implement or planning phase exists, use it (for testing with seeded DBs)
1153
+ // --conversation flag takes priority
1154
+ let { conversationId, sessionId } = ctx
1155
+ if (opts.conversation) {
1156
+ const specified = ctx.db.prepare(`SELECT id FROM conversations WHERE id = ?`).get(opts.conversation)
1157
+ if (specified) {
1158
+ conversationId = specified.id
1159
+ const existingSess = ctx.db.prepare(`SELECT session_id FROM sessions WHERE conversation_id = ? ORDER BY started_at DESC LIMIT 1`).get(conversationId)
1160
+ if (existingSess) sessionId = existingSess.session_id
1161
+ else {
1162
+ sessionId = randomUUID()
1163
+ ctx.db.prepare(`INSERT INTO sessions(session_id, conversation_id) VALUES (?,?)`).run(sessionId, conversationId)
1164
+ }
1165
+ }
1166
+ } else {
1167
+ const existingImpl = ctx.db.prepare(`SELECT id FROM conversations WHERE phase IN ('implement','planning') ORDER BY last_active DESC LIMIT 1`).get()
1168
+ if (existingImpl) {
1169
+ conversationId = existingImpl.id
1170
+ const existingSess = ctx.db.prepare(`SELECT session_id FROM sessions WHERE conversation_id = ? ORDER BY started_at DESC LIMIT 1`).get(conversationId)
1171
+ if (existingSess) sessionId = existingSess.session_id
1172
+ else {
1173
+ sessionId = randomUUID()
1174
+ ctx.db.prepare(`INSERT INTO sessions(session_id, conversation_id) VALUES (?,?)`).run(sessionId, conversationId)
1175
+ }
1176
+ }
1177
+ }
1178
+
1179
+ const io = { write: s => process.stdout.write(s), println: s => console.log(s), ask: async () => 'y' }
1180
+ const execCtx = { sessionId, conversationId, cwd: ctx.cwd, settings: ctx.settings }
1181
+ const result = await executeBuiltinTool(ctx.db, execCtx, toolName, toolInput, io)
1182
+ appendEntry(ctx.db, sessionId, 'tool_result', makeToolResultPayload(randomUUID(), result.content, result.is_error), null)
1183
+ console.log(JSON.stringify(result))
1184
+ process.exit(result.is_error ? 1 : 0)
1185
+ }
1186
+
1187
+ // ── CLI: --compact ───────────────────────────────────────────
1188
+ async function runCompact(opts) {
1189
+ const ctx = bootstrap(opts)
1190
+ const { db, dbPath, settings, cwd } = ctx
1191
+ // Use specified conversation or most recent
1192
+ const convId = opts.conversation || ctx.conversationId
1193
+ const sess = db.prepare(`SELECT session_id FROM sessions WHERE conversation_id = ? ORDER BY started_at DESC LIMIT 1`).get(convId)
1194
+ const sessionId = sess?.session_id || ctx.sessionId
1195
+ const rt = { sessionId, conversationId: convId, runtimeDbPath: dbPath, cwd, bareMode: opts.bare, settings }
1196
+ const summary = await compactConversation(db, rt, async (text) => `[Compacted: \${text.split('\\n').length} messages]`, s => console.log(s))
1197
+ if (summary) console.log(`Summary: \${summary}`)
1198
+ process.exit(0)
1199
+ }
1200
+
1201
+ // ── CLI: --transition ────────────────────────────────────────
1202
+ function runTransition(opts) {
1203
+ const ctx = bootstrap(opts)
1204
+ const convId = opts.conversation || ctx.conversationId
1205
+ const toPhase = opts.transition
1206
+ const check = canTransition(ctx.db, convId, toPhase)
1207
+ if (!check.ok) { console.error(check.reason); process.exit(1) }
1208
+ setPhase(ctx.db, convId, toPhase, 'cli_admin')
1209
+ console.log(`Phase: \${toPhase}`)
1210
+ process.exit(0)
1211
+ }
1212
+
1213
+ // ── Entry point ──────────────────────────────────────────────
1214
+ async function main() {
1215
+ const opts = parseArgs(process.argv)
1216
+ if (opts.help) {
1217
+ console.log(`ona — SDLC SQLite REPL (CLEAN_ROOM_SPEC; multi-provider)
1218
+ Usage: ona [--bare] [--cwd DIR] [--eval JSON] [--transition PHASE] [--init-db]
1219
+
1220
+ Flags:
1221
+ --eval '{"tool":"...","input":{...}}' Execute a single tool and exit
1222
+ --transition <phase> --conversation <id> Transition phase and exit
1223
+ --init-db Initialize DB and exit
1224
+
1225
+ Environment:
1226
+ AGENT_SDLC_DB SQLite path (default: ~/.ona/agent.db)
1227
+ ANTHROPIC_API_KEY API key for claude_code_subscription
1228
+ ANTHROPIC_AUTH_TOKEN Bearer token
1229
+ SDLC_DISABLE_ALL_HOOKS=1 Skip hooks
1230
+
1231
+ Commands: /help /phase /plan /test /verify /done /model /login /logout /status /config /mcp /clear /exit`)
1232
+ return
1233
+ }
1234
+
1235
+ // CLI flag dispatch — no model/TTY needed
1236
+ if (opts.initDb) { bootstrap(opts); console.log(`DB: \${process.env.AGENT_SDLC_DB}`); return }
1237
+ if (opts.compact) { await runCompact(opts); return }
1238
+ if (opts.eval) { await runEval(opts); return }
1239
+ if (opts.transition) { runTransition(opts); return }
1240
+
1241
+ await autoDetectLmStudioModel()
1242
+
1243
+ // Provision Laya venv and warm the sidecar before the first turn.
1244
+ // This is mandatory — if it fails, print the error and abort.
1245
+ try {
1246
+ await ensureLayaReady()
1247
+ } catch (e) {
1248
+ process.stderr.write(`[startup] Laya provisioning failed: ${e.message}\n`)
1249
+ process.stderr.write('[startup] Cannot start: Laya (System-1) is required. Fix the error above and retry.\n')
1250
+ process.exit(1)
1251
+ }
1252
+
1253
+ if (process.stdin.isTTY) {
1254
+ await mainInteractive(opts)
1255
+ } else {
1256
+ await mainPipe(opts)
1257
+ }
1258
+ }
1259
+
1260
+ main().catch(e => { console.error(e); process.exit(1) })