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
@@ -1,216 +1,55 @@
1
- import { createRequire } from 'node:module'
2
- import { fileURLToPath } from 'node:url'
3
- import path from 'node:path'
4
- import fs from 'node:fs'
5
-
6
- // ---------------------------------------------------------------------------
7
- // Package version (read once at module load)
8
- // ---------------------------------------------------------------------------
9
-
10
- /**
11
- * Resolve the package version from the nearest package.json.
12
- * Falls back to '0.0.0' if unavailable.
13
- *
14
- * @returns {string}
15
- */
16
- function resolvePackageVersion() {
17
- try {
18
- const require = createRequire(import.meta.url)
19
- // Walk up from this file to find package.json
20
- let dir = path.dirname(fileURLToPath(import.meta.url))
21
- const { root } = path.parse(dir)
22
- while (dir !== root) {
23
- const pkgPath = path.join(dir, 'package.json')
24
- if (fs.existsSync(pkgPath)) {
25
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
26
- if (pkg.name === 'tissues' && pkg.version) return pkg.version
27
- }
28
- dir = path.dirname(dir)
29
- }
30
- } catch {
31
- // ignore
32
- }
33
- return '0.0.0'
34
- }
35
-
36
- const PKG_VERSION = resolvePackageVersion()
37
-
38
- // ---------------------------------------------------------------------------
39
- // Helpers
40
- // ---------------------------------------------------------------------------
41
-
42
- /**
43
- * Return the current UTC timestamp in ISO 8601 format.
44
- *
45
- * @returns {string}
46
- */
47
- function nowISO() {
48
- return new Date().toISOString()
49
- }
50
-
51
1
  // ---------------------------------------------------------------------------
52
2
  // Public API
53
3
  // ---------------------------------------------------------------------------
54
4
 
55
5
  /**
56
6
  * @typedef {object} AttributionOpts
57
- * @property {string} [agent] - agent identifier (e.g. 'claude-opus-4-6')
58
- * @property {string} [session] - session or conversation ID
59
- * @property {number} [pid] - process ID (opt-in; omitted by default)
60
- * @property {string} [model] - AI model used (if any)
61
- * @property {string} [trigger] - how the issue was created (e.g. 'cli-create')
62
- * @property {string} [fingerprint] - content fingerprint (sha256:...)
63
- * @property {string} [idempotencyKey] - deterministic idempotency key
64
- * @property {number} [risk] - risk score [0,1]
65
- * @property {number} [complexity] - complexity estimate [0,1]
66
- * @property {number} [confidence] - AI confidence score [0,1]
67
- * @property {string[]} [contextTags] - free-form tags for filtering/search
68
- * @property {string} [createdAt] - override timestamp (defaults to now)
7
+ * @property {string} [session] - session or conversation ID
8
+ * @property {string} [model] - AI model used (if any)
69
9
  */
70
10
 
71
11
  /**
72
12
  * Build a normalized attribution metadata object from raw options.
73
13
  *
74
- * The returned object includes all provided fields plus automatic defaults
75
- * (`created_at`, `created_via`). Undefined/null fields are omitted.
76
- * `pid` is opt-in — it is only included if explicitly passed.
14
+ * Only includes session and model fields the user actually cares about.
15
+ * Undefined/null fields are omitted.
77
16
  *
78
17
  * @param {AttributionOpts} opts
79
18
  * @returns {object} metadata record
80
19
  */
