tissues 0.5.2 → 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.
Files changed (40) hide show
  1. package/README.md +94 -40
  2. package/package.json +3 -4
  3. package/src/cli.js +26 -22
  4. package/src/commands/ai.js +268 -0
  5. package/src/commands/auth.js +4 -4
  6. package/src/commands/config.js +1035 -12
  7. package/src/commands/create.js +523 -157
  8. package/src/commands/drafts.js +288 -0
  9. package/src/commands/enhancements.js +282 -0
  10. package/src/commands/list.js +7 -5
  11. package/src/commands/status.js +81 -19
  12. package/src/commands/templates.js +157 -0
  13. package/src/lib/ai/adapters/anthropic.js +52 -0
  14. package/src/lib/ai/adapters/base.js +45 -0
  15. package/src/lib/ai/adapters/command.js +68 -0
  16. package/src/lib/ai/adapters/gemini.js +56 -0
  17. package/src/lib/ai/adapters/ollama.js +60 -0
  18. package/src/lib/ai/adapters/openai-compat.js +51 -0
  19. package/src/lib/ai/adapters/openai.js +44 -0
  20. package/src/lib/ai/body-template.js +75 -0
  21. package/src/lib/ai/enhance.js +107 -0
  22. package/src/lib/ai/enhancement-adapter.js +109 -0
  23. package/src/lib/ai/index.js +122 -0
  24. package/src/lib/ai/pipeline.js +97 -0
  25. package/src/lib/ai/prompt.js +39 -0
  26. package/src/lib/ai/router.js +216 -0
  27. package/src/lib/ai/steps.js +492 -0
  28. package/src/lib/attribution.js +18 -179
  29. package/src/lib/clipboard.js +147 -0
  30. package/src/lib/color.js +9 -0
  31. package/src/lib/dedup.js +67 -32
  32. package/src/lib/defaults.js +54 -2
  33. package/src/lib/drafts.js +439 -0
  34. package/src/lib/enhancements.js +436 -0
  35. package/src/lib/gh.js +102 -21
  36. package/src/lib/repo-picker.js +2 -0
  37. package/src/lib/safety.js +1 -1
  38. package/src/lib/templates.js +8 -12
  39. package/src/lib/theme.js +9 -0
  40. package/src/commands/use.js +0 -19
