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
@@ -4,6 +4,10 @@ import { GeminiAdapter } from './adapters/gemini.js'
4
4
  import { OllamaAdapter } from './adapters/ollama.js'
5
5
  import { OpenAICompatAdapter } from './adapters/openai-compat.js'
6
6
  import { CommandAdapter } from './adapters/command.js'
7
+ import { GeminiCliAdapter } from './adapters/gemini-cli.js'
8
+ import { ClaudeCliAdapter } from './adapters/claude-cli.js'
9
+ import { CodexCliAdapter } from './adapters/codex-cli.js'
10
+ import { OpenClawAdapter } from './adapters/openclaw.js'
7
11
 
8
12
  const ADAPTER_MAP = {
9
13
  anthropic: AnthropicAdapter,
@@ -12,6 +16,38 @@ const ADAPTER_MAP = {
12
16
  ollama: OllamaAdapter,
13
17
  'openai-compat': OpenAICompatAdapter,
14
18
  command: CommandAdapter,
19
+ 'gemini-cli': GeminiCliAdapter,
20
+ 'claude-cli': ClaudeCliAdapter,
21
+ 'codex-cli': CodexCliAdapter,
22
+ openclaw: OpenClawAdapter,
23
+ }
24
+
25
+ /**
26
+ * Merge custom providers from both old (`ai.commands`) and new (`ai.providers`) keys,
27
+ * and migrate the legacy singleton `ai.command` into the registry.
28
+ *
29
+ * `ai.providers` wins on conflict with `ai.commands`.
30
+ *
31
+ * @param {object} ai - the `config.ai` object
32
+ * @returns {object} merged custom providers map
33
+ */
34
+ export function migrateProviderConfig(ai) {
35
+ const merged = {}
36
+
37
+ // Legacy: ai.commands (old key)
38
+ if (ai.commands && typeof ai.commands === 'object') {
39
+ Object.assign(merged, ai.commands)
40
+ }
41
+
42
+ // Canonical: ai.providers (new key, wins on conflict)
43
+ if (ai.providers && typeof ai.providers === 'object') {
44
+ Object.assign(merged, ai.providers)
45
+ }
46
+
47
+ // Note: ai.command (legacy singleton) is NOT merged here — the built-in
48
+ // "command" provider already reads it via buildAdapterConfig.
49
+
50
+ return merged
15
51
  }
16
52
 
17
53
  /**
@@ -23,14 +59,15 @@ const ADAPTER_MAP = {
23
59
  * 3. Default provider + model from config
24
60
  *
25
61
  * @param {object} config - merged config object
26
- * @param {{ template?: string, labels?: string[], provider?: string, model?: string }} context
27
- * @returns {{ adapter: import('./adapters/base.js').BaseAdapter, model: string }}
62
+ * @param {{ template?: string, labels?: string[], provider?: string, model?: string, enhancements?: string[] }} context
63
+ * @returns {{ adapter: import('./adapters/base.js').BaseAdapter, model: string, enhancements?: string[] }}
28
64
  */
29
65
  export function resolveRoute(config, context = {}) {
30
66
  const ai = config.ai || {}
31
67
 
32
68
  let provider = context.provider || null
33
69
  let model = context.model || null
70
+ let enhancements = null
34
71
 
35
72
  // 2. Check routing rules (only if no explicit override)
36
73
  if (!provider && Array.isArray(ai.routes)) {
@@ -38,6 +75,7 @@ export function resolveRoute(config, context = {}) {
38
75
  if (matchesRule(rule.match, context)) {
39
76
  provider = rule.provider || provider
40
77
  model = rule.model || model
78
+ if (rule.enhancements) enhancements = rule.enhancements
41
79
  break
42
80
  }
43
81
  }
@@ -47,12 +85,57 @@ export function resolveRoute(config, context = {}) {
47
85
  if (!provider) provider = ai.provider || 'anthropic'
48
86
  if (!model) model = ai.model || ai.models?.[provider] || null
49
87
 
50
- const AdapterClass = ADAPTER_MAP[provider]
51
- if (!AdapterClass) throw new Error(`Unknown AI provider: ${provider}`)
88
+ let AdapterClass = ADAPTER_MAP[provider]
89
+ let adapterConfig
90
+
91
+ if (!AdapterClass) {
92
+ // Check custom providers registry (merged from ai.commands + ai.providers)
93
+ const customProviders = migrateProviderConfig(ai)
94
+ const cmdEntry = customProviders[provider]
95
+ if (!cmdEntry) throw new Error(`Unknown AI provider: ${provider}`)
96
+ AdapterClass = CommandAdapter
97
+ adapterConfig = { command: cmdEntry.command || null, timeout: cmdEntry.timeout || undefined }
98
+ } else {
99
+ // Build adapter config based on provider type
100
+ adapterConfig = buildAdapterConfig(ai, provider)
101
+ }
102
+
103
+ const adapter = new AdapterClass(adapterConfig)
104
+
105
+ const result = { adapter, model }
106
+ if (enhancements) result.enhancements = enhancements
107
+ return result
108
+ }
109
+
110
+ /**
111
+ * Resolve an adapter + model for a specific provider name.
112
+ *
113
+ * Checks ADAPTER_MAP first, then the merged custom providers registry.
114
+ * Used by the pipeline for per-step provider overrides.
115
+ *
116
+ * @param {object} config - merged config object
117
+ * @param {string} providerName - provider to resolve
118
+ * @param {string} [modelOverride] - optional model override
119
+ * @returns {{ adapter: import('./adapters/base.js').BaseAdapter, model: string }}
120
+ */
121
+ export function resolveProviderAdapter(config, providerName, modelOverride) {
122
+ const ai = config.ai || {}
123
+
124
+ let AdapterClass = ADAPTER_MAP[providerName]
125
+ let adapterConfig
126
+
127
+ if (!AdapterClass) {
128
+ const customProviders = migrateProviderConfig(ai)
129
+ const cmdEntry = customProviders[providerName]
130
+ if (!cmdEntry) throw new Error(`Unknown AI provider: ${providerName}`)
131
+ AdapterClass = CommandAdapter
132
+ adapterConfig = { command: cmdEntry.command || null, timeout: cmdEntry.timeout || undefined }
133
+ } else {
134
+ adapterConfig = buildAdapterConfig(ai, providerName)
135
+ }
52
136
 
53
- // Build adapter config based on provider type
54
- const adapterConfig = buildAdapterConfig(ai, provider)
55
137
  const adapter = new AdapterClass(adapterConfig)
138
+ const model = modelOverride || ai.model || ai.models?.[providerName] || null
56
139
 
57
140
  return { adapter, model }
58
141
  }
@@ -89,6 +172,21 @@ function buildAdapterConfig(ai, provider) {
89
172
  return { baseUrl: ai.custom?.url, apiKey: resolveApiKey(ai, 'openai-compat') }
90
173
  case 'command':
91
174
  return { command: ai.command || null }
175
+ case 'gemini-cli':
176
+ return { binary: 'gemini', model: ai.models?.['gemini-cli'] || null }
177
+ case 'claude-cli':
178
+ return { binary: 'claude', model: ai.models?.['claude-cli'] || null }
179
+ case 'codex-cli':
180
+ return { binary: 'codex', model: ai.models?.['codex-cli'] || null }
181
+ case 'openclaw': {
182
+ const port = process.env.OPENCLAW_GATEWAY_PORT || '18790'
183
+ return {
184
+ gatewayUrl: ai.openclaw?.gatewayUrl || `http://localhost:${port}`,
185
+ token: ai.openclaw?.token || process.env.OPENCLAW_GATEWAY_TOKEN || null,
186
+ agentId: ai.openclaw?.agentId || null,
187
+ model: ai.models?.openclaw || null,
188
+ }
189
+ }
92
190
  default:
93
191
  return { apiKey: resolveApiKey(ai, provider) }
94
192
  }
@@ -120,9 +218,22 @@ function matchesRule(match, context) {
120
218
  }
121
219
 
122
220
  /**
123
- * List available provider names.
221
+ * List built-in provider names.
124
222
  * @returns {string[]}
125
223
  */
126
224
  export function listProviders() {
127
225
  return Object.keys(ADAPTER_MAP)
128
226
  }
227
+
228
+ /**
229
+ * List all available provider names: built-in + custom providers from config.
230
+ * @param {object} config - merged config object
231
+ * @returns {string[]}
232
+ */
233
+ export function listAllProviders(config) {
234
+ const builtIn = Object.keys(ADAPTER_MAP)
235
+ const ai = config?.ai || {}
236
+ const customProviders = migrateProviderConfig(ai)
237
+ const custom = Object.keys(customProviders)
238
+ return [...builtIn, ...custom.filter((k) => !builtIn.includes(k))]
239
+ }
@@ -0,0 +1,481 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { resolveRoute, resolveProviderAdapter, listProviders, listAllProviders, migrateProviderConfig } from './router.js'
4
+
5
+ const baseConfig = {
6
+ ai: {
7
+ enabled: true,
8
+ provider: 'anthropic',
9
+ model: null,
10
+ models: {
11
+ anthropic: 'claude-haiku-4-5-20251001',
12
+ openai: 'gpt-4o-mini',
13
+ gemini: 'gemini-2.0-flash',
14
+ },
15
+ keys: {
16
+ anthropic: 'sk-ant-test',
17
+ openai: 'sk-openai-test',
18
+ gemini: 'gm-test',
19
+ },
20
+ routes: [
21
+ { match: { template: 'bug' }, provider: 'gemini', model: 'gemini-2.0-flash' },
22
+ { match: { template: 'security' }, provider: 'anthropic', model: 'claude-sonnet-4-5-20241022' },
23
+ { match: { labels: ['P0-critical'] }, provider: 'anthropic', model: 'claude-opus-4-6' },
24
+ ],
25
+ },
26
+ }
27
+
28
+ describe('resolveRoute', () => {
29
+ it('uses default provider when no context', () => {
30
+ const { adapter, model } = resolveRoute(baseConfig, {})
31
+ assert.equal(adapter.name, 'anthropic')
32
+ assert.equal(model, 'claude-haiku-4-5-20251001')
33
+ })
34
+
35
+ it('matches template routing rule', () => {
36
+ const { adapter, model } = resolveRoute(baseConfig, { template: 'bug' })
37
+ assert.equal(adapter.name, 'gemini')
38
+ assert.equal(model, 'gemini-2.0-flash')
39
+ })
40
+
41
+ it('matches security template routing rule', () => {
42
+ const { adapter, model } = resolveRoute(baseConfig, { template: 'security' })
43
+ assert.equal(adapter.name, 'anthropic')
44
+ assert.equal(model, 'claude-sonnet-4-5-20241022')
45
+ })
46
+
47
+ it('matches label routing rule', () => {
48
+ const { adapter, model } = resolveRoute(baseConfig, { labels: ['P0-critical', 'urgent'] })
49
+ assert.equal(adapter.name, 'anthropic')
50
+ assert.equal(model, 'claude-opus-4-6')
51
+ })
52
+
53
+ it('explicit provider overrides routes', () => {
54
+ const { adapter, model } = resolveRoute(baseConfig, {
55
+ template: 'bug',
56
+ provider: 'openai',
57
+ model: 'gpt-4o',
58
+ })
59
+ assert.equal(adapter.name, 'openai')
60
+ assert.equal(model, 'gpt-4o')
61
+ })
62
+
63
+ it('falls through to default when no rule matches', () => {
64
+ const { adapter, model } = resolveRoute(baseConfig, { template: 'feature' })
65
+ assert.equal(adapter.name, 'anthropic')
66
+ assert.equal(model, 'claude-haiku-4-5-20251001')
67
+ })
68
+
69
+ it('throws on unknown provider', () => {
70
+ assert.throws(
71
+ () => resolveRoute(baseConfig, { provider: 'unknown' }),
72
+ /Unknown AI provider: unknown/,
73
+ )
74
+ })
75
+
76
+ it('first matching rule wins', () => {
77
+ const config = {
78
+ ai: {
79
+ ...baseConfig.ai,
80
+ routes: [
81
+ { match: { template: 'bug' }, provider: 'openai' },
82
+ { match: { template: 'bug' }, provider: 'gemini' },
83
+ ],
84
+ },
85
+ }
86
+ const { adapter } = resolveRoute(config, { template: 'bug' })
87
+ assert.equal(adapter.name, 'openai')
88
+ })
89
+ })
90
+
91
+ describe('resolveRoute — ollama', () => {
92
+ it('creates OllamaAdapter with baseUrl from config', () => {
93
+ const config = {
94
+ ai: {
95
+ provider: 'ollama',
96
+ models: { ollama: 'mistral' },
97
+ ollama: { url: 'http://myhost:11434' },
98
+ },
99
+ }
100
+ const { adapter, model } = resolveRoute(config, {})
101
+ assert.equal(adapter.name, 'ollama')
102
+ assert.equal(model, 'mistral')
103
+ assert.equal(adapter.config.baseUrl, 'http://myhost:11434')
104
+ assert.equal(adapter.isConfigured(), true)
105
+ })
106
+ })
107
+
108
+ describe('resolveRoute — openai-compat', () => {
109
+ it('creates OpenAICompatAdapter with baseUrl and optional key', () => {
110
+ const config = {
111
+ ai: {
112
+ provider: 'openai-compat',
113
+ models: { 'openai-compat': 'my-model' },
114
+ custom: { url: 'http://localhost:8080' },
115
+ keys: { 'openai-compat': 'sk-test' },
116
+ },
117
+ }
118
+ const { adapter, model } = resolveRoute(config, {})
119
+ assert.equal(adapter.name, 'openai-compat')
120
+ assert.equal(model, 'my-model')
121
+ assert.equal(adapter.config.baseUrl, 'http://localhost:8080')
122
+ assert.equal(adapter.config.apiKey, 'sk-test')
123
+ assert.equal(adapter.isConfigured(), true)
124
+ })
125
+
126
+ it('isConfigured returns false without baseUrl', () => {
127
+ const config = {
128
+ ai: {
129
+ provider: 'openai-compat',
130
+ custom: { url: null },
131
+ },
132
+ }
133
+ const { adapter } = resolveRoute(config, {})
134
+ assert.equal(adapter.isConfigured(), false)
135
+ })
136
+ })
137
+
138
+ describe('resolveRoute — command', () => {
139
+ it('creates CommandAdapter with command from config', () => {
140
+ const config = {
141
+ ai: {
142
+ provider: 'command',
143
+ command: 'my-ai-tool enhance',
144
+ },
145
+ }
146
+ const { adapter } = resolveRoute(config, {})
147
+ assert.equal(adapter.name, 'command')
148
+ assert.equal(adapter.config.command, 'my-ai-tool enhance')
149
+ assert.equal(adapter.isConfigured(), true)
150
+ })
151
+
152
+ it('isConfigured returns false without command', () => {
153
+ const config = {
154
+ ai: {
155
+ provider: 'command',
156
+ command: null,
157
+ },
158
+ }
159
+ const { adapter } = resolveRoute(config, {})
160
+ assert.equal(adapter.isConfigured(), false)
161
+ })
162
+ })
163
+
164
+ describe('listProviders', () => {
165
+ it('returns known providers', () => {
166
+ const providers = listProviders()
167
+ assert.ok(providers.includes('anthropic'))
168
+ assert.ok(providers.includes('openai'))
169
+ assert.ok(providers.includes('gemini'))
170
+ assert.ok(providers.includes('ollama'))
171
+ assert.ok(providers.includes('openai-compat'))
172
+ assert.ok(providers.includes('command'))
173
+ assert.ok(providers.includes('gemini-cli'))
174
+ assert.ok(providers.includes('claude-cli'))
175
+ assert.ok(providers.includes('codex-cli'))
176
+ assert.ok(providers.includes('openclaw'))
177
+ })
178
+ })
179
+
180
+ describe('resolveRoute — named CLI commands (legacy ai.commands)', () => {
181
+ it('resolves a named command from ai.commands to CommandAdapter', () => {
182
+ const config = {
183
+ ai: {
184
+ provider: 'my-cli',
185
+ commands: {
186
+ 'my-cli': { command: 'co gemini', timeout: 120000 },
187
+ },
188
+ },
189
+ }
190
+ const { adapter } = resolveRoute(config, {})
191
+ assert.equal(adapter.name, 'command')
192
+ assert.equal(adapter.config.command, 'co gemini')
193
+ assert.equal(adapter.config.timeout, 120000)
194
+ assert.equal(adapter.isConfigured(), true)
195
+ })
196
+
197
+ it('named command works via explicit --provider flag', () => {
198
+ const config = {
199
+ ai: {
200
+ provider: 'anthropic',
201
+ keys: { anthropic: 'sk-test' },
202
+ commands: {
203
+ 'my-claude': { command: 'claude --print' },
204
+ },
205
+ },
206
+ }
207
+ const { adapter } = resolveRoute(config, { provider: 'my-claude' })
208
+ assert.equal(adapter.name, 'command')
209
+ assert.equal(adapter.config.command, 'claude --print')
210
+ assert.equal(adapter.config.timeout, undefined)
211
+ })
212
+
213
+ it('named command works in routing rules', () => {
214
+ const config = {
215
+ ai: {
216
+ provider: 'anthropic',
217
+ keys: { anthropic: 'sk-test' },
218
+ commands: {
219
+ 'my-gemini': { command: 'co gemini' },
220
+ },
221
+ routes: [
222
+ { match: { template: 'bug' }, provider: 'my-gemini' },
223
+ ],
224
+ },
225
+ }
226
+ const { adapter } = resolveRoute(config, { template: 'bug' })
227
+ assert.equal(adapter.name, 'command')
228
+ assert.equal(adapter.config.command, 'co gemini')
229
+ })
230
+
231
+ it('legacy provider: "command" still works', () => {
232
+ const config = {
233
+ ai: {
234
+ provider: 'command',
235
+ command: 'old-tool',
236
+ },
237
+ }
238
+ const { adapter } = resolveRoute(config, {})
239
+ assert.equal(adapter.name, 'command')
240
+ assert.equal(adapter.config.command, 'old-tool')
241
+ })
242
+
243
+ it('throws on unknown provider not in commands', () => {
244
+ const config = {
245
+ ai: {
246
+ provider: 'nonexistent',
247
+ commands: {},
248
+ },
249
+ }
250
+ assert.throws(
251
+ () => resolveRoute(config, {}),
252
+ /Unknown AI provider: nonexistent/,
253
+ )
254
+ })
255
+
256
+ it('named command without command string is not configured', () => {
257
+ const config = {
258
+ ai: {
259
+ provider: 'bad-cmd',
260
+ commands: {
261
+ 'bad-cmd': {},
262
+ },
263
+ },
264
+ }
265
+ const { adapter } = resolveRoute(config, {})
266
+ assert.equal(adapter.name, 'command')
267
+ assert.equal(adapter.isConfigured(), false)
268
+ })
269
+ })
270
+
271
+ describe('resolveRoute — ai.providers (new canonical key)', () => {
272
+ it('resolves a provider from ai.providers', () => {
273
+ const config = {
274
+ ai: {
275
+ provider: 'my-tool',
276
+ providers: {
277
+ 'my-tool': { command: 'my-tool enhance', timeout: 30000 },
278
+ },
279
+ },
280
+ }
281
+ const { adapter } = resolveRoute(config, {})
282
+ assert.equal(adapter.name, 'command')
283
+ assert.equal(adapter.config.command, 'my-tool enhance')
284
+ assert.equal(adapter.config.timeout, 30000)
285
+ })
286
+
287
+ it('ai.providers wins over ai.commands on conflict', () => {
288
+ const config = {
289
+ ai: {
290
+ provider: 'my-tool',
291
+ commands: {
292
+ 'my-tool': { command: 'old-command' },
293
+ },
294
+ providers: {
295
+ 'my-tool': { command: 'new-command' },
296
+ },
297
+ },
298
+ }
299
+ const { adapter } = resolveRoute(config, {})
300
+ assert.equal(adapter.config.command, 'new-command')
301
+ })
302
+
303
+ it('ai.commands entries still work alongside ai.providers', () => {
304
+ const config = {
305
+ ai: {
306
+ provider: 'anthropic',
307
+ keys: { anthropic: 'sk-test' },
308
+ commands: {
309
+ 'old-tool': { command: 'old cmd' },
310
+ },
311
+ providers: {
312
+ 'new-tool': { command: 'new cmd' },
313
+ },
314
+ },
315
+ }
316
+ // old-tool from commands still works
317
+ const { adapter: old } = resolveRoute(config, { provider: 'old-tool' })
318
+ assert.equal(old.config.command, 'old cmd')
319
+ // new-tool from providers works
320
+ const { adapter: nw } = resolveRoute(config, { provider: 'new-tool' })
321
+ assert.equal(nw.config.command, 'new cmd')
322
+ })
323
+ })
324
+
325
+ describe('migrateProviderConfig', () => {
326
+ it('merges ai.commands and ai.providers', () => {
327
+ const ai = {
328
+ commands: { a: { command: 'cmd-a' } },
329
+ providers: { b: { command: 'cmd-b' } },
330
+ }
331
+ const merged = migrateProviderConfig(ai)
332
+ assert.equal(merged.a.command, 'cmd-a')
333
+ assert.equal(merged.b.command, 'cmd-b')
334
+ })
335
+
336
+ it('ai.providers wins on conflict', () => {
337
+ const ai = {
338
+ commands: { x: { command: 'old' } },
339
+ providers: { x: { command: 'new' } },
340
+ }
341
+ const merged = migrateProviderConfig(ai)
342
+ assert.equal(merged.x.command, 'new')
343
+ })
344
+
345
+ it('does not include ai.command (handled by built-in command provider)', () => {
346
+ const ai = { command: 'legacy-tool' }
347
+ const merged = migrateProviderConfig(ai)
348
+ assert.deepEqual(merged, {})
349
+ })
350
+
351
+ it('works with empty ai object', () => {
352
+ const merged = migrateProviderConfig({})
353
+ assert.deepEqual(merged, {})
354
+ })
355
+ })
356
+
357
+ describe('resolveProviderAdapter', () => {
358
+ it('resolves a built-in provider', () => {
359
+ const config = {
360
+ ai: {
361
+ keys: { anthropic: 'sk-test' },
362
+ models: { anthropic: 'claude-haiku-4-5-20251001' },
363
+ },
364
+ }
365
+ const { adapter, model } = resolveProviderAdapter(config, 'anthropic')
366
+ assert.equal(adapter.name, 'anthropic')
367
+ assert.equal(model, 'claude-haiku-4-5-20251001')
368
+ })
369
+
370
+ it('resolves a custom provider from ai.providers', () => {
371
+ const config = {
372
+ ai: {
373
+ providers: {
374
+ 'my-cli': { command: 'my-tool run', timeout: 5000 },
375
+ },
376
+ },
377
+ }
378
+ const { adapter } = resolveProviderAdapter(config, 'my-cli')
379
+ assert.equal(adapter.name, 'command')
380
+ assert.equal(adapter.config.command, 'my-tool run')
381
+ assert.equal(adapter.config.timeout, 5000)
382
+ })
383
+
384
+ it('resolves a custom provider from legacy ai.commands', () => {
385
+ const config = {
386
+ ai: {
387
+ commands: {
388
+ 'old-cli': { command: 'old-tool' },
389
+ },
390
+ },
391
+ }
392
+ const { adapter } = resolveProviderAdapter(config, 'old-cli')
393
+ assert.equal(adapter.name, 'command')
394
+ assert.equal(adapter.config.command, 'old-tool')
395
+ })
396
+
397
+ it('applies model override', () => {
398
+ const config = {
399
+ ai: {
400
+ keys: { openai: 'sk-test' },
401
+ models: { openai: 'gpt-4o-mini' },
402
+ },
403
+ }
404
+ const { model } = resolveProviderAdapter(config, 'openai', 'gpt-4o')
405
+ assert.equal(model, 'gpt-4o')
406
+ })
407
+
408
+ it('throws on unknown provider', () => {
409
+ assert.throws(
410
+ () => resolveProviderAdapter({ ai: {} }, 'nonexistent'),
411
+ /Unknown AI provider: nonexistent/,
412
+ )
413
+ })
414
+ })
415
+
416
+ describe('listAllProviders', () => {
417
+ it('includes built-in providers and named commands', () => {
418
+ const config = {
419
+ ai: {
420
+ commands: {
421
+ 'my-gemini': { command: 'co gemini' },
422
+ 'my-claude': { command: 'claude --print' },
423
+ },
424
+ },
425
+ }
426
+ const all = listAllProviders(config)
427
+ assert.ok(all.includes('anthropic'))
428
+ assert.ok(all.includes('openai'))
429
+ assert.ok(all.includes('command'))
430
+ assert.ok(all.includes('my-gemini'))
431
+ assert.ok(all.includes('my-claude'))
432
+ // New built-in CLI adapters
433
+ assert.ok(all.includes('gemini-cli'))
434
+ assert.ok(all.includes('claude-cli'))
435
+ assert.ok(all.includes('codex-cli'))
436
+ assert.ok(all.includes('openclaw'))
437
+ })
438
+
439
+ it('includes providers from ai.providers', () => {
440
+ const config = {
441
+ ai: {
442
+ providers: {
443
+ 'new-tool': { command: 'new cmd' },
444
+ },
445
+ },
446
+ }
447
+ const all = listAllProviders(config)
448
+ assert.ok(all.includes('new-tool'))
449
+ })
450
+
451
+ it('merges ai.commands and ai.providers in listing', () => {
452
+ const config = {
453
+ ai: {
454
+ commands: { 'old-tool': { command: 'old' } },
455
+ providers: { 'new-tool': { command: 'new' } },
456
+ },
457
+ }
458
+ const all = listAllProviders(config)
459
+ assert.ok(all.includes('old-tool'))
460
+ assert.ok(all.includes('new-tool'))
461
+ })
462
+
463
+ it('does not duplicate built-in names', () => {
464
+ const config = {
465
+ ai: {
466
+ commands: {
467
+ anthropic: { command: 'echo test' },
468
+ },
469
+ },
470
+ }
471
+ const all = listAllProviders(config)
472
+ const count = all.filter((p) => p === 'anthropic').length
473
+ assert.equal(count, 1)
474
+ })
475
+
476
+ it('works with no commands configured', () => {
477
+ const all = listAllProviders({ ai: {} })
478
+ assert.ok(all.includes('anthropic'))
479
+ assert.ok(all.length >= 10)
480
+ })
481
+ })
@@ -114,9 +114,11 @@ const dedupStep = {
114
114
  displayName: 'Duplicate check',
115
115
  maxTokens: 1024,
116
116
 
117
- shouldRun(ctx) {
118
- // Only useful when there are existing issues to compare against
119
- return ctx.existingIssues?.length > 0
117
+ shouldRun() {
118
+ // Disabled: deterministic dedup (Jaccard) runs before the pipeline.
119
+ // The AI step hallucinated connections between unrelated issues (#56).
120
+ // Keep the code for future opt-in via config.
121
+ return false
120
122
  },
121
123
 
122
124
  buildMessages(ctx) {
@@ -462,6 +464,21 @@ export const ALL_STEPS = [
462
464
  formatStep,
463
465
  ]
464
466
 
467
+ /**
468
+ * Map of built-in step name → step object.
469
+ * Used by the enhancement adapter to bridge built-in logic with custom prompts.
470
+ */
471
+ export const BUILT_IN_STEPS_MAP = {
472
+ triage: triageStep,
473
+ dedup: dedupStep,
474
+ context: contextStep,
475
+ scope: scopeStep,
476
+ complexity: complexityStep,
477
+ risk: riskStep,
478
+ labels: labelsStep,
479
+ format: formatStep,
480
+ }
481
+
465
482
  /**
466
483
  * Get a step by name.
467
484
  * @param {string} name
@@ -470,3 +487,6 @@ export const ALL_STEPS = [
470
487
  export function getStep(name) {
471
488
  return ALL_STEPS.find((s) => s.name === name)
472
489
  }
490
+
491
+ // Re-export helpers for use by the enhancement adapter
492
+ export { tryParseJSON, contextSummary, priorStepsSummary }