tissues 0.5.1 → 0.6.0

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.
@@ -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,228 @@ 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 aiContext = {
405
+ template: templateName,
406
+ labels: opts.labels ? opts.labels.split(',').map((l) => l.trim()).filter(Boolean) : [],
407
+ provider: opts.provider,
408
+ model: opts.model,
409
+ }
410
+ if (checkAvailable(config, aiContext)) {
411
+ const pipelineConfig = config.ai?.pipeline || {}
412
+ const usePipeline = opts.pipeline === true ||
413
+ (pipelineConfig.enabled && opts.pipeline !== false)
414
+
415
+ if (usePipeline) {
416
+ // Multi-step pipeline enhancement
417
+ let aiSpinner = ora('Enhancing with AI pipeline...').start()
418
+ try {
419
+ // Gather existing issues and repo labels for pipeline context
420
+ let existingIssues = []
421
+ let repoLabels = []
422
+ try { existingIssues = listIssues(repo) } catch { /* non-fatal */ }
423
+ try { repoLabels = listLabels(repo) } catch { /* non-fatal */ }
424
+
425
+ // When --title is explicit, skip triage (no rawInput to analyze)
426
+ const rawInput = opts.title ? '' : (opts._rawInput || '')
427
+
428
+ pipelineResult = await runEnhancePipeline(config, {
429
+ rawInput,
430
+ title,
431
+ description,
432
+ instructions,
433
+ templateBody: renderedTemplate,
434
+ labels,
435
+ existingIssues,
436
+ repoLabels,
437
+ }, {
438
+ onStepStart(step) { aiSpinner.text = `${step.displayName}...` },
439
+ onStepDone(step, ctx) {
440
+ let detail = ''
441
+ if (step.name === 'triage') detail = ctx.title ? `: ${ctx.title}` : ''
442
+ if (step.name === 'dedup') detail = ctx.dedupScore ? ` (${ctx.dedupScore.level})` : ' (none)'
443
+ if (step.name === 'scope') detail = ctx.scopeAnalysis ? ` (${ctx.scopeAnalysis.files.length} files)` : ''
444
+ if (step.name === 'complexity') detail = ctx.complexity != null ? `: ${ctx.complexity}/10` : ''
445
+ if (step.name === 'risk') detail = ctx.risk != null ? `: ${ctx.risk}/10` : ''
446
+ if (step.name === 'labels') detail = ctx.aiLabels?.length ? `: ${ctx.aiLabels.join(', ')}` : ''
447
+ aiSpinner.succeed(`${step.displayName}${detail}`)
448
+ aiSpinner = ora('').start()
449
+ },
450
+ onStepSkip(step) { /* silent */ },
451
+ onStepFail(step, err) {
452
+ aiSpinner.warn(`${step.displayName} failed`)
453
+ console.warn(dim(` ${err.message}`))
454
+ aiSpinner = ora('').start()
455
+ },
456
+ }, aiContext)
457
+
458
+ aiSpinner.stop()
459
+ body = pipelineResult.body || renderedTemplate
460
+
461
+ // Use AI-derived title if triage step produced one
462
+ if (pipelineResult.title && pipelineResult.title !== title) {
463
+ title = pipelineResult.title
464
+ }
465
+
466
+ // Merge AI-suggested labels
467
+ if (pipelineResult.aiLabels?.length) {
468
+ for (const l of pipelineResult.aiLabels) {
469
+ if (!labels.includes(l)) labels.push(l)
470
+ }
471
+ }
472
+
473
+ // Collect AI dedup matches as related issues
474
+ if (pipelineResult.dedupScore?.matches?.length) {
475
+ const seen = new Set(relatedIssues.map((i) => i.number))
476
+ for (const m of pipelineResult.dedupScore.matches) {
477
+ if (m.number && !seen.has(m.number)) {
478
+ relatedIssues.push({ number: m.number })
479
+ seen.add(m.number)
480
+ }
481
+ }
482
+ }
483
+
484
+ // Warn on high AI dedup confidence
485
+ if (pipelineResult.dedupScore?.level === 'high' && !opts.force) {
486
+ console.warn(yellow('\nAI detected likely duplicate:'))
487
+ for (const m of pipelineResult.dedupScore.matches || []) {
488
+ console.warn(dim(` #${m.number}: ${m.reason}`))
489
+ }
490
+ console.warn()
491
+ }
492
+ } catch (err) {
493
+ aiSpinner.stop()
494
+ if (/\b401\b/.test(err.message)) {
495
+ console.warn(red(' API key is invalid or expired.'))
496
+ console.warn(dim(' Run ') + cyan('tissues config') + dim(' to update your API key.'))
497
+ } else {
498
+ console.warn(yellow('Pipeline failed — falling back to single-shot enhancement'))
499
+ console.warn(dim(` ${err.message}`))
500
+ }
501
+ // Fall back to single-shot
502
+ try {
503
+ body = await enhance(config, title, description, renderedTemplate, instructions, aiContext)
504
+ } catch {
505
+ // Use template as-is
506
+ }
507
+ }
508
+ } else {
509
+ // Single-shot enhancement (original behavior)
510
+ const aiSpinner = ora('Enhancing with AI...').start()
511
+ try {
512
+ body = await enhance(config, title, description, renderedTemplate, instructions, aiContext)
513
+ aiSpinner.succeed('Enhanced')
514
+ } catch (err) {
515
+ if (/\b401\b/.test(err.message)) {
516
+ aiSpinner.fail('AI enhancement failed')
517
+ console.warn(red(' API key is invalid or expired.'))
518
+ console.warn(dim(' Run ') + cyan('tissues config') + dim(' to update your API key.'))
519
+ console.warn(dim(' Continuing without AI enhancement.\n'))
520
+ } else {
521
+ aiSpinner.warn(`AI enhancement failed — using template as-is`)
522
+ console.warn(dim(` ${err.message}`))
523
+ }
524
+ }
525
+ }
305
526
  }
