@asobacloud/nehanda 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/LICENSE.md +57 -0
- package/README.md +444 -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
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
function nextSeq(db, sessionId) {
|
|
4
|
+
const r = db
|
|
5
|
+
.prepare(`SELECT COALESCE(MAX(sequence), -1) AS m FROM transcript_entries WHERE session_id = ?`)
|
|
6
|
+
.get(sessionId)
|
|
7
|
+
return (r?.m ?? -1) + 1
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function appendEntry(db, sessionId, entryType, payload, toolUseId = null) {
|
|
11
|
+
const seq = nextSeq(db, sessionId)
|
|
12
|
+
const payloadJson = JSON.stringify(payload)
|
|
13
|
+
const info = db
|
|
14
|
+
.prepare(
|
|
15
|
+
`INSERT INTO transcript_entries(session_id, sequence, entry_type, payload_json, tool_use_id)
|
|
16
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
17
|
+
)
|
|
18
|
+
.run(sessionId, seq, entryType, payloadJson, toolUseId)
|
|
19
|
+
return info.lastInsertRowid
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Build Anthropic Messages API messages array from transcript (§2.5). */
|
|
23
|
+
export function transcriptToAnthropicMessages(db, sessionId) {
|
|
24
|
+
// Check for collapse_commit — if present, start from there
|
|
25
|
+
const collapse = db.prepare(
|
|
26
|
+
`SELECT sequence, payload_json FROM transcript_entries WHERE session_id = ? AND entry_type = 'collapse_commit' ORDER BY sequence DESC LIMIT 1`
|
|
27
|
+
).get(sessionId)
|
|
28
|
+
|
|
29
|
+
let rows
|
|
30
|
+
if (collapse) {
|
|
31
|
+
const summary = JSON.parse(collapse.payload_json).summary || ''
|
|
32
|
+
rows = [
|
|
33
|
+
{ entry_type: 'user', payload_json: JSON.stringify({ _t: 'user', content: [{ type: 'text', text: `[Previous conversation summary]\n${summary}` }] }) },
|
|
34
|
+
...db.prepare(
|
|
35
|
+
`SELECT entry_type, payload_json FROM transcript_entries WHERE session_id = ? AND sequence > ? AND entry_type != 'collapse_commit' ORDER BY sequence ASC`
|
|
36
|
+
).all(sessionId, collapse.sequence),
|
|
37
|
+
]
|
|
38
|
+
} else {
|
|
39
|
+
rows = db.prepare(
|
|
40
|
+
`SELECT entry_type, payload_json FROM transcript_entries WHERE session_id = ? ORDER BY sequence ASC`
|
|
41
|
+
).all(sessionId)
|
|
42
|
+
}
|
|
43
|
+
const messages = []
|
|
44
|
+
let pendingToolResults = []
|
|
45
|
+
|
|
46
|
+
const flushToolResults = () => {
|
|
47
|
+
if (!pendingToolResults.length) return
|
|
48
|
+
messages.push({ role: 'user', content: pendingToolResults })
|
|
49
|
+
pendingToolResults = []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
let payload
|
|
54
|
+
try { payload = JSON.parse(row.payload_json) } catch { continue }
|
|
55
|
+
|
|
56
|
+
if (row.entry_type === 'user') {
|
|
57
|
+
flushToolResults()
|
|
58
|
+
const text = extractTextContent(payload)
|
|
59
|
+
if (text) messages.push({ role: 'user', content: text })
|
|
60
|
+
} else if (row.entry_type === 'assistant') {
|
|
61
|
+
flushToolResults()
|
|
62
|
+
const content = assistantPayloadToApiContent(payload)
|
|
63
|
+
if (content.length) messages.push({ role: 'assistant', content })
|
|
64
|
+
} else if (row.entry_type === 'tool_result') {
|
|
65
|
+
pendingToolResults.push({
|
|
66
|
+
type: 'tool_result',
|
|
67
|
+
tool_use_id: payload.tool_use_id,
|
|
68
|
+
content: typeof payload.content === 'string' ? payload.content : JSON.stringify(payload.content),
|
|
69
|
+
is_error: Boolean(payload.is_error),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
flushToolResults()
|
|
74
|
+
return messages
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Build OpenAI /v1/chat/completions messages. */
|
|
78
|
+
export function transcriptToOpenAIMessages(db, sessionId) {
|
|
79
|
+
const collapse = db.prepare(
|
|
80
|
+
`SELECT sequence, payload_json FROM transcript_entries WHERE session_id = ? AND entry_type = 'collapse_commit' ORDER BY sequence DESC LIMIT 1`
|
|
81
|
+
).get(sessionId)
|
|
82
|
+
|
|
83
|
+
let rows
|
|
84
|
+
if (collapse) {
|
|
85
|
+
const summary = JSON.parse(collapse.payload_json).summary || ''
|
|
86
|
+
rows = [
|
|
87
|
+
{ entry_type: 'user', payload_json: JSON.stringify({ _t: 'user', content: [{ type: 'text', text: `[Previous conversation summary]\n${summary}` }] }) },
|
|
88
|
+
...db.prepare(
|
|
89
|
+
`SELECT entry_type, payload_json FROM transcript_entries WHERE session_id = ? AND sequence > ? AND entry_type != 'collapse_commit' ORDER BY sequence ASC`
|
|
90
|
+
).all(sessionId, collapse.sequence),
|
|
91
|
+
]
|
|
92
|
+
} else {
|
|
93
|
+
rows = db.prepare(
|
|
94
|
+
`SELECT entry_type, payload_json FROM transcript_entries WHERE session_id = ? ORDER BY sequence ASC`
|
|
95
|
+
).all(sessionId)
|
|
96
|
+
}
|
|
97
|
+
const messages = []
|
|
98
|
+
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
let payload
|
|
101
|
+
try { payload = JSON.parse(row.payload_json) } catch { continue }
|
|
102
|
+
|
|
103
|
+
if (row.entry_type === 'user') {
|
|
104
|
+
const text = extractTextContent(payload)
|
|
105
|
+
if (text) messages.push({ role: 'user', content: text })
|
|
106
|
+
} else if (row.entry_type === 'assistant') {
|
|
107
|
+
const blocks = payload.content
|
|
108
|
+
if (!Array.isArray(blocks)) continue
|
|
109
|
+
const textParts = []
|
|
110
|
+
const toolUses = []
|
|
111
|
+
for (const b of blocks) {
|
|
112
|
+
if (b?.type === 'text' && typeof b.text === 'string') textParts.push(b.text)
|
|
113
|
+
else if (b?.type === 'tool_use' && b.id && b.name) {
|
|
114
|
+
toolUses.push({
|
|
115
|
+
id: b.id,
|
|
116
|
+
type: 'function',
|
|
117
|
+
function: {
|
|
118
|
+
name: b.name,
|
|
119
|
+
arguments: JSON.stringify(b.input && typeof b.input === 'object' ? b.input : {}),
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (toolUses.length) {
|
|
125
|
+
messages.push({
|
|
126
|
+
role: 'assistant',
|
|
127
|
+
content: textParts.length ? textParts.join('\n') : null,
|
|
128
|
+
tool_calls: toolUses,
|
|
129
|
+
})
|
|
130
|
+
} else if (textParts.length) {
|
|
131
|
+
messages.push({ role: 'assistant', content: textParts.join('\n') })
|
|
132
|
+
}
|
|
133
|
+
} else if (row.entry_type === 'tool_result') {
|
|
134
|
+
const id = payload.tool_use_id
|
|
135
|
+
const s = typeof payload.content === 'string' ? payload.content : JSON.stringify(payload.content ?? '')
|
|
136
|
+
if (id) messages.push({ role: 'tool', tool_call_id: id, content: s })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return messages
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function extractTextContent(payload) {
|
|
143
|
+
const blocks = payload.content
|
|
144
|
+
if (!Array.isArray(blocks)) return ''
|
|
145
|
+
return blocks.filter(b => b?.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function assistantPayloadToApiContent(payload) {
|
|
149
|
+
const blocks = payload.content
|
|
150
|
+
if (!Array.isArray(blocks)) return []
|
|
151
|
+
const out = []
|
|
152
|
+
for (const b of blocks) {
|
|
153
|
+
if (b?.type === 'text' && typeof b.text === 'string') out.push({ type: 'text', text: b.text })
|
|
154
|
+
else if (b?.type === 'tool_use') out.push({ type: 'tool_use', id: b.id, name: b.name, input: b.input && typeof b.input === 'object' ? b.input : {} })
|
|
155
|
+
}
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function makeUserPayload(text) {
|
|
160
|
+
return { _t: 'user', uuid: randomUUID(), content: [{ type: 'text', text }] }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function makeAssistantPayload(contentBlocks) {
|
|
164
|
+
return { _t: 'assistant', uuid: randomUUID(), content: contentBlocks }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function makeToolResultPayload(toolUseId, content, isError) {
|
|
168
|
+
return { _t: 'tool_result', tool_use_id: toolUseId, content, is_error: isError }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Epistemic isolation filter — §8.5.1.
|
|
173
|
+
*
|
|
174
|
+
* Strips SEARCH/REPLACE implementation blocks from assistant messages before
|
|
175
|
+
* building the context for the TEST phase. This guarantees the test-generation
|
|
176
|
+
* agent cannot read the implementation it is supposed to independently verify.
|
|
177
|
+
*
|
|
178
|
+
* A block is defined as any sequence matching:
|
|
179
|
+
* <<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE
|
|
180
|
+
* or the equivalent file-path-prefixed form used by Nehanda.
|
|
181
|
+
*
|
|
182
|
+
* Given: an array of OpenAI-format messages and the active workflow phase
|
|
183
|
+
* When: phase === 'test'
|
|
184
|
+
* Then: every assistant message has SEARCH/REPLACE blocks replaced with a
|
|
185
|
+
* redaction placeholder; all other messages pass through unchanged.
|
|
186
|
+
* When: phase !== 'test'
|
|
187
|
+
* Then: messages are returned unmodified.
|
|
188
|
+
*/
|
|
189
|
+
export function applyEpistemicFilter(messages, phase) {
|
|
190
|
+
if (phase !== 'test') return messages
|
|
191
|
+
|
|
192
|
+
const SEARCH_REPLACE_RE = /<<<<<<< SEARCH[\s\S]*?>>>>>>> REPLACE/g
|
|
193
|
+
const PLACEHOLDER = '[implementation details redacted for epistemic isolation]'
|
|
194
|
+
|
|
195
|
+
return messages.map(msg => {
|
|
196
|
+
if (msg.role !== 'assistant') return msg
|
|
197
|
+
|
|
198
|
+
// String content
|
|
199
|
+
if (typeof msg.content === 'string') {
|
|
200
|
+
const filtered = msg.content.replace(SEARCH_REPLACE_RE, PLACEHOLDER)
|
|
201
|
+
return filtered === msg.content ? msg : { ...msg, content: filtered }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Array content (text blocks + possible tool_calls)
|
|
205
|
+
if (Array.isArray(msg.content)) {
|
|
206
|
+
const filtered = msg.content.map(block => {
|
|
207
|
+
if (typeof block === 'string') {
|
|
208
|
+
return block.replace(SEARCH_REPLACE_RE, PLACEHOLDER)
|
|
209
|
+
}
|
|
210
|
+
if (block?.type === 'text' && typeof block.text === 'string') {
|
|
211
|
+
const t = block.text.replace(SEARCH_REPLACE_RE, PLACEHOLDER)
|
|
212
|
+
return t === block.text ? block : { ...block, text: t }
|
|
213
|
+
}
|
|
214
|
+
return block
|
|
215
|
+
})
|
|
216
|
+
// Only return a new object if something actually changed
|
|
217
|
+
const changed = filtered.some((b, i) => b !== msg.content[i])
|
|
218
|
+
return changed ? { ...msg, content: filtered } : msg
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return msg
|
|
222
|
+
})
|
|
223
|
+
}
|
package/lib/ui.mjs
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Terminal UI — colors, formatting, spinner, markdown rendering
|
|
2
|
+
// Reference: claude-code components/Spinner.tsx, components/Markdown.tsx, components/PromptInput/
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { marked } from 'marked'
|
|
5
|
+
import TerminalRenderer from 'marked-terminal'
|
|
6
|
+
|
|
7
|
+
// Configure marked for terminal output
|
|
8
|
+
marked.setOptions({
|
|
9
|
+
renderer: new TerminalRenderer({
|
|
10
|
+
code: chalk.yellow,
|
|
11
|
+
codespan: chalk.yellow,
|
|
12
|
+
strong: chalk.bold,
|
|
13
|
+
em: chalk.italic,
|
|
14
|
+
heading: chalk.bold.cyan,
|
|
15
|
+
hr: () => chalk.dim('─'.repeat(60)),
|
|
16
|
+
listitem: text => ` ${chalk.dim('•')} ${text}`,
|
|
17
|
+
paragraph: text => text + '\n',
|
|
18
|
+
table: chalk.reset,
|
|
19
|
+
link: (href, title, text) => `${text} ${chalk.dim.underline(href)}`,
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export function renderMarkdown(text) {
|
|
24
|
+
if (!text) return ''
|
|
25
|
+
try {
|
|
26
|
+
return marked(text).replace(/\n{3,}/g, '\n\n').trimEnd()
|
|
27
|
+
} catch {
|
|
28
|
+
return text
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Colors ──────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export const colors = {
|
|
35
|
+
banner: chalk.bold.hex('#cc785c'), // warm bronze for ona branding
|
|
36
|
+
version: chalk.dim,
|
|
37
|
+
provider: chalk.cyan,
|
|
38
|
+
model: chalk.bold.white,
|
|
39
|
+
endpoint: chalk.dim,
|
|
40
|
+
prompt: chalk.bold.hex('#cc785c'), // matches banner
|
|
41
|
+
promptArrow: chalk.bold.hex('#cc785c'),
|
|
42
|
+
assistant: chalk.reset,
|
|
43
|
+
toolName: chalk.bold.yellow,
|
|
44
|
+
toolLabel: chalk.dim.yellow,
|
|
45
|
+
toolResult: chalk.dim,
|
|
46
|
+
toolError: chalk.red,
|
|
47
|
+
error: chalk.red,
|
|
48
|
+
success: chalk.green,
|
|
49
|
+
dim: chalk.dim,
|
|
50
|
+
info: chalk.blue,
|
|
51
|
+
warning: chalk.yellow,
|
|
52
|
+
command: chalk.cyan,
|
|
53
|
+
key: chalk.dim,
|
|
54
|
+
value: chalk.white,
|
|
55
|
+
header: chalk.bold.underline,
|
|
56
|
+
separator: chalk.dim,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Banner ──────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export function printBanner(version, dbPath, bare) {
|
|
62
|
+
const inner = ` ona v${version} `
|
|
63
|
+
const width = inner.length
|
|
64
|
+
const lines = []
|
|
65
|
+
lines.push('')
|
|
66
|
+
lines.push(colors.banner(` ╭${'─'.repeat(width)}╮`))
|
|
67
|
+
lines.push(colors.banner(` │`) + `${inner}` + colors.banner(`│`))
|
|
68
|
+
lines.push(colors.banner(` ╰${'─'.repeat(width)}╯`))
|
|
69
|
+
if (bare) lines.push(colors.dim(` [bare mode]`))
|
|
70
|
+
lines.push(colors.dim(` DB: ${dbPath}`))
|
|
71
|
+
lines.push('')
|
|
72
|
+
return lines.join('\n')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function printProviderBanner(provider, wireModel, endpoint) {
|
|
76
|
+
const lines = []
|
|
77
|
+
lines.push(` ${colors.key('Provider:')} ${colors.provider(provider)}`)
|
|
78
|
+
lines.push(` ${colors.key('Model:')} ${colors.model(wireModel)}`)
|
|
79
|
+
lines.push(` ${colors.key('Endpoint:')} ${colors.endpoint(endpoint)}`)
|
|
80
|
+
lines.push('')
|
|
81
|
+
return lines.join('\n')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Prompt ──────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export function formatPrompt() {
|
|
87
|
+
return colors.promptArrow('❯ ')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Help ────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export function formatHelp(provider, dbPath) {
|
|
93
|
+
const cmd = (name, desc) => ` ${colors.command(name.padEnd(18))} ${colors.dim(desc)}`
|
|
94
|
+
const lines = [
|
|
95
|
+
'',
|
|
96
|
+
colors.header('Commands'),
|
|
97
|
+
cmd('/phase', 'Show current SDLC phase'),
|
|
98
|
+
cmd('/plan', 'Show plan status'),
|
|
99
|
+
cmd('/code', 'Implement approved plan'),
|
|
100
|
+
cmd('/test', 'Generate and run tests'),
|
|
101
|
+
cmd('/verify', 'Coverage report'),
|
|
102
|
+
cmd('/done', 'Complete workflow'),
|
|
103
|
+
'',
|
|
104
|
+
cmd('/init', 'Create Ona.md'),
|
|
105
|
+
cmd('/diff', 'Uncommitted changes'),
|
|
106
|
+
cmd('/cost', 'Token usage and cost'),
|
|
107
|
+
cmd('/doctor', 'Environment diagnostics'),
|
|
108
|
+
cmd('/permissions', 'Permission rules'),
|
|
109
|
+
cmd('/pr-comments', 'PR comments (requires gh)'),
|
|
110
|
+
cmd('/compact', 'Compact conversation'),
|
|
111
|
+
cmd('/team', 'Manage teams'),
|
|
112
|
+
'',
|
|
113
|
+
cmd('/model [name]', 'Change model'),
|
|
114
|
+
cmd('/login', 'Store credentials'),
|
|
115
|
+
cmd('/logout', 'Clear credentials'),
|
|
116
|
+
cmd('/status', 'Auth status'),
|
|
117
|
+
cmd('/config', 'Show / set settings'),
|
|
118
|
+
cmd('/config set <key> <value>', 'Set any config value'),
|
|
119
|
+
cmd('/mcp', 'MCP server management & guide'),
|
|
120
|
+
cmd('/mcp list', 'List all discovered tools & descriptions'),
|
|
121
|
+
cmd('/mcp status', 'Show configured MCP servers'),
|
|
122
|
+
cmd('/mcp env [server] [KEY] [val]', 'Set MCP env vars (API keys)'),
|
|
123
|
+
cmd('/clear', 'New conversation'),
|
|
124
|
+
cmd('/exit', 'Quit (/quit)'),
|
|
125
|
+
'',
|
|
126
|
+
` ${colors.key('Provider:')} ${colors.provider(provider)}`,
|
|
127
|
+
` ${colors.key('DB:')} ${colors.dim(dbPath)}`,
|
|
128
|
+
'',
|
|
129
|
+
]
|
|
130
|
+
return lines.join('\n')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function formatMcpHelp() {
|
|
134
|
+
const cmd = (name, desc) => ` ${colors.command(name.padEnd(28))} ${colors.dim(desc)}`
|
|
135
|
+
const lines = [
|
|
136
|
+
'',
|
|
137
|
+
colors.header('MCP Server & Tool Commands'),
|
|
138
|
+
cmd('/mcp list', 'Discover and list all available MCP tools'),
|
|
139
|
+
cmd('/mcp status', 'Show configured servers and command paths'),
|
|
140
|
+
cmd('/mcp reload [server]', 'Reload mcp.json configs into session'),
|
|
141
|
+
cmd('/mcp add <name> <cmd> [args]', 'Register a new MCP server'),
|
|
142
|
+
cmd('/mcp env [server] [KEY] [val]', 'View or set server environment variables'),
|
|
143
|
+
'',
|
|
144
|
+
colors.dim(' Tip: Run /mcp list to inspect tools injected into the assistant.'),
|
|
145
|
+
'',
|
|
146
|
+
]
|
|
147
|
+
return lines.join('\n')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Tool display ────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
export function formatToolStart(toolName) {
|
|
153
|
+
return colors.toolLabel(' ┌ ') + colors.toolName(toolName)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function formatToolResult(toolName, content, isError) {
|
|
157
|
+
const icon = isError ? colors.toolError('✗') : colors.success('✓')
|
|
158
|
+
const label = colors.toolLabel(' └ ')
|
|
159
|
+
const preview = (content || '').split('\n')[0].slice(0, 80)
|
|
160
|
+
return label + icon + ' ' + colors.dim(preview)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Spinner ─────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
166
|
+
|
|
167
|
+
export class Spinner {
|
|
168
|
+
constructor(io) {
|
|
169
|
+
this.io = io
|
|
170
|
+
this.frame = 0
|
|
171
|
+
this.interval = null
|
|
172
|
+
this.active = false
|
|
173
|
+
this.message = 'Thinking'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
start(message) {
|
|
177
|
+
if (this.active) return
|
|
178
|
+
this.active = true
|
|
179
|
+
this.message = message || 'Thinking'
|
|
180
|
+
this.frame = 0
|
|
181
|
+
this.interval = setInterval(() => {
|
|
182
|
+
const f = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length]
|
|
183
|
+
this.io.write(`\r${colors.dim(f + ' ' + this.message + '...')}`)
|
|
184
|
+
this.frame++
|
|
185
|
+
}, 80)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
stop() {
|
|
189
|
+
if (!this.active) return
|
|
190
|
+
this.active = false
|
|
191
|
+
if (this.interval) {
|
|
192
|
+
clearInterval(this.interval)
|
|
193
|
+
this.interval = null
|
|
194
|
+
}
|
|
195
|
+
this.io.write('\r' + ' '.repeat(this.message.length + 20) + '\r')
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Model change ────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export function formatModelChange(provider, modelId, wireModel, isCustom = false) {
|
|
202
|
+
const customSuffix = isCustom ? colors.dim(' (custom)') : ''
|
|
203
|
+
return ` ${colors.success('✓')} Model: ${colors.provider(provider)} ${colors.dim('/')} ${colors.model(wireModel)}${customSuffix}`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Status ──────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
export function formatStatus(status) {
|
|
209
|
+
const lines = ['', colors.header('Auth Status')]
|
|
210
|
+
for (const [k, v] of Object.entries(status)) {
|
|
211
|
+
if (k === 'alsoConfigured') {
|
|
212
|
+
const ac = v || {}
|
|
213
|
+
if (ac.ignoredHints?.length) {
|
|
214
|
+
lines.push(` ${colors.key('Notes:')}`)
|
|
215
|
+
for (const hint of ac.ignoredHints) lines.push(` ${colors.dim(hint)}`)
|
|
216
|
+
}
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
const display = typeof v === 'object' ? JSON.stringify(v) : String(v)
|
|
220
|
+
lines.push(` ${colors.key(k + ':')} ${colors.value(display)}`)
|
|
221
|
+
}
|
|
222
|
+
lines.push('')
|
|
223
|
+
return lines.join('\n')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Config ──────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
export function formatConfig(settings) {
|
|
229
|
+
const lines = ['', colors.header('Settings')]
|
|
230
|
+
|
|
231
|
+
// Show model config with custom indicator
|
|
232
|
+
if (settings.model_config) {
|
|
233
|
+
const { provider, model_id, custom_model_name, base_url } = settings.model_config
|
|
234
|
+
lines.push(` ${colors.key('Provider:')} ${colors.provider(provider)}`)
|
|
235
|
+
|
|
236
|
+
if (custom_model_name) {
|
|
237
|
+
lines.push(` ${colors.key('Model:')} ${colors.model(custom_model_name)} ${colors.dim('(custom)')}`)
|
|
238
|
+
} else {
|
|
239
|
+
lines.push(` ${colors.key('Model:')} ${colors.model(model_id)}`)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (base_url) {
|
|
243
|
+
lines.push(` ${colors.key('Base URL:')} ${colors.dim(base_url)}`)
|
|
244
|
+
}
|
|
245
|
+
lines.push('')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Show full JSON for other settings
|
|
249
|
+
const json = JSON.stringify(settings, null, 2)
|
|
250
|
+
for (const line of json.split('\n')) {
|
|
251
|
+
lines.push(' ' + colors.dim(line))
|
|
252
|
+
}
|
|
253
|
+
lines.push('')
|
|
254
|
+
return lines.join('\n')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Separator ───────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
export function separator() {
|
|
260
|
+
return colors.separator('─'.repeat(60))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Session summary ──────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
export function formatSessionSummary(sessionId, tokens, model, provider) {
|
|
266
|
+
const { input = 0, output = 0, calls = 0 } = tokens
|
|
267
|
+
const total = input + output
|
|
268
|
+
const sep = colors.dim('─'.repeat(48))
|
|
269
|
+
|
|
270
|
+
const lines = [
|
|
271
|
+
'',
|
|
272
|
+
sep,
|
|
273
|
+
` ${colors.header('Session Summary')}`,
|
|
274
|
+
` ${colors.key('Session:')} ${colors.dim(sessionId)}`,
|
|
275
|
+
` ${colors.key('Model:')} ${colors.provider(provider)} ${colors.dim('/')} ${colors.model(model)}`,
|
|
276
|
+
` ${colors.key('API calls:')} ${colors.value(String(calls))}`,
|
|
277
|
+
'',
|
|
278
|
+
` ${colors.header('Token Usage')}`,
|
|
279
|
+
` ${colors.key('Input:')} ${colors.value(input.toLocaleString())}`,
|
|
280
|
+
` ${colors.key('Output:')} ${colors.value(output.toLocaleString())}`,
|
|
281
|
+
` ${colors.key('Total:')} ${colors.value(total.toLocaleString())}`,
|
|
282
|
+
sep,
|
|
283
|
+
'',
|
|
284
|
+
]
|
|
285
|
+
return lines.join('\n')
|
|
286
|
+
}
|
package/lib/workflow.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { withTransaction } from './store.mjs'
|
|
2
|
+
|
|
3
|
+
const MUTATING = new Set(['Write', 'Edit', 'Bash', 'NotebookEdit'])
|
|
4
|
+
const PHASES = new Set(['idle', 'planning', 'implement', 'test', 'verify', 'done'])
|
|
5
|
+
|
|
6
|
+
/** §8.2 — valid transitions. */
|
|
7
|
+
const TRANSITIONS = {
|
|
8
|
+
idle: new Set(['planning']),
|
|
9
|
+
planning: new Set(['planning', 'implement']),
|
|
10
|
+
implement: new Set(['planning', 'test']),
|
|
11
|
+
test: new Set(['planning', 'verify']),
|
|
12
|
+
verify: new Set(['planning', 'done']),
|
|
13
|
+
done: new Set(['planning']),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** §8.3 — deny mutating tools ONLY during planning phase without an approved plan. */
|
|
17
|
+
export function planningGateDeniesTool(db, conversationId, toolName) {
|
|
18
|
+
if (!MUTATING.has(toolName)) return false
|
|
19
|
+
const conv = db.prepare(`SELECT phase FROM conversations WHERE id = ?`).get(conversationId)
|
|
20
|
+
const phase = conv?.phase || 'idle'
|
|
21
|
+
// Only block during planning phase — all other phases allow mutating tools
|
|
22
|
+
if (phase !== 'planning') return false
|
|
23
|
+
// In planning phase, block unless there's an approved plan
|
|
24
|
+
const approved = db.prepare(`SELECT 1 AS ok FROM plans WHERE conversation_id = ? AND status = 'approved' LIMIT 1`).get(conversationId)
|
|
25
|
+
return !approved
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Check if a transition is valid per §8.2. */
|
|
29
|
+
export function canTransition(db, conversationId, toPhase) {
|
|
30
|
+
if (!PHASES.has(toPhase)) return { ok: false, reason: `Invalid phase: ${toPhase}` }
|
|
31
|
+
const conv = db.prepare(`SELECT phase FROM conversations WHERE id = ?`).get(conversationId)
|
|
32
|
+
const from = conv?.phase || 'idle'
|
|
33
|
+
const allowed = TRANSITIONS[from]
|
|
34
|
+
if (!allowed || !allowed.has(toPhase)) {
|
|
35
|
+
return { ok: false, reason: `Cannot transition from ${from} to ${toPhase} (§8.2)` }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (from === 'planning' && toPhase === 'implement') {
|
|
39
|
+
const approved = db.prepare(`SELECT 1 AS ok FROM plans WHERE conversation_id = ? AND status = 'approved' LIMIT 1`).get(conversationId)
|
|
40
|
+
if (!approved) return { ok: false, reason: 'Cannot enter implement: no approved plan (§8.2)' }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (from === 'implement' && toPhase === 'test') {
|
|
44
|
+
// Implementation complete — tests must be generated next
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (from === 'test' && toPhase === 'verify') {
|
|
48
|
+
// §8.6.2 Coverage gate: all plan-traced tests must pass
|
|
49
|
+
const results = db.prepare(`SELECT detail FROM events WHERE conversation_id = ? AND event_type = 'test_result'`).all(conversationId)
|
|
50
|
+
if (!results.length) return { ok: false, reason: 'Cannot transition to verify: no test results recorded. Run /test first.' }
|
|
51
|
+
const parsed = results.map(r => { try { return JSON.parse(r.detail) } catch { return null } }).filter(Boolean)
|
|
52
|
+
const failing = parsed.filter(r => !r.passed)
|
|
53
|
+
if (failing.length) return { ok: false, reason: `Cannot transition to verify: ${failing.length} test(s) failing.` }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (from === 'verify' && toPhase === 'done') {
|
|
57
|
+
// Operator approval required — handled by caller
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { ok: true }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Set the phase for a conversation.
|
|
65
|
+
* @param {object} db - Database connection
|
|
66
|
+
* @param {string} conversationId - Conversation ID
|
|
67
|
+
* @param {string} phase - Target phase
|
|
68
|
+
* @param {string} [source] - Source of the phase transition (e.g., 'terminal_tool', 'cli_admin')
|
|
69
|
+
*
|
|
70
|
+
* §2, §3.2: Phase transitions should occur through terminal tools.
|
|
71
|
+
* Terminal tools (EnterPlanMode, ExitPlanMode, SubmitImplementation, SubmitTest) validate
|
|
72
|
+
* the current phase before calling setPhase(), ensuring legitimate transitions.
|
|
73
|
+
* CLI commands provide administrative overrides for debugging/manual control.
|
|
74
|
+
*
|
|
75
|
+
* Enforcement is achieved through:
|
|
76
|
+
* 1. Terminal tools validate current phase before calling setPhase()
|
|
77
|
+
* 2. canTransition() validates allowed state transitions (§8.2)
|
|
78
|
+
* 3. Phase-based capability masking prevents unauthorized tool use
|
|
79
|
+
* 4. Instruction interceptor catches protocol violations
|
|
80
|
+
*/
|
|
81
|
+
export function setPhase(db, conversationId, phase, source = 'unknown') {
|
|
82
|
+
withTransaction(db, () => {
|
|
83
|
+
db.prepare(`UPDATE conversations SET phase = ?, last_active = datetime('now') WHERE id = ?`).run(phase, conversationId)
|
|
84
|
+
db.prepare(`INSERT INTO events(conversation_id, event_type, detail) VALUES (?, 'phase', ?)`).run(conversationId, phase)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getPhase(db, conversationId) {
|
|
89
|
+
const r = db.prepare(`SELECT phase FROM conversations WHERE id = ?`).get(conversationId)
|
|
90
|
+
return r?.phase || 'idle'
|
|
91
|
+
}
|
package/lib/worktree.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
|
|
4
|
+
let currentWorktree = null // { originalCwd, worktreePath, worktreeBranch, name }
|
|
5
|
+
|
|
6
|
+
const SLUG_RE = /^[a-zA-Z0-9._-]{1,64}$/
|
|
7
|
+
|
|
8
|
+
export function getCurrentWorktree() { return currentWorktree }
|
|
9
|
+
|
|
10
|
+
export function createWorktree(cwd, name) {
|
|
11
|
+
if (!name || !SLUG_RE.test(name)) {
|
|
12
|
+
return { ok: false, error: `Invalid worktree name: "${name}". Use alphanumeric, dots, dashes, underscores (max 64 chars).` }
|
|
13
|
+
}
|
|
14
|
+
if (currentWorktree) {
|
|
15
|
+
return { ok: false, error: `Already in worktree: ${currentWorktree.name}. Exit first.` }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Find git root
|
|
19
|
+
const root = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' })
|
|
20
|
+
if (root.status !== 0) return { ok: false, error: 'Not in a git repository.' }
|
|
21
|
+
const gitRoot = root.stdout.trim()
|
|
22
|
+
|
|
23
|
+
// Get current branch
|
|
24
|
+
const branch = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: gitRoot, encoding: 'utf8' })
|
|
25
|
+
const baseBranch = branch.stdout?.trim() || 'HEAD'
|
|
26
|
+
|
|
27
|
+
// Create worktree
|
|
28
|
+
const wtPath = path.join(gitRoot, '.ona', 'worktrees', name)
|
|
29
|
+
const wtBranch = `worktree-${name.replace(/\//g, '+')}`
|
|
30
|
+
const result = spawnSync('git', ['worktree', 'add', '-B', wtBranch, wtPath, baseBranch], { cwd: gitRoot, encoding: 'utf8' })
|
|
31
|
+
if (result.status !== 0) {
|
|
32
|
+
return { ok: false, error: `git worktree add failed: ${(result.stderr || result.stdout || '').trim()}` }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
currentWorktree = { originalCwd: cwd, worktreePath: wtPath, worktreeBranch: wtBranch, name }
|
|
36
|
+
return { ok: true, worktreePath: wtPath, worktreeBranch: wtBranch }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function removeWorktree() {
|
|
40
|
+
if (!currentWorktree) return { ok: false, error: 'Not in a worktree.' }
|
|
41
|
+
|
|
42
|
+
const { worktreePath, worktreeBranch, originalCwd } = currentWorktree
|
|
43
|
+
|
|
44
|
+
// Remove worktree
|
|
45
|
+
const rm = spawnSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: originalCwd, encoding: 'utf8' })
|
|
46
|
+
if (rm.status !== 0) {
|
|
47
|
+
return { ok: false, error: `git worktree remove failed: ${(rm.stderr || '').trim()}` }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Delete branch
|
|
51
|
+
spawnSync('git', ['branch', '-D', worktreeBranch], { cwd: originalCwd, encoding: 'utf8' })
|
|
52
|
+
|
|
53
|
+
const result = { ok: true, originalCwd, removedPath: worktreePath }
|
|
54
|
+
currentWorktree = null
|
|
55
|
+
return result
|
|
56
|
+
}
|