tissues 0.6.1 → 0.6.2

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 (42) hide show
  1. package/README.md +78 -1
  2. package/package.json +4 -2
  3. package/src/cli.js +10 -1
  4. package/src/commands/ai.js +147 -173
  5. package/src/commands/create.js +6 -6
  6. package/src/commands/create.test.js +381 -0
  7. package/src/commands/flush.test.js +299 -0
  8. package/src/commands/list.js +3 -2
  9. package/src/commands/providers.js +347 -0
  10. package/src/commands/providers.test.js +28 -0
  11. package/src/commands/storage.js +167 -0
  12. package/src/commands/sync.js +225 -0
  13. package/src/daemon/sync.js +189 -0
  14. package/src/lib/ai/adapters/claude-cli.js +55 -0
  15. package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
  16. package/src/lib/ai/adapters/codex-cli.js +77 -0
  17. package/src/lib/ai/adapters/gemini-cli.js +55 -0
  18. package/src/lib/ai/adapters/openclaw.js +91 -0
  19. package/src/lib/ai/agent-actions.js +271 -0
  20. package/src/lib/ai/agent.js +323 -0
  21. package/src/lib/ai/discovery.js +89 -0
  22. package/src/lib/ai/discovery.test.js +74 -0
  23. package/src/lib/ai/enhancement-adapter.test.js +188 -0
  24. package/src/lib/ai/pipeline.test.js +257 -0
  25. package/src/lib/ai/prompt.test.js +30 -0
  26. package/src/lib/ai/router.js +23 -0
  27. package/src/lib/ai/router.test.js +481 -0
  28. package/src/lib/ai/steps.test.js +335 -0
  29. package/src/lib/attribution.test.js +64 -0
  30. package/src/lib/cache.js +408 -0
  31. package/src/lib/db.js +42 -0
  32. package/src/lib/dedup.js +8 -18
  33. package/src/lib/dedup.test.js +227 -0
  34. package/src/lib/defaults.js +20 -0
  35. package/src/lib/defaults.test.js +217 -0
  36. package/src/lib/drafts-perf.test.js +203 -0
  37. package/src/lib/drafts.test.js +300 -0
  38. package/src/lib/enhancements.test.js +294 -0
  39. package/src/lib/gh.js +60 -0
  40. package/src/lib/safety.test.js +217 -0
  41. package/src/lib/storage.js +298 -0
  42. package/src/lib/templates.test.js +207 -0
