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.
@@ -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
+ }
@@ -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
- }