306
527
  }
307
528
 
308
529
  // -----------------------------------------------------------------------
309
- // 9. Add attribution
530
+ // 6b. Embed uploaded images in body
531
+ // -----------------------------------------------------------------------
532
+ if (imageUrls.length > 0) {
533
+ const imageMarkdown = imageUrls
534
+ .map(({ filename, url }) => `![${filename}](${url})`)
535
+ .join('\n')
536
+ body = body.trimEnd() + '\n\n' + imageMarkdown
537
+ }
538
+
539
+ // -----------------------------------------------------------------------
540
+ // 6c. Add related issues (dedup matches)
541
+ // -----------------------------------------------------------------------
542
+ if (relatedIssues.length > 0) {
543
+ const refs = relatedIssues.map((i) => `#${i.number}`).join(' ')
544
+ body = body.trimEnd() + `\n\n---\n**Possibly Duplicate:** ${refs}`
545
+ }
546
+
547
+ // -----------------------------------------------------------------------
548
+ // 7. Add attribution
310
549
  // -----------------------------------------------------------------------
311
- const fingerprint = computeFingerprint(title, body)
312
550
  const attributionBlock = renderAttribution({
313
- agent,
314
551
  session: session ?? undefined,
315
- trigger: 'cli-create',
316
- fingerprint: `sha256:${fingerprint}`,
552
+ model: opts.model ?? undefined,
317
553
  })
318
554
 
319
- body = body.trimEnd() + '\n\n' + attributionBlock
555
+ if (attributionBlock) {
556
+ body = body.trimEnd() + '\n\n' + attributionBlock
557
+ }
320
558
 
321
559
  // -----------------------------------------------------------------------
322
- // 10. Parse labels
560
+ // 8. Update draft with processed body — draft → pending
323
561
  // -----------------------------------------------------------------------
324
- const labels = opts.labels
325
- ? opts.labels
326
- .split(',')
327
- .map((l) => l.trim())
328
- .filter(Boolean)
329
- : []
562
+ if (draft) {
563
+ updateDraftItem(draft.id, { title, body, labels }, repoRoot)
564
+ }
330
565
 
331
566
  // -----------------------------------------------------------------------
332
567
  // Dry run — stop here
333
568
  // -----------------------------------------------------------------------
