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,436 @@
1
+ /**
2
+ * Enhancement definitions — first-class pipeline step descriptors.
3
+ *
4
+ * Three-tier priority (same as templates): repo > user > built-in.
5
+ * File format: YAML-ish frontmatter + system prompt body (.md files).
6
+ */
7
+
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import os from 'node:os'
11
+ import { findRepoRoot, loadConfig } from './defaults.js'
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Built-in enhancements (extracted from steps.js prompts)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export const BUILT_IN_ENHANCEMENTS = {
18
+ triage: {
19
+ key: 'triage',
20
+ name: 'Input Analysis',
21
+ maxTokens: 1024,
22
+ mode: 'always',
23
+ format: 'json',
24
+ contextKey: 'triage',
25
+ order: 10,
26
+ requires: [],
27
+ isStructural: true,
28
+ provider: null,
29
+ prompt: [
30
+ 'You extract a structured GitHub issue title and description from freeform user input.',
31
+ 'Return a JSON object with:',
32
+ ' title: string — concise issue title, ≤80 characters, imperative mood (e.g. "Fix login timeout on Safari")',
33
+ ' description: string — structured description expanding on the input, in markdown',
34
+ 'The title must capture the core intent. The description should organize and expand the raw input into clear context.',
35
+ 'Return ONLY valid JSON.',
36
+ ].join('\n'),
37
+ },
38
+ dedup: {
39
+ key: 'dedup',
40
+ name: 'Duplicate Check',
41
+ maxTokens: 1024,
42
+ mode: 'auto',
43
+ format: 'json',
44
+ contextKey: 'dedupScore',
45
+ order: 15,
46
+ requires: [],
47
+ isStructural: false,
48
+ provider: null,
49
+ prompt: [
50
+ 'You are a duplicate detection system for GitHub issues.',
51
+ 'Compare the new issue against existing open issues.',
52
+ 'Return a JSON object with:',
53
+ ' confidence: number 0-100 (how likely this is a duplicate)',
54
+ ' level: "high" | "medium" | "low" | "none"',
55
+ ' matches: array of { number, reason } for similar issues',
56
+ 'Return ONLY valid JSON.',
57
+ ].join('\n'),
58
+ },
59
+ context: {
60
+ key: 'context',
61
+ name: 'Context Extraction',
62
+ maxTokens: 1024,
63
+ mode: 'always',
64
+ format: 'json',
65
+ contextKey: 'structuredContext',
66
+ order: 20,
67
+ requires: [],
68
+ isStructural: false,
69
+ provider: null,
70
+ prompt: [
71
+ 'You are an expert at understanding software issues.',
72
+ 'Extract structured context from the issue description.',
73
+ 'Return a JSON object with:',
74
+ ' problem: string — the core problem in one sentence',
75
+ ' files: string[] — file paths mentioned or implied',
76
+ ' errors: string[] — error messages mentioned',
77
+ ' sessionContext: string — any relevant session/environment context',
78
+ 'Return ONLY valid JSON.',
79
+ ].join('\n'),
80
+ },
81
+ scope: {
82
+ key: 'scope',
83
+ name: 'Scope Analysis',
84
+ maxTokens: 1024,
85
+ mode: 'auto',
86
+ format: 'json',
87
+ contextKey: 'scopeAnalysis',
88
+ order: 30,
89
+ requires: [],
90
+ isStructural: false,
91
+ provider: null,
92
+ prompt: [
93
+ 'You are a software scope analyzer.',
94
+ 'Given an issue description and context, identify the files and areas affected.',
95
+ 'Return a JSON object with:',
96
+ ' files: array of { path: string, purpose: string, deps: string[] }',
97
+ ' affectedAreas: string[] — high-level areas (e.g. "auth", "UI", "database")',
98
+ 'Return ONLY valid JSON.',
99
+ ].join('\n'),
100
+ },
101
+ complexity: {
102
+ key: 'complexity',
103
+ name: 'Complexity Scoring',
104
+ maxTokens: 512,
105
+ mode: 'auto',
106
+ format: 'json',
107
+ contextKey: 'complexity',
108
+ order: 40,
109
+ requires: [],
110
+ isStructural: false,
111
+ provider: null,
112
+ prompt: [
113
+ 'You assess implementation complexity for GitHub issues.',
114
+ 'Return a JSON object with:',
115
+ ' score: number 1-10 (1=trivial, 10=massive refactor)',
116
+ ' rationale: string — one sentence explaining the score',
117
+ 'Return ONLY valid JSON.',
118
+ ].join('\n'),
119
+ },
120
+ risk: {
121
+ key: 'risk',
122
+ name: 'Risk Assessment',
123
+ maxTokens: 512,
124
+ mode: 'auto',
125
+ format: 'json',
126
+ contextKey: 'risk',
127
+ order: 50,
128
+ requires: [],
129
+ isStructural: false,
130
+ provider: null,
131
+ prompt: [
132
+ 'You assess implementation risk for GitHub issues.',
133
+ 'Consider: breaking changes, data loss potential, security implications, blast radius.',
134
+ 'Return a JSON object with:',
135
+ ' score: number 1-10 (1=no risk, 10=extremely risky)',
136
+ ' rationale: string — one sentence explaining the score',
137
+ 'Return ONLY valid JSON.',
138
+ ].join('\n'),
139
+ },
140
+ labels: {
141
+ key: 'labels',
142
+ name: 'Label Suggestion',
143
+ maxTokens: 512,
144
+ mode: 'auto',
145
+ format: 'json',
146
+ contextKey: 'aiLabels',
147
+ order: 60,
148
+ requires: [],
149
+ isStructural: false,
150
+ provider: null,
151
+ prompt: [
152
+ 'You suggest GitHub labels for issues.',
153
+ 'Return a JSON object with:',
154
+ ' labels: string[] — labels to apply (must be from the available list)',
155
+ ' reasoning: string — brief explanation',
156
+ 'Only suggest labels that genuinely fit. Return ONLY valid JSON.',
157
+ ].join('\n'),
158
+ },
159
+ format: {
160
+ key: 'format',
161
+ name: 'Body Formatting',
162
+ maxTokens: 4096,
163
+ mode: 'always',
164
+ format: 'markdown',
165
+ contextKey: 'body',
166
+ order: 90,
167
+ requires: [],
168
+ isStructural: true,
169
+ provider: null,
170
+ prompt: [
171
+ 'You are an expert at writing clear, well-structured GitHub issues.',
172
+ 'Given an issue title, description, and analysis from prior steps,',
173
+ 'write a complete issue body in markdown.',
174
+ ].join('\n'),
175
+ },
176
+ }
177
+
178
+ /**
179
+ * Return the list of built-in enhancement keys.
180
+ * @returns {string[]}
181
+ */
182
+ export function builtInEnhancementKeys() {
183
+ return Object.keys(BUILT_IN_ENHANCEMENTS)
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Frontmatter parser
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Parse an enhancement file: YAML-ish frontmatter + system prompt body.
192
+ *
193
+ * @param {string} content - raw file content
194
+ * @returns {{ meta: object, prompt: string }}
195
+ */
196
+ export function parseEnhancementFile(content) {
197
+ const meta = {}
198
+ let prompt = content
199
+
200
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/)
201
+ if (fmMatch) {
202
+ const frontmatter = fmMatch[1]
203
+ prompt = fmMatch[2].trim()
204
+
205
+ for (const line of frontmatter.split('\n')) {
206
+ const m = line.match(/^(\w+):\s*(.+)$/)
207
+ if (!m) continue
208
+ const [, key, rawVal] = m
209
+ let val = rawVal.trim()
210
+
211
+ // Parse arrays: ["a", "b"] or [a, b]
212
+ if (val.startsWith('[') && val.endsWith(']')) {
213
+ val = val.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean)
214
+ } else if (val === 'true') {
215
+ val = true
216
+ } else if (val === 'false') {
217
+ val = false
218
+ } else if (/^\d+$/.test(val)) {
219
+ val = Number(val)
220
+ }
221
+
222
+ meta[key] = val
223
+ }
224
+ }
225
+
226
+ return { meta, prompt }
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Helpers
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /**
234
+ * Resolve the enhancement directory for a given source.
235
+ *
236
+ * @param {'repo' | 'user'} source
237
+ * @param {string|null} repoRoot
238
+ * @param {object} cfg - loaded config object
239
+ * @returns {string}
240
+ */
241
+ export function resolveEnhancementDir(source, repoRoot, cfg) {
242
+ if (source === 'user') {
243
+ return path.join(os.homedir(), '.config', 'tissues', 'enhancements')
244
+ }
245
+ // repo source
246
+ const enhDir = cfg.enhancements?.dir ?? '.tissues/enhancements'
247
+ if (path.isAbsolute(enhDir)) return enhDir
248
+ if (repoRoot) return path.join(repoRoot, enhDir)
249
+ return path.resolve(enhDir)
250
+ }
251
+
252
+ /**
253
+ * Read all `.md` files from a directory and return enhancement descriptors.
254
+ *
255
+ * @param {string} dir
256
+ * @param {'repo' | 'user'} source
257
+ * @returns {Array<object>}
258
+ */
259
+ function readEnhancementsFromDir(dir, source) {
260
+ try {
261
+ const entries = fs.readdirSync(dir)
262
+ return entries
263
+ .filter((f) => f.endsWith('.md'))
264
+ .map((f) => {
265
+ const key = path.basename(f, '.md')
266
+ const raw = fs.readFileSync(path.join(dir, f), 'utf8')
267
+ const { meta, prompt } = parseEnhancementFile(raw)
268
+
269
+ return {
270
+ key,
271
+ name: meta.name || key.charAt(0).toUpperCase() + key.slice(1),
272
+ maxTokens: meta.maxTokens || 1024,
273
+ mode: meta.mode || 'auto',
274
+ format: meta.format || 'json',
275
+ contextKey: meta.contextKey || key,
276
+ order: meta.order != null ? meta.order : 50,
277
+ requires: Array.isArray(meta.requires) ? meta.requires : [],
278
+ isStructural: false,
279
+ provider: meta.provider || null,
280
+ prompt,
281
+ source,
282
+ }
283
+ })
284
+ } catch {
285
+ return []
286
+ }
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Public API
291
+ // ---------------------------------------------------------------------------
292
+
293
+ /**
294
+ * List all available enhancements from all sources.
295
+ *
296
+ * Priority (highest → lowest): repo > user > built-in
297
+ *
298
+ * @param {string} [repoRoot] - path to repo root; auto-detected if omitted
299
+ * @returns {Array<{ key: string, name: string, source: string, mode: string, order: number }>}
300
+ */
301
+ export function listEnhancements(repoRoot) {
302
+ const root = repoRoot ?? findRepoRoot()
303
+ const cfg = loadConfig(root)
304
+
305
+ // Built-in (lowest priority)
306
+ const builtIn = Object.entries(BUILT_IN_ENHANCEMENTS).map(([key, enh]) => ({
307
+ key,
308
+ name: enh.name,
309
+ source: 'built-in',
310
+ mode: enh.mode,
311
+ order: enh.order,
312
+ provider: enh.provider || null,
313
+ }))
314
+
315
+ // User enhancements
316
+ const userDir = resolveEnhancementDir('user', root, cfg)
317
+ const userEnhancements = readEnhancementsFromDir(userDir, 'user').map(({ key, name, mode, order, provider }) => ({
318
+ key, name, source: 'user', mode, order, provider: provider || null,
319
+ }))
320
+
321
+ // Repo enhancements (highest priority)
322
+ const repoDir = resolveEnhancementDir('repo', root, cfg)
323
+ const repoEnhancements = readEnhancementsFromDir(repoDir, 'repo').map(({ key, name, mode, order, provider }) => ({
324
+ key, name, source: 'repo', mode, order, provider: provider || null,
325
+ }))
326
+
327
+ return [...repoEnhancements, ...userEnhancements, ...builtIn]
328
+ }
329
+
330
+ /**
331
+ * Load a single enhancement by key, applying source priority:
332
+ * repo > user > built-in
333
+ *
334
+ * @param {string} name - enhancement key
335
+ * @param {string} [repoRoot]
336
+ * @returns {object} full enhancement descriptor with prompt
337
+ * @throws {Error} if enhancement not found
338
+ */
339
+ export function loadEnhancement(name, repoRoot) {
340
+ const root = repoRoot ?? findRepoRoot()
341
+ const cfg = loadConfig(root)
342
+
343
+ // 1. Repo
344
+ const repoDir = resolveEnhancementDir('repo', root, cfg)
345
+ const repoFile = path.join(repoDir, `${name}.md`)
346
+ if (fs.existsSync(repoFile)) {
347
+ const raw = fs.readFileSync(repoFile, 'utf8')
348
+ return buildDescriptor(name, raw, 'repo')
349
+ }
350
+
351
+ // 2. User
352
+ const userDir = resolveEnhancementDir('user', root, cfg)
353
+ const userFile = path.join(userDir, `${name}.md`)
354
+ if (fs.existsSync(userFile)) {
355
+ const raw = fs.readFileSync(userFile, 'utf8')
356
+ return buildDescriptor(name, raw, 'user')
357
+ }
358
+
359
+ // 3. Built-in
360
+ const builtIn = BUILT_IN_ENHANCEMENTS[name]
361
+ if (builtIn) {
362
+ return { ...builtIn, source: 'built-in' }
363
+ }
364
+
365
+ throw new Error(
366
+ `Enhancement "${name}" not found. ` +
367
+ `Available: ${Object.keys(BUILT_IN_ENHANCEMENTS).join(', ')}`,
368
+ )
369
+ }
370
+
371
+ /**
372
+ * Load all enhancements, merged by priority and sorted by order.
373
+ *
374
+ * @param {string} [repoRoot]
375
+ * @returns {object[]} sorted array of enhancement descriptors
376
+ */
377
+ export function loadAllEnhancements(repoRoot) {
378
+ const root = repoRoot ?? findRepoRoot()
379
+ const cfg = loadConfig(root)
380
+
381
+ // Start with built-ins
382
+ const merged = new Map()
383
+ for (const [key, enh] of Object.entries(BUILT_IN_ENHANCEMENTS)) {
384
+ merged.set(key, { ...enh, source: 'built-in' })
385
+ }
386
+
387
+ // User overrides
388
+ const userDir = resolveEnhancementDir('user', root, cfg)
389
+ for (const enh of readEnhancementsFromDir(userDir, 'user')) {
390
+ if (merged.has(enh.key)) {
391
+ // Override built-in but preserve isStructural
392
+ const builtIn = merged.get(enh.key)
393
+ merged.set(enh.key, { ...builtIn, ...enh, isStructural: builtIn.isStructural })
394
+ } else {
395
+ merged.set(enh.key, enh)
396
+ }
397
+ }
398
+
399
+ // Repo overrides (highest priority)
400
+ const repoDir = resolveEnhancementDir('repo', root, cfg)
401
+ for (const enh of readEnhancementsFromDir(repoDir, 'repo')) {
402
+ if (merged.has(enh.key)) {
403
+ const existing = merged.get(enh.key)
404
+ merged.set(enh.key, { ...existing, ...enh, isStructural: existing.isStructural || false })
405
+ } else {
406
+ merged.set(enh.key, enh)
407
+ }
408
+ }
409
+
410
+ // Sort by order
411
+ return [...merged.values()].sort((a, b) => a.order - b.order)
412
+ }
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // Internal helpers
416
+ // ---------------------------------------------------------------------------
417
+
418
+ function buildDescriptor(key, rawContent, source) {
419
+ const { meta, prompt } = parseEnhancementFile(rawContent)
420
+ const builtIn = BUILT_IN_ENHANCEMENTS[key]
421
+
422
+ return {
423
+ key,
424
+ name: meta.name || (builtIn ? builtIn.name : key.charAt(0).toUpperCase() + key.slice(1)),
425
+ maxTokens: meta.maxTokens || (builtIn ? builtIn.maxTokens : 1024),
426
+ mode: meta.mode || (builtIn ? builtIn.mode : 'auto'),
427
+ format: meta.format || (builtIn ? builtIn.format : 'json'),
428
+ contextKey: meta.contextKey || (builtIn ? builtIn.contextKey : key),
429
+ order: meta.order != null ? meta.order : (builtIn ? builtIn.order : 50),
430
+ requires: Array.isArray(meta.requires) ? meta.requires : (builtIn ? builtIn.requires : []),
431
+ isStructural: builtIn ? builtIn.isStructural : false,
432
+ provider: meta.provider || null,
433
+ prompt,
434
+ source,
435
+ }
436
+ }
package/src/lib/gh.js CHANGED
@@ -4,7 +4,9 @@
4
4
  */
5
5
 
6
6
  import { execFileSync, execSync } from 'node:child_process'
7
- import chalk from 'chalk'
7
+ import fs from 'node:fs'
8
+ import nodePath from 'node:path'
9
+ import { red, cyan, dim } from './color.js'
8
10
 
9
11
  // ---------------------------------------------------------------------------
10
12
  // gh availability
@@ -22,14 +24,14 @@ export function requireGh() {
22
24
  }).trim()
23
25
  return path
24
26
  } catch {
25
- console.error(chalk.red('\n gh CLI is required but not installed.\n'))
27
+ console.error(red('\n gh CLI is required but not installed.\n'))
26
28
  console.error(' Install it with one of:')
27
- console.error(chalk.cyan(' brew install gh ') + chalk.dim('# macOS'))
28
- console.error(chalk.cyan(' sudo apt install gh ') + chalk.dim('# Debian/Ubuntu'))
29
- console.error(chalk.cyan(' winget install GitHub.cli ') + chalk.dim('# Windows'))
30
- console.error(chalk.cyan(' conda install -c conda-forge gh ') + chalk.dim('# conda'))
29
+ console.error(cyan(' brew install gh ') + dim('# macOS'))
30
+ console.error(cyan(' sudo apt install gh ') + dim('# Debian/Ubuntu'))
31
+ console.error(cyan(' winget install GitHub.cli ') + dim('# Windows'))
32
+ console.error(cyan(' conda install -c conda-forge gh ') + dim('# conda'))
31
33
  console.error()
32
- console.error(chalk.dim(' More: https://cli.github.com'))
34
+ console.error(dim(' More: https://cli.github.com'))
33
35
  console.error()
34
36
  process.exit(1)
35
37
  }
@@ -62,9 +64,9 @@ export function requireAuth() {
62
64
  const token = getToken()
63
65
  if (token) return token
64
66
 
65
- console.error(chalk.red('\n Not authenticated with GitHub.\n'))
67
+ console.error(red('\n Not authenticated with GitHub.\n'))
66
68
  console.error(' Run:')
67
- console.error(chalk.cyan(' gh auth login'))
69
+ console.error(cyan(' gh auth login'))
68
70
  console.error()
69
71
  process.exit(1)
70
72
  }
