tissues 0.6.0 → 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.
@@ -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
@@ -321,16 +321,22 @@ export function addLabelsToIssue(repo, issueNumber, labels) {
321
321
  */
322
322
  export function listIssues(repo, opts = {}) {
323
323
  const limit = opts.limit ?? 100
324
- const raw = execFileSync('gh', [
325
- 'issue', 'list',
326
- '--repo', repo,
327
- '--state', 'open',
328
- '--limit', String(limit),
329
- '--json', 'number,title,url,labels,createdAt',
330
- ], {
331
- encoding: 'utf8',
332
- stdio: ['ignore', 'pipe', 'ignore'],
333
- }).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
+ }
334
340
 
335
341
  if (!raw) return []
336
342
  const issues = JSON.parse(raw)