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
@@ -8,35 +8,63 @@ import { checkSafety, recordSuccess, recordFailure } from '../lib/safety.js'
8
8
  import { checkDuplicate, recordCreation } from '../lib/dedup.js'
9
9
  import { listTemplates, loadTemplate, renderTemplate } from '../lib/templates.js'
10
10
  import { renderAttribution } from '../lib/attribution.js'
11
- import { computeFingerprint } from '../lib/dedup.js'
12
11
  import { pickRepo } from '../lib/repo-picker.js'
12
+ import { writeDraft, updateDraftItem, writeToDrafts, markComplete, markFailed, hasPending, countPending, attachFile } from '../lib/drafts.js'
13
13
  import {
14
14
  requireAuth,
15
15
  createIssue,
16
+ verifyIssue,
16
17
  listLabels,
18
+ listIssues,
17
19
  createLabel,
18
20
  addLabelsToIssue,
21
+ uploadImageToRepo,
19
22
  } from '../lib/gh.js'
20
- import chalk from 'chalk'
23
+ import { enhance, checkAvailable } from '../lib/ai/index.js'
24
+ import { runEnhancePipeline } from '../lib/ai/enhance.js'
25
+ import { clipboardHasImage, saveClipboardImage } from '../lib/clipboard.js'
26
+ import { bold, dim, red, green, yellow, cyan } from '../lib/color.js'
21
27
  import ora from 'ora'
28
+ import { theme } from '../lib/theme.js'
22
29
 
23
30
  // ---------------------------------------------------------------------------
24
31
  // Helpers
25
32
  // ---------------------------------------------------------------------------
26
33
 
34
+ /**
35
+ * Split freeform user input into a title and body.
36
+ *
37
+ * Rules:
38
+ * 1. Explicit newlines → first line = title, rest = body
39
+ * 2. Short input (≤80 chars) → just a title
40
+ * 3. Long single line → break at last word boundary before 80 chars
41
+ *
42
+ * @param {string} text
43
+ * @returns {{ title: string, body: string }}
44
+ */
45
+ function splitInput(text) {
46
+ const trimmed = text.trim()
47
+ if (trimmed.includes('\n')) {
48
+ const idx = trimmed.indexOf('\n')
49
+ return { title: trimmed.slice(0, idx).trim(), body: trimmed.slice(idx + 1).trim() }
50
+ }
51
+ if (trimmed.length <= 80) return { title: trimmed, body: '' }
52
+ const cut = trimmed.lastIndexOf(' ', 80)
53
+ const at = cut > 20 ? cut : 80
54
+ return { title: trimmed.slice(0, at).trim(), body: trimmed }
55
+ }
56
+
27
57
  /**
28
58
  * Print the circuit breaker state if it is not 'closed'.
29
59
  *
30
60
  * @param {string} circuitState
31
61
  */
32
62
  function warnIfCircuitNotClosed(circuitState) {
33
- if (circuitState === 'closed') return
34
- const stateColor = circuitState === 'open' ? chalk.red : chalk.yellow
63
+ if (circuitState !== 'open') return
35
64
  console.warn(
36
- chalk.yellow('[safety]') +
37
- ' Circuit breaker is ' +
38
- stateColor(circuitState.toUpperCase()) +
39
- '. Use `tissues status` to view details.',
65
+ yellow('[safety]') +
66
+ ' Issue submission is paused due to recent failures.' +
67
+ ' Run ' + cyan('tissues status') + ' for details.',
40
68
  )
41
69
  }
42
70
 
@@ -52,7 +80,7 @@ function formatDedupResult(result) {
52
80
  if (result.existingIssue) {
53
81
  const { number, title, url } = result.existingIssue
54
82
  if (number) parts.push(` Existing issue: #${number}${title ? ` — ${title}` : ''}`)
55
- if (url) parts.push(` URL: ${chalk.cyan(url)}`)
83
+ if (url) parts.push(` URL: ${cyan(url)}`)
56
84
  }
57
85
  return parts.join('\n')
58
86
  }
@@ -76,34 +104,10 @@ function runPostCreateHook(hookCmd, ctx) {
76
104
  stdio: 'inherit',
77
105
  })
78
106
  } catch (err) {
79
- console.warn(chalk.yellow(`[hooks] postCreate hook failed: ${err.message}`))
107
+ console.warn(yellow(`[hooks] postCreate hook failed: ${err.message}`))
80
108
  }
81
109
  }
82
110
 
83
- // ---------------------------------------------------------------------------
84
- // AI enhancement stub
85
- // ---------------------------------------------------------------------------
86
-
87
- /**
88
- * Enhance issue body with AI. Currently a structured placeholder.
89
- * Wire up real AI (OpenAI / Anthropic) here.
90
- *
91
- * @param {string} title - issue title
92
- * @param {string} description - the actual issue content describing what the issue is about;
93
- * this is rendered into the template body via {{description}} and forms the core of the issue
94
- * @param {string} templateBody - already-rendered template body to use as context
95
- * @param {string} [instructions] - optional AI prompt instructions that guide *how* the AI
96
- * writes or post-processes the issue body (e.g. "keep it under 200 words",
97
- * "after creating, send to this webhook: https://..."). Does NOT appear in the issue itself.
98
- * @returns {Promise<string>}
99
- */
100
- async function enhanceWithAI(title, description, templateBody, instructions) {
101
- // TODO: wire up real AI (OpenAI / Anthropic)
102
- // - description goes INTO the issue body (rendered via {{description}} in the template)
103
- // - instructions guide the AI behaviour but are NOT included in the output
104
- return templateBody
105
- }
106
-
107
111
  // ---------------------------------------------------------------------------