@@ -0,0 +1,216 @@
1
+ import { AnthropicAdapter } from './adapters/anthropic.js'
2
+ import { OpenAIAdapter } from './adapters/openai.js'
3
+ import { GeminiAdapter } from './adapters/gemini.js'
4
+ import { OllamaAdapter } from './adapters/ollama.js'
5
+ import { OpenAICompatAdapter } from './adapters/openai-compat.js'
6
+ import { CommandAdapter } from './adapters/command.js'
7
+
8
+ const ADAPTER_MAP = {
9
+ anthropic: AnthropicAdapter,
10
+ openai: OpenAIAdapter,
11
+ gemini: GeminiAdapter,
12
+ ollama: OllamaAdapter,
13
+ 'openai-compat': OpenAICompatAdapter,
14
+ command: CommandAdapter,
15
+ }
16
+
17
+ /**
18
+ * Merge custom providers from both old (`ai.commands`) and new (`ai.providers`) keys,
19
+ * and migrate the legacy singleton `ai.command` into the registry.
20
+ *
21
+ * `ai.providers` wins on conflict with `ai.commands`.
22
+ *
23
+ * @param {object} ai - the `config.ai` object
24
+ * @returns {object} merged custom providers map
25
+ */
26
+ export function migrateProviderConfig(ai) {
27
+ const merged = {}
28
+
29
+ // Legacy: ai.commands (old key)
30
+ if (ai.commands && typeof ai.commands === 'object') {
31
+ Object.assign(merged, ai.commands)
32
+ }
33
+
34
+ // Canonical: ai.providers (new key, wins on conflict)
35
+ if (ai.providers && typeof ai.providers === 'object') {
36
+ Object.assign(merged, ai.providers)
37
+ }
38
+
39
+ // Note: ai.command (legacy singleton) is NOT merged here — the built-in
40
+ // "command" provider already reads it via buildAdapterConfig.
41
+
42
+ return merged
43
+ }
44
+
45
+ /**
46
+ * Resolve which adapter + model to use for a given issue context.
47
+ *
48
+ * Resolution order:
49
+ * 1. Explicit provider/model from context (CLI flags)
50
+ * 2. Routing rules from config (first match wins)
51
+ * 3. Default provider + model from config
52
+ *
53
+ * @param {object} config - merged config object
54
+ * @param {{ template?: string, labels?: string[], provider?: string, model?: string, enhancements?: string[] }} context
55
+ * @returns {{ adapter: import('./adapters/base.js').BaseAdapter, model: string, enhancements?: string[] }}
56
+ */
57
+ export function resolveRoute(config, context = {}) {
58
+ const ai = config.ai || {}
59
+
60
+ let provider = context.provider || null
61
+ let model = context.model || null
62
+ let enhancements = null
63
+
64
+ // 2. Check routing rules (only if no explicit override)
65
+ if (!provider && Array.isArray(ai.routes)) {
66
+ for (const rule of ai.routes) {
67
+ if (matchesRule(rule.match, context)) {
68
+ provider = rule.provider || provider
69
+ model = rule.model || model
70
+ if (rule.enhancements) enhancements = rule.enhancements
71
+ break
72
+ }
73
+ }
74
+ }
75
+
76
+ // 3. Fall back to defaults
77
+ if (!provider) provider = ai.provider || 'anthropic'
78
+ if (!model) model = ai.model || ai.models?.[provider] || null
79
+
80
+ let AdapterClass = ADAPTER_MAP[provider]
81
+ let adapterConfig
82
+
83
+ if (!AdapterClass) {
84
+ // Check custom providers registry (merged from ai.commands + ai.providers)
85
+ const customProviders = migrateProviderConfig(ai)
86
+ const cmdEntry = customProviders[provider]
87
+ if (!cmdEntry) throw new Error(`Unknown AI provider: ${provider}`)
88
+ AdapterClass = CommandAdapter
89
+ adapterConfig = { command: cmdEntry.command || null, timeout: cmdEntry.timeout || undefined }
90
+ } else {
91
+ // Build adapter config based on provider type
92
+ adapterConfig = buildAdapterConfig(ai, provider)
93
+ }
94
+
95
+ const adapter = new AdapterClass(adapterConfig)
96
+
97
+ const result = { adapter, model }
98
+ if (enhancements) result.enhancements = enhancements
99
+ return result
100
+ }
101
+
102
+ /**
103
+ * Resolve an adapter + model for a specific provider name.
104
+ *
105
+ * Checks ADAPTER_MAP first, then the merged custom providers registry.
106
+ * Used by the pipeline for per-step provider overrides.
107
+ *
108
+ * @param {object} config - merged config object
109
+ * @param {string} providerName - provider to resolve
110
+ * @param {string} [modelOverride] - optional model override
111
+ * @returns {{ adapter: import('./adapters/base.js').BaseAdapter, model: string }}
112
+ */
113
+ export function resolveProviderAdapter(config, providerName, modelOverride) {
114
+ const ai = config.ai || {}
115
+
116
+ let AdapterClass = ADAPTER_MAP[providerName]
117
+ let adapterConfig
118
+
119
+ if (!AdapterClass) {
120
+ const customProviders = migrateProviderConfig(ai)
121
+ const cmdEntry = customProviders[providerName]
122
+ if (!cmdEntry) throw new Error(`Unknown AI provider: ${providerName}`)
123
+ AdapterClass = CommandAdapter
124
+ adapterConfig = { command: cmdEntry.command || null, timeout: cmdEntry.timeout || undefined }
125
+ } else {
126
+ adapterConfig = buildAdapterConfig(ai, providerName)
127
+ }
128
+
129
+ const adapter = new AdapterClass(adapterConfig)
130
+ const model = modelOverride || ai.model || ai.models?.[providerName] || null
131
+
132
+ return { adapter, model }
133
+ }
134
+
135
+ // Env var names for API key fallback (checked when config key is not set)
136
+ const KEY_ENV_VARS = {
137
+ anthropic: 'TISSUES_ANTHROPIC_KEY',
138
+ openai: 'TISSUES_OPENAI_KEY',
139
+ gemini: 'TISSUES_GEMINI_KEY',
140
+ 'openai-compat': 'TISSUES_OPENAI_COMPAT_KEY',
141
+ }
142
+
143
+ /**
144
+ * Resolve API key: config value first, then env var fallback.
145
+ */
146
+ function resolveApiKey(ai, provider) {
147
+ const configKey = provider === 'openai-compat'
148
+ ? ai.keys?.['openai-compat']
149
+ : ai.keys?.[provider]
150
+ if (configKey) return configKey
151
+
152
+ const envVar = KEY_ENV_VARS[provider]
153
+ return envVar ? process.env[envVar] || null : null
154
+ }
155
+
156
+ /**
157
+ * Build the config object for a specific adapter.
158
+ */
159
+ function buildAdapterConfig(ai, provider) {
160
+ switch (provider) {
161
+ case 'ollama':
162
+ return { baseUrl: ai.ollama?.url }
163
+ case 'openai-compat':
164
+ return { baseUrl: ai.custom?.url, apiKey: resolveApiKey(ai, 'openai-compat') }
165
+ case 'command':
166
+ return { command: ai.command || null }
167
+ default:
168
+ return { apiKey: resolveApiKey(ai, provider) }
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Check if a rule's match criteria apply to the given context.
174
+ *
175
+ * @param {object} match - e.g. { template: 'bug' } or { labels: ['P0-critical'] }
176
+ * @param {object} context
177
+ * @returns {boolean}
178
+ */
179
+ function matchesRule(match, context) {
180
+ if (!match) return false
181
+
182
+ if (match.template && context.template) {
183
+ if (match.template !== context.template) return false
184
+ } else if (match.template) {
185
+ return false
186
+ }
187
+
188
+ if (match.labels && Array.isArray(match.labels)) {
189
+ const contextLabels = context.labels || []
190
+ const hasAny = match.labels.some((l) => contextLabels.includes(l))
191
+ if (!hasAny) return false
192
+ }
193
+
194
+ return true
195
+ }
196
+
197
+ /**
198
+ * List built-in provider names.
199
+ * @returns {string[]}
200
+ */
201
+ export function listProviders() {
202
+ return Object.keys(ADAPTER_MAP)
203
+ }
204
+
205
+ /**
206
+ * List all available provider names: built-in + custom providers from config.
207
+ * @param {object} config - merged config object
208
+ * @returns {string[]}
209
+ */
210
+ export function listAllProviders(config) {
211
+ const builtIn = Object.keys(ADAPTER_MAP)
212
+ const ai = config?.ai || {}
213
+ const customProviders = migrateProviderConfig(ai)
214
+ const custom = Object.keys(customProviders)
215
+ return [...builtIn, ...custom.filter((k) => !builtIn.includes(k))]
216
+ }
@@ -0,0 +1,492 @@
1
+ /**
2
+ * Pipeline step definitions for multi-step AI issue enhancement.
3
+ *
4
+ * Each step is a plain object with:
5
+ * - name: unique identifier
6
+ * - displayName: human-readable label for progress display
7
+ * - maxTokens: token budget for this step's LLM call
8
+ * - shouldRun: (ctx, stepConfig) => boolean — whether to run in 'auto' mode
9
+ * - buildMessages: (ctx) => [{ role, content }] — prompt for the LLM
10
+ * - parseResponse: (raw, ctx) => void — parse LLM output and mutate ctx
11
+ */
12
+
13
+ import { buildBodyGuidance } from './body-template.js'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function tryParseJSON(raw) {
20
+ let cleaned = raw.trim()
21
+ // Strip markdown fences if present
22
+ if (cleaned.startsWith('```')) {
23
+ cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '')
24
+ }
25
+ return JSON.parse(cleaned)
26
+ }
27
+
28
+ function contextSummary(ctx) {
29
+ const parts = [`Title: ${ctx.title}`]
30
+ if (ctx.description) parts.push(`Description: ${ctx.description}`)
31
+ if (ctx.instructions) parts.push(`User instructions: ${ctx.instructions}`)
32
+ if (ctx.templateBody) parts.push(`Template:\n${ctx.templateBody}`)
33
+ if (ctx.labels?.length) parts.push(`Labels: ${ctx.labels.join(', ')}`)
34
+ return parts.join('\n\n')
35
+ }
36
+
37
+ function priorStepsSummary(ctx) {
38
+ const parts = []
39
+ if (ctx.dedupScore != null) {
40
+ parts.push(`Dedup confidence: ${ctx.dedupScore.confidence}/100 (${ctx.dedupScore.level})`)
41
+ }
42
+ if (ctx.structuredContext) {
43
+ parts.push(`Context: ${JSON.stringify(ctx.structuredContext)}`)
44
+ }
45
+ if (ctx.scopeAnalysis) {
46
+ parts.push(`Scope: ${JSON.stringify(ctx.scopeAnalysis)}`)
47
+ }
48
+ if (ctx.complexity != null) {
49
+ parts.push(`Complexity: ${ctx.complexity}/10 — ${ctx.complexityRationale || ''}`)
50
+ }
51
+ if (ctx.risk != null) {
52
+ parts.push(`Risk: ${ctx.risk}/10 — ${ctx.riskRationale || ''}`)
53
+ }
54
+ if (ctx.aiLabels?.length) {
55
+ parts.push(`AI labels: ${ctx.aiLabels.join(', ')}`)
56
+ }
57
+ return parts.length > 0 ? '\n\nPrior analysis:\n' + parts.join('\n') : ''
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Step: triage — extract title + description from freeform input
62
+ // ---------------------------------------------------------------------------
63
+
64
+ const triageStep = {
65
+ name: 'triage',
66
+ displayName: 'Input analyzed',
67
+ maxTokens: 1024,
68
+
69
+ shouldRun(ctx) {
70
+ return !!(ctx.rawInput && ctx.rawInput.length > 0)
71
+ },
72
+
73
+ buildMessages(ctx) {
74
+ return [
75
+ {
76
+ role: 'system',
77
+ content: [
78
+ 'You extract a structured GitHub issue title and description from freeform user input.',
79
+ 'Return a JSON object with:',
80
+ ' title: string — concise issue title, ≤80 characters, imperative mood (e.g. "Fix login timeout on Safari")',
81
+ ' description: string — structured description expanding on the input, in markdown',
82
+ 'The title must capture the core intent. The description should organize and expand the raw input into clear context.',
83
+ 'Return ONLY valid JSON.',
84
+ ].join('\n'),
85
+ },
86
+ {
87
+ role: 'user',
88
+ content: ctx.rawInput,
89
+ },
90
+ ]
91
+ },
92
+
93
+ parseResponse(raw, ctx) {
94
+ try {
95
+ const parsed = tryParseJSON(raw)
96
+ if (parsed.title && typeof parsed.title === 'string') {
97
+ ctx.title = parsed.title.slice(0, 80)
98
+ }
99
+ if (parsed.description && typeof parsed.description === 'string') {
100
+ ctx.description = parsed.description
101
+ }
102
+ } catch {
103
+ // Parse failure — keep splitInput() values as fallback
104
+ }
105
+ },
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Step: dedup
110
+ // ---------------------------------------------------------------------------
111
+
112
+ const dedupStep = {
113
+ name: 'dedup',
114
+ displayName: 'Duplicate check',
115
+ maxTokens: 1024,
116
+
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
122
+ },
123
+
124
+ buildMessages(ctx) {
125
+ const existing = (ctx.existingIssues || [])
126
+ .slice(0, 20)
127
+ .map((i) => `#${i.number}: ${i.title}`)
128
+ .join('\n')
129
+
130
+ return [
131
+ {
132
+ role: 'system',
133
+ content: [
134
+ 'You are a duplicate detection system for GitHub issues.',
135
+ 'Compare the new issue against existing open issues.',
136
+ 'Return a JSON object with:',
137
+ ' confidence: number 0-100 (how likely this is a duplicate)',
138
+ ' level: "high" | "medium" | "low" | "none"',
139
+ ' matches: array of { number, reason } for similar issues',
140
+ 'Return ONLY valid JSON.',
141
+ ].join('\n'),
142
+ },
143
+ {
144
+ role: 'user',
145
+ content: `New issue:\nTitle: ${ctx.title}\nDescription: ${ctx.description || 'none'}\n\nExisting open issues:\n${existing}`,
146
+ },
147
+ ]
148
+ },
149
+
150
+ parseResponse(raw, ctx) {
151
+ try {
152
+ const parsed = tryParseJSON(raw)
153
+ ctx.dedupScore = {
154
+ confidence: Math.min(100, Math.max(0, Number(parsed.confidence) || 0)),
155
+ level: ['high', 'medium', 'low', 'none'].includes(parsed.level) ? parsed.level : 'none',
156
+ matches: Array.isArray(parsed.matches) ? parsed.matches : [],
157
+ }
158
+ } catch {
159
+ ctx.dedupScore = null
160
+ }
161
+ },
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Step: context
166
+ // ---------------------------------------------------------------------------
167
+
168
+ const contextStep = {
169
+ name: 'context',
170
+ displayName: 'Context gathered',
171
+ maxTokens: 1024,
172
+
173
+ shouldRun() {
174
+ return true
175
+ },
176
+
177
+ buildMessages(ctx) {
178
+ return [
179
+ {
180
+ role: 'system',
181
+ content: [
182
+ 'You are an expert at understanding software issues.',
183
+ 'Extract structured context from the issue description.',
184
+ 'Return a JSON object with:',
185
+ ' problem: string — the core problem in one sentence',
186
+ ' files: string[] — file paths mentioned or implied',
187
+ ' errors: string[] — error messages mentioned',
188
+ ' sessionContext: string — any relevant session/environment context',
189
+ 'Return ONLY valid JSON.',
190
+ ].join('\n'),
191
+ },
192
+ {
193
+ role: 'user',
194
+ content: contextSummary(ctx),
195
+ },
196
+ ]
197
+ },
198
+
199
+ parseResponse(raw, ctx) {
200
+ try {
201
+ const parsed = tryParseJSON(raw)
202
+ ctx.structuredContext = {
203
+ problem: parsed.problem || '',
204
+ files: Array.isArray(parsed.files) ? parsed.files : [],
205
+ errors: Array.isArray(parsed.errors) ? parsed.errors : [],
206
+ sessionContext: parsed.sessionContext || '',
207
+ }
208
+ } catch {
209
+ ctx.structuredContext = null
210
+ }
211
+ },
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Step: scope
216
+ // ---------------------------------------------------------------------------
217
+
218
+ const scopeStep = {
219
+ name: 'scope',
220
+ displayName: 'Scope analyzed',
221
+ maxTokens: 1024,
222
+
223
+ shouldRun(ctx) {
224
+ // Only run if description mentions code/files or context step found files
225
+ const desc = (ctx.description || '').toLowerCase()
226
+ const hasCodeMentions = /\.(js|ts|py|go|rs|rb|java|css|html|json|yml|yaml|md)\b/.test(desc) ||
227
+ /\b(file|module|component|function|class|import|require)\b/.test(desc)
228
+ const hasContextFiles = ctx.structuredContext?.files?.length > 0
229
+ return hasCodeMentions || hasContextFiles
230
+ },
231
+
232
+ buildMessages(ctx) {
233
+ return [
234
+ {
235
+ role: 'system',
236
+ content: [
237
+ 'You are a software scope analyzer.',
238
+ 'Given an issue description and context, identify the files and areas affected.',
239
+ 'Return a JSON object with:',
240
+ ' files: array of { path: string, purpose: string, deps: string[] }',
241
+ ' affectedAreas: string[] — high-level areas (e.g. "auth", "UI", "database")',
242
+ 'Return ONLY valid JSON.',
243
+ ].join('\n'),
244
+ },
245
+ {
246
+ role: 'user',
247
+ content: contextSummary(ctx) + priorStepsSummary(ctx),
248
+ },
249
+ ]
250
+ },
251
+
252
+ parseResponse(raw, ctx) {
253
+ try {
254
+ const parsed = tryParseJSON(raw)
255
+ ctx.scopeAnalysis = {
256
+ files: Array.isArray(parsed.files) ? parsed.files : [],
257
+ affectedAreas: Array.isArray(parsed.affectedAreas) ? parsed.affectedAreas : [],
258
+ }
259
+ } catch {
260
+ ctx.scopeAnalysis = null
261
+ }
262
+ },
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Step: complexity
267
+ // ---------------------------------------------------------------------------
268
+
269
+ const complexityStep = {
270
+ name: 'complexity',
271
+ displayName: 'Complexity scored',
272
+ maxTokens: 512,
273
+
274
+ shouldRun(ctx) {
275
+ // Run if we have scope analysis or a non-trivial description
276
+ return ctx.scopeAnalysis != null || (ctx.description || '').length > 50
277
+ },
278
+
279
+ buildMessages(ctx) {
280
+ return [
281
+ {
282
+ role: 'system',
283
+ content: [
284
+ 'You assess implementation complexity for GitHub issues.',
285
+ 'Return a JSON object with:',
286
+ ' score: number 1-10 (1=trivial, 10=massive refactor)',
287
+ ' rationale: string — one sentence explaining the score',
288
+ 'Return ONLY valid JSON.',
289
+ ].join('\n'),
290
+ },
291
+ {
292
+ role: 'user',
293
+ content: contextSummary(ctx) + priorStepsSummary(ctx),
294
+ },
295
+ ]
296
+ },
297
+
298
+ parseResponse(raw, ctx) {
299
+ try {
300
+ const parsed = tryParseJSON(raw)
301
+ ctx.complexity = Math.min(10, Math.max(1, Number(parsed.score) || 5))
302
+ ctx.complexityRationale = parsed.rationale || ''
303
+ } catch {
304
+ ctx.complexity = null
305
+ ctx.complexityRationale = null
306
+ }
307
+ },
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Step: risk
312
+ // ---------------------------------------------------------------------------
313
+
314
+ const riskStep = {
315
+ name: 'risk',
316
+ displayName: 'Risk assessed',
317
+ maxTokens: 512,
318
+
319
+ shouldRun(ctx) {
320
+ return ctx.scopeAnalysis != null || (ctx.description || '').length > 50
321
+ },
322
+
323
+ buildMessages(ctx) {
324
+ return [
325
+ {
326
+ role: 'system',
327
+ content: [
328
+ 'You assess implementation risk for GitHub issues.',
329
+ 'Consider: breaking changes, data loss potential, security implications, blast radius.',
330
+ 'Return a JSON object with:',
331
+ ' score: number 1-10 (1=no risk, 10=extremely risky)',
332
+ ' rationale: string — one sentence explaining the score',
333
+ 'Return ONLY valid JSON.',
334
+ ].join('\n'),
335
+ },
336
+ {
337
+ role: 'user',
338
+ content: contextSummary(ctx) + priorStepsSummary(ctx),
339
+ },
340
+ ]
341
+ },
342
+
343
+ parseResponse(raw, ctx) {
344
+ try {
345
+ const parsed = tryParseJSON(raw)
346
+ ctx.risk = Math.min(10, Math.max(1, Number(parsed.score) || 3))
347
+ ctx.riskRationale = parsed.rationale || ''
348
+ } catch {
349
+ ctx.risk = null
350
+ ctx.riskRationale = null
351
+ }
352
+ },
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Step: labels
357
+ // ---------------------------------------------------------------------------
358
+
359
+ const labelsStep = {
360
+ name: 'labels',
361
+ displayName: 'Labels suggested',
362
+ maxTokens: 512,
363
+
364
+ shouldRun(ctx) {
365
+ // Run if there are repo labels to choose from
366
+ return ctx.repoLabels?.length > 0
367
+ },
368
+
369
+ buildMessages(ctx) {
370
+ const available = (ctx.repoLabels || []).join(', ')
371
+ return [
372
+ {
373
+ role: 'system',
374
+ content: [
375
+ 'You suggest GitHub labels for issues.',
376
+ `Available labels in this repo: ${available}`,
377
+ 'Return a JSON object with:',
378
+ ' labels: string[] — labels to apply (must be from the available list)',
379
+ ' reasoning: string — brief explanation',
380
+ 'Only suggest labels that genuinely fit. Return ONLY valid JSON.',
381
+ ].join('\n'),
382
+ },
383
+ {
384
+ role: 'user',
385
+ content: contextSummary(ctx) + priorStepsSummary(ctx),
386
+ },
387
+ ]
388
+ },
389
+
390
+ parseResponse(raw, ctx) {
391
+ try {
392
+ const parsed = tryParseJSON(raw)
393
+ const suggested = Array.isArray(parsed.labels) ? parsed.labels : []
394
+ // Filter to only labels that actually exist in the repo
395
+ const valid = ctx.repoLabels || []
396
+ ctx.aiLabels = suggested.filter((l) => valid.includes(l))
397
+ } catch {
398
+ ctx.aiLabels = null
399
+ }
400
+ },
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Step: format
405
+ // ---------------------------------------------------------------------------
406
+
407
+ const formatStep = {
408
+ name: 'format',
409
+ displayName: 'Body formatted',
410
+ maxTokens: 4096,
411
+
412
+ shouldRun() {
413
+ return true
414
+ },
415
+
416
+ buildMessages(ctx) {
417
+ const guidance = buildBodyGuidance(ctx)
418
+ return [
419
+ {
420
+ role: 'system',
421
+ content: [
422
+ 'You are an expert at writing clear, well-structured GitHub issues.',
423
+ 'Given an issue title, description, and analysis from prior steps,',
424
+ 'write a complete issue body in markdown.',
425
+ '',
426
+ guidance,
427
+ ].join('\n'),
428
+ },
429
+ {
430
+ role: 'user',
431
+ content: contextSummary(ctx) + priorStepsSummary(ctx),
432
+ },
433
+ ]
434
+ },
435
+
436
+ parseResponse(raw, ctx) {
437
+ // The format step returns raw markdown, not JSON
438
+ // Strip wrapping code fences (```markdown ... ```) that LLMs often add
439
+ let cleaned = raw.trim()
440
+ const fenceMatch = cleaned.match(/^```(?:markdown|md)?\n([\s\S]*)\n```$/)
441
+ if (fenceMatch) cleaned = fenceMatch[1].trim()
442
+ if (cleaned.length > 0) {
443
+ ctx.body = cleaned
444
+ }
445
+ // If empty, ctx.body stays as whatever it was (template fallback)
446
+ },
447
+ }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // Exports
451
+ // ---------------------------------------------------------------------------
452
+
453
+ /**
454
+ * All pipeline steps in execution order.
455
+ */
456
+ export const ALL_STEPS = [
457
+ triageStep,
458
+ dedupStep,
459
+ contextStep,
460
+ scopeStep,
461
+ complexityStep,
462
+ riskStep,
463
+ labelsStep,
464
+ formatStep,
465
+ ]
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
+
482
+ /**
483
+ * Get a step by name.
484
+ * @param {string} name
485
+ * @returns {object|undefined}
486
+ */
487
+ export function getStep(name) {
488
+ return ALL_STEPS.find((s) => s.name === name)
489
+ }
490
+
491
+ // Re-export helpers for use by the enhancement adapter
492
+ export { tryParseJSON, contextSummary, priorStepsSummary }