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.
- package/README.md +93 -39
- package/package.json +3 -4
- package/src/cli.js +24 -22
- package/src/commands/ai.js +266 -0
- package/src/commands/auth.js +4 -4
- package/src/commands/config.js +961 -12
- package/src/commands/create.js +516 -157
- package/src/commands/drafts.js +288 -0
- package/src/commands/list.js +7 -5
- package/src/commands/status.js +81 -19
- package/src/commands/templates.js +157 -0
- package/src/lib/ai/adapters/anthropic.js +52 -0
- package/src/lib/ai/adapters/base.js +45 -0
- package/src/lib/ai/adapters/command.js +58 -0
- package/src/lib/ai/adapters/gemini.js +56 -0
- package/src/lib/ai/adapters/ollama.js +60 -0
- package/src/lib/ai/adapters/openai-compat.js +51 -0
- package/src/lib/ai/adapters/openai.js +44 -0
- package/src/lib/ai/body-template.js +60 -0
- package/src/lib/ai/enhance.js +70 -0
- package/src/lib/ai/index.js +122 -0
- package/src/lib/ai/pipeline.js +79 -0
- package/src/lib/ai/prompt.js +39 -0
- package/src/lib/ai/router.js +128 -0
- package/src/lib/ai/steps.js +472 -0
- package/src/lib/attribution.js +18 -179
- package/src/lib/clipboard.js +147 -0
- package/src/lib/color.js +9 -0
- package/src/lib/dedup.js +33 -4
- package/src/lib/defaults.js +38 -2
- package/src/lib/drafts.js +439 -0
- package/src/lib/gh.js +86 -11
- package/src/lib/repo-picker.js +2 -0
- package/src/lib/safety.js +1 -1
- package/src/lib/templates.js +8 -12
- package/src/lib/theme.js +9 -0
- package/src/commands/use.js +0 -19
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline step definitions for multi-step AI issue enhancement.
|
|
3
|
+
*
|
|
4
|
+
* Each step is a plain object with:
|
|
5
|
+
* - name: unique identifier
|
|
6
|
+
* - displayName: human-readable label for progress display
|
|
7
|
+
* - maxTokens: token budget for this step's LLM call
|
|
8
|
+
* - shouldRun: (ctx, stepConfig) => boolean — whether to run in 'auto' mode
|
|
9
|
+
* - buildMessages: (ctx) => [{ role, content }] — prompt for the LLM
|
|
10
|
+
* - parseResponse: (raw, ctx) => void — parse LLM output and mutate ctx
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { buildBodyGuidance } from './body-template.js'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
function tryParseJSON(raw) {
|
|
20
|
+
let cleaned = raw.trim()
|
|
21
|
+
// Strip markdown fences if present
|
|
22
|
+
if (cleaned.startsWith('```')) {
|
|
23
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '')
|
|
24
|
+
}
|
|
25
|
+
return JSON.parse(cleaned)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function contextSummary(ctx) {
|
|
29
|
+
const parts = [`Title: ${ctx.title}`]
|
|
30
|
+
if (ctx.description) parts.push(`Description: ${ctx.description}`)
|
|
31
|
+
if (ctx.instructions) parts.push(`User instructions: ${ctx.instructions}`)
|
|
32
|
+
if (ctx.templateBody) parts.push(`Template:\n${ctx.templateBody}`)
|
|
33
|
+
if (ctx.labels?.length) parts.push(`Labels: ${ctx.labels.join(', ')}`)
|
|
34
|
+
return parts.join('\n\n')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function priorStepsSummary(ctx) {
|
|
38
|
+
const parts = []
|
|
39
|
+
if (ctx.dedupScore != null) {
|
|
40
|
+
parts.push(`Dedup confidence: ${ctx.dedupScore.confidence}/100 (${ctx.dedupScore.level})`)
|
|
41
|
+
}
|
|
42
|
+
if (ctx.structuredContext) {
|
|
43
|
+
parts.push(`Context: ${JSON.stringify(ctx.structuredContext)}`)
|
|
44
|
+
}
|
|
45
|
+
if (ctx.scopeAnalysis) {
|
|
46
|
+
parts.push(`Scope: ${JSON.stringify(ctx.scopeAnalysis)}`)
|
|
47
|
+
}
|
|
48
|
+
if (ctx.complexity != null) {
|
|
49
|
+
parts.push(`Complexity: ${ctx.complexity}/10 — ${ctx.complexityRationale || ''}`)
|
|
50
|
+
}
|
|
51
|
+
if (ctx.risk != null) {
|
|
52
|
+
parts.push(`Risk: ${ctx.risk}/10 — ${ctx.riskRationale || ''}`)
|
|
53
|
+
}
|
|
54
|
+
if (ctx.aiLabels?.length) {
|
|
55
|
+
parts.push(`AI labels: ${ctx.aiLabels.join(', ')}`)
|
|
56
|
+
}
|
|
57
|
+
return parts.length > 0 ? '\n\nPrior analysis:\n' + parts.join('\n') : ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Step: triage — extract title + description from freeform input
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const triageStep = {
|
|
65
|
+
name: 'triage',
|
|
66
|
+
displayName: 'Input analyzed',
|
|
67
|
+
maxTokens: 1024,
|
|
68
|
+
|
|
69
|
+
shouldRun(ctx) {
|
|
70
|
+
return !!(ctx.rawInput && ctx.rawInput.length > 0)
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
buildMessages(ctx) {
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
role: 'system',
|
|
77
|
+
content: [
|
|
78
|
+
'You extract a structured GitHub issue title and description from freeform user input.',
|
|
79
|
+
'Return a JSON object with:',
|
|
80
|
+
' title: string — concise issue title, ≤80 characters, imperative mood (e.g. "Fix login timeout on Safari")',
|
|
81
|
+
' description: string — structured description expanding on the input, in markdown',
|
|
82
|
+
'The title must capture the core intent. The description should organize and expand the raw input into clear context.',
|
|
83
|
+
'Return ONLY valid JSON.',
|
|
84
|
+
].join('\n'),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
role: 'user',
|
|
88
|
+
content: ctx.rawInput,
|
|
89
|
+
},
|
|
90
|
+
]
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
parseResponse(raw, ctx) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = tryParseJSON(raw)
|
|
96
|
+
if (parsed.title && typeof parsed.title === 'string') {
|
|
97
|
+
ctx.title = parsed.title.slice(0, 80)
|
|
98
|
+
}
|
|
99
|
+
if (parsed.description && typeof parsed.description === 'string') {
|
|
100
|
+
ctx.description = parsed.description
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Parse failure — keep splitInput() values as fallback
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Step: dedup
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
const dedupStep = {
|
|
113
|
+
name: 'dedup',
|
|
114
|
+
displayName: 'Duplicate check',
|
|
115
|
+
maxTokens: 1024,
|
|
116
|
+
|
|
117
|
+
shouldRun(ctx) {
|
|
118
|
+
// Only useful when there are existing issues to compare against
|
|
119
|
+
return ctx.existingIssues?.length > 0
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
buildMessages(ctx) {
|
|
123
|
+
const existing = (ctx.existingIssues || [])
|
|
124
|
+
.slice(0, 20)
|
|
125
|
+
.map((i) => `#${i.number}: ${i.title}`)
|
|
126
|
+
.join('\n')
|
|
127
|
+
|
|
128
|
+
return [
|
|
129
|
+
{
|
|
130
|
+
role: 'system',
|
|
131
|
+
content: [
|
|
132
|
+
'You are a duplicate detection system for GitHub issues.',
|
|
133
|
+
'Compare the new issue against existing open issues.',
|
|
134
|
+
'Return a JSON object with:',
|
|
135
|
+
' confidence: number 0-100 (how likely this is a duplicate)',
|
|
136
|
+
' level: "high" | "medium" | "low" | "none"',
|
|
137
|
+
' matches: array of { number, reason } for similar issues',
|
|
138
|
+
'Return ONLY valid JSON.',
|
|
139
|
+
].join('\n'),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
role: 'user',
|
|
143
|
+
content: `New issue:\nTitle: ${ctx.title}\nDescription: ${ctx.description || 'none'}\n\nExisting open issues:\n${existing}`,
|
|
144
|
+
},
|
|
145
|
+
]
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
parseResponse(raw, ctx) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = tryParseJSON(raw)
|
|
151
|
+
ctx.dedupScore = {
|
|
152
|
+
confidence: Math.min(100, Math.max(0, Number(parsed.confidence) || 0)),
|
|
153
|
+
level: ['high', 'medium', 'low', 'none'].includes(parsed.level) ? parsed.level : 'none',
|
|
154
|
+
matches: Array.isArray(parsed.matches) ? parsed.matches : [],
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
ctx.dedupScore = null
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Step: context
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
const contextStep = {
|
|
167
|
+
name: 'context',
|
|
168
|
+
displayName: 'Context gathered',
|
|
169
|
+
maxTokens: 1024,
|
|
170
|
+
|
|
171
|
+
shouldRun() {
|
|
172
|
+
return true
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
buildMessages(ctx) {
|
|
176
|
+
return [
|
|
177
|
+
{
|
|
178
|
+
role: 'system',
|
|
179
|
+
content: [
|
|
180
|
+
'You are an expert at understanding software issues.',
|
|
181
|
+
'Extract structured context from the issue description.',
|
|
182
|
+
'Return a JSON object with:',
|
|
183
|
+
' problem: string — the core problem in one sentence',
|
|
184
|
+
' files: string[] — file paths mentioned or implied',
|
|
185
|
+
' errors: string[] — error messages mentioned',
|
|
186
|
+
' sessionContext: string — any relevant session/environment context',
|
|
187
|
+
'Return ONLY valid JSON.',
|
|
188
|
+
].join('\n'),
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
role: 'user',
|
|
192
|
+
content: contextSummary(ctx),
|
|
193
|
+
},
|
|
194
|
+
]
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
parseResponse(raw, ctx) {
|
|
198
|
+
try {
|
|
199
|
+
const parsed = tryParseJSON(raw)
|
|
200
|
+
ctx.structuredContext = {
|
|
201
|
+
problem: parsed.problem || '',
|
|
202
|
+
files: Array.isArray(parsed.files) ? parsed.files : [],
|
|
203
|
+
errors: Array.isArray(parsed.errors) ? parsed.errors : [],
|
|
204
|
+
sessionContext: parsed.sessionContext || '',
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
ctx.structuredContext = null
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Step: scope
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
const scopeStep = {
|
|
217
|
+
name: 'scope',
|
|
218
|
+
displayName: 'Scope analyzed',
|
|
219
|
+
maxTokens: 1024,
|
|
220
|
+
|
|
221
|
+
shouldRun(ctx) {
|
|
222
|
+
// Only run if description mentions code/files or context step found files
|
|
223
|
+
const desc = (ctx.description || '').toLowerCase()
|
|
224
|
+
const hasCodeMentions = /\.(js|ts|py|go|rs|rb|java|css|html|json|yml|yaml|md)\b/.test(desc) ||
|
|
225
|
+
/\b(file|module|component|function|class|import|require)\b/.test(desc)
|
|
226
|
+
const hasContextFiles = ctx.structuredContext?.files?.length > 0
|
|
227
|
+
return hasCodeMentions || hasContextFiles
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
buildMessages(ctx) {
|
|
231
|
+
return [
|
|
232
|
+
{
|
|
233
|
+
role: 'system',
|
|
234
|
+
content: [
|
|
235
|
+
'You are a software scope analyzer.',
|
|
236
|
+
'Given an issue description and context, identify the files and areas affected.',
|
|
237
|
+
'Return a JSON object with:',
|
|
238
|
+
' files: array of { path: string, purpose: string, deps: string[] }',
|
|
239
|
+
' affectedAreas: string[] — high-level areas (e.g. "auth", "UI", "database")',
|
|
240
|
+
'Return ONLY valid JSON.',
|
|
241
|
+
].join('\n'),
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
role: 'user',
|
|
245
|
+
content: contextSummary(ctx) + priorStepsSummary(ctx),
|
|
246
|
+
},
|
|
247
|
+
]
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
parseResponse(raw, ctx) {
|
|
251
|
+
try {
|
|
252
|
+
const parsed = tryParseJSON(raw)
|
|
253
|
+
ctx.scopeAnalysis = {
|
|
254
|
+
files: Array.isArray(parsed.files) ? parsed.files : [],
|
|
255
|
+
affectedAreas: Array.isArray(parsed.affectedAreas) ? parsed.affectedAreas : [],
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
ctx.scopeAnalysis = null
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Step: complexity
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
const complexityStep = {
|
|
268
|
+
name: 'complexity',
|
|
269
|
+
displayName: 'Complexity scored',
|
|
270
|
+
maxTokens: 512,
|
|
271
|
+
|
|
272
|
+
shouldRun(ctx) {
|
|
273
|
+
// Run if we have scope analysis or a non-trivial description
|
|
274
|
+
return ctx.scopeAnalysis != null || (ctx.description || '').length > 50
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
buildMessages(ctx) {
|
|
278
|
+
return [
|
|
279
|
+
{
|
|
280
|
+
role: 'system',
|
|
281
|
+
content: [
|
|
282
|
+
'You assess implementation complexity for GitHub issues.',
|
|
283
|
+
'Return a JSON object with:',
|
|
284
|
+
' score: number 1-10 (1=trivial, 10=massive refactor)',
|
|
285
|
+
' rationale: string — one sentence explaining the score',
|
|
286
|
+
'Return ONLY valid JSON.',
|
|
287
|
+
].join('\n'),
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
role: 'user',
|
|
291
|
+
content: contextSummary(ctx) + priorStepsSummary(ctx),
|
|
292
|
+
},
|
|
293
|
+
]
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
parseResponse(raw, ctx) {
|
|
297
|
+
try {
|
|
298
|
+
const parsed = tryParseJSON(raw)
|
|
299
|
+
ctx.complexity = Math.min(10, Math.max(1, Number(parsed.score) || 5))
|
|
300
|
+
ctx.complexityRationale = parsed.rationale || ''
|
|
301
|
+
} catch {
|
|
302
|
+
ctx.complexity = null
|
|
303
|
+
ctx.complexityRationale = null
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Step: risk
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
const riskStep = {
|
|
313
|
+
name: 'risk',
|
|
314
|
+
displayName: 'Risk assessed',
|
|
315
|
+
maxTokens: 512,
|
|
316
|
+
|
|
317
|
+
shouldRun(ctx) {
|
|
318
|
+
return ctx.scopeAnalysis != null || (ctx.description || '').length > 50
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
buildMessages(ctx) {
|
|
322
|
+
return [
|
|
323
|
+
{
|
|
324
|
+
role: 'system',
|
|
325
|
+
content: [
|
|
326
|
+
'You assess implementation risk for GitHub issues.',
|
|
327
|
+
'Consider: breaking changes, data loss potential, security implications, blast radius.',
|
|
328
|
+
'Return a JSON object with:',
|
|
329
|
+
' score: number 1-10 (1=no risk, 10=extremely risky)',
|
|
330
|
+
' rationale: string — one sentence explaining the score',
|
|
331
|
+
'Return ONLY valid JSON.',
|
|
332
|
+
].join('\n'),
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
role: 'user',
|
|
336
|
+
content: contextSummary(ctx) + priorStepsSummary(ctx),
|
|
337
|
+
},
|
|
338
|
+
]
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
parseResponse(raw, ctx) {
|
|
342
|
+
try {
|
|
343
|
+
const parsed = tryParseJSON(raw)
|
|
344
|
+
ctx.risk = Math.min(10, Math.max(1, Number(parsed.score) || 3))
|
|
345
|
+
ctx.riskRationale = parsed.rationale || ''
|
|
346
|
+
} catch {
|
|
347
|
+
ctx.risk = null
|
|
348
|
+
ctx.riskRationale = null
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Step: labels
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
const labelsStep = {
|
|
358
|
+
name: 'labels',
|
|
359
|
+
displayName: 'Labels suggested',
|
|
360
|
+
maxTokens: 512,
|
|
361
|
+
|
|
362
|
+
shouldRun(ctx) {
|
|
363
|
+
// Run if there are repo labels to choose from
|
|
364
|
+
return ctx.repoLabels?.length > 0
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
buildMessages(ctx) {
|
|
368
|
+
const available = (ctx.repoLabels || []).join(', ')
|
|
369
|
+
return [
|
|
370
|
+
{
|
|
371
|
+
role: 'system',
|
|
372
|
+
content: [
|
|
373
|
+
'You suggest GitHub labels for issues.',
|
|
374
|
+
`Available labels in this repo: ${available}`,
|
|
375
|
+
'Return a JSON object with:',
|
|
376
|
+
' labels: string[] — labels to apply (must be from the available list)',
|
|
377
|
+
' reasoning: string — brief explanation',
|
|
378
|
+
'Only suggest labels that genuinely fit. Return ONLY valid JSON.',
|
|
379
|
+
].join('\n'),
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
role: 'user',
|
|
383
|
+
content: contextSummary(ctx) + priorStepsSummary(ctx),
|
|
384
|
+
},
|
|
385
|
+
]
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
parseResponse(raw, ctx) {
|
|
389
|
+
try {
|
|
390
|
+
const parsed = tryParseJSON(raw)
|
|
391
|
+
const suggested = Array.isArray(parsed.labels) ? parsed.labels : []
|
|
392
|
+
// Filter to only labels that actually exist in the repo
|
|
393
|
+
const valid = ctx.repoLabels || []
|
|
394
|
+
ctx.aiLabels = suggested.filter((l) => valid.includes(l))
|
|
395
|
+
} catch {
|
|
396
|
+
ctx.aiLabels = null
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
// Step: format
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
const formatStep = {
|
|
406
|
+
name: 'format',
|
|
407
|
+
displayName: 'Body formatted',
|
|
408
|
+
maxTokens: 4096,
|
|
409
|
+
|
|
410
|
+
shouldRun() {
|
|
411
|
+
return true
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
buildMessages(ctx) {
|
|
415
|
+
const guidance = buildBodyGuidance(ctx)
|
|
416
|
+
return [
|
|
417
|
+
{
|
|
418
|
+
role: 'system',
|
|
419
|
+
content: [
|
|
420
|
+
'You are an expert at writing clear, well-structured GitHub issues.',
|
|
421
|
+
'Given an issue title, description, and analysis from prior steps,',
|
|
422
|
+
'write a complete issue body in markdown.',
|
|
423
|
+
'',
|
|
424
|
+
guidance,
|
|
425
|
+
].join('\n'),
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
role: 'user',
|
|
429
|
+
content: contextSummary(ctx) + priorStepsSummary(ctx),
|
|
430
|
+
},
|
|
431
|
+
]
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
parseResponse(raw, ctx) {
|
|
435
|
+
// The format step returns raw markdown, not JSON
|
|
436
|
+
// Strip wrapping code fences (```markdown ... ```) that LLMs often add
|
|
437
|
+
let cleaned = raw.trim()
|
|
438
|
+
const fenceMatch = cleaned.match(/^```(?:markdown|md)?\n([\s\S]*)\n```$/)
|
|
439
|
+
if (fenceMatch) cleaned = fenceMatch[1].trim()
|
|
440
|
+
if (cleaned.length > 0) {
|
|
441
|
+
ctx.body = cleaned
|
|
442
|
+
}
|
|
443
|
+
// If empty, ctx.body stays as whatever it was (template fallback)
|
|
444
|
+
},
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// Exports
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* All pipeline steps in execution order.
|
|
453
|
+
*/
|
|
454
|
+
export const ALL_STEPS = [
|
|
455
|
+
triageStep,
|
|
456
|
+
dedupStep,
|
|
457
|
+
contextStep,
|
|
458
|
+
scopeStep,
|
|
459
|
+
complexityStep,
|
|
460
|
+
riskStep,
|
|
461
|
+
labelsStep,
|
|
462
|
+
formatStep,
|
|
463
|
+
]
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Get a step by name.
|
|
467
|
+
* @param {string} name
|
|
468
|
+
* @returns {object|undefined}
|
|
469
|
+
*/
|
|
470
|
+
export function getStep(name) {
|
|
471
|
+
return ALL_STEPS.find((s) => s.name === name)
|
|
472
|
+
}
|
package/src/lib/attribution.js
CHANGED
|
@@ -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} [
|
|
58
|
-
* @property {string} [
|
|
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
|
-
*
|
|
75
|
-
*
|
|
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
|
|
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
|
-
*
|
|
139
|
-
*
|
|
35
|
+
* ---
|
|
36
|
+
* tissues-meta:
|
|
140
37
|
* session: abc123
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
|
46
|
+
const entries = Object.entries(meta)
|
|
47
|
+
if (entries.length === 0) return null
|
|
153
48
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
}
|