@asobacloud/nehanda 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.md +57 -0
  3. package/README.md +444 -2
  4. package/bin/agent.mjs +1260 -0
  5. package/bin/nehanda-ui.mjs +824 -0
  6. package/lib/anthropicHeaders.mjs +8 -0
  7. package/lib/app.mjs +313 -0
  8. package/lib/auth.mjs +255 -0
  9. package/lib/bashguard.mjs +159 -0
  10. package/lib/compact.mjs +306 -0
  11. package/lib/energy-middleware.mjs +318 -0
  12. package/lib/hookShapeOrder.mjs +30 -0
  13. package/lib/hookplane.mjs +228 -0
  14. package/lib/instructions.mjs +23 -0
  15. package/lib/jev_read.mjs +216 -0
  16. package/lib/jev_write.mjs +253 -0
  17. package/lib/laya.mjs +296 -0
  18. package/lib/laya_sidecar.mjs +198 -0
  19. package/lib/mcp-config.mjs +189 -0
  20. package/lib/mcp.mjs +169 -0
  21. package/lib/modelConfig.mjs +152 -0
  22. package/lib/modelDiscovery.mjs +205 -0
  23. package/lib/openaiCompat.mjs +147 -0
  24. package/lib/orchestrate.mjs +1163 -0
  25. package/lib/paths.mjs +20 -0
  26. package/lib/permissions.mjs +44 -0
  27. package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
  28. package/lib/scripts/audit-code-integrity.py +437 -0
  29. package/lib/scripts/ensure-laya-env.sh +55 -0
  30. package/lib/scripts/js-safety-checker.cjs +308 -0
  31. package/lib/scripts/laya_worker.py +171 -0
  32. package/lib/scripts/odse-transform.py +97 -0
  33. package/lib/scripts/python-safety-checker.py +197 -0
  34. package/lib/scripts/shell-safety-checker.sh +306 -0
  35. package/lib/settings.mjs +147 -0
  36. package/lib/store.mjs +278 -0
  37. package/lib/team.mjs +69 -0
  38. package/lib/testgen.mjs +146 -0
  39. package/lib/tools/AuditCodeIntegrity.json +30 -0
  40. package/lib/tools/JsSafetyChecker.json +29 -0
  41. package/lib/tools/PythonSafetyChecker.json +29 -0
  42. package/lib/tools/ShellSafetyChecker.json +29 -0
  43. package/lib/tools.mjs +907 -0
  44. package/lib/transcript.mjs +223 -0
  45. package/lib/ui.mjs +286 -0
  46. package/lib/workflow.mjs +91 -0
  47. package/lib/worktree.mjs +56 -0
  48. package/package.json +50 -4
  49. package/schema.sql +181 -0
  50. package/scripts/postinstall.mjs +62 -0
  51. package/scripts/sdlc-acceptance.sh +477 -0
  52. package/scripts/verify-sdlc-hook-order.mjs +26 -0
  53. package/templates/test_e2e.sh +32 -0
  54. package/templates/test_file.sh +14 -0
  55. package/templates/test_hook.sh +27 -0
  56. package/templates/test_http.sh +17 -0
  57. package/templates/test_phase.sh +23 -0
  58. package/templates/test_process.sh +14 -0
  59. package/templates/test_tool.sh +24 -0
  60. package/templates/validate_test.sh +55 -0
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }