tissues 0.6.0 → 0.6.1

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.
@@ -0,0 +1,282 @@
1
+ import { Command } from 'commander'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+ import { dim, red, green } from '../lib/color.js'
6
+ import { select, input, editor } from '@inquirer/prompts'
7
+ import {
8
+ listEnhancements,
9
+ loadEnhancement,
10
+ builtInEnhancementKeys,
11
+ BUILT_IN_ENHANCEMENTS,
12
+ parseEnhancementFile,
13
+ } from '../lib/enhancements.js'
14
+ import { findRepoRoot, loadConfig } from '../lib/defaults.js'
15
+ import { listProviders, listAllProviders } from '../lib/ai/index.js'
16
+ import { theme } from '../lib/theme.js'
17
+
18
+ const PROVIDER_LABELS = {
19
+ anthropic: 'Anthropic',
20
+ openai: 'OpenAI',
21
+ gemini: 'Gemini',
22
+ ollama: 'Ollama',
23
+ 'openai-compat': 'OpenAI Custom',
24
+ command: 'Command',
25
+ }
26
+
27
+ const builtInProviders = new Set(listProviders())
28
+
29
+ function formatProviderName(name) {
30
+ if (!name) return null
31
+ if (builtInProviders.has(name)) return PROVIDER_LABELS[name] || name
32
+ return `${name} ${dim('(custom)')}`
33
+ }
34
+
35
+ function isCancelled(err) {
36
+ return err?.name === 'ExitPromptError' || err?.message?.includes('User force closed')
37
+ }
38
+
39
+ async function promptOrBack(fn) {
40
+ try { return await fn() } catch (err) { if (isCancelled(err)) return Symbol.for('back'); throw err }
41
+ }
42
+
43
+ function userEnhancementDir() {
44
+ return path.join(os.homedir(), '.config', 'tissues', 'enhancements')
45
+ }
46
+
47
+ export const enhancementsCommand = new Command('enhancements')
48
+ .description('Manage AI pipeline enhancements (view, edit, create)')
49
+ .action(async () => {
50
+ while (true) {
51
+ const repoRoot = findRepoRoot()
52
+ const enhancements = listEnhancements(repoRoot)
53
+ const builtInKeys = builtInEnhancementKeys()
54
+
55
+ // Deduplicate by key (higher-priority sources shadow lower ones)
56
+ const seen = new Set()
57
+ const choices = []
58
+ for (const enh of enhancements) {
59
+ if (!seen.has(enh.key)) {
60
+ seen.add(enh.key)
61
+ const provLabel = enh.provider ? formatProviderName(enh.provider) : 'default'
62
+ choices.push({
63
+ name: `${enh.key.padEnd(18)} ${dim(provLabel)}`,
64
+ value: enh.key,
65
+ })
66
+ }
67
+ }
68
+ choices.push({ name: green('Create New Enhancement'), value: '_create' })
69
+ choices.push({ name: dim('Done'), value: 'done' })
70
+
71
+ const chosen = await promptOrBack(() => select({ message: 'Enhancements', choices, theme }))
72
+ if (chosen === Symbol.for('back') || chosen === 'done') break
73
+
74
+ if (chosen === '_create') {
75
+ await createNewEnhancement(builtInKeys, seen)
76
+ continue
77
+ }
78
+
79
+ // View / edit existing enhancement
80
+ await viewEnhancement(chosen, repoRoot, builtInKeys)
81
+ console.log()
82
+ }
83
+ })
84
+
85
+ async function createNewEnhancement(builtInKeys, existingKeys) {
86
+ const name = await promptOrBack(() => input({ message: 'Enhancement key (lowercase, no spaces)', theme }))
87
+ if (name === Symbol.for('back') || !name) return
88
+
89
+ const key = name.trim().toLowerCase().replace(/\s+/g, '-')
90
+ if (!key) return
91
+
92
+ // Block reusing exact built-in names
93
+ if (builtInKeys.includes(key)) {
94
+ console.log(red(` "${key}" is a built-in enhancement. Use edit to customize it instead.`))
95
+ return
96
+ }
97
+
98
+ // Block duplicates
99
+ if (existingKeys.has(key)) {
100
+ console.log(red(` Enhancement "${key}" already exists.`))
101
+ return
102
+ }
103
+
104
+ const displayName = await promptOrBack(() =>
105
+ input({ message: 'Display name', default: key.charAt(0).toUpperCase() + key.slice(1), theme }),
106
+ )
107
+ if (displayName === Symbol.for('back')) return
108
+
109
+ const contextKey = await promptOrBack(() =>
110
+ input({ message: 'Context key (where result is stored)', default: key, theme }),
111
+ )
112
+ if (contextKey === Symbol.for('back')) return
113
+
114
+ const skeleton = [
115
+ '---',
116
+ `name: ${displayName}`,
117
+ 'maxTokens: 1024',
118
+ 'mode: auto',
119
+ 'format: json',
120
+ `contextKey: ${contextKey}`,
121
+ 'order: 50',
122
+ 'requires: []',
123
+ '---',
124
+ `You are an expert at analyzing GitHub issues.`,
125
+ `Assess the ${key} aspects of the proposed change.`,
126
+ 'Return a JSON object with your analysis.',
127
+ 'Return ONLY valid JSON.',
128
+ ].join('\n')
129
+
130
+ const body = await promptOrBack(() =>
131
+ editor({
132
+ message: 'Enhancement file (opens editor)',
133
+ default: skeleton,
134
+ theme,
135
+ }),
136
+ )
137
+ if (body === Symbol.for('back') || !body) return
138
+
139
+ const dir = userEnhancementDir()
140
+ fs.mkdirSync(dir, { recursive: true })
141
+ const filePath = path.join(dir, `${key}.md`)
142
+ fs.writeFileSync(filePath, body, 'utf8')
143
+ console.log(green(` ✔ Enhancement "${key}" created: ${filePath}`))
144
+ }
145
+
146
+ async function viewEnhancement(key, repoRoot, builtInKeys) {
147
+ let enh
148
+ try {
149
+ enh = loadEnhancement(key, repoRoot)
150
+ } catch (err) {
151
+ console.log(red(` ${err.message}`))
152
+ return
153
+ }
154
+
155
+ console.log(`\n ${enh.name} ${dim(`(${enh.source})`)}`)
156
+ console.log(dim(' ─'.repeat(20)))
157
+ console.log(dim(` Mode: ${enh.mode} Format: ${enh.format} Tokens: ${enh.maxTokens} Order: ${enh.order}`))
158
+ if (enh.provider) console.log(dim(` Provider: ${enh.provider}`))
159
+ if (enh.contextKey) console.log(dim(` Context key: ${enh.contextKey}`))
160
+ if (enh.requires?.length) console.log(dim(` Requires: ${enh.requires.join(', ')}`))
161
+ console.log()
162
+
163
+ // Show prompt preview (truncated)
164
+ const preview = enh.prompt.split('\n').slice(0, 8).join('\n')
165
+ console.log(dim(preview))
166
+ if (enh.prompt.split('\n').length > 8) console.log(dim(' ...'))
167
+ console.log()
168
+
169
+ const actionChoices = [
170
+ { name: 'Edit', value: 'edit' },
171
+ { name: `Provider ${formatProviderName(enh.provider) || dim('default')}`, value: 'provider' },
172
+ ]
173
+ if (builtInKeys.includes(key) && enh.source === 'built-in') {
174
+ actionChoices[0] = { name: 'Customize (creates user copy)', value: 'edit' }
175
+ }
176
+ // Only allow deleting user enhancements
177
+ const userFile = path.join(userEnhancementDir(), `${key}.md`)
178
+ if (fs.existsSync(userFile)) {
179
+ actionChoices.push({ name: red('Delete user copy'), value: 'delete' })
180
+ }
181
+ actionChoices.push({ name: dim('Back'), value: 'back' })
182
+
183
+ const action = await promptOrBack(() => select({ message: enh.name, choices: actionChoices, theme }))
184
+ if (action === Symbol.for('back') || action === 'back') return
185
+
186
+ if (action === 'provider') {
187
+ const cfg = loadConfig()
188
+ const allProviders = listAllProviders(cfg)
189
+ const providerChoices = [
190
+ { name: 'Default (use pipeline provider)', value: '_default' },
191
+ ...allProviders.map((p) => ({
192
+ name: formatProviderName(p),
193
+ value: p,
194
+ })),
195
+ { name: dim('Back'), value: 'back' },
196
+ ]
197
+
198
+ const chosen = await promptOrBack(() =>
199
+ select({
200
+ message: `Provider for ${enh.name}`,
201
+ choices: providerChoices,
202
+ default: enh.provider || '_default',
203
+ theme,
204
+ }),
205
+ )
206
+ if (chosen === Symbol.for('back') || chosen === 'back') return
207
+
208
+ const newProvider = chosen === '_default' ? null : chosen
209
+
210
+ // Update the .md file frontmatter (creates user copy if built-in)
211
+ const updatedEnh = { ...enh, provider: newProvider }
212
+ const content = buildFileContent(updatedEnh)
213
+ const dir = userEnhancementDir()
214
+ fs.mkdirSync(dir, { recursive: true })
215
+ fs.writeFileSync(path.join(dir, `${key}.md`), content, 'utf8')
216
+
217
+ if (newProvider) {
218
+ console.log(green(` ✔ ${enh.name}: → ${newProvider}`))
219
+ } else {
220
+ console.log(green(` ✔ ${enh.name}: using default provider`))
221
+ }
222
+ if (enh.source === 'built-in') {
223
+ console.log(dim(` User copy created (overrides built-in)`))
224
+ }
225
+ return
226
+ }
227
+
228
+ if (action === 'delete') {
229
+ fs.unlinkSync(userFile)
230
+ console.log(green(` ✔ User copy of "${key}" deleted`))
231
+ if (builtInKeys.includes(key)) {
232
+ console.log(dim(` Built-in "${key}" will be used again.`))
233
+ }
234
+ return
235
+ }
236
+
237
+ // Edit — build the file content from the current enhancement
238
+ const currentContent = buildFileContent(enh)
239
+
240
+ const newBody = await promptOrBack(() =>
241
+ editor({
242
+ message: `Edit ${key}`,
243
+ default: currentContent,
244
+ theme,
245
+ }),
246
+ )
247
+ if (newBody === Symbol.for('back') || !newBody) return
248
+
249
+ // Save as user enhancement
250
+ const dir = userEnhancementDir()
251
+ fs.mkdirSync(dir, { recursive: true })
252
+ fs.writeFileSync(path.join(dir, `${key}.md`), newBody, 'utf8')
253
+
254
+ if (enh.source === 'built-in') {
255
+ console.log(green(` ✔ User copy of "${key}" created (overrides built-in)`))
256
+ } else {
257
+ console.log(green(` ✔ Enhancement "${key}" updated`))
258
+ }
259
+ }
260
+
261
+ function buildFileContent(enh) {
262
+ const lines = [
263
+ '---',
264
+ `name: ${enh.name}`,
265
+ `maxTokens: ${enh.maxTokens}`,
266
+ `mode: ${enh.mode}`,
267
+ `format: ${enh.format}`,
268
+ `contextKey: ${enh.contextKey}`,
269
+ `order: ${enh.order}`,
270
+ ]
271
+ if (enh.provider) {
272
+ lines.push(`provider: ${enh.provider}`)
273
+ }
274
+ if (enh.requires?.length) {
275
+ lines.push(`requires: [${enh.requires.map((r) => `"${r}"`).join(', ')}]`)
276
+ } else {
277
+ lines.push('requires: []')
278
+ }
279
+ lines.push('---')
280
+ lines.push(enh.prompt)
281
+ return lines.join('\n')
282
+ }
@@ -16,27 +16,35 @@ export class CommandAdapter extends BaseAdapter {
16
16
  const command = this.config.command
17
17
  if (!command) throw new Error('Command not configured (ai.command)')
18
18
 
19
- const timeoutMs = opts.maxTokens
20
- ? Math.max(DEFAULT_TIMEOUT_MS, opts.maxTokens * 15) // rough heuristic
21
- : DEFAULT_TIMEOUT_MS
19
+ const timeoutMs = this.config.timeout
20
+ || (opts.maxTokens
21
+ ? Math.max(DEFAULT_TIMEOUT_MS, opts.maxTokens * 15) // rough heuristic
22
+ : DEFAULT_TIMEOUT_MS)
22
23
 
23
- const payload = JSON.stringify({
24
+ // Extract the last user message as the prompt text
25
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
26
+ const promptText = lastUserMsg?.content || ''
27
+
28
+ // Check if command uses {prompt} placeholder (arg mode) or JSON stdin
29
+ const usesPlaceholder = command.includes('{prompt}')
30
+
31
+ const resolvedCommand = usesPlaceholder
32
+ ? command.replace(/\{prompt\}/g, promptText)
33
+ : command
34
+
35
+ const payload = usesPlaceholder ? null : JSON.stringify({
24
36
  messages: messages.map((m) => ({ role: m.role, content: m.content })),
25
37
  model: opts.model || null,
26
38
  maxTokens: opts.maxTokens || 4096,
27
39
  })
28
40
 
29
41
  return new Promise((resolve, reject) => {
30
- // Split command string into executable + args
31
- const parts = command.split(/\s+/)
32
- const exe = parts[0]
33
- const args = parts.slice(1)
34
-
35
- const isWindows = process.platform === 'win32'
36
- const child = execFile(exe, args, {
42
+ // Always use shell to support quoted args, pipes, builtins, etc.
43
+ const shell = process.platform === 'win32' ? true : '/bin/sh'
44
+ const child = execFile('sh', ['-c', resolvedCommand], {
37
45
  timeout: timeoutMs,
38
46
  maxBuffer: 10 * 1024 * 1024, // 10 MB
39
- shell: isWindows,
47
+ shell: false, // we're already wrapping in sh -c
40
48
  }, (err, stdout, stderr) => {
41
49
  if (err) {
42
50
  const msg = stderr?.trim() || err.message
@@ -51,7 +59,9 @@ export class CommandAdapter extends BaseAdapter {
51
59
  resolve(text)
52
60
  })
53
61
 
54
- child.stdin.write(payload)
62
+ if (payload) {
63
+ child.stdin.write(payload)
64
+ }
55
65
  child.stdin.end()
56
66
  })
57
67
  }
@@ -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:',
@@ -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
  }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Bridge between enhancement descriptors and pipeline step objects.
3
+ *
4
+ * Converts enhancement descriptors (from enhancements.js) into step objects
5
+ * that pipeline.js can execute. Built-in steps retain their specialized
6
+ * shouldRun/parseResponse logic; custom steps get generic implementations.
7
+ */
8
+
9
+ import { BUILT_IN_STEPS_MAP, tryParseJSON, contextSummary, priorStepsSummary } from './steps.js'
10
+ import { buildBodyGuidance } from './body-template.js'
11
+
12
+ /**
13
+ * Convert an enhancement descriptor into a pipeline step object.
14
+ *
15
+ * @param {object} enhancement - descriptor from enhancements.js
16
+ * @returns {object} step object compatible with pipeline.js
17
+ */
18
+ export function enhancementToStep(enhancement) {
19
+ const builtInStep = BUILT_IN_STEPS_MAP[enhancement.key]
20
+
21
+ // If this is a built-in enhancement, use the built-in step's specialized
22
+ // shouldRun and parseResponse, but allow the prompt to be overridden
23
+ if (builtInStep) {
24
+ const promptOverridden = enhancement.source && enhancement.source !== 'built-in'
25
+
26
+ return {
27
+ name: enhancement.key,
28
+ displayName: enhancement.name,
29
+ maxTokens: enhancement.maxTokens,
30
+ provider: enhancement.provider || null,
31
+
32
+ shouldRun: builtInStep.shouldRun,
33
+ parseResponse: builtInStep.parseResponse,
34
+
35
+ buildMessages(ctx) {
36
+ if (promptOverridden && enhancement.prompt) {
37
+ // Use the user/repo-overridden prompt with the standard user message
38
+ return buildCustomMessages(enhancement, ctx)
39
+ }
40
+ return builtInStep.buildMessages(ctx)
41
+ },
42
+ }
43
+ }
44
+
45
+ // Custom enhancement — generic implementation
46
+ return {
47
+ name: enhancement.key,
48
+ displayName: enhancement.name,
49
+ maxTokens: enhancement.maxTokens,
50
+ provider: enhancement.provider || null,
51
+
52
+ shouldRun(ctx) {
53
+ // Check requires — all required context keys must be present
54
+ if (enhancement.requires?.length > 0) {
55
+ return enhancement.requires.every((key) => ctx[key] != null)
56
+ }
57
+ return true
58
+ },
59
+
60
+ buildMessages(ctx) {
61
+ return buildCustomMessages(enhancement, ctx)
62
+ },
63
+
64
+ parseResponse(raw, ctx) {
65
+ if (enhancement.format === 'json') {
66
+ try {
67
+ const parsed = tryParseJSON(raw)
68
+ ctx[enhancement.contextKey] = parsed
69
+ } catch {
70
+ ctx[enhancement.contextKey] = null
71
+ }
72
+ } else {
73
+ // markdown — store raw text
74
+ let cleaned = raw.trim()
75
+ const fenceMatch = cleaned.match(/^```(?:markdown|md)?\n([\s\S]*)\n```$/)
76
+ if (fenceMatch) cleaned = fenceMatch[1].trim()
77
+ if (cleaned.length > 0) {
78
+ ctx[enhancement.contextKey] = cleaned
79
+ }
80
+ }
81
+ },
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Build messages for a custom or prompt-overridden enhancement.
87
+ */
88
+ function buildCustomMessages(enhancement, ctx) {
89
+ let systemContent = enhancement.prompt
90
+
91
+ // For the format step, append body guidance
92
+ if (enhancement.key === 'format') {
93
+ const guidance = buildBodyGuidance(ctx)
94
+ systemContent = systemContent + '\n\n' + guidance
95
+ }
96
+
97
+ // For the labels step, inject available labels
98
+ if (enhancement.key === 'labels' && ctx.repoLabels?.length) {
99
+ systemContent = systemContent.replace(
100
+ /Return ONLY valid JSON\.$/m,
101
+ `Available labels in this repo: ${ctx.repoLabels.join(', ')}\nReturn ONLY valid JSON.`,
102
+ )
103
+ }
104
+
105
+ return [
106
+ { role: 'system', content: systemContent },
107
+ { role: 'user', content: contextSummary(ctx) + priorStepsSummary(ctx) },
108
+ ]
109
+ }
@@ -1,7 +1,7 @@
1
- import { resolveRoute, listProviders } from './router.js'
1
+ import { resolveRoute, resolveProviderAdapter, listProviders, listAllProviders } from './router.js'
2
2
  import { buildMessages } from './prompt.js'
3
3
 
4
- export { listProviders }
4
+ export { listProviders, listAllProviders, resolveProviderAdapter }
5
5
 
6
6
  // ---------------------------------------------------------------------------
7
7
  // Token usage tracking (in-memory, resets on process exit)
@@ -5,19 +5,22 @@
5
5
  * Failed steps log a warning and continue (graceful degradation).
6
6
  */
7
7
 
8
+ import { resolveProviderAdapter } from './router.js'
9
+
8
10
  /**
9
11
  * Run a sequence of pipeline steps.
10
12
  *
11
13
  * @param {object[]} steps - array of step objects (from steps.js)
12
14
  * @param {object} ctx - mutable PipelineContext, accumulates results
13
15
  * @param {{ adapter: object, model: string }} route - resolved adapter + model
16
+ * @param {object} [config] - merged config object (enables per-step provider overrides)
14
17
  * @param {object} [budgets] - token budget config
15
18
  * @param {object} [callbacks] - { onStepStart, onStepDone, onStepSkip, onStepFail }
16
19
  * @param {{ checkBudgets: Function, recordUsage: Function }} [budgetHelpers]
17
20
  * @returns {Promise<object>} the final ctx
18
21
  */
19
- export async function runPipeline(steps, ctx, route, budgets, callbacks, budgetHelpers) {
20
- const { adapter, model } = route
22
+ export async function runPipeline(steps, ctx, route, config, budgets, callbacks, budgetHelpers) {
23
+ const { adapter: defaultAdapter, model: defaultModel } = route
21
24
  const cb = callbacks || {}
22
25
  const { checkBudgets, recordUsage } = budgetHelpers || {}
23
26
 
@@ -43,6 +46,21 @@ export async function runPipeline(steps, ctx, route, budgets, callbacks, budgetH
43
46
 
44
47
  cb.onStepStart?.(step)
45
48
 
49
+ // Resolve per-step adapter override
50
+ let adapter = defaultAdapter
51
+ let model = defaultModel
52
+ if (step.provider && config) {
53
+ try {
54
+ const override = resolveProviderAdapter(config, step.provider)
55
+ adapter = override.adapter
56
+ model = override.model
57
+ } catch (err) {
58
+ // Unknown/unconfigured provider — warn and fall back to default
59
+ cb.onStepFail?.(step, new Error(`Provider override "${step.provider}" failed: ${err.message}, using default`))
60
+ // Continue with default adapter (don't skip — aligns with "never lose data" principle)
61
+ }
62
+ }
63
+
46
64
  try {
47
65
  // Check budget before the call (skip for format when budget already exhausted — it degrades gracefully)
48
66
  if (checkBudgets && budgets && !budgetExhausted) {