tissues 0.6.0 → 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 (52) hide show
  1. package/README.md +78 -1
  2. package/package.json +4 -2
  3. package/src/cli.js +12 -1
  4. package/src/commands/ai.js +149 -173
  5. package/src/commands/config.js +143 -69
  6. package/src/commands/create.js +16 -9
  7. package/src/commands/create.test.js +381 -0
  8. package/src/commands/enhancements.js +282 -0
  9. package/src/commands/flush.test.js +299 -0
  10. package/src/commands/list.js +3 -2
  11. package/src/commands/providers.js +347 -0
  12. package/src/commands/providers.test.js +28 -0
  13. package/src/commands/storage.js +167 -0
  14. package/src/commands/sync.js +225 -0
  15. package/src/daemon/sync.js +189 -0
  16. package/src/lib/ai/adapters/claude-cli.js +55 -0
  17. package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
  18. package/src/lib/ai/adapters/codex-cli.js +77 -0
  19. package/src/lib/ai/adapters/command.js +23 -13
  20. package/src/lib/ai/adapters/gemini-cli.js +55 -0
  21. package/src/lib/ai/adapters/openclaw.js +91 -0
  22. package/src/lib/ai/agent-actions.js +271 -0
  23. package/src/lib/ai/agent.js +323 -0
  24. package/src/lib/ai/body-template.js +15 -0
  25. package/src/lib/ai/discovery.js +89 -0
  26. package/src/lib/ai/discovery.test.js +74 -0
  27. package/src/lib/ai/enhance.js +48 -11
  28. package/src/lib/ai/enhancement-adapter.js +109 -0
  29. package/src/lib/ai/enhancement-adapter.test.js +188 -0
  30. package/src/lib/ai/index.js +2 -2
  31. package/src/lib/ai/pipeline.js +20 -2
  32. package/src/lib/ai/pipeline.test.js +257 -0
  33. package/src/lib/ai/prompt.test.js +30 -0
  34. package/src/lib/ai/router.js +118 -7
  35. package/src/lib/ai/router.test.js +481 -0
  36. package/src/lib/ai/steps.js +23 -3
  37. package/src/lib/ai/steps.test.js +335 -0
  38. package/src/lib/attribution.test.js +64 -0
  39. package/src/lib/cache.js +408 -0
  40. package/src/lib/db.js +42 -0
  41. package/src/lib/dedup.js +44 -48
  42. package/src/lib/dedup.test.js +227 -0
  43. package/src/lib/defaults.js +37 -1
  44. package/src/lib/defaults.test.js +217 -0
  45. package/src/lib/drafts-perf.test.js +203 -0
  46. package/src/lib/drafts.test.js +300 -0
  47. package/src/lib/enhancements.js +436 -0
  48. package/src/lib/enhancements.test.js +294 -0
  49. package/src/lib/gh.js +76 -10
  50. package/src/lib/safety.test.js +217 -0
  51. package/src/lib/storage.js +298 -0
  52. 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
+ }
@@ -38,6 +38,21 @@ export function buildBodyGuidance(ctx) {
38
38
  }
39
39
  }
40
40
 
41
+ // Include sections for custom enhancement results not covered above
42
+ const knownKeys = new Set([
43
+ 'structuredContext', 'scopeAnalysis', 'complexity', 'complexityRationale',
44
+ 'risk', 'riskRationale', 'aiLabels', 'dedupScore', 'body',
45
+ 'rawInput', 'title', 'description', 'instructions', 'templateBody',
46
+ 'labels', 'existingIssues', 'repoLabels', '_stepConfigs', 'triage',
47
+ ])
48
+ for (const [key, value] of Object.entries(ctx)) {
49
+ if (!knownKeys.has(key) && value != null && !key.startsWith('_')) {
50
+ // Custom enhancement produced a result — add a section hint
51
+ const heading = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1')
52
+ lines.push(`## ${heading}`)
53
+ }
54
+ }
55
+
41
56
  lines.push(
42
57
  '',
43
58
  'Rules:',
@@ -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
+ })
@@ -9,14 +9,16 @@ import { resolveRoute } from './router.js'
9
9
  import { runPipeline } from './pipeline.js'
10
10
  import { ALL_STEPS } from './steps.js'