334
569
  if (opts.dryRun) {
335
570
  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:')}`)
571
+ console.log(cyan(`\n─── DRY RUN ${border.slice(11)}`))
572
+ console.log(` ${bold('Repo: ')} ${repo}`)
573
+ console.log(` ${bold('Title: ')} ${title}`)
574
+ console.log(` ${bold('Agent: ')} ${agent}`)
575
+ console.log(` ${bold('Template:')} ${template.name} (${template.source})`)
576
+ if (labels.length > 0) console.log(` ${bold('Labels: ')} ${labels.join(', ')}`)
577
+ console.log(`\n ${bold('Body:')}`)
343
578
  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`))
579
+ if (attributionBlock) {
580
+ console.log(`\n ${dim('─── attribution ───')}`)
581
+ console.log(dim(attributionBlock))
582
+ }
583
+ console.log(cyan(`${border}\n No issue created.\n`))
584
+ return null
585
+ }
586
+
587
+ // -----------------------------------------------------------------------
588
+ // 9. Draft mode — stop here, issue is already saved
589
+ // -----------------------------------------------------------------------
590
+ if (opts.draft) {
591
+ console.log()
592
+ console.log(green('✔') + ' ' + bold('Draft saved.'))
593
+ console.log(` ${dim('Title:')} ${title}`)
594
+ if (labels.length > 0) console.log(` ${dim('Labels:')} ${labels.join(', ')}`)
595
+ console.log(dim(' Run ') + cyan('tissues drafts') + dim(' to review and send.'))
347
596
  return null
348
597
  }
349
598
 
350
599
  // -----------------------------------------------------------------------
351
- // 11. Create issue (with graceful label handling)
600
+ // 10. Check safety (skip circuit breaker for interactive/human wizard)
601
+ // -----------------------------------------------------------------------
602
+ if (!isInteractive) {
603
+ const safetyResult = checkSafety(repo, agent, config.safety)
604
+ warnIfCircuitNotClosed(safetyResult.circuitState)
605
+
606
+ if (!safetyResult.allowed) {
607
+ // Issue is saved in drafts — tell the user and exit cleanly
608
+ console.log()
609
+ console.log(
610
+ yellow('⏸') + ' ' + bold('Issue saved to drafts.') +
611
+ ' Safety check blocked submission:',
612
+ )
613
+ console.log(dim(` ${safetyResult.reason}`))
614
+ console.log(dim(' Run ') + cyan('tissues drafts') + dim(' to send when ready.'))
615
+ process.exit(0)
616
+ }
617
+ }
618
+
619
+ // -----------------------------------------------------------------------
620
+ // 12. Create issue (with graceful label handling)
352
621
  // -----------------------------------------------------------------------
353
622
  let existingLabels = labels
354
623
  let missingLabels = []
@@ -366,21 +635,38 @@ async function runCreate(opts) {
366
635
  const createSpinner = ora('Creating issue...').start()
367
636
  let issue
368
637
  try {
369
- issue = await createIssue(repo, { title, body, labels: existingLabels })
370
- createSpinner.succeed(`Issue created: ${chalk.cyan(issue.url)}`)
638
+ issue = createIssue(repo, { title, body, labels: existingLabels })
639
+ createSpinner.succeed(`Issue created: ${cyan(issue.url)}`)
371
640
  } catch (err) {
372
641
  // GitHub API failures are NOT safety failures — do not record to circuit
373
642
  createSpinner.fail(`Failed to create issue: ${err.message}`)
643
+ markFailed(draft.id, err.message, repoRoot)
644
+ console.log(dim(' Issue saved to drafts. Run ') + cyan('tissues drafts') + dim(' to retry.'))
374
645
  throw new Error(`Failed to create issue: ${err.message}`)
375
646
  }
376
647
 
648
+ // -----------------------------------------------------------------------
649
+ // 13. Verify issue exists before removing draft
650
+ // -----------------------------------------------------------------------
651
+ const verified = verifyIssue(repo, issue.number)
652
+ if (!verified) {
653
+ console.warn(
654
+ yellow(` Issue #${issue.number} could not be verified — keeping in drafts`),
655
+ )
656
+ console.log(dim(' Run ') + cyan('tissues drafts') + dim(' to retry.'))
657
+ // Don't throw — the issue URL was returned, it may still be fine
658
+ } else {
659
+ markComplete(draft.id, { issueNumber: issue.number, issueUrl: issue.url }, repoRoot)
660
+ }
661
+
377
662
  if (missingLabels.length > 0) {
378
663
  console.log(
379
- chalk.yellow(`Labels not found: ${missingLabels.join(', ')} — issue created without them`),
664
+ yellow(`Labels not found: ${missingLabels.join(', ')} — issue created without them`),
380
665
  )
381
666
  const createMissing = await confirm({
382
667
  message: 'Create missing labels and apply them?',
383
668
  default: true,
669
+ theme,
384
670
  })
385
671
  if (createMissing) {
386
672
  for (const labelName of missingLabels) {
@@ -394,26 +680,45 @@ async function runCreate(opts) {
394
680
  }
395
681
  try {
396
682
  addLabelsToIssue(repo, issue.number, missingLabels)
397
- console.log(chalk.green('✓ Labels created and applied'))
683
+ console.log(green('✓ Labels created and applied'))
398
684
  } catch (err) {
399
- console.warn(chalk.yellow(`Failed to apply labels: ${err.message}`))
685
+ console.warn(yellow(`Failed to apply labels: ${err.message}`))
400
686
  }
401
687
  }
402
688
  }
403
689
 
404
690
  // -----------------------------------------------------------------------
405
- // 12. Record success
691
+ // 13b. Attachment notice
692
+ // -----------------------------------------------------------------------
693
+ if (attachments.length > 0) {
694
+ const nonUploaded = attachments.length - imageUrls.length
695
+ if (nonUploaded > 0) {
696
+ const { getAttachmentDir } = await import('../lib/drafts.js')
697
+ const attachDir = getAttachmentDir(draft.id, repoRoot)
698
+ console.log(
699
+ dim(` 📎 ${nonUploaded} non-image attachment${nonUploaded === 1 ? '' : 's'} saved locally. Upload manually to the issue.`),
700
+ )
701
+ console.log(dim(` ${attachDir}`))
702
+ }
703
+ if (imageUrls.length > 0) {
704
+ console.log(dim(` 📎 ${imageUrls.length} image${imageUrls.length === 1 ? '' : 's'} uploaded and embedded in issue body.`))
705
+ }
706
+ }
707
+
708
+ // -----------------------------------------------------------------------
709
+ // 14. Record success
406
710
  // -----------------------------------------------------------------------
407
711
  try {
408
712
  await recordCreation(repo, {
409
713
  title,
410
714
  body,
411
715
  issueNumber: issue.number,
716
+ url: issue.url,
412
717
  agent,
413
718
  })
414
719
  } catch (err) {
415
720
  // Non-fatal — dedup DB write failure shouldn't abort the command
416
- console.warn(chalk.dim(`[dedup] Failed to record fingerprint: ${err.message}`))
721
+ console.warn(dim(`[dedup] Failed to record fingerprint: ${err.message}`))
417
722
  }
418
723
 
419
724
  recordSuccess(repo, agent)
@@ -441,7 +746,7 @@ export const createCommand = new Command('create')
441
746
  .description('Create a new GitHub issue')
442
747
  .argument('[title...]', 'Issue title (positional shorthand for --title)')
443
748
  .option('--repo <repo>', 'Repository override (owner/name)')
444
- .option('--template <name>', 'Template to use (bug, feature, security, performance, refactor)')
749
+ .option('--template <name>', 'Template to use (default, bug, feature, or custom)')
445
750
  .option('--agent <name>', 'Agent identifier for attribution (default: human)')
446
751
  .option('--session <id>', 'Session ID for attribution')
447
752
  .option('--title <title>', 'Issue title (skips interactive prompt)')
@@ -450,7 +755,13 @@ export const createCommand = new Command('create')
450
755
  .option('--labels <labels>', 'Comma-separated labels to apply')
451
756
  .option('--force', 'Skip dedup warnings (still blocks on exact matches)')
452
757
  .option('--dry-run', 'Check dedup and safety without creating the issue')
758
+ .option('--draft', 'Save to drafts without creating the issue')
759
+ .option('--attach <paths>', 'Comma-separated file paths to attach')
453
760
  .option('--no-enhance', 'Skip AI enhancement, use rendered template as-is')
761
+ .option('--pipeline', 'Force multi-step AI pipeline even if config disabled')
762
+ .option('--no-pipeline', 'Force single-shot AI enhancement even if pipeline enabled')
763
+ .option('--provider <name>', 'AI provider override (anthropic, openai, gemini)')
764
+ .option('--model <name>', 'AI model override')
454
765
  .option(
455
766
  '--batch <file>',
456
767
  'Create multiple issues from a JSON file (title, body, template, labels, agent, session per item)',
@@ -459,6 +770,24 @@ export const createCommand = new Command('create')
459
770
  // Positional words → prefill the prompt (user can still edit)
460
771
  // --title flag → skip the prompt entirely (scripted/batch use)
461
772
  if (titleArg?.length && !opts.title) opts._titleDefault = titleArg.join(' ')
773
+
774
+ // -----------------------------------------------------------------------
775
+ // Recovery check — notify user if there are pending draft items
776
+ // -----------------------------------------------------------------------
777
+ if (!opts.batch && !opts.dryRun) {
778
+ const repoRoot = findRepoRoot()
779
+ const pending = countPending(repoRoot)
780
+ if (pending > 0) {
781
+ console.log(
782
+ cyan('✨') +
783
+ ` ${bold(`${pending} issue${pending === 1 ? '' : 's'} saved`)} from a previous session.`,
784
+ )
785
+ console.log(
786
+ dim(' Run ') + cyan('tissues drafts') + dim(' to manage them, or continue to create a new issue.\n'),
787
+ )
788
+ }
789
+ }
790
+
462
791
  // -----------------------------------------------------------------------
463
792
  // Batch mode
464
793
  // -----------------------------------------------------------------------
@@ -467,18 +796,18 @@ export const createCommand = new Command('create')
467
796
  try {
468
797
  items = JSON.parse(fs.readFileSync(opts.batch, 'utf8'))
469
798
  } catch (err) {
470
- console.error(chalk.red(`Failed to read batch file: ${err.message}`))
799
+ console.error(red(`Failed to read batch file: ${err.message}`))
471
800
  process.exit(1)
472
801
  }
473
802
 
474
803
  if (!Array.isArray(items)) {
475
- console.error(chalk.red('Batch file must contain a JSON array'))
804
+ console.error(red('Batch file must contain a JSON array'))
476
805
  process.exit(1)
477
806
  }
478
807
 
479
808
  const results = []
480
809
  for (const [i, item] of items.entries()) {
481
- console.log(chalk.dim(`\n[${i + 1}/${items.length}] "${item.title}"`))
810
+ console.log(dim(`\n[${i + 1}/${items.length}] "${item.title}"`))
482
811
  // item fields override CLI opts; labels array → comma-string for runCreate
483
812
  const itemOpts = {
484
813
  ...opts,
@@ -493,10 +822,10 @@ export const createCommand = new Command('create')
493
822
  }
494
823
  }
495
824
 
496
- console.log(chalk.bold('\nBatch summary:'))
825
+ console.log(bold('\nBatch summary:'))
497
826
  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}`))
827
+ if (r.ok) console.log(green(` ✔ ${r.title}`) + dim(` — ${r.url}`))
828
+ else console.log(red(` ✖ ${r.title}`) + dim(` — ${r.error}`))
500
829
  }
501
830
  process.exit(results.every((r) => r.ok) ? 0 : 1)
502
831
  return
@@ -507,7 +836,37 @@ export const createCommand = new Command('create')
507
836
  // -----------------------------------------------------------------------
508
837
  try {
509
838
  const result = await runCreate(opts)
510
- if (result === null) process.exit(0) // dry-run
839
+ if (result === null) process.exit(0) // dry-run or draft
840
+ } catch (err) {
841
+ process.exit(err.isAbort ? 0 : 1)
842
+ }
843
+ })
844
+
845
+ // ---------------------------------------------------------------------------
846
+ // Draft command — alias for create --draft
847
+ // ---------------------------------------------------------------------------
848
+
849
+ export const draftCommand = new Command('draft')
850
+ .description('Draft an issue without creating it')
851
+ .argument('[title...]', 'Issue title')
852
+ .option('--repo <repo>', 'Repository override (owner/name)')
853
+ .option('--template <name>', 'Template to use')
854
+ .option('--agent <name>', 'Agent identifier for attribution (default: human)')
855
+ .option('--session <id>', 'Session ID for attribution')
856
+ .option('--title <title>', 'Issue title (skips interactive prompt)')
857
+ .option('--body <body>', 'Issue body / description (skips interactive prompt)')
858
+ .option('--instructions <text>', 'AI enhancement instruction')
859
+ .option('--labels <labels>', 'Comma-separated labels to apply')
860
+ .option('--attach <paths>', 'Comma-separated file paths to attach')
861
+ .option('--no-enhance', 'Skip AI enhancement, use rendered template as-is')
862
+ .option('--provider <name>', 'AI provider override (anthropic, openai, gemini)')
863
+ .option('--model <name>', 'AI model override')
864
+ .action(async (titleArg, opts) => {
865
+ if (titleArg?.length && !opts.title) opts._titleDefault = titleArg.join(' ')
866
+ opts.draft = true
867
+ try {
868
+ await runCreate(opts)
869
+ process.exit(0)
511
870
  } catch (err) {
512
871
  process.exit(err.isAbort ? 0 : 1)
513
872
  }