108
112
  // AbortError — thrown for user-initiated cancellations (exit 0)
109
113
  // ---------------------------------------------------------------------------
@@ -127,7 +131,7 @@ class AbortError extends Error {
127
131
  * @throws {AbortError} when the user cancels interactively
128
132
  * @throws {Error} on hard failures
129
133
  */
130
- async function runCreate(opts) {
134
+ export async function runCreate(opts) {
131
135
  // -----------------------------------------------------------------------
132
136
  // 0. Ensure authenticated
133
137
  // -----------------------------------------------------------------------
@@ -141,7 +145,7 @@ async function runCreate(opts) {
141
145
  repo = store.get('activeRepo')
142
146
  }
143
147
  if (!repo) {
144
- console.log(chalk.yellow('No active repo. Pick one:\n'))
148
+ console.log(yellow('No active repo. Pick one:\n'))
145
149
  repo = await pickRepo()
146
150
  console.log()
147
151
  }
@@ -151,45 +155,190 @@ async function runCreate(opts) {
151
155
  // -----------------------------------------------------------------------
152
156
  const repoRoot = findRepoRoot()
153
157
  const config = loadConfig(repoRoot)
158
+ const agent = opts.agent ?? config.attribution?.defaultAgent ?? 'human'
154
159
 
155
160
  // -----------------------------------------------------------------------
156
- // 3. Check safety
161
+ // 3. Get inputs
157
162
  // -----------------------------------------------------------------------
158
- const agent = opts.agent ?? config.attribution?.defaultAgent ?? 'human'
159
- const safetyResult = checkSafety(repo, agent, config.safety)
160
-
161
- warnIfCircuitNotClosed(safetyResult.circuitState)
163
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY
164
+
165
+ let title, description, instructions
166
+
167
+ if (opts.title) {
168
+ // --title flag: skip wizard entirely (scripted / batch / ai use)
169
+ title = opts.title
170
+ description = opts.body ?? undefined
171
+ instructions = opts.instructions ?? undefined
172
+ } else if (isInteractive) {
173
+ // Single freeform prompt
174
+ const rawInput = await input({
175
+ message: "What's the issue",
176
+ default: opts._titleDefault,
177
+ theme,
178
+ })
179
+ if (!rawInput || !rawInput.trim()) {
180
+ console.error(red('Input is required.'))
181
+ throw new Error('Input is required')
182
+ }
183
+ const split = splitInput(rawInput)
184
+ title = split.title
185
+ description = opts.body ?? (split.body || undefined)
186
+ instructions = opts.instructions ?? undefined
187
+ // Pass the full user text as instructions for AI enhancement
188
+ if (!instructions && rawInput.trim().length > title.length) {
189
+ instructions = rawInput.trim()
190
+ }
191
+ // Preserve raw input for triage step — AI will refine title/description
192
+ opts._rawInput = rawInput.trim()
193
+ } else {
194
+ // Non-interactive without --title
195
+ title = opts._titleDefault
196
+ description = opts.body ?? undefined
197
+ instructions = opts.instructions ?? undefined
198
+ }
162
199
 
163
- if (!safetyResult.allowed) {
164
- recordFailure(repo, agent, config.safety)
165
- console.error(chalk.red('Safety check failed:'), safetyResult.reason)
166
- throw new Error('Safety check failed')
200
+ if (!title || !title.trim()) {
201
+ if (!isInteractive) {
202
+ console.error(red('Input is required. Pass --title <title> when running non-interactively.'))
203
+ } else {
204
+ console.error(red('Input is required.'))
205
+ }
206
+ throw new Error('Input is required')
167
207
  }
168
208
 
169
209
  // -----------------------------------------------------------------------
170
- // 4. Get inputs
210
+ // 3b. Parse labels early (needed by dedup context and draft write)
171
211
  // -----------------------------------------------------------------------
172
- const title =
173
- opts.title ?? (await input({ message: 'Issue title', default: opts._titleDefault }))
174
- if (!title || !title.trim()) {
175
- console.error(chalk.red('Title is required.'))
176
- throw new Error('Title is required')
212
+ const labels = opts.labels
213
+ ? opts.labels
214
+ .split(',')
215
+ .map((l) => l.trim())
216
+ .filter(Boolean)
217
+ : []
218
+
219
+ // -----------------------------------------------------------------------
220
+ // 3c. Check dedup BEFORE writing draft (no phantom drafts on block)
221
+ // -----------------------------------------------------------------------
222
+ const relatedIssues = []
223
+
224
+ if (!opts.draft) {
225
+ const dedupSpinner = ora('Checking for duplicates...').start()
226
+ let dedupResult
227
+ try {
228
+ dedupResult = await checkDuplicate(repo, {
229
+ title,
230
+ body: description,
231
+ agent,
232
+ })
233
+ dedupSpinner.stop()
234
+ } catch (err) {
235
+ dedupSpinner.warn(`Dedup check failed (skipping): ${err.message}`)
236
+ dedupResult = { action: 'allow', results: [] }
237
+ }
238
+
239
+ if (dedupResult.action === 'block') {
240
+ if (!isInteractive) recordFailure(repo, agent, config.safety)
241
+ const blockReasons = dedupResult.results.filter((r) => r.action === 'block')
242
+ for (const r of blockReasons) {
243
+ console.error(formatDedupResult(r))
244
+ }
245
+ console.error(red('Duplicate detected — issue not created.'))
246
+ throw new Error('Duplicate detected')
247
+ }
248
+
249
+ if (dedupResult.action === 'warn' && !opts.force) {
250
+ console.warn(yellow('\nSimilar issue(s) found:'))
251
+ for (const r of dedupResult.results.filter((r) => r.action === 'warn')) {
252
+ console.warn(formatDedupResult(r))
253
+ if (r.existingIssue?.number) {
254
+ relatedIssues.push({ number: r.existingIssue.number })
255
+ }
256
+ }
257
+ console.warn()
258
+
259
+ const proceed = await confirm({
260
+ message: 'Create anyway?',
261
+ default: false,
262
+ theme,
263
+ })
264
+
265
+ if (!proceed) {
266
+ console.log(dim('Aborted.'))
267
+ throw new AbortError('Aborted by user')
268
+ }
269
+ }
177
270
  }
178
271
 
179
- const description =
180
- opts.body ??
181
- (await input({
182
- message: 'Issue description (optional)',
183
- }))
272
+ // -----------------------------------------------------------------------
273
+ // 3d. Write draft — user data is safe from this point on
274
+ // -----------------------------------------------------------------------
275
+ const draft = opts.dryRun
276
+ ? null
277
+ : writeDraft({ repo, title, description, instructions, labels }, repoRoot)
278
+
279
+ // -----------------------------------------------------------------------
280
+ // 3e. Attach files (--attach flag and clipboard image detection)
281
+ // -----------------------------------------------------------------------
282
+ const attachPaths = opts.attach
283
+ ? opts.attach.split(',').map((p) => p.trim()).filter(Boolean)
284
+ : []
285
+
286
+ const attachments = []
287
+ const imageUrls = [] // URLs from uploaded images for embedding in body
288
+ if (draft) {
289
+ // Attach files from --attach flag
290
+ for (const fp of attachPaths) {
291
+ try {
292
+ const meta = attachFile(draft.id, fp, repoRoot)
293
+ attachments.push(meta)
294
+ console.log(green(' ✔') + ` Attached: ${meta.filename} (${Math.round(meta.size / 1024)} KB)`)
295
+
296
+ // Upload image files to repo for embedding
297
+ if (/\.(png|jpe?g|gif|webp)$/i.test(fp)) {
298
+ try {
299
+ const imgName = `${Date.now()}-${meta.filename}`
300
+ const { url } = uploadImageToRepo(repo, fp, imgName)
301
+ imageUrls.push({ filename: meta.filename, url })
302
+ console.log(green(' ✔') + ` Uploaded to repo`)
303
+ } catch (uploadErr) {
304
+ console.warn(yellow(` ⚠ Could not upload ${meta.filename}: ${uploadErr.message}`))
305
+ }
306
+ }
307
+ } catch (err) {
308
+ console.warn(yellow(` ⚠ Could not attach ${fp}: ${err.message}`))
309
+ }
310
+ }
184
311
 
185
- const instructions =
186
- opts.instructions ??
187
- (await input({
188
- message: 'Issue instruction (optional, guides AI enhancement)',
189
- }))
312
+ // Auto-detect clipboard image in interactive mode (no confirmation prompt)
313
+ if (isInteractive && !opts.dryRun && clipboardHasImage()) {
314
+ const { getAttachmentDir } = await import('../lib/drafts.js')
315
+ const attachDir = getAttachmentDir(draft.id, repoRoot)
316
+ fs.mkdirSync(attachDir, { recursive: true })
317
+ const clipName = `clipboard-${Date.now()}.png`
318
+ const destPath = `${attachDir}/${clipName}`
319
+ const saved = saveClipboardImage(destPath)
320
+ if (saved) {
321
+ const stat = fs.statSync(destPath)
322
+ const meta = { filename: clipName, size: stat.size, type: 'image/png' }
323
+ attachments.push(meta)
324
+ console.log(green(' ✔') + ` Clipboard image attached (${Math.round(stat.size / 1024)} KB)`)
325
+
326
+ // Upload to repo for embedding
327
+ try {
328
+ const { url } = uploadImageToRepo(repo, destPath, clipName)
329
+ imageUrls.push({ filename: 'Clipboard image', url })
330
+ console.log(green(' ✔') + ` Uploaded to repo`)
331
+ } catch (uploadErr) {
332
+ console.warn(yellow(` ⚠ Could not upload clipboard image: ${uploadErr.message}`))
333
+ }
334
+ } else {
335
+ console.warn(yellow(' ⚠ Could not save clipboard image'))
336
+ }
337
+ }
338
+ }
190
339
 
191
340
  // -----------------------------------------------------------------------
192
- // 5. Pick template
341
+ // 4. Pick template
193
342
  // -----------------------------------------------------------------------
194
343
  let templateName = opts.template
195
344
 
@@ -208,7 +357,7 @@ async function runCreate(opts) {
208
357
  if (!seen.has(tpl.key)) {
209
358
  seen.add(tpl.key)
210
359
  choices.push({
211
- name: `${tpl.name} ${chalk.dim(`(${tpl.source})`)}`,
360
+ name: `${tpl.name} ${dim(`(${tpl.source})`)}`,
212
361
  value: tpl.key,
213
362
  })
214
363
  }
@@ -218,6 +367,7 @@ async function runCreate(opts) {
218
367
  templateName = await select({
219
368
  message: 'Choose a template',
220
369
  choices,
370
+ theme,
221
371
  })
222
372
  } else {
223
373
  templateName = 'default'
@@ -228,60 +378,14 @@ async function runCreate(opts) {
228
378
  try {
229
379
  template = loadTemplate(templateName, repoRoot)
230
380
  } catch (err) {
231
- console.error(chalk.red(`Template error: ${err.message}`))
381
+ console.error(red(`Template error: ${err.message}`))
232
382
  throw new Error(`Template error: ${err.message}`)
233
383
  }
234
384
 
235
385
  // -----------------------------------------------------------------------
236
- // 6. Check dedup
386
+ // 5. Render template
237
387
  // -----------------------------------------------------------------------
238
388
  const session = opts.session ?? null
239
- const dedupSpinner = ora('Checking for duplicates...').start()
240
- let dedupResult
241
- try {
242
- dedupResult = await checkDuplicate(repo, {
243
- title,
244
- body: description,
245
- agent,
246
- // No idempotency key by default — callers who need deterministic keys
247
- // can pass --session and combine with agent + title in future work
248
- })
249
- dedupSpinner.stop()
250
- } catch (err) {
251
- dedupSpinner.warn(`Dedup check failed (skipping): ${err.message}`)
252
- dedupResult = { action: 'allow', results: [] }
253
- }
254
-
255
- if (dedupResult.action === 'block') {
256
- recordFailure(repo, agent, config.safety)
257
- console.error(chalk.red('Duplicate detected — issue not created.'))
258
- for (const r of dedupResult.results.filter((r) => r.action === 'block')) {
259
- console.error(formatDedupResult(r))
260
- }
261
- throw new Error('Duplicate detected')
262
- }
263
-
264
- if (dedupResult.action === 'warn' && !opts.force) {
265
- console.warn(chalk.yellow('\nSimilar issue(s) found:'))
266
- for (const r of dedupResult.results.filter((r) => r.action === 'warn')) {
267
- console.warn(formatDedupResult(r))
268
- }
269
- console.warn()
270
-
271
- const proceed = await confirm({
272
- message: 'Create anyway?',
273
- default: false,
274
- })
275
-
276
- if (!proceed) {
277
- console.log(chalk.dim('Aborted.'))
278
- throw new AbortError('Aborted by user')
279
- }
280
- }
281
-
282
- // -----------------------------------------------------------------------
283
- // 7. Render template
284
- // -----------------------------------------------------------------------
285
389
  const renderedTemplate = renderTemplate(template.body, {
286
390
  title,
287
391
  description: description || '',
@@ -292,63 +396,234 @@ async function runCreate(opts) {
292
396
  })
293
397
 
294
398
  // -----------------------------------------------------------------------
295
- // 8. AI enhancement (skip for --no-enhance or --dry-run)
399
+ // 6. AI enhancement (skip for --no-enhance or --dry-run)
296
400
  // -----------------------------------------------------------------------
297
401
  let body = renderedTemplate
402
+ let pipelineResult = null
298
403
  if (opts.enhance !== false && !opts.dryRun) {
299
- const aiSpinner = ora('Enhancing with AI...').start()
300
- try {
301
- body = await enhanceWithAI(title, description, renderedTemplate, instructions)
302
- aiSpinner.succeed('Enhanced')
303
- } catch {
304
- aiSpinner.warn('AI enhancement unavailable using template as-is')
404
+ const enhancementNames = opts.enhancements
405
+ ? opts.enhancements.split(',').map((s) => s.trim()).filter(Boolean)
406
+ : undefined
407
+ const aiContext = {
408
+ template: templateName,
409
+ labels: opts.labels ? opts.labels.split(',').map((l) => l.trim()).filter(Boolean) : [],
410
+ provider: opts.provider,
411
+ model: opts.model,
412
+ enhancements: enhancementNames,
413
+ }
414
+ if (checkAvailable(config, aiContext)) {
415
+ const pipelineConfig = config.ai?.pipeline || {}
416
+ const usePipeline = opts.pipeline === true ||
417
+ (pipelineConfig.enabled && opts.pipeline !== false)
418
+
419
+ if (usePipeline) {
420
+ // Multi-step pipeline enhancement
421
+ let aiSpinner = ora('Enhancing with AI pipeline...').start()
422
+ try {
423
+ // Gather existing issues and repo labels for pipeline context
424
+ let existingIssues = []
425
+ let repoLabels = []
426
+ try { existingIssues = listIssues(repo) } catch { /* non-fatal */ }
427
+ try { repoLabels = listLabels(repo) } catch { /* non-fatal */ }
428
+
429
+ // When --title is explicit, skip triage (no rawInput to analyze)
430
+ const rawInput = opts.title ? '' : (opts._rawInput || '')
431
+
432
+ pipelineResult = await runEnhancePipeline(config, {
433
+ rawInput,
434
+ title,
435
+ description,
436
+ instructions,
437
+ templateBody: renderedTemplate,
438
+ labels,
439
+ existingIssues,
440
+ repoLabels,
441
+ }, {
442
+ onStepStart(step) {
443
+ if (!aiSpinner.isSpinning) aiSpinner = ora().start()
444
+ aiSpinner.text = `${step.displayName}...`
445
+ },
446
+ onStepDone(step, ctx) {
447
+ let detail = ''
448
+ if (step.name === 'triage') detail = ctx.title ? `: ${ctx.title}` : ''
449
+ if (step.name === 'dedup') detail = ctx.dedupScore ? ` (${ctx.dedupScore.level})` : ' (none)'
450
+ if (step.name === 'scope') detail = ctx.scopeAnalysis ? ` (${ctx.scopeAnalysis.files.length} files)` : ''
451
+ if (step.name === 'complexity') detail = ctx.complexity != null ? `: ${ctx.complexity}/10` : ''
452
+ if (step.name === 'risk') detail = ctx.risk != null ? `: ${ctx.risk}/10` : ''
453
+ if (step.name === 'labels') detail = ctx.aiLabels?.length ? `: ${ctx.aiLabels.join(', ')}` : ''
454
+ aiSpinner.succeed(`${step.displayName}${detail}`)
455
+ },
456
+ onStepSkip(step) { /* silent */ },
457
+ onStepFail(step, err) {
458
+ if (!aiSpinner.isSpinning) aiSpinner = ora().start()
459
+ aiSpinner.warn(`${step.displayName} failed`)
460
+ console.warn(dim(` ${err.message}`))
461
+ },
462
+ }, aiContext)
463
+
464
+ aiSpinner.stop()
465
+ body = pipelineResult.body || renderedTemplate
466
+
467
+ // Use AI-derived title if triage step produced one
468
+ if (pipelineResult.title && pipelineResult.title !== title) {
469
+ title = pipelineResult.title
470
+ }
471
+
472
+ // Merge AI-suggested labels
473
+ if (pipelineResult.aiLabels?.length) {
474
+ for (const l of pipelineResult.aiLabels) {
475
+ if (!labels.includes(l)) labels.push(l)
476
+ }
477
+ }
478
+
479
+ // Collect AI dedup matches as related issues
480
+ if (pipelineResult.dedupScore?.matches?.length) {
481
+ const seen = new Set(relatedIssues.map((i) => i.number))
482
+ for (const m of pipelineResult.dedupScore.matches) {
483
+ if (m.number && !seen.has(m.number)) {
484
+ relatedIssues.push({ number: m.number })
485
+ seen.add(m.number)
486
+ }
487
+ }
488
+ }
489
+
490
+ // Warn on high AI dedup confidence
491
+ if (pipelineResult.dedupScore?.level === 'high' && !opts.force) {
492
+ console.warn(yellow('\nAI detected likely duplicate:'))
493
+ for (const m of pipelineResult.dedupScore.matches || []) {
494
+ console.warn(dim(` #${m.number}: ${m.reason}`))
495
+ }
496
+ console.warn()
497
+ }
498
+ } catch (err) {
499
+ aiSpinner.stop()
500
+ if (/\b401\b/.test(err.message)) {
501
+ console.warn(red(' API key is invalid or expired.'))
502
+ console.warn(dim(' Run ') + cyan('tissues config') + dim(' to update your API key.'))
503
+ } else {
504
+ console.warn(yellow('Pipeline failed — falling back to single-shot enhancement'))
505
+ console.warn(dim(` ${err.message}`))
506
+ }
507
+ // Fall back to single-shot
508
+ try {
509
+ body = await enhance(config, title, description, renderedTemplate, instructions, aiContext)
510
+ } catch {
511
+ // Use template as-is
512
+ }
513
+ }
514
+ } else {
515
+ // Single-shot enhancement (original behavior)
516
+ const aiSpinner = ora('Enhancing with AI...').start()
517
+ try {
518
+ body = await enhance(config, title, description, renderedTemplate, instructions, aiContext)
519
+ aiSpinner.succeed('Enhanced')
520
+ } catch (err) {
521
+ if (/\b401\b/.test(err.message)) {
522
+ aiSpinner.fail('AI enhancement failed')
523
+ console.warn(red(' API key is invalid or expired.'))
524
+ console.warn(dim(' Run ') + cyan('tissues config') + dim(' to update your API key.'))
525
+ console.warn(dim(' Continuing without AI enhancement.\n'))
526
+ } else {
527
+ aiSpinner.warn(`AI enhancement failed — using template as-is`)
528
+ console.warn(dim(` ${err.message}`))
529
+ }
530
+ }
531
+ }
305
532
  }
306
533
  }
307
534
 
308
535
  // -----------------------------------------------------------------------
309
- // 9. Add attribution
536
+ // 6b. Embed uploaded images in body
537
+ // -----------------------------------------------------------------------
538
+ if (imageUrls.length > 0) {
539
+ const imageMarkdown = imageUrls
540
+ .map(({ filename, url }) => `![${filename}](${url})`)
541
+ .join('\n')
542
+ body = body.trimEnd() + '\n\n' + imageMarkdown
543
+ }
544
+
545
+ // -----------------------------------------------------------------------
546
+ // 6c. Add related issues (dedup matches)
547
+ // -----------------------------------------------------------------------
548
+ if (relatedIssues.length > 0) {
549
+ const refs = relatedIssues.map((i) => `#${i.number}`).join(' ')
550
+ body = body.trimEnd() + `\n\n---\n**Possibly Duplicate:** ${refs}`
551
+ }
552
+
553
+ // -----------------------------------------------------------------------
554
+ // 7. Add attribution
310
555
  // -----------------------------------------------------------------------
311
- const fingerprint = computeFingerprint(title, body)
312
556
  const attributionBlock = renderAttribution({
313
- agent,
314
557
  session: session ?? undefined,
315
- trigger: 'cli-create',
316
- fingerprint: `sha256:${fingerprint}`,
558
+ model: opts.model ?? undefined,
317
559
  })
318
560
 
319
- body = body.trimEnd() + '\n\n' + attributionBlock
561
+ if (attributionBlock) {
562
+ body = body.trimEnd() + '\n\n' + attributionBlock
563
+ }
320
564
 
321
565
  // -----------------------------------------------------------------------
322
- // 10. Parse labels
566
+ // 8. Update draft with processed body — draft → pending
323
567
  // -----------------------------------------------------------------------
324
- const labels = opts.labels
325
- ? opts.labels
326
- .split(',')
327
- .map((l) => l.trim())
328
- .filter(Boolean)
329
- : []
568
+ if (draft) {
569
+ updateDraftItem(draft.id, { title, body, labels }, repoRoot)
570
+ }
330
571
 
331
572
  // -----------------------------------------------------------------------
332
573
  // Dry run — stop here
333
574
  // -----------------------------------------------------------------------
334
575
  if (opts.dryRun) {
335
576
  const border = '─'.repeat(44)
336
- console.log(chalk.cyan(`\n─── DRY RUN ${border.slice(11)}`))
337
- console.log(` ${chalk.bold('Repo: ')} ${repo}`)
338
- console.log(` ${chalk.bold('Title: ')} ${title}`)
339
- console.log(` ${chalk.bold('Agent: ')} ${agent}`)
340
- console.log(` ${chalk.bold('Template:')} ${template.name} (${template.source})`)
341
- if (labels.length > 0) console.log(` ${chalk.bold('Labels: ')} ${labels.join(', ')}`)
342
- console.log(`\n ${chalk.bold('Body:')}`)
577
+ console.log(cyan(`\n─── DRY RUN ${border.slice(11)}`))
578
+ console.log(` ${bold('Repo: ')} ${repo}`)
579
+ console.log(` ${bold('Title: ')} ${title}`)
580
+ console.log(` ${bold('Agent: ')} ${agent}`)
581
+ console.log(` ${bold('Template:')} ${template.name} (${template.source})`)
582
+ if (labels.length > 0) console.log(` ${bold('Labels: ')} ${labels.join(', ')}`)
583
+ console.log(`\n ${bold('Body:')}`)
343
584
  console.log(renderedTemplate)
344
- console.log(`\n ${chalk.dim('─── attribution (invisible on GitHub) ───')}`)
345
- console.log(chalk.dim(attributionBlock))
346
- console.log(chalk.cyan(`${border}\n No issue created.\n`))
585
+ if (attributionBlock) {
586
+ console.log(`\n ${dim('─── attribution ───')}`)
587
+ console.log(dim(attributionBlock))
588
+ }
589
+ console.log(cyan(`${border}\n No issue created.\n`))
590
+ return null
591
+ }
592
+
593
+ // -----------------------------------------------------------------------
594
+ // 9. Draft mode — stop here, issue is already saved
595
+ // -----------------------------------------------------------------------
596
+ if (opts.draft) {
597
+ console.log()
598
+ console.log(green('✔') + ' ' + bold('Draft saved.'))
599
+ console.log(` ${dim('Title:')} ${title}`)
600
+ if (labels.length > 0) console.log(` ${dim('Labels:')} ${labels.join(', ')}`)
601
+ console.log(dim(' Run ') + cyan('tissues drafts') + dim(' to review and send.'))
347
602
  return null
348
603
  }
349
604
 
350
605
  // -----------------------------------------------------------------------
351
- // 11. Create issue (with graceful label handling)
606
+ // 10. Check safety (skip circuit breaker for interactive/human wizard)
607
+ // -----------------------------------------------------------------------
608
+ if (!isInteractive) {
609
+ const safetyResult = checkSafety(repo, agent, config.safety)
610
+ warnIfCircuitNotClosed(safetyResult.circuitState)
611
+
612
+ if (!safetyResult.allowed) {
613
+ // Issue is saved in drafts — tell the user and exit cleanly
614
+ console.log()
615
+ console.log(
616
+ yellow('⏸') + ' ' + bold('Issue saved to drafts.') +
617
+ ' Safety check blocked submission:',
618
+ )
619
+ console.log(dim(` ${safetyResult.reason}`))
620
+ console.log(dim(' Run ') + cyan('tissues drafts') + dim(' to send when ready.'))
621
+ process.exit(0)
622
+ }
623
+ }
624
+
625
+ // -----------------------------------------------------------------------
626
+ // 12. Create issue (with graceful label handling)
352
627
  // -----------------------------------------------------------------------
353
628
  let existingLabels = labels
354
629
  let missingLabels = []
@@ -366,21 +641,38 @@ async function runCreate(opts) {
366
641
  const createSpinner = ora('Creating issue...').start()
367
642
  let issue
368
643
  try {
369
- issue = await createIssue(repo, { title, body, labels: existingLabels })
370
- createSpinner.succeed(`Issue created: ${chalk.cyan(issue.url)}`)
644
+ issue = createIssue(repo, { title, body, labels: existingLabels })
645
+ createSpinner.succeed(`Issue created: ${cyan(issue.url)}`)
371
646
  } catch (err) {
372
647
  // GitHub API failures are NOT safety failures — do not record to circuit
373
648
  createSpinner.fail(`Failed to create issue: ${err.message}`)
649
+ markFailed(draft.id, err.message, repoRoot)
650
+ console.log(dim(' Issue saved to drafts. Run ') + cyan('tissues drafts') + dim(' to retry.'))
374
651
  throw new Error(`Failed to create issue: ${err.message}`)
375
652
  }
376
653
 
654
+ // -----------------------------------------------------------------------
655
+ // 13. Verify issue exists before removing draft
656
+ // -----------------------------------------------------------------------
657
+ const verified = verifyIssue(repo, issue.number)
658
+ if (!verified) {
659
+ console.warn(
660
+ yellow(` Issue #${issue.number} could not be verified — keeping in drafts`),
661
+ )
662
+ console.log(dim(' Run ') + cyan('tissues drafts') + dim(' to retry.'))
663
+ // Don't throw — the issue URL was returned, it may still be fine
664
+ } else {
665
+ markComplete(draft.id, { issueNumber: issue.number, issueUrl: issue.url }, repoRoot)
666
+ }
667
+
377
668
  if (missingLabels.length > 0) {
378
669
  console.log(
379
- chalk.yellow(`Labels not found: ${missingLabels.join(', ')} — issue created without them`),
670
+ yellow(`Labels not found: ${missingLabels.join(', ')} — issue created without them`),
380
671
  )
381
672
  const createMissing = await confirm({
382
673
  message: 'Create missing labels and apply them?',
383
674
  default: true,
675
+ theme,
384
676
  })
385
677
  if (createMissing) {
386
678
  for (const labelName of missingLabels) {
@@ -394,26 +686,45 @@ async function runCreate(opts) {
394
686
  }
395
687
  try {
396
688
  addLabelsToIssue(repo, issue.number, missingLabels)
397
- console.log(chalk.green('✓ Labels created and applied'))
689
+ console.log(green('✓ Labels created and applied'))
398
690
  } catch (err) {
399
- console.warn(chalk.yellow(`Failed to apply labels: ${err.message}`))
691
+ console.warn(yellow(`Failed to apply labels: ${err.message}`))
400
692
  }
401
693
  }
402
694
  }
403
695
 
404
696
  // -----------------------------------------------------------------------
405
- // 12. Record success
697
+ // 13b. Attachment notice
698
+ // -----------------------------------------------------------------------
699
+ if (attachments.length > 0) {
700
+ const nonUploaded = attachments.length - imageUrls.length
701
+ if (nonUploaded > 0) {
702
+ const { getAttachmentDir } = await import('../lib/drafts.js')
703
+ const attachDir = getAttachmentDir(draft.id, repoRoot)
704
+ console.log(
705
+ dim(` 📎 ${nonUploaded} non-image attachment${nonUploaded === 1 ? '' : 's'} saved locally. Upload manually to the issue.`),
706
+ )
707
+ console.log(dim(` ${attachDir}`))
708
+ }
709
+ if (imageUrls.length > 0) {
710
+ console.log(dim(` 📎 ${imageUrls.length} image${imageUrls.length === 1 ? '' : 's'} uploaded and embedded in issue body.`))
711
+ }
712
+ }
713
+
714
+ // -----------------------------------------------------------------------
715
+ // 14. Record success
406
716
  // -----------------------------------------------------------------------
407
717
  try {
408
718
  await recordCreation(repo, {
409
719
  title,
410
720
  body,
411
721
  issueNumber: issue.number,
722
+ url: issue.url,
412
723
  agent,
413
724
  })
414
725
  } catch (err) {
415
726
  // Non-fatal — dedup DB write failure shouldn't abort the command
416
- console.warn(chalk.dim(`[dedup] Failed to record fingerprint: ${err.message}`))
727
+ console.warn(dim(`[dedup] Failed to record fingerprint: ${err.message}`))
417
728
  }
418
729
 
419
730
  recordSuccess(repo, agent)
@@ -441,7 +752,7 @@ export const createCommand = new Command('create')
441
752
  .description('Create a new GitHub issue')
442
753
  .argument('[title...]', 'Issue title (positional shorthand for --title)')
443
754
  .option('--repo <repo>', 'Repository override (owner/name)')
444
- .option('--template <name>', 'Template to use (bug, feature, security, performance, refactor)')
755
+ .option('--template <name>', 'Template to use (default, bug, feature, or custom)')
445
756
  .option('--agent <name>', 'Agent identifier for attribution (default: human)')
446
757
  .option('--session <id>', 'Session ID for attribution')
447
758
  .option('--title <title>', 'Issue title (skips interactive prompt)')
@@ -450,7 +761,14 @@ export const createCommand = new Command('create')
450
761
  .option('--labels <labels>', 'Comma-separated labels to apply')
451
762
  .option('--force', 'Skip dedup warnings (still blocks on exact matches)')
452
763
  .option('--dry-run', 'Check dedup and safety without creating the issue')
764
+ .option('--draft', 'Save to drafts without creating the issue')
765
+ .option('--attach <paths>', 'Comma-separated file paths to attach')
453
766
  .option('--no-enhance', 'Skip AI enhancement, use rendered template as-is')
767
+ .option('--pipeline', 'Force multi-step AI pipeline even if config disabled')
768
+ .option('--no-pipeline', 'Force single-shot AI enhancement even if pipeline enabled')
769
+ .option('--enhancements <names>', 'Comma-separated enhancement names to run (e.g. "context,risk")')
770
+ .option('--provider <name>', 'AI provider override (anthropic, openai, gemini)')
771
+ .option('--model <name>', 'AI model override')
454
772
  .option(
455
773
  '--batch <file>',
456
774
  'Create multiple issues from a JSON file (title, body, template, labels, agent, session per item)',
@@ -459,6 +777,24 @@ export const createCommand = new Command('create')
459
777
  // Positional words → prefill the prompt (user can still edit)
460
778
  // --title flag → skip the prompt entirely (scripted/batch use)
461
779
  if (titleArg?.length && !opts.title) opts._titleDefault = titleArg.join(' ')
780
+
781
+ // -----------------------------------------------------------------------
782
+ // Recovery check — notify user if there are pending draft items
783
+ // -----------------------------------------------------------------------
784
+ if (!opts.batch && !opts.dryRun) {
785
+ const repoRoot = findRepoRoot()
786
+ const pending = countPending(repoRoot)
787
+ if (pending > 0) {
788
+ console.log(
789
+ cyan('✨') +
790
+ ` ${bold(`${pending} issue${pending === 1 ? '' : 's'} saved`)} from a previous session.`,
791
+ )
792
+ console.log(
793
+ dim(' Run ') + cyan('tissues drafts') + dim(' to manage them, or continue to create a new issue.\n'),
794
+ )
795
+ }
796
+ }
797
+
462
798
  // -----------------------------------------------------------------------
463
799
  // Batch mode
464
800
  // -----------------------------------------------------------------------
@@ -467,18 +803,18 @@ export const createCommand = new Command('create')
467
803
  try {
468
804
  items = JSON.parse(fs.readFileSync(opts.batch, 'utf8'))
469
805
  } catch (err) {
470
- console.error(chalk.red(`Failed to read batch file: ${err.message}`))
806
+ console.error(red(`Failed to read batch file: ${err.message}`))
471
807
  process.exit(1)
472
808
  }
473
809
 
474
810
  if (!Array.isArray(items)) {
475
- console.error(chalk.red('Batch file must contain a JSON array'))
811
+ console.error(red('Batch file must contain a JSON array'))
476
812
  process.exit(1)
477
813
  }
478
814
 
479
815
  const results = []
480
816
  for (const [i, item] of items.entries()) {
481
- console.log(chalk.dim(`\n[${i + 1}/${items.length}] "${item.title}"`))
817
+ console.log(dim(`\n[${i + 1}/${items.length}] "${item.title}"`))
482
818
  // item fields override CLI opts; labels array → comma-string for runCreate
483
819
  const itemOpts = {
484
820
  ...opts,
@@ -493,10 +829,10 @@ export const createCommand = new Command('create')
493
829
  }
494
830
  }
495
831
 
496
- console.log(chalk.bold('\nBatch summary:'))
832
+ console.log(bold('\nBatch summary:'))
497
833
  for (const r of results) {
498
- if (r.ok) console.log(chalk.green(` ✔ ${r.title}`) + chalk.dim(` — ${r.url}`))
499
- else console.log(chalk.red(` ✖ ${r.title}`) + chalk.dim(` — ${r.error}`))
834
+ if (r.ok) console.log(green(` ✔ ${r.title}`) + dim(` — ${r.url}`))
835
+ else console.log(red(` ✖ ${r.title}`) + dim(` — ${r.error}`))
500
836
  }
501
837
  process.exit(results.every((r) => r.ok) ? 0 : 1)
502
838
  return
@@ -507,7 +843,37 @@ export const createCommand = new Command('create')
507
843
  // -----------------------------------------------------------------------
508
844
  try {
509
845
  const result = await runCreate(opts)
510
- if (result === null) process.exit(0) // dry-run
846
+ if (result === null) process.exit(0) // dry-run or draft
847
+ } catch (err) {
848
+ process.exit(err.isAbort ? 0 : 1)
849
+ }
850
+ })
851
+
852
+ // ---------------------------------------------------------------------------
853
+ // Draft command — alias for create --draft
854
+ // ---------------------------------------------------------------------------
855
+
856
+ export const draftCommand = new Command('draft')
857
+ .description('Draft an issue without creating it')
858
+ .argument('[title...]', 'Issue title')
859
+ .option('--repo <repo>', 'Repository override (owner/name)')
860
+ .option('--template <name>', 'Template to use')
861
+ .option('--agent <name>', 'Agent identifier for attribution (default: human)')
862
+ .option('--session <id>', 'Session ID for attribution')
863
+ .option('--title <title>', 'Issue title (skips interactive prompt)')
864
+ .option('--body <body>', 'Issue body / description (skips interactive prompt)')
865
+ .option('--instructions <text>', 'AI enhancement instruction')
866
+ .option('--labels <labels>', 'Comma-separated labels to apply')
867
+ .option('--attach <paths>', 'Comma-separated file paths to attach')
868
+ .option('--no-enhance', 'Skip AI enhancement, use rendered template as-is')
869
+ .option('--provider <name>', 'AI provider override (anthropic, openai, gemini)')
870
+ .option('--model <name>', 'AI model override')
871
+ .action(async (titleArg, opts) => {
872
+ if (titleArg?.length && !opts.title) opts._titleDefault = titleArg.join(' ')
873
+ opts.draft = true
874
+ try {
875
+ await runCreate(opts)
876
+ process.exit(0)
511
877
  } catch (err) {
512
878
  process.exit(err.isAbort ? 0 : 1)
513
879
  }