81
20
  export function buildAttribution(opts = {}) {
82
- const {
83
- agent,
84
- session,
85
- pid,
86
- model,
87
- trigger,
88
- fingerprint,
89
- idempotencyKey,
90
- risk,
91
- complexity,
92
- confidence,
93
- contextTags,
94
- createdAt,
95
- } = opts
96
-
21
+ const { session, model } = opts
97
22
  const meta = {}
98
23
 
99
- // Identity
100
- if (agent != null) meta.agent = String(agent)
101
24
  if (session != null) meta.session = String(session)
102
25
  if (model != null) meta.model = String(model)
103
26
 
104
- // Process info (pid is opt-in — pass it explicitly to include)
105
- if (pid != null) meta.pid = Number(pid)
106
- meta.trigger = trigger != null ? String(trigger) : 'cli-create'
107
-
108
- // Deduplication handles
109
- if (fingerprint != null) meta.fingerprint = String(fingerprint)
110
- if (idempotencyKey != null) meta.idempotency_key = String(idempotencyKey)
111
-
112
- // Scoring (numeric, clamped to [0,1])
113
- if (risk != null) meta.risk = Math.max(0, Math.min(1, Number(risk)))
114
- if (complexity != null) meta.complexity = Math.max(0, Math.min(1, Number(complexity)))
115
- if (confidence != null) meta.confidence = Math.max(0, Math.min(1, Number(confidence)))
116
-
117
- // Tags
118
- if (Array.isArray(contextTags) && contextTags.length > 0) {
119
- meta.context_tags = contextTags.map(String)
120
- }
121
-
122
- // Timestamps / versioning
123
- meta.created_at = createdAt ?? nowISO()
124
- meta.created_via = `tissues-cli/${PKG_VERSION.split('.').slice(0, 2).join('.')}`
125
-
126
27
  return meta
127
28
  }
128
29
 
129
30
  /**
130
- * Render attribution metadata as an HTML comment block for inclusion at the
131
- * bottom of a GitHub issue body.
132
- *
133
- * The format is a YAML-like key: value listing inside an HTML comment, which
134
- * GitHub renders as invisible text. Other tools (including tissues itself)
135
- * can parse it back via `parseAttribution`.
31
+ * Render attribution metadata as a YAML frontmatter block for inclusion at
32
+ * the bottom of a GitHub issue body.
136
33
  *
137
34
  * @example
138
- * <!-- tissues-meta
139
- * agent: claude-opus-4-6
35
+ * ---
36
+ * tissues-meta:
140
37
  * session: abc123
141
- * trigger: cli-create
142
- * fingerprint: sha256:deadbeef
143
- * created_at: 2026-02-19T15:30:00Z
144
- * created_via: tissues-cli/0.1
145
- * -->
38
+ * model: claude-opus-4-6
39
+ * ---
146
40
  *
147
41
  * @param {AttributionOpts} opts
148
- * @returns {string}
42
+ * @returns {string | null} frontmatter block, or null if no fields to render
149
43
  */