@@ -75,11 +77,26 @@ export function requireAuth() {
75
77
  */
76
78
  export function getAuthStatus() {
77
79
  try {
78
- const raw = execFileSync('gh', ['auth', 'status', '--json'], {
80
+ const raw = execFileSync('gh', ['auth', 'status', '--json', 'hosts'], {
79
81
  encoding: 'utf8',
80
82
  stdio: ['ignore', 'pipe', 'ignore'],
81
83
  }).trim()
82
- return JSON.parse(raw)
84
+ const data = JSON.parse(raw)
85
+ // Normalize: flatten hosts map into an accounts array for callers
86
+ const accounts = []
87
+ if (data.hosts) {
88
+ for (const entries of Object.values(data.hosts)) {
89
+ for (const entry of entries) {
90
+ accounts.push({
91
+ login: entry.login,
92
+ active: !!entry.active,
93
+ host: entry.host,
94
+ state: entry.state,
95
+ })
96
+ }
97
+ }
98
+ }
99
+ return { accounts }
83
100
  } catch {
84
101
  // Older gh versions may not support --json, fall back to text parsing
85
102
  try {
@@ -217,6 +234,32 @@ export function createIssue(repo, { title, body, labels }) {
217
234
  return { number, url }
218
235
  }
219
236
 
237
+ /**
238
+ * Verify that a GitHub issue exists and is open.
239
+ * Used after `createIssue()` to confirm the issue was actually created
240
+ * before removing it from the outbox.
241
+ *
242
+ * @param {string} repo - owner/name
243
+ * @param {number} number - issue number
244
+ * @returns {boolean} true if the issue exists and is open
245
+ */
246
+ export function verifyIssue(repo, number) {
247
+ try {
248
+ const raw = execFileSync('gh', [
249
+ 'issue', 'view', String(number),
250
+ '--repo', repo,
251
+ '--json', 'number,state',
252
+ ], {
253
+ encoding: 'utf8',
254
+ stdio: ['ignore', 'pipe', 'ignore'],
255
+ }).trim()
256
+ const data = JSON.parse(raw)
257
+ return data.state === 'OPEN'
258
+ } catch {
259
+ return false
260
+ }
261
+ }
262
+
220
263
  /**
221
264
  * List all label names for a repo.
222
265
  * @param {string} repo - owner/name
@@ -278,16 +321,22 @@ export function addLabelsToIssue(repo, issueNumber, labels) {
278
321
  */
279
322
  export function listIssues(repo, opts = {}) {
280
323
  const limit = opts.limit ?? 100
281
- const raw = execFileSync('gh', [
282
- 'issue', 'list',
283
- '--repo', repo,
284
- '--state', 'open',
285
- '--limit', String(limit),
286
- '--json', 'number,title,url,labels,createdAt',
287
- ], {
288
- encoding: 'utf8',
289
- stdio: ['ignore', 'pipe', 'ignore'],
290
- }).trim()
324
+ let raw
325
+ try {
326
+ raw = execFileSync('gh', [
327
+ 'issue', 'list',
328
+ '--repo', repo,
329
+ '--state', 'open',
330
+ '--limit', String(limit),
331
+ '--json', 'number,title,url,labels,createdAt',
332
+ ], {
333
+ encoding: 'utf8',
334
+ stdio: ['ignore', 'pipe', 'pipe'],
335
+ }).trim()
336
+ } catch (err) {
337
+ const reason = (err.stderr || err.message || '').trim()
338
+ throw new Error(`gh issue list failed: ${reason}`)
339
+ }
291
340
 
292
341
  if (!raw) return []
293
342
  const issues = JSON.parse(raw)
@@ -300,6 +349,38 @@ export function listIssues(repo, opts = {}) {
300
349
  }))
301
350
  }
302
351
 
352
+ /**
353
+ * Upload an image file to a repo's .tissues/images/ directory and return
354
+ * the raw URL for embedding in markdown.
355
+ *
356
+ * Uses the GitHub Contents API via `gh api` to commit the file.
357
+ *
358
+ * @param {string} repo - owner/name
359
+ * @param {string} localPath - path to the local image file
360
+ * @param {string} [filename] - optional filename override
361
+ * @returns {{ url: string, path: string }} raw URL and repo path
362
+ */
363
+ export function uploadImageToRepo(repo, localPath, filename) {
364
+ const name = filename || nodePath.basename(localPath)
365
+ const repoPath = `.tissues/images/${name}`
366
+ const content = fs.readFileSync(localPath).toString('base64')
367
+
368
+ const raw = execFileSync('gh', [
369
+ 'api',
370
+ `repos/${repo}/contents/${repoPath}`,
371
+ '--method', 'PUT',
372
+ '-f', `message=tissues: attach ${name}`,
373
+ '-f', `content=${content}`,
374
+ ], {
375
+ encoding: 'utf8',
376
+ stdio: ['ignore', 'pipe', 'pipe'],
377
+ }).trim()
378
+
379
+ const data = JSON.parse(raw)
380
+ const url = data.content?.download_url || `https://raw.githubusercontent.com/${repo}/HEAD/${repoPath}`
381
+ return { url, path: repoPath }
382
+ }
383
+
303
384
  /**
304
385
  * List repos the user has access to.
305
386
  * @param {{ limit?: number }} [opts]
@@ -1,5 +1,6 @@
1
1
  import { search } from '@inquirer/prompts'
2
2
  import { store, setConfig } from './config.js'
3
+ import { theme } from './theme.js'
3
4
  import { listRepos } from './gh.js'
4
5
  import ora from 'ora'
5
6
 
@@ -46,6 +47,7 @@ export async function pickRepo() {
46
47
  .filter((r) => r.toLowerCase().includes(term))
47
48
  .map((r) => ({ name: r, value: r }))
48
49
  },
50
+ theme,
49
51
  })
50
52
 
51
53
  trackRepoUsage(repo)
package/src/lib/safety.js CHANGED
@@ -110,7 +110,7 @@ export function checkSafety(repo, agent, config = {}) {
110
110
 
111
111
  // Step 3: half-open → log and allow the probe through (no rate checks)
112
112
  if (circuitState === 'half-open') {
113
- console.warn(`[safety] Circuit is half-open for ${repo}/${agent}allowing probe request through.`)
113
+ // half-open: silently allow the probe through no user-facing noise
114
114
  return {
115
115
  allowed: true,
116
116
  circuitState,
@@ -20,18 +20,14 @@ const BUILT_IN_TEMPLATES = {
20
20
  name: 'Feature Request',
21
21
  body: `## Feature\n\n{{description}}\n\n## Motivation\n\n## Proposed solution\n\n## Alternatives considered\n\n`,
22
22
  },
23
- security: {
24
- name: 'Security Issue',
25
- body: `## Security Issue\n\n{{description}}\n\n## Severity\n\n## Affected components\n\n## Suggested fix\n\n`,
26
- },
27
- performance: {
28
- name: 'Performance Issue',
29
- body: `## Performance\n\n{{description}}\n\n## Current metric\n\n## Target metric\n\n## Affected area\n\n`,
30
- },
31
- refactor: {
32
- name: 'Refactor',
33
- body: `## Refactor\n\n{{description}}\n\n## Motivation\n\n## Scope\n\n## Risk assessment\n\n`,
34
- },
23
+ }
24
+
25
+ /**
26
+ * Return the list of built-in template keys.
27
+ * @returns {string[]}
28
+ */
29
+ export function builtInTemplateKeys() {
30
+ return Object.keys(BUILT_IN_TEMPLATES)
35
31
  }
36
32
 
37
33
  // ---------------------------------------------------------------------------
@@ -0,0 +1,9 @@
1
+ import { bold, dim } from './color.js'
2
+
3
+ export const theme = {
4
+ prefix: { idle: dim('›'), done: dim('›') },
5
+ style: {
6
+ message: (text) => bold(text),
7
+ defaultAnswer: (text) => dim(text),
8
+ },
9
+ }
@@ -1,19 +0,0 @@
1
- import { Command } from 'commander'
2
- import { pickRepo } from '../lib/repo-picker.js'
3
- import { setConfig } from '../lib/config.js'
4
- import chalk from 'chalk'
5
-
6
- export const useCommand = new Command('use')
7
- .description('Set the active repository context')
8
- .argument('[repo]', 'Repository in owner/name format (e.g. owner/repo)')
9
- .option('--repo <repo>', 'Repository in owner/name format (alias for positional argument)')
10
- .action(async (repoArg, opts) => {
11
- const repo = repoArg || opts.repo
12
- if (repo) {
13
- setConfig({ activeRepo: repo })
14
- console.log(chalk.green(`✓ Active repo set to ${repo}`))
15
- } else {
16
- const selected = await pickRepo()
17
- console.log(chalk.green(`\n✓ Active repo set to ${selected}`))
18
- }
19
- })