@@ -0,0 +1,323 @@
1
+ /**
2
+ * General-purpose AI agent for tissues CLI.
3
+ *
4
+ * Builds a token-efficient system prompt from local cache + filesystem,
5
+ * dispatches JSON-based actions, and supports multi-turn conversation.
6
+ */
7
+
8
+ import { store } from '../config.js'
9
+ import { loadConfig, findRepoRoot } from '../defaults.js'
10
+ import { ensureFresh, getCachedIssues } from '../cache.js'
11
+ import { getAuthenticatedUser } from '../gh.js'
12
+ import { listTemplates } from '../templates.js'
13
+ import { listEnhancements } from '../enhancements.js'
14
+ import { countPending } from '../drafts.js'
15
+ import { resolveRoute } from './router.js'
16
+ import { checkAvailable } from './index.js'
17
+ import { ACTION_SCHEMAS, executeAction } from './agent-actions.js'
18
+ import { bold, dim, cyan, green, yellow, red } from '../color.js'
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // System prompt builder
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Build the system prompt with dynamic state from cache + filesystem.
26
+ * Designed to be token-efficient (~1000-1400 tokens, max ~2000).
27
+ *
28
+ * @param {{ activeRepo: string, config: object, repoRoot: string, user?: string }} context
29
+ * @returns {string}
30
+ */
31
+ export function buildAgentSystemPrompt(context) {
32
+ const sections = []
33
+
34
+ // Section 1: Identity + constraints + guardrails
35
+ sections.push([
36
+ 'You are the tissues CLI agent. You manage GitHub issues via JSON actions.',
37
+ 'Return ONLY a single JSON object: { "action": "<name>", "params": { ... } }',
38
+ '',
39
+ 'Rules:',
40
+ '- Output MUST be valid JSON. No markdown, no explanation, no text outside JSON.',
41
+ '- For read-only queries (list, show), execute immediately.',
42
+ '- For mutations (create, set, switch), include all required params.',
43
+ '- Use "answer" action for conversational replies or to ask clarifying questions.',
44
+ '',
45
+ 'Guardrails:',
46
+ '- You ONLY handle tissues/GitHub issue operations. Nothing else.',
47
+ '- NEVER generate code, open URLs, write files, or answer off-topic questions.',
48
+ '- If asked something outside your scope (weather, coding help, general knowledge),',
49
+ ' respond with: {"action":"answer","params":{"text":"I can only help with GitHub issues via tissues. Try `tissues --help` for available commands."}}',
50
+ ].join('\n'))
51
+
52
+ // Section 2: Action schemas (compact)
53
+ const schemaLines = ['Available actions:']
54
+ for (const schema of ACTION_SCHEMAS) {
55
+ const paramStr = Object.entries(schema.params)
56
+ .map(([k, v]) => `${k}: ${v}`)
57
+ .join(', ')
58
+ const confirm = schema.requiresConfirmation ? ' [confirms]' : ''
59
+ schemaLines.push(` ${schema.name}(${paramStr})${confirm} — ${schema.description}`)
60
+ }
61
+ sections.push(schemaLines.join('\n'))
62
+
63
+ // Section 3: CLI command reference (so agent can point users to commands)
64
+ sections.push([
65
+ 'CLI commands (you cannot run these, but can tell users about them):',
66
+ ' tissues auth — configure GitHub authentication',
67
+ ' tissues config — view/set configuration',
68
+ ' tissues create — create an issue interactively',
69
+ ' tissues draft — manage pending issue drafts',
70
+ ' tissues list — list issues from cache',
71
+ ' tissues status — show safety status (circuit breaker, rate limits)',
72
+ ' tissues templates — list/manage issue templates',
73
+ ' tissues enhancements — list AI enhancement plugins',
74
+ ' tissues drafts — review and submit pending drafts',
75
+ ' tissues ai — run AI agent (this is you)',
76
+ ' tissues sync — force-sync cache with GitHub',
77
+ ' tissues storage — manage local database',
78
+ ].join('\n'))
79
+
80
+ // Section 4: Dynamic state snapshot (from cache, zero network calls)
81
+ const stateLines = ['Current state:']
82
+
83
+ if (context.activeRepo) stateLines.push(` Active repo: ${context.activeRepo}`)
84
+ if (context.user) stateLines.push(` User: ${context.user}`)
85
+
86
+ // Repos from cache
87
+ try {
88
+ const repos = ensureFresh('_global', 'repos', { acceptStale: true })
89
+ if (repos.length > 0) stateLines.push(` Available repos: ${repos.slice(0, 15).join(', ')}${repos.length > 15 ? ` (+${repos.length - 15} more)` : ''}`)
90
+ } catch { /* no cached repos */ }
91
+
92
+ // Templates from filesystem
93
+ try {
94
+ const templates = listTemplates(context.repoRoot)
95
+ const keys = [...new Set(templates.map(t => t.key))]
96
+ if (keys.length > 0) stateLines.push(` Templates: ${keys.join(', ')}`)
97
+ } catch { /* ok */ }
98
+
99
+ // Enhancements from filesystem
100
+ try {
101
+ const enhancements = listEnhancements(context.repoRoot)
102
+ const keys = [...new Set(enhancements.map(e => e.key))]
103
+ if (keys.length > 0) stateLines.push(` Enhancements: ${keys.join(', ')}`)
104
+ } catch { /* ok */ }
105
+
106
+ // Labels from cache
107
+ if (context.activeRepo) {
108
+ try {
109
+ const labels = ensureFresh(context.activeRepo, 'labels', { acceptStale: true })
110
+ if (labels.length > 0) stateLines.push(` Labels: ${labels.join(', ')}`)
111
+ } catch { /* ok */ }
112
+ }
113
+
114
+ // Pending drafts
115
+ try {
116
+ const pending = countPending(context.repoRoot)
117
+ if (pending > 0) stateLines.push(` Pending drafts: ${pending}`)
118
+ } catch { /* ok */ }
119
+
120
+ // AI provider info
121
+ const ai = context.config?.ai || {}
122
+ stateLines.push(` AI provider: ${ai.provider || 'none'}`)
123
+
124
+ // Recent issues — prefer last-24h with labels+author, fallback to top 10 titles
125
+ if (context.activeRepo) {
126
+ try {
127
+ let issueLimit = 30
128
+ const allIssues = getCachedIssues(context.activeRepo, { state: 'open', limit: issueLimit })
129
+ const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
130
+ let recentIssues = allIssues.filter(i => i.updatedAt && i.updatedAt >= oneDayAgo)
131
+
132
+ if (recentIssues.length > 0) {
133
+ stateLines.push(` Issues updated in last 24h:`)
134
+ for (const i of recentIssues.slice(0, issueLimit)) {
135
+ const labelStr = i.labels?.length ? ` [${i.labels.join(',')}]` : ''
136
+ const authorStr = i.author ? ` @${i.author}` : ''
137
+ stateLines.push(` #${i.number}: ${i.title}${labelStr}${authorStr}`)
138
+ }
139
+ } else if (allIssues.length > 0) {
140
+ // Fallback: show top 10 titles
141
+ stateLines.push(` Recent open issues:`)
142
+ for (const i of allIssues.slice(0, 10)) {
143
+ stateLines.push(` #${i.number}: ${i.title}`)
144
+ }
145
+ }
146
+ } catch { /* ok */ }
147
+ }
148
+
149
+ sections.push(stateLines.join('\n'))
150
+
151
+ // Token safety valve: if prompt is too long, trim issues and rebuild
152
+ let prompt = sections.join('\n\n')
153
+ if (prompt.length > 6000) {
154
+ // Rebuild the state section with halved issue count
155
+ const trimmedStateLines = stateLines.filter(l => !l.startsWith(' #'))
156
+ try {
157
+ const allIssues = getCachedIssues(context.activeRepo, { state: 'open', limit: 5 })
158
+ if (allIssues.length > 0) {
159
+ trimmedStateLines.push(` Recent open issues:`)
160
+ for (const i of allIssues.slice(0, 5)) {
161
+ trimmedStateLines.push(` #${i.number}: ${i.title}`)
162
+ }
163
+ }
164
+ } catch { /* ok */ }
165
+ sections[sections.length - 1] = trimmedStateLines.join('\n')
166
+ prompt = sections.join('\n\n')
167
+ }
168
+
169
+ return prompt
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Response parser
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Parse an AI response into an action object.
178
+ *
179
+ * @param {string} raw - raw AI response text
180
+ * @returns {{ action: string, params: object }}
181
+ */
182
+ export function parseAgentResponse(raw) {
183
+ let cleaned = raw.trim()
184
+
185
+ // Strip markdown code fences if present
186
+ if (cleaned.startsWith('```')) {
187
+ cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '')
188
+ }
189
+
190
+ // Try to extract JSON from mixed content
191
+ const jsonMatch = cleaned.match(/\{[\s\S]*\}/)
192
+ if (jsonMatch) {
193
+ cleaned = jsonMatch[0]
194
+ }
195
+
196
+ const parsed = JSON.parse(cleaned)
197
+
198
+ if (!parsed.action) {
199
+ // If the response has create-style fields (title, body), treat as create_issue
200
+ if (parsed.title) {
201
+ return { action: 'create_issue', params: parsed }
202
+ }
203
+ throw new Error('Response missing "action" field')
204
+ }
205
+
206
+ return { action: parsed.action, params: parsed.params || parsed }
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Single-shot dispatch
211
+ // ---------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Run a single-shot AI agent: one LLM call, parse action, execute.
215
+ *
216
+ * @param {string} prompt - user's natural language prompt
217
+ * @param {object} config - merged config
218
+ * @param {{ activeRepo: string, repoRoot: string, user?: string }} context
219
+ * @param {{ provider?: string, model?: string, dryRun?: boolean }} [opts]
220
+ * @returns {Promise<{ action: string, result: any } | null>}
221
+ */
222
+ export async function runAgentSingleShot(prompt, config, context, opts = {}) {
223
+ const systemPrompt = buildAgentSystemPrompt({ ...context, config })
224
+ const messages = [
225
+ { role: 'system', content: systemPrompt },
226
+ { role: 'user', content: prompt },
227
+ ]
228
+
229
+ const aiContext = { provider: opts.provider, model: opts.model }
230
+ const { adapter, model } = resolveRoute(config, aiContext)
231
+ const raw = await adapter.complete(messages, { model, maxTokens: 2048 })
232
+
233
+ let action, params
234
+ try {
235
+ ({ action, params } = parseAgentResponse(raw))
236
+ } catch {
237
+ // AI responded conversationally instead of JSON — treat as answer
238
+ action = 'answer'
239
+ params = { text: raw.trim() }
240
+ }
241
+ return { action, params, raw }
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Multi-turn conversation loop
246
+ // ---------------------------------------------------------------------------
247
+
248
+ /**
249
+ * Run the agent in multi-turn chat mode.
250
+ *
251
+ * @param {object} config
252
+ * @param {{ activeRepo: string, repoRoot: string, user?: string }} context
253
+ * @param {{ provider?: string, model?: string, onInput: () => Promise<string>, onOutput: (text: string) => void }} opts
254
+ */
255
+ export async function runAgentLoop(config, context, opts) {
256
+ const { onInput, onOutput } = opts
257
+ const aiContext = { provider: opts.provider, model: opts.model }
258
+
259
+ const systemPrompt = buildAgentSystemPrompt({ ...context, config })
260
+ const messages = [{ role: 'system', content: systemPrompt }]
261
+
262
+ // Token management: keep last N turns to stay within context limits
263
+ const MAX_TURNS = 20
264
+ const SYSTEM_MSGS = 1 // system prompt is always first
265
+
266
+ while (true) {
267
+ const userInput = await onInput()
268
+ if (!userInput || /^(exit|quit|done|bye)$/i.test(userInput.trim())) {
269
+ onOutput(dim('Goodbye.'))
270
+ break
271
+ }
272
+
273
+ messages.push({ role: 'user', content: userInput })
274
+
275
+ // Trim old turns if conversation is getting long
276
+ while (messages.length > SYSTEM_MSGS + MAX_TURNS * 2) {
277
+ // Remove oldest user/assistant pair (keep system prompt)
278
+ messages.splice(SYSTEM_MSGS, 2)
279
+ }
280
+
281
+ let raw
282
+ try {
283
+ const { adapter, model } = resolveRoute(config, aiContext)
284
+ raw = await adapter.complete(messages, { model, maxTokens: 2048 })
285
+ } catch (err) {
286
+ onOutput(red(`AI error: ${err.message}`))
287
+ continue
288
+ }
289
+
290
+ messages.push({ role: 'assistant', content: raw })
291
+
292
+ let action, params
293
+ try {
294
+ ({ action, params } = parseAgentResponse(raw))
295
+ } catch {
296
+ // If we can't parse, show the raw response
297
+ onOutput(raw)
298
+ continue
299
+ }
300
+
301
+ // Execute action
302
+ const result = await executeAction(action, params || {}, { ...context, config })
303
+
304
+ if (result.display) {
305
+ onOutput(result.display)
306
+ } else if (result.result) {
307
+ onOutput(typeof result.result === 'string' ? result.result : JSON.stringify(result.result, null, 2))
308
+ }
309
+
310
+ // Feed result back as a system message for context
311
+ if (action !== 'answer') {
312
+ messages.push({
313
+ role: 'user',
314
+ content: `[System: ${action} result: ${JSON.stringify(result.result).slice(0, 500)}]`,
315
+ })
316
+ }
317
+
318
+ // If it was just an answer with no follow-up question, check if we should continue
319
+ if (action === 'answer' && !result.result?.includes('?')) {
320
+ // Still continue — user drives the loop
321
+ }
322
+ }
323
+ }
@@ -0,0 +1,89 @@
1
+ import { execFile } from 'node:child_process'
2
+
3
+ /**
4
+ * Check if a binary is available on PATH.
5
+ * @param {string} name - binary name
6
+ * @returns {Promise<boolean>}
7
+ */
8
+ function hasBinary(name) {
9
+ const cmd = process.platform === 'win32' ? 'where' : 'command'
10
+ const args = process.platform === 'win32' ? [name] : ['-v', name]
11
+ return new Promise((resolve) => {
12
+ execFile(cmd, args, { timeout: 5000 }, (err) => resolve(!err))
13
+ })
14
+ }
15
+
16
+ /**
17
+ * Auto-discover available AI providers on this system.
18
+ *
19
+ * Checks for:
20
+ * - CLI binaries: gemini, claude, codex
21
+ * - OpenClaw gateway (env vars)
22
+ * - API keys in env
23
+ *
24
+ * @returns {Promise<Array<{ name: string, type: 'cli'|'api'|'openclaw', status: 'available'|'configured', config: object }>>}
25
+ */
26
+ export async function discoverProviders() {
27
+ const results = []
28
+
29
+ // CLI binaries — check in parallel
30
+ const cliChecks = [
31
+ { name: 'gemini-cli', binary: 'gemini' },
32
+ { name: 'claude-cli', binary: 'claude' },
33
+ { name: 'codex-cli', binary: 'codex' },
34
+ ]
35
+
36
+ const binaryResults = await Promise.all(
37
+ cliChecks.map(async ({ name, binary }) => ({
38
+ name,
39
+ binary,
40
+ found: await hasBinary(binary),
41
+ })),
42
+ )
43
+
44
+ for (const { name, binary, found } of binaryResults) {
45
+ if (found) {
46
+ results.push({
47
+ name,
48
+ type: 'cli',
49
+ status: 'available',
50
+ config: { binary },
51
+ })
52
+ }
53
+ }
54
+
55
+ // OpenClaw gateway
56
+ const openclawToken = process.env.OPENCLAW_GATEWAY_TOKEN
57
+ const openclawPort = process.env.OPENCLAW_GATEWAY_PORT || '18790'
58
+ if (openclawToken) {
59
+ results.push({
60
+ name: 'openclaw',
61
+ type: 'openclaw',
62
+ status: 'configured',
63
+ config: {
64
+ gatewayUrl: `http://localhost:${openclawPort}`,
65
+ token: openclawToken,
66
+ },
67
+ })
68
+ }
69
+
70
+ // API keys in environment
71
+ const apiKeyChecks = [
72
+ { name: 'anthropic', env: 'ANTHROPIC_API_KEY' },
73
+ { name: 'openai', env: 'OPENAI_API_KEY' },
74
+ { name: 'gemini', env: 'GEMINI_API_KEY' },
75
+ ]
76
+
77
+ for (const { name, env } of apiKeyChecks) {
78
+ if (process.env[env]) {
79
+ results.push({
80
+ name,
81
+ type: 'api',
82
+ status: 'configured',
83
+ config: { envVar: env },
84
+ })
85
+ }
86
+ }
87
+
88
+ return results
89
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, it, beforeEach, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { discoverProviders } from './discovery.js'
4
+
5
+ describe('discoverProviders', () => {
6
+ const savedEnv = {}
7
+
8
+ beforeEach(() => {
9
+ // Save and clear relevant env vars
10
+ for (const key of ['OPENCLAW_GATEWAY_TOKEN', 'OPENCLAW_GATEWAY_PORT', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY']) {
11
+ savedEnv[key] = process.env[key]
12
+ delete process.env[key]
13
+ }
14
+ })
15
+
16
+ afterEach(() => {
17
+ // Restore env vars
18
+ for (const [key, val] of Object.entries(savedEnv)) {
19
+ if (val === undefined) delete process.env[key]
20
+ else process.env[key] = val
21
+ }
22
+ })
23
+
24
+ it('returns an array', async () => {
25
+ const result = await discoverProviders()
26
+ assert.ok(Array.isArray(result))
27
+ })
28
+
29
+ it('discovers API keys from environment', async () => {
30
+ process.env.ANTHROPIC_API_KEY = 'sk-ant-test'
31
+ process.env.OPENAI_API_KEY = 'sk-openai-test'
32
+ const result = await discoverProviders()
33
+ const apiProviders = result.filter((r) => r.type === 'api')
34
+ const names = apiProviders.map((p) => p.name)
35
+ assert.ok(names.includes('anthropic'))
36
+ assert.ok(names.includes('openai'))
37
+ assert.ok(!names.includes('gemini'))
38
+ })
39
+
40
+ it('discovers OpenClaw from environment', async () => {
41
+ process.env.OPENCLAW_GATEWAY_TOKEN = 'test-token'
42
+ process.env.OPENCLAW_GATEWAY_PORT = '9999'
43
+ const result = await discoverProviders()
44
+ const openclaw = result.find((r) => r.name === 'openclaw')
45
+ assert.ok(openclaw)
46
+ assert.equal(openclaw.type, 'openclaw')
47
+ assert.equal(openclaw.status, 'configured')
48
+ assert.equal(openclaw.config.gatewayUrl, 'http://localhost:9999')
49
+ })
50
+
51
+ it('uses default port for OpenClaw when not set', async () => {
52
+ process.env.OPENCLAW_GATEWAY_TOKEN = 'test-token'
53
+ const result = await discoverProviders()
54
+ const openclaw = result.find((r) => r.name === 'openclaw')
55
+ assert.equal(openclaw.config.gatewayUrl, 'http://localhost:18790')
56
+ })
57
+
58
+ it('does not include OpenClaw without token', async () => {
59
+ const result = await discoverProviders()
60
+ const openclaw = result.find((r) => r.name === 'openclaw')
61
+ assert.equal(openclaw, undefined)
62
+ })
63
+
64
+ it('each result has required shape', async () => {
65
+ process.env.ANTHROPIC_API_KEY = 'sk-test'
66
+ const result = await discoverProviders()
67
+ for (const item of result) {
68
+ assert.ok(item.name, 'has name')
69
+ assert.ok(['cli', 'api', 'openclaw'].includes(item.type), `valid type: ${item.type}`)
70
+ assert.ok(['available', 'configured'].includes(item.status), `valid status: ${item.status}`)
71
+ assert.ok(item.config && typeof item.config === 'object', 'has config object')
72
+ }
73
+ })
74
+ })
@@ -0,0 +1,188 @@
1
+ import { test, describe } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { enhancementToStep } from './enhancement-adapter.js'
4
+ import { BUILT_IN_ENHANCEMENTS } from '../enhancements.js'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ function baseCtx(overrides = {}) {
11
+ return {
12
+ rawInput: '',
13
+ title: 'Fix auth bug',
14
+ description: 'The login page throws a 401 error',
15
+ instructions: '',
16
+ templateBody: '## Summary\n\n{{description}}',
17
+ labels: ['bug'],
18
+ existingIssues: [],
19
+ repoLabels: ['bug', 'feature', 'P0-critical'],
20
+ dedupScore: null,
21
+ structuredContext: null,
22
+ scopeAnalysis: null,
23
+ complexity: null,
24
+ complexityRationale: null,
25
+ risk: null,
26
+ riskRationale: null,
27
+ aiLabels: null,
28
+ body: '',
29
+ ...overrides,
30
+ }
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Built-in enhancement → step conversion
35
+ // ---------------------------------------------------------------------------
36
+
37
+ describe('enhancementToStep — built-in enhancements', () => {
38
+ test('converts built-in triage to step with correct fields', () => {
39
+ const step = enhancementToStep(BUILT_IN_ENHANCEMENTS.triage)
40
+ assert.equal(step.name, 'triage')
41
+ assert.equal(step.displayName, 'Input Analysis')
42
+ assert.equal(step.maxTokens, 1024)
43
+ assert.ok(typeof step.shouldRun === 'function')
44
+ assert.ok(typeof step.buildMessages === 'function')
45
+ assert.ok(typeof step.parseResponse === 'function')
46
+ })
47
+
48
+ test('built-in step uses original shouldRun', () => {
49
+ const step = enhancementToStep(BUILT_IN_ENHANCEMENTS.triage)
50
+ assert.equal(step.shouldRun(baseCtx({ rawInput: 'hello' })), true)
51
+ assert.equal(step.shouldRun(baseCtx({ rawInput: '' })), false)
52
+ })
53
+
54
+ test('built-in step uses original buildMessages', () => {
55
+ const step = enhancementToStep(BUILT_IN_ENHANCEMENTS.context)
56
+ const ctx = baseCtx()
57
+ const msgs = step.buildMessages(ctx)
58
+ assert.ok(Array.isArray(msgs))
59
+ assert.equal(msgs[0].role, 'system')
60
+ assert.equal(msgs[1].role, 'user')
61
+ })
62
+
63
+ test('built-in step uses original parseResponse', () => {
64
+ const step = enhancementToStep(BUILT_IN_ENHANCEMENTS.complexity)
65
+ const ctx = baseCtx()
66
+ step.parseResponse(JSON.stringify({ score: 7, rationale: 'complex' }), ctx)
67
+ assert.equal(ctx.complexity, 7)
68
+ assert.equal(ctx.complexityRationale, 'complex')
69
+ })
70
+
71
+ test('all built-in enhancements convert to valid steps', () => {
72
+ for (const [key, enh] of Object.entries(BUILT_IN_ENHANCEMENTS)) {
73
+ const step = enhancementToStep(enh)
74
+ assert.equal(step.name, key)
75
+ assert.ok(step.displayName)
76
+ assert.ok(typeof step.maxTokens === 'number')
77
+ assert.ok(typeof step.shouldRun === 'function')
78
+ assert.ok(typeof step.buildMessages === 'function')
79
+ assert.ok(typeof step.parseResponse === 'function')
80
+ }
81
+ })
82
+ })
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Prompt override
86
+ // ---------------------------------------------------------------------------
87
+
88
+ describe('enhancementToStep — prompt override', () => {
89
+ test('user-overridden built-in uses custom prompt', () => {
90
+ const overridden = {
91
+ ...BUILT_IN_ENHANCEMENTS.context,
92
+ source: 'user',
93
+ prompt: 'Custom context prompt here.',
94
+ }
95
+ const step = enhancementToStep(overridden)
96
+ const msgs = step.buildMessages(baseCtx())
97
+ // Should use the custom prompt, not the built-in
98
+ assert.ok(msgs[0].content.includes('Custom context prompt here.'))
99
+ })
100
+
101
+ test('built-in source uses original buildMessages', () => {
102
+ const builtIn = { ...BUILT_IN_ENHANCEMENTS.context, source: 'built-in' }
103
+ const step = enhancementToStep(builtIn)
104
+ const msgs = step.buildMessages(baseCtx())
105
+ // Should use built-in prompt (includes "Extract structured context")
106
+ assert.ok(msgs[0].content.includes('Extract structured context'))
107
+ })
108
+ })
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Custom enhancement → step conversion
112
+ // ---------------------------------------------------------------------------
113
+
114
+ describe('enhancementToStep — custom enhancements', () => {
115
+ const customEnh = {
116
+ key: 'security',
117
+ name: 'Security Review',
118
+ maxTokens: 1024,
119
+ mode: 'auto',
120
+ format: 'json',
121
+ contextKey: 'securityScore',
122
+ order: 55,
123
+ requires: ['scopeAnalysis'],
124
+ isStructural: false,
125
+ prompt: 'You are a security reviewer.\nReturn JSON.',
126
+ source: 'user',
127
+ }
128
+
129
+ test('converts custom to step with correct fields', () => {
130
+ const step = enhancementToStep(customEnh)
131
+ assert.equal(step.name, 'security')
132
+ assert.equal(step.displayName, 'Security Review')
133
+ assert.equal(step.maxTokens, 1024)
134
+ })
135
+
136
+ test('custom shouldRun checks requires array', () => {
137
+ const step = enhancementToStep(customEnh)
138
+ // Should not run when scopeAnalysis is missing
139
+ assert.equal(step.shouldRun(baseCtx({ scopeAnalysis: null })), false)
140
+ // Should run when scopeAnalysis is present
141
+ assert.equal(step.shouldRun(baseCtx({ scopeAnalysis: { files: [] } })), true)
142
+ })
143
+
144
+ test('custom shouldRun returns true when no requires', () => {
145
+ const noReqs = { ...customEnh, requires: [] }
146
+ const step = enhancementToStep(noReqs)
147
+ assert.equal(step.shouldRun(baseCtx()), true)
148
+ })
149
+
150
+ test('custom buildMessages uses prompt and context', () => {
151
+ const step = enhancementToStep(customEnh)
152
+ const msgs = step.buildMessages(baseCtx())
153
+ assert.equal(msgs[0].role, 'system')
154
+ assert.ok(msgs[0].content.includes('You are a security reviewer.'))
155
+ assert.equal(msgs[1].role, 'user')
156
+ assert.ok(msgs[1].content.includes('Fix auth bug'))
157
+ })
158
+
159
+ test('custom JSON parseResponse stores at contextKey', () => {
160
+ const step = enhancementToStep(customEnh)
161
+ const ctx = baseCtx()
162
+ step.parseResponse(JSON.stringify({ score: 8, rationale: 'risky' }), ctx)
163
+ assert.deepEqual(ctx.securityScore, { score: 8, rationale: 'risky' })
164
+ })
165
+
166
+ test('custom JSON parseResponse handles malformed JSON', () => {
167
+ const step = enhancementToStep(customEnh)
168
+ const ctx = baseCtx()
169
+ step.parseResponse('not json', ctx)
170
+ assert.equal(ctx.securityScore, null)
171
+ })
172
+
173
+ test('custom markdown parseResponse stores raw text', () => {
174
+ const mdEnh = { ...customEnh, format: 'markdown', contextKey: 'review' }
175
+ const step = enhancementToStep(mdEnh)
176
+ const ctx = baseCtx()
177
+ step.parseResponse('## Review\n\nLooks good.', ctx)
178
+ assert.equal(ctx.review, '## Review\n\nLooks good.')
179
+ })
180
+
181
+ test('custom markdown parseResponse strips code fences', () => {
182
+ const mdEnh = { ...customEnh, format: 'markdown', contextKey: 'review' }
183
+ const step = enhancementToStep(mdEnh)
184
+ const ctx = baseCtx()
185
+ step.parseResponse('```markdown\n## Review\n\nContent here.\n```', ctx)
186
+ assert.equal(ctx.review, '## Review\n\nContent here.')
187
+ })
188
+ })