150
44
  export function renderAttribution(opts = {}) {
151
45
  const meta = buildAttribution(opts)
152
- const lines = ['<!-- tissues-meta']
46
+ const entries = Object.entries(meta)
47
+ if (entries.length === 0) return null
153
48
 
154
- for (const [key, value] of Object.entries(meta)) {
155
- if (Array.isArray(value)) {
156
- // Render arrays as comma-separated values on a single line
157
- lines.push(`${key}: ${value.join(', ')}`)
158
- } else {
159
- lines.push(`${key}: ${value}`)
160
- }
49
+ const lines = ['---', 'tissues-meta:']
50
+ for (const [key, value] of entries) {
51
+ lines.push(`${key}: ${value}`)
161
52
  }
162
-
163
- lines.push('-->')
53
+ lines.push('---')
164
54
  return lines.join('\n')
165
55
  }
166
-
167
- /**
168
- * Parse a `<!-- tissues-meta ... -->` attribution block from an issue body.
169
- *
170
- * Returns the parsed key/value pairs as a plain object, or `null` if no
171
- * attribution block is present in `issueBody`.
172
- *
173
- * Numeric fields (`risk`, `complexity`, `confidence`) are coerced to
174
- * numbers. The `context_tags` field is split back into an array.
175
- *
176
- * @param {string} issueBody - raw GitHub issue body markdown
177
- * @returns {object|null} parsed metadata, or null if not found
178
- */
179
- export function parseAttribution(issueBody) {
180
- if (!issueBody) return null
181
-
182
- // Match the comment block (non-greedy, handle CRLF)
183
- const match = issueBody.match(/<!--\s*tissues-meta\s*([\s\S]*?)-->/m)
184
- if (!match) return null
185
-
186
- const block = match[1]
187
- const meta = {}
188
-
189
- for (const line of block.split(/\r?\n/)) {
190
- const trimmed = line.trim()
191
- if (!trimmed) continue
192
-
193
- const colonIdx = trimmed.indexOf(':')
194
- if (colonIdx === -1) continue
195
-
196
- const key = trimmed.slice(0, colonIdx).trim()
197
- const rawValue = trimmed.slice(colonIdx + 1).trim()
198
-
199
- if (!key) continue
200
-
201
- // Type coercions
202
- if (['pid'].includes(key)) {
203
- const n = Number(rawValue)
204
- meta[key] = Number.isNaN(n) ? rawValue : n
205
- } else if (['risk', 'complexity', 'confidence'].includes(key)) {
206
- const n = parseFloat(rawValue)
207
- meta[key] = Number.isNaN(n) ? rawValue : n
208
- } else if (key === 'context_tags') {
209
- meta[key] = rawValue.split(',').map((t) => t.trim()).filter(Boolean)
210
- } else {
211
- meta[key] = rawValue
212
- }
213
- }
214
-
215
- return Object.keys(meta).length > 0 ? meta : null
216
- }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Clipboard image detection and extraction.
3
+ *
4
+ * macOS: uses osascript to read «class PNGf» from NSPasteboard.
5
+ * Linux: uses xclip (X11) or wl-paste (Wayland).
6
+ * Windows: not supported yet.
7
+ */
8
+
9
+ import { execFileSync, spawnSync } from 'node:child_process'
10
+ import fs from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+
14
+ /**
15
+ * Check if the system clipboard currently holds image data.
16
+ *
17
+ * @returns {boolean}
18
+ */
19
+ export function clipboardHasImage() {
20
+ const platform = os.platform()
21
+ if (platform === 'darwin') return clipboardHasImageMac()
22
+ if (platform === 'linux') return clipboardHasImageLinux()
23
+ return false
24
+ }
25
+
26
+ /**
27
+ * Save clipboard image to a PNG file on disk.
28
+ *
29
+ * @param {string} destPath - where to write the PNG
30
+ * @returns {boolean} true if saved successfully
31
+ */
32
+ export function saveClipboardImage(destPath) {
33
+ const platform = os.platform()
34
+ if (platform === 'darwin') return saveClipboardImageMac(destPath)
35
+ if (platform === 'linux') return saveClipboardImageLinux(destPath)
36
+ return false
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // macOS
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function clipboardHasImageMac() {
44
+ try {
45
+ const info = execFileSync('osascript', ['-e', 'clipboard info'], {
46
+ encoding: 'utf8',
47
+ stdio: ['ignore', 'pipe', 'ignore'],
48
+ })
49
+ return info.includes('PNGf') || info.includes('TIFF picture')
50
+ } catch {
51
+ return false
52
+ }
53
+ }
54
+
55
+ function saveClipboardImageMac(destPath) {
56
+ // Prefer pngpaste if installed (more reliable)
57
+ const hasPngpaste = spawnSync('which', ['pngpaste'], {
58
+ encoding: 'utf8',
59
+ stdio: ['ignore', 'pipe', 'ignore'],
60
+ }).status === 0
61
+
62
+ if (hasPngpaste) {
63
+ const result = spawnSync('pngpaste', [destPath], {
64
+ encoding: 'utf8',
65
+ stdio: ['ignore', 'pipe', 'pipe'],
66
+ })
67
+ return result.status === 0
68
+ }
69
+
70
+ // Fallback: osascript with temp script file to avoid quoting issues
71
+ const scriptFile = path.join(os.tmpdir(), `tissues-clip-${Date.now()}.scpt`)
72
+ const script = [
73
+ 'try',
74
+ ' set theImage to (the clipboard as «class PNGf»)',
75
+ ` set fd to open for access POSIX file "${destPath}" with write permission`,
76
+ ' set eof of fd to 0',
77
+ ' write theImage to fd',
78
+ ' close access fd',
79
+ ' return "ok"',
80
+ 'on error errMsg',
81
+ ' try',
82
+ ` close access POSIX file "${destPath}"`,
83
+ ' end try',
84
+ ' return "fail"',
85
+ 'end try',
86
+ ].join('\n')
87
+
88
+ try {
89
+ fs.writeFileSync(scriptFile, script, 'utf8')
90
+ const result = execFileSync('osascript', [scriptFile], {
91
+ encoding: 'utf8',
92
+ stdio: ['ignore', 'pipe', 'ignore'],
93
+ }).trim()
94
+ return result === 'ok'
95
+ } catch {
96
+ return false
97
+ } finally {
98
+ try { fs.unlinkSync(scriptFile) } catch { /* ignore */ }
99
+ }
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Linux
104
+ // ---------------------------------------------------------------------------
105
+
106
+ function clipboardHasImageLinux() {
107
+ // Try wl-paste first (Wayland), then xclip (X11)
108
+ const wl = spawnSync('wl-paste', ['--list-types'], {
109
+ encoding: 'utf8',
110
+ stdio: ['ignore', 'pipe', 'ignore'],
111
+ })
112
+ if (wl.status === 0) {
113
+ return wl.stdout.includes('image/png') || wl.stdout.includes('image/jpeg')
114
+ }
115
+
116
+ const xc = spawnSync('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], {
117
+ encoding: 'utf8',
118
+ stdio: ['ignore', 'pipe', 'ignore'],
119
+ })
120
+ if (xc.status === 0) {
121
+ return xc.stdout.includes('image/png') || xc.stdout.includes('image/jpeg')
122
+ }
123
+
124
+ return false
125
+ }
126
+
127
+ function saveClipboardImageLinux(destPath) {
128
+ // Wayland
129
+ const wlResult = spawnSync('wl-paste', ['--type', 'image/png'], {
130
+ stdio: ['ignore', 'pipe', 'ignore'],
131
+ })
132
+ if (wlResult.status === 0 && wlResult.stdout.length > 0) {
133
+ fs.writeFileSync(destPath, wlResult.stdout)
134
+ return true
135
+ }
136
+
137
+ // X11
138
+ const xcResult = spawnSync('xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o'], {
139
+ stdio: ['ignore', 'pipe', 'ignore'],
140
+ })
141
+ if (xcResult.status === 0 && xcResult.stdout.length > 0) {
142
+ fs.writeFileSync(destPath, xcResult.stdout)
143
+ return true
144
+ }
145
+
146
+ return false
147
+ }
@@ -0,0 +1,9 @@
1
+ import { styleText } from 'node:util'
2
+
3
+ export const bold = (s) => styleText('bold', s)
4
+ export const dim = (s) => styleText('dim', s)
5
+ export const red = (s) => styleText('red', s)
6
+ export const green = (s) => styleText('green', s)
7
+ export const yellow = (s) => styleText('yellow', s)
8
+ export const cyan = (s) => styleText('cyan', s)
9
+ export const blue = (s) => styleText('blue', s)
package/src/lib/dedup.js CHANGED
@@ -45,42 +45,44 @@ export function computeIdempotencyKey({ agent, trigger, issueType, repo }) {
45
45
  return crypto.createHash('sha256').update(raw).digest('hex')
46
46
  }
47
47
 
48
+ const STOP_WORDS = new Set([
49
+ 'a','an','the','in','on','at','to','for','of','with','by','from',
50
+ 'is','are','was','were','be','been','being','has','have','had',
51
+ 'do','does','did','will','would','could','should','can','may',
52
+ 'not','no','but','or','and','if','then','so','as','it','its',
53
+ 'this','that','we','our','i','my','you','your','they','their',
54
+ ])
55
+
48
56
  /**
49
- * Compute Levenshtein distance between two strings.
57
+ * Tokenize text: normalize, split on spaces, remove stop words.
50
58
  *
51
- * @param {string} a
52
- * @param {string} b
53
- * @returns {number}
59
+ * @param {string} text
60
+ * @returns {string[]}
54
61
  */
55
- function levenshteinDistance(a, b) {
56
- const m = a.length
57
- const n = b.length
58
- const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)])
59
- for (let j = 0; j <= n; j++) dp[0][j] = j
60
- for (let i = 1; i <= m; i++) {
61
- for (let j = 1; j <= n; j++) {
62
- dp[i][j] =
63
- a[i - 1] === b[j - 1]
64
- ? dp[i - 1][j - 1]
65
- : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
66
- }
67
- }
68
- return dp[m][n]
62
+ function tokenize(text) {
63
+ return normalizeText(text)
64
+ .split(' ')
65
+ .filter(w => w.length > 0 && !STOP_WORDS.has(w))
69
66
  }
