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,44 @@
1
+ import { BaseAdapter } from './base.js'
2
+
3
+ const DEFAULT_MODEL = 'gpt-4o-mini'
4
+ const DEFAULT_MAX_TOKENS = 4096
5
+ const API_URL = 'https://api.openai.com/v1/chat/completions'
6
+
7
+ export class OpenAIAdapter extends BaseAdapter {
8
+ get name() {
9
+ return 'openai'
10
+ }
11
+
12
+ async complete(messages, opts = {}) {
13
+ const apiKey = this.config.apiKey
14
+ if (!apiKey) throw new Error('OpenAI API key not configured (ai.keys.openai)')
15
+
16
+ const model = opts.model || DEFAULT_MODEL
17
+ const maxTokens = opts.maxTokens || DEFAULT_MAX_TOKENS
18
+
19
+ const body = {
20
+ model,
21
+ max_tokens: maxTokens,
22
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
23
+ }
24
+
25
+ const res = await fetch(API_URL, {
26
+ method: 'POST',
27
+ headers: {
28
+ 'Content-Type': 'application/json',
29
+ Authorization: `Bearer ${apiKey}`,
30
+ },
31
+ body: JSON.stringify(body),
32
+ })
33
+
34
+ if (!res.ok) {
35
+ const text = await res.text().catch(() => '')
36
+ throw new Error(`OpenAI API error ${res.status}: ${this.sanitizeErrorBody(text)}`)
37
+ }
38
+
39
+ const data = await res.json()
40
+ const content = data.choices?.[0]?.message?.content
41
+ if (!content) throw new Error('OpenAI returned no content')
42
+ return content
43
+ }
44
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Rich body section guidance for the pipeline `format` step.
3
+ *
4
+ * Provides the target structure that the LLM should produce when formatting
5
+ * an issue body. Sections are conditionally included based on what upstream
6
+ * pipeline steps produced.
7
+ */
8
+
9
+ export const RICH_BODY_SECTIONS = [
10
+ { key: 'summary', heading: 'Summary', always: true },
11
+ { key: 'context', heading: 'Context', requires: 'structuredContext' },
12
+ { key: 'problem', heading: 'Problem', always: true },
13
+ { key: 'files', heading: 'Files Involved', requires: 'scopeAnalysis' },
14
+ { key: 'solution', heading: 'Proposed Solution', always: true },
15
+ { key: 'acceptance', heading: 'Acceptance Criteria', always: true },
16
+ { key: 'complexity', heading: 'Scope & Complexity', requires: 'complexity' },
17
+ ]
18
+
19
+ /**
20
+ * Build a guidance string for the `format` step's system prompt.
21
+ *
22
+ * Only includes section headings for which upstream data exists (or that are
23
+ * always-on). This prevents the LLM from hallucinating sections it has no
24
+ * data for.
25
+ *
26
+ * @param {object} ctx - PipelineContext accumulated so far
27
+ * @returns {string} markdown-formatted guidance
28
+ */
29
+ export function buildBodyGuidance(ctx) {
30
+ const lines = [
31
+ 'Structure the issue body with these markdown sections:',
32
+ '',
33
+ ]
34
+
35
+ for (const section of RICH_BODY_SECTIONS) {
36
+ if (section.always || ctx[section.requires] != null) {
37
+ lines.push(`## ${section.heading}`)
38
+ }
39
+ }
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
+
56
+ lines.push(
57
+ '',
58
+ 'Rules:',
59
+ '- Use bullet points for lists, not paragraphs.',
60
+ '- Keep each section concise (2-5 bullet points).',
61
+ '- If a section has no relevant information, omit it entirely.',
62
+ '- Return ONLY the markdown body — no preamble, no explanation, no code fences.',
63
+ )
64
+
65
+ if (ctx.complexity != null && ctx.risk != null) {
66
+ lines.push(
67
+ '',
68
+ 'In the "Scope & Complexity" section, include:',
69
+ `- Complexity: ${ctx.complexity}/10 — ${ctx.complexityRationale || ''}`,
70
+ `- Risk: ${ctx.risk}/10 — ${ctx.riskRationale || ''}`,
71
+ )
72
+ }
73
+
74
+ return lines.join('\n')
75
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Bridge between the create command and the pipeline runner.
3
+ *
4
+ * Sets up the PipelineContext, resolves routing, filters steps by config,
5
+ * and calls runPipeline.
6
+ */
7
+
8
+ import { resolveRoute } from './router.js'
9
+ import { runPipeline } from './pipeline.js'
10
+ import { ALL_STEPS } from './steps.js'
11
+ import { checkBudgets, recordUsage } from './index.js'
12
+ import { loadAllEnhancements } from '../enhancements.js'
13
+ import { enhancementToStep } from './enhancement-adapter.js'
14
+
15
+ /**
16
+ * Run the full enhancement pipeline.
17
+ *
18
+ * @param {object} config - merged config
19
+ * @param {object} input - { title, description, instructions, templateBody, labels, existingIssues, repoLabels, rawInput }
20
+ * @param {object} [callbacks] - pipeline callbacks for progress display
21
+ * @param {{ provider?: string, model?: string, template?: string, enhancements?: string[] }} [routeContext] - routing context
22
+ * @returns {Promise<object>} PipelineContext with all accumulated results
23
+ */
24
+ export async function runEnhancePipeline(config, input, callbacks, routeContext = {}) {
25
+ const ai = config.ai || {}
26
+ const budgets = ai.budgets || {}
27
+ const pipelineConfig = ai.pipeline || {}
28
+
29
+ // Resolve adapter + model
30
+ const route = resolveRoute(config, routeContext)
31
+
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')
75
+ }
76
+
77
+ // Build the initial PipelineContext
78
+ const ctx = {
79
+ // Input data
80
+ rawInput: input.rawInput || '',
81
+ title: input.title,
82
+ description: input.description || '',
83
+ instructions: input.instructions || '',
84
+ templateBody: input.templateBody || '',
85
+ labels: input.labels || [],
86
+ existingIssues: input.existingIssues || [],
87
+ repoLabels: input.repoLabels || [],
88
+
89
+ // Step config (internal)
90
+ _stepConfigs: stepConfigs,
91
+
92
+ // Accumulated results — steps populate these
93
+ dedupScore: null,
94
+ structuredContext: null,
95
+ scopeAnalysis: null,
96
+ complexity: null,
97
+ complexityRationale: null,
98
+ risk: null,
99
+ riskRationale: null,
100
+ aiLabels: null,
101
+ body: input.templateBody || '', // fallback to template
102
+ }
103
+
104
+ await runPipeline(activeSteps, ctx, route, config, budgets, callbacks, { checkBudgets, recordUsage })
105
+
106
+ return ctx
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
+ }
@@ -0,0 +1,122 @@
1
+ import { resolveRoute, resolveProviderAdapter, listProviders, listAllProviders } from './router.js'
2
+ import { buildMessages } from './prompt.js'
3
+
4
+ export { listProviders, listAllProviders, resolveProviderAdapter }
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Token usage tracking (in-memory, resets on process exit)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const tokenLog = []
11
+
12
+ export function recordUsage(tokens) {
13
+ tokenLog.push({ tokens, timestamp: Date.now() })
14
+ }
15
+
16
+ function getUsage(windowMs) {
17
+ const cutoff = Date.now() - windowMs
18
+ return tokenLog
19
+ .filter((e) => e.timestamp >= cutoff)
20
+ .reduce((sum, e) => sum + e.tokens, 0)
21
+ }
22
+
23
+ /**
24
+ * Check token budgets before making a request.
25
+ * Throws if a budget would be exceeded.
26
+ */
27
+ export function checkBudgets(budgets, requestTokens) {
28
+ if (!budgets) return
29
+
30
+ if (budgets.maxTokensPerRequest && requestTokens > budgets.maxTokensPerRequest) {
31
+ throw new Error(
32
+ `Token budget: request (${requestTokens}) exceeds per-request limit (${budgets.maxTokensPerRequest})`,
33
+ )
34
+ }
35
+
36
+ if (budgets.maxTokensPerHour) {
37
+ const hourlyUsed = getUsage(60 * 60 * 1000)
38
+ if (hourlyUsed + requestTokens > budgets.maxTokensPerHour) {
39
+ throw new Error(
40
+ `Token budget: hourly usage (${hourlyUsed} + ${requestTokens}) would exceed limit (${budgets.maxTokensPerHour})`,
41
+ )
42
+ }
43
+ }
44
+
45
+ if (budgets.maxTokensPerDay) {
46
+ const dailyUsed = getUsage(24 * 60 * 60 * 1000)
47
+ if (dailyUsed + requestTokens > budgets.maxTokensPerDay) {
48
+ throw new Error(
49
+ `Token budget: daily usage (${dailyUsed} + ${requestTokens}) would exceed limit (${budgets.maxTokensPerDay})`,
50
+ )
51
+ }
52
+ }
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Public API
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Check if AI enhancement is available (enabled + key configured).
61
+ *
62
+ * @param {object} config - merged config
63
+ * @param {{ provider?: string }} [context]
64
+ * @returns {boolean}
65
+ */
66
+ export function checkAvailable(config, context = {}) {
67
+ const ai = config.ai || {}
68
+ if (!ai.enabled) return false
69
+ try {
70
+ const { adapter } = resolveRoute(config, context)
71
+ return adapter.isConfigured()
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Enhance an issue body using AI.
79
+ *
80
+ * @param {object} config - merged config
81
+ * @param {string} title - issue title
82
+ * @param {string} description - actual issue content
83
+ * @param {string} templateBody - already-rendered template
84
+ * @param {string} [instructions] - optional AI guidance
85
+ * @param {{ template?: string, labels?: string[], provider?: string, model?: string }} [context] - routing context
86
+ * @returns {Promise<string>} enhanced body
87
+ */
88
+ export async function enhance(config, title, description, templateBody, instructions, context = {}) {
89
+ const ai = config.ai || {}
90
+ const budgets = ai.budgets || {}
91
+ const { adapter, model } = resolveRoute(config, context)
92
+ const messages = buildMessages(title, description, templateBody, instructions)
93
+
94
+ // Enforce per-request cap: clamp maxTokens to budget
95
+ const requestMax = budgets.maxTokensPerRequest || 4096
96
+
97
+ // Check rolling budgets before the call
98
+ checkBudgets(budgets, requestMax)
99
+
100
+ let result = await adapter.complete(messages, { model, maxTokens: requestMax })
101
+
102
+ // Strip wrapping code fences (```markdown ... ```) that LLMs often add
103
+ const fenceMatch = result.match(/^```(?:markdown|md)?\n([\s\S]*)\n```$/)
104
+ if (fenceMatch) result = fenceMatch[1].trim()
105
+
106
+ // Record approximate usage (response length in chars / 4 is a rough token estimate)
107
+ const approxTokens = Math.ceil(result.length / 4)
108
+ recordUsage(approxTokens)
109
+
110
+ return result
111
+ }
112
+
113
+ /**
114
+ * Get current token usage stats.
115
+ * @returns {{ hourly: number, daily: number }}
116
+ */
117
+ export function getTokenUsage() {
118
+ return {
119
+ hourly: getUsage(60 * 60 * 1000),
120
+ daily: getUsage(24 * 60 * 60 * 1000),
121
+ }
122
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Pipeline runner for multi-step AI issue enhancement.
3
+ *
4
+ * Executes steps sequentially, accumulating state in a shared PipelineContext.
5
+ * Failed steps log a warning and continue (graceful degradation).
6
+ */
7
+
8
+ import { resolveProviderAdapter } from './router.js'
9
+
10
+ /**
11
+ * Run a sequence of pipeline steps.
12
+ *
13
+ * @param {object[]} steps - array of step objects (from steps.js)
14
+ * @param {object} ctx - mutable PipelineContext, accumulates results
15
+ * @param {{ adapter: object, model: string }} route - resolved adapter + model
16
+ * @param {object} [config] - merged config object (enables per-step provider overrides)
17
+ * @param {object} [budgets] - token budget config
18
+ * @param {object} [callbacks] - { onStepStart, onStepDone, onStepSkip, onStepFail }
19
+ * @param {{ checkBudgets: Function, recordUsage: Function }} [budgetHelpers]
20
+ * @returns {Promise<object>} the final ctx
21
+ */
22
+ export async function runPipeline(steps, ctx, route, config, budgets, callbacks, budgetHelpers) {
23
+ const { adapter: defaultAdapter, model: defaultModel } = route
24
+ const cb = callbacks || {}
25
+ const { checkBudgets, recordUsage } = budgetHelpers || {}
26
+
27
+ let budgetExhausted = false
28
+
29
+ for (const step of steps) {
30
+ // If budget was exhausted, skip all remaining steps except format (which degrades gracefully)
31
+ if (budgetExhausted && step.name !== 'format') {
32
+ cb.onStepSkip?.(step, 'budget exhausted')
33
+ continue
34
+ }
35
+
36
+ // Check shouldRun
37
+ const stepConfig = ctx._stepConfigs?.[step.name] || 'auto'
38
+ if (stepConfig === 'never') {
39
+ cb.onStepSkip?.(step, 'disabled')
40
+ continue
41
+ }
42
+ if (stepConfig === 'auto' && !step.shouldRun(ctx)) {
43
+ cb.onStepSkip?.(step, 'auto-skipped')
44
+ continue
45
+ }
46
+
47
+ cb.onStepStart?.(step)
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
+
64
+ try {
65
+ // Check budget before the call (skip for format when budget already exhausted — it degrades gracefully)
66
+ if (checkBudgets && budgets && !budgetExhausted) {
67
+ try {
68
+ checkBudgets(budgets, step.maxTokens)
69
+ } catch {
70
+ budgetExhausted = true
71
+ cb.onStepFail?.(step, new Error('Token budget exceeded'))
72
+ continue
73
+ }
74
+ }
75
+
76
+ // Build messages and call the adapter
77
+ const messages = step.buildMessages(ctx)
78
+ const raw = await adapter.complete(messages, { model, maxTokens: step.maxTokens })
79
+
80
+ // Record approximate usage
81
+ if (recordUsage) {
82
+ const approxTokens = Math.ceil(raw.length / 4)
83
+ recordUsage(approxTokens)
84
+ }
85
+
86
+ // Parse response and update context
87
+ step.parseResponse(raw, ctx)
88
+
89
+ cb.onStepDone?.(step, ctx)
90
+ } catch (err) {
91
+ cb.onStepFail?.(step, err)
92
+ // Non-fatal — continue to next step
93
+ }
94
+ }
95
+
96
+ return ctx
97
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Build messages array for AI enhancement of an issue body.
3
+ *
4
+ * @param {string} title - issue title
5
+ * @param {string} description - actual issue content (what the issue is about)
6
+ * @param {string} templateBody - already-rendered template body
7
+ * @param {string} [instructions] - optional AI prompt guidance (not issue content)
8
+ * @returns {Array<{ role: 'system'|'user', content: string }>}
9
+ */
10
+ export function buildMessages(title, description, templateBody, instructions) {
11
+ const systemContent = [
12
+ 'You are an expert at writing clear, well-structured GitHub issues.',
13
+ 'Given an issue title, description, and a rendered template, improve the issue body.',
14
+ 'Fill in template sections with relevant detail inferred from the title and description.',
15
+ 'Keep the markdown structure intact. Be concise and actionable.',
16
+ 'Return ONLY the improved issue body markdown — no preamble, no explanation, no code fences.',
17
+ ]
18
+ if (instructions) {
19
+ systemContent.push('', 'Additional instructions from the user:', instructions)
20
+ }
21
+
22
+ const userContent = [
23
+ `Title: ${title}`,
24
+ '',
25
+ description ? `Description: ${description}` : '',
26
+ '',
27
+ 'Template body to improve:',
28
+ '```',
29
+ templateBody,
30
+ '```',
31
+ ]
32
+ .filter((line) => line !== undefined)
33
+ .join('\n')
34
+
35
+ return [
36
+ { role: 'system', content: systemContent.join('\n') },
37
+ { role: 'user', content: userContent },
38
+ ]
39
+ }