@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.
- package/LICENSE +21 -0
- package/LICENSE.md +57 -0
- package/README.md +404 -2
- package/bin/agent.mjs +1260 -0
- package/bin/nehanda-ui.mjs +824 -0
- package/lib/anthropicHeaders.mjs +8 -0
- package/lib/app.mjs +313 -0
- package/lib/auth.mjs +255 -0
- package/lib/bashguard.mjs +159 -0
- package/lib/compact.mjs +306 -0
- package/lib/energy-middleware.mjs +318 -0
- package/lib/hookShapeOrder.mjs +30 -0
- package/lib/hookplane.mjs +228 -0
- package/lib/instructions.mjs +23 -0
- package/lib/jev_read.mjs +216 -0
- package/lib/jev_write.mjs +253 -0
- package/lib/laya.mjs +296 -0
- package/lib/laya_sidecar.mjs +198 -0
- package/lib/mcp-config.mjs +189 -0
- package/lib/mcp.mjs +169 -0
- package/lib/modelConfig.mjs +152 -0
- package/lib/modelDiscovery.mjs +205 -0
- package/lib/openaiCompat.mjs +147 -0
- package/lib/orchestrate.mjs +1163 -0
- package/lib/paths.mjs +20 -0
- package/lib/permissions.mjs +44 -0
- package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
- package/lib/scripts/audit-code-integrity.py +437 -0
- package/lib/scripts/ensure-laya-env.sh +55 -0
- package/lib/scripts/js-safety-checker.cjs +308 -0
- package/lib/scripts/laya_worker.py +171 -0
- package/lib/scripts/odse-transform.py +97 -0
- package/lib/scripts/python-safety-checker.py +197 -0
- package/lib/scripts/shell-safety-checker.sh +306 -0
- package/lib/settings.mjs +147 -0
- package/lib/store.mjs +278 -0
- package/lib/team.mjs +69 -0
- package/lib/testgen.mjs +146 -0
- package/lib/tools/AuditCodeIntegrity.json +30 -0
- package/lib/tools/JsSafetyChecker.json +29 -0
- package/lib/tools/PythonSafetyChecker.json +29 -0
- package/lib/tools/ShellSafetyChecker.json +29 -0
- package/lib/tools.mjs +907 -0
- package/lib/transcript.mjs +223 -0
- package/lib/ui.mjs +286 -0
- package/lib/workflow.mjs +91 -0
- package/lib/worktree.mjs +56 -0
- package/package.json +50 -4
- package/schema.sql +181 -0
- package/scripts/postinstall.mjs +62 -0
- package/scripts/sdlc-acceptance.sh +477 -0
- package/scripts/verify-sdlc-hook-order.mjs +26 -0
- package/templates/test_e2e.sh +32 -0
- package/templates/test_file.sh +14 -0
- package/templates/test_hook.sh +27 -0
- package/templates/test_http.sh +17 -0
- package/templates/test_phase.sh +23 -0
- package/templates/test_process.sh +14 -0
- package/templates/test_tool.sh +24 -0
- 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) })
|