70
67
 
71
68
  /**
72
- * Compute similarity between two strings as a value in [0, 1].
73
- * 1.0 means identical, 0.0 means completely different.
69
+ * Compute Jaccard similarity between two strings based on token sets.
70
+ * Tokens are words with stop words removed.
71
+ * Returns a value in [0, 1]: 1.0 = identical token sets, 0.0 = no overlap.
74
72
  *
75
73
  * @param {string} a
76
74
  * @param {string} b
77
75
  * @returns {number}
78
76
  */
79
- export function levenshteinSimilarity(a, b) {
80
- if (a === b) return 1
81
- const maxLen = Math.max(a.length, b.length)
82
- if (maxLen === 0) return 1
83
- return 1 - levenshteinDistance(a, b) / maxLen
77
+ export function jaccardSimilarity(a, b) {
78
+ const setA = new Set(tokenize(a))
79
+ const setB = new Set(tokenize(b))
80
+ if (setA.size === 0 && setB.size === 0) return 1
81
+ if (setA.size === 0 || setB.size === 0) return 0
82
+ let intersection = 0
83
+ for (const w of setA) if (setB.has(w)) intersection++
84
+ const union = new Set([...setA, ...setB]).size
85
+ return intersection / union
84
86
  }
85
87
 
86
88
  /**
@@ -104,6 +106,27 @@ async function getCachedIssues(repo) {
104
106
  * @typedef {{ action: 'block' | 'warn' | 'allow', reason: string, existingIssue?: { number: number, title: string, url: string } }} LayerResult
105
107
  */
106
108
 
109
+ /**
110
+ * Score dedup confidence from cumulative overlap signals.
111
+ *
112
+ * Used by the AI dedup pipeline step to convert raw LLM overlap scores
113
+ * into deterministic confidence levels.
114
+ *
115
+ * @param {{ titleOverlap?: number, fileMentions?: number, labelMatches?: number }} scores
116
+ * - titleOverlap: 0-50 points (how similar the titles are)
117
+ * - fileMentions: 0-30 points (shared file references)
118
+ * - labelMatches: 0-20 points (shared labels)
119
+ * @returns {{ confidence: number, level: 'high' | 'medium' | 'low' | 'none' }}
120
+ */
121
+ export function scoreDedupConfidence({ titleOverlap = 0, fileMentions = 0, labelMatches = 0 } = {}) {
122
+ const confidence = Math.min(100, Math.max(0, titleOverlap + fileMentions + labelMatches))
123
+ let level = 'none'
124
+ if (confidence >= 70) level = 'high'
125
+ else if (confidence >= 40) level = 'medium'
126
+ else if (confidence > 20) level = 'low'
127
+ return { confidence, level }
128
+ }
129
+
107
130
  /**
108
131
  * Run the full deduplication pipeline for a prospective issue.
109
132
  *
@@ -146,12 +169,16 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
146
169
  })
147
170
  }
148
171
 
149
- // Layer 3: fuzzy title match
150
- const normalizedTitle = normalizeText(title)
172
+ // Layer 3: fuzzy title match (token-set Jaccard)
173
+ const newTokens = tokenize(title)
174
+ const isShortTitle = newTokens.length <= 2
151
175
  const openIssues = await getCachedIssues(repo)
152
176
  for (const issue of openIssues) {
153
- const similarity = levenshteinSimilarity(normalizedTitle, normalizeText(issue.title))
154
- if (similarity > 0.95) {
177
+ const similarity = jaccardSimilarity(title, issue.title)
178
+ // Short-title guard: ≤2 meaningful tokens require exact match to block, ≥0.80 to warn
179
+ const blockThreshold = isShortTitle ? 1.0 : 0.80
180
+ const warnThreshold = isShortTitle ? 0.80 : 0.50
181
+ if (similarity >= blockThreshold) {
155
182
  results.push({
156
183
  action: 'block',
157
184
  reason: `Fuzzy title match (similarity ${(similarity * 100).toFixed(1)}%) with existing issue`,
@@ -162,7 +189,7 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
162
189
  },
163
190
  })
164
191
  break
165
- } else if (similarity > 0.80) {
192
+ } else if (similarity >= warnThreshold) {
166
193
  results.push({
167
194
  action: 'warn',
168
195
  reason: `Fuzzy title is similar (similarity ${(similarity * 100).toFixed(1)}%) to existing issue`,
@@ -190,16 +217,24 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
190
217
  /**
191
218
  * Persist deduplication records after a successful issue creation.
192
219
  * Stores both the content fingerprint and, if provided, the idempotency key.
220
+ * Also appends the new issue to the in-memory cache so subsequent dedup
221
+ * checks within the same process (or cache TTL window) see it immediately.
193
222
  *
194
223
  * @param {string} repo
195
- * @param {{ title: string, body: string, issueNumber: number, idempotencyKey?: string, agent?: string }} options
224
+ * @param {{ title: string, body: string, issueNumber: number, url?: string, idempotencyKey?: string, agent?: string }} options
196
225
  * @returns {Promise<void>}
197
226
  */
198
- export async function recordCreation(repo, { title, body, issueNumber, idempotencyKey, agent }) {
227
+ export async function recordCreation(repo, { title, body, issueNumber, url, idempotencyKey, agent }) {
199
228
  const fingerprint = computeFingerprint(title, body ?? '')
200
229
  await storeFingerprint(repo, fingerprint, issueNumber, title, agent ?? 'unknown')
201
230
 
202
231
  if (idempotencyKey) {
203
232
  await storeIdempotencyKey(idempotencyKey, repo, issueNumber)
204
233
  }
234
+
235
+ // Atomically append to the issues cache so back-to-back runs catch duplicates
236
+ const cached = issuesCache.get(repo)
237
+ if (cached) {
238
+ cached.issues.unshift({ number: issueNumber, title, url: url ?? null })
239
+ }
205
240
  }
@@ -36,11 +36,63 @@ export const BUILT_IN_DEFAULTS = {
36
36
  default: 'default', // default template name
37
37
  },
38
38
 
39
+ // Enhancements
40
+ enhancements: {
41
+ dir: '.tissues/enhancements', // relative to repo root, or absolute
42
+ },
43
+
39
44
  // AI
40
45
  ai: {
41
46
  enabled: true,
42
- provider: 'anthropic', // or 'openai'
43
- model: 'claude-haiku-4-5-20251001',
47
+ provider: 'anthropic',
48
+ model: null, // global model override
49
+ models: { // per-provider defaults
50
+ anthropic: 'claude-haiku-4-5-20251001',
51
+ openai: 'gpt-4o-mini',
52
+ gemini: 'gemini-2.0-flash',
53
+ ollama: 'llama3.2',
54
+ 'openai-compat': null, // user must set
55
+ command: null, // not applicable
56
+ },
57
+ keys: {
58
+ anthropic: null,
59
+ openai: null,
60
+ gemini: null,
61
+ 'openai-compat': null,
62
+ },
63
+ ollama: { url: 'http://localhost:11434' },
64
+ custom: { url: null }, // openai-compat base URL
65
+ command: null, // e.g. 'co inference'
66
+ providers: {}, // named custom providers: { "my-gemini": { command: "co gemini", timeout: 120000 } }
67
+ routes: [], // routing rules
68
+ budgets: {
69
+ maxTokensPerRequest: 4096, // hard cap per single AI call
70
+ maxTokensPerHour: 50000, // rolling hourly token budget
71
+ maxTokensPerDay: 200000, // rolling daily token budget
72
+ },
73
+ pipeline: {
74
+ enabled: true, // multi-step enhancement pipeline
75
+ steps: { // legacy key (alias for enhancements)
76
+ triage: 'always', // 'always' | 'auto' | 'never'
77
+ dedup: 'auto', // 'always' | 'auto' | 'never'
78
+ context: 'always',
79
+ scope: 'auto',
80
+ complexity: 'auto',
81
+ risk: 'auto',
82
+ labels: 'auto',
83
+ format: 'always',
84
+ },
85
+ enhancements: { // preferred key (same shape as steps)
86
+ triage: 'always',
87
+ dedup: 'auto',
88
+ context: 'always',
89
+ scope: 'auto',
90
+ complexity: 'auto',
91
+ risk: 'auto',
92
+ labels: 'auto',
93
+ format: 'always',
94
+ },
95
+ },
44
96
  },
45
97
 
46
98
  // Hooks (shell commands to run)