11
11
  import { checkBudgets, recordUsage } from './index.js'
12
+ import { loadAllEnhancements } from '../enhancements.js'
13
+ import { enhancementToStep } from './enhancement-adapter.js'
12
14
 
13
15
  /**
14
16
  * Run the full enhancement pipeline.
15
17
  *
16
18
  * @param {object} config - merged config
17
- * @param {object} input - { title, description, instructions, templateBody, labels, existingIssues, repoLabels }
19
+ * @param {object} input - { title, description, instructions, templateBody, labels, existingIssues, repoLabels, rawInput }
18
20
  * @param {object} [callbacks] - pipeline callbacks for progress display
19
- * @param {{ provider?: string, model?: string, template?: string }} [routeContext] - routing context
21
+ * @param {{ provider?: string, model?: string, template?: string, enhancements?: string[] }} [routeContext] - routing context
20
22
  * @returns {Promise<object>} PipelineContext with all accumulated results
21
23
  */
22
24
  export async function runEnhancePipeline(config, input, callbacks, routeContext = {}) {
@@ -27,11 +29,49 @@ export async function runEnhancePipeline(config, input, callbacks, routeContext
27
29
  // Resolve adapter + model
28
30
  const route = resolveRoute(config, routeContext)
29
31
 
30
- // Build step configs from pipeline.steps config
31
- const stepConfigs = {}
32
- const cfgSteps = pipelineConfig.steps || {}
33
- for (const step of ALL_STEPS) {
34
- stepConfigs[step.name] = cfgSteps[step.name] || 'auto'
32
+ // Load all enhancements (three-tier: repo > user > built-in)
33
+ let enhancements
34
+ try {
35
+ enhancements = loadAllEnhancements(input._repoRoot)
36
+ } catch {
37
+ enhancements = null
38
+ }
39
+
40
+ let activeSteps
41
+ let stepConfigs = {}
42
+
43
+ if (enhancements && enhancements.length > 0) {
44
+ // Enhancement-based resolution
45
+ const enhancementNames = routeContext.enhancements || route.enhancements || null
46
+
47
+ // Build step configs from config (supports both old `steps` and new `enhancements` key)
48
+ const cfgEnhancements = pipelineConfig.enhancements || pipelineConfig.steps || {}
49
+
50
+ // Filter enhancements if specific names requested
51
+ let filtered = enhancements
52
+ if (enhancementNames && enhancementNames.length > 0) {
53
+ const requested = new Set(enhancementNames)
54
+ // Always include structural enhancements (triage, format)
55
+ filtered = enhancements.filter(
56
+ (enh) => enh.isStructural || requested.has(enh.key),
57
+ )
58
+ }
59
+
60
+ // Build step configs and convert to step objects
61
+ for (const enh of filtered) {
62
+ stepConfigs[enh.key] = cfgEnhancements[enh.key] || enh.mode || 'auto'
63
+ }
64
+
65
+ activeSteps = filtered
66
+ .filter((enh) => stepConfigs[enh.key] !== 'never')
67
+ .map((enh) => enhancementToStep(enh))
68
+ } else {
69
+ // Fallback: use hardcoded ALL_STEPS (backward compat)
70
+ const cfgSteps = pipelineConfig.steps || {}
71
+ for (const step of ALL_STEPS) {
72
+ stepConfigs[step.name] = cfgSteps[step.name] || 'auto'
73
+ }
74
+ activeSteps = ALL_STEPS.filter((s) => stepConfigs[s.name] !== 'never')
35
75
  }
36
76
 
37
77
  // Build the initial PipelineContext
@@ -61,10 +101,7 @@ export async function runEnhancePipeline(config, input, callbacks, routeContext
61
101
  body: input.templateBody || '', // fallback to template
62
102
  }
63
103
 
64
- // Filter steps to only those not set to 'never'
65
- const activeSteps = ALL_STEPS.filter((s) => stepConfigs[s.name] !== 'never')
66
-
67
- await runPipeline(activeSteps, ctx, route, budgets, callbacks, { checkBudgets, recordUsage })
104
+ await runPipeline(activeSteps, ctx, route, config, budgets, callbacks, { checkBudgets, recordUsage })
68
105
 
69
106
  return ctx
70
107
  }