claude-code-session-manager 0.13.1 → 0.14.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.
Files changed (43) hide show
  1. package/dist/assets/{TiptapBody-Btu-_mZq.js → TiptapBody-CzNkD0kT.js} +1 -1
  2. package/dist/assets/{cssMode-EkBJI3CN.js → cssMode-DUMBBgBO.js} +1 -1
  3. package/dist/assets/{freemarker2-DFbgmGC_.js → freemarker2-BjnoO8uS.js} +1 -1
  4. package/dist/assets/{handlebars-CUy9oTnL.js → handlebars-CggfdTjZ.js} +1 -1
  5. package/dist/assets/{html-CUFKxIqK.js → html-Bcnp_pNA.js} +1 -1
  6. package/dist/assets/{htmlMode-hBSk7Fab.js → htmlMode-CFws8rf5.js} +1 -1
  7. package/dist/assets/{index-BGIAQ9_i.js → index-0geebEag.js} +1494 -1193
  8. package/dist/assets/{index-D4dlTF2R.css → index-DOo1Vtox.css} +1 -1
  9. package/dist/assets/{javascript-CAH2ooMO.js → javascript-DI4e0TwT.js} +1 -1
  10. package/dist/assets/{jsonMode-BSMCRMaw.js → jsonMode-Ca0fgEAF.js} +1 -1
  11. package/dist/assets/{liquid-CSMs05cs.js → liquid-E7hk4hx9.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-DEyYbu0m.js → lspLanguageFeatures-Wh7EFavn.js} +1 -1
  13. package/dist/assets/{mdx-BocaqTLI.js → mdx-iXIhpMBJ.js} +1 -1
  14. package/dist/assets/{python-Dj6u2CWq.js → python-laxy9eG1.js} +1 -1
  15. package/dist/assets/{razor-D5OWmIwh.js → razor-CatPL_eG.js} +1 -1
  16. package/dist/assets/{tsMode-Cy6xJd5e.js → tsMode-D2WL55Cf.js} +1 -1
  17. package/dist/assets/{typescript-CsWWOqVn.js → typescript-B5DFbVoi.js} +1 -1
  18. package/dist/assets/{xml-DiTZK7ii.js → xml-BsStStQW.js} +1 -1
  19. package/dist/assets/{yaml-pdLhZQr9.js → yaml-rg2IIDpu.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +2 -1
  22. package/src/main/__tests__/runVerify.test.cjs +388 -0
  23. package/src/main/config.cjs +1 -7
  24. package/src/main/files.cjs +4 -37
  25. package/src/main/historyAggregator.cjs +37 -14
  26. package/src/main/index.cjs +53 -134
  27. package/src/main/ipcSchemas.cjs +4 -0
  28. package/src/main/lib/childWithLog.cjs +218 -0
  29. package/src/main/lib/expandHome.cjs +21 -0
  30. package/src/main/lib/insideHome.cjs +74 -21
  31. package/src/main/lib/openExternalApp.cjs +141 -0
  32. package/src/main/pluginInstall.cjs +39 -1
  33. package/src/main/pty.cjs +11 -4
  34. package/src/main/queueOps.cjs +2 -2
  35. package/src/main/runVerify.cjs +527 -0
  36. package/src/main/scheduler.cjs +335 -286
  37. package/src/main/search.cjs +1 -6
  38. package/src/main/superagent.cjs +10 -0
  39. package/src/main/supervisor.cjs +16 -8
  40. package/src/main/transcripts.cjs +6 -0
  41. package/src/main/watchers.cjs +2 -2
  42. package/src/preload/api.d.ts +16 -7
  43. package/src/preload/index.cjs +1 -0
@@ -0,0 +1,527 @@
1
+ /**
2
+ * runVerify.cjs — post-run reconciliation verifier.
3
+ *
4
+ * Called by scheduler.cjs after every agent exit=0, before marking a job
5
+ * "completed". Pattern-matches tool_result content from the run log and checks
6
+ * dependency prerequisites to catch false-positives (clean-exit ≠ done).
7
+ *
8
+ * Pure: no IPC, no queue writes. Writes a verdicts.json sidecar to runDir as
9
+ * a by-product so the renderer and any offline tooling can read the result
10
+ * without re-parsing the log.
11
+ *
12
+ * Exported API:
13
+ * verifyRun({ runDir, prdPath, queueEntry, allJobs }) → Promise<Verdict>
14
+ *
15
+ * Verdict shape:
16
+ * { verdict: 'clean'|'halt'|'deps_unmet'|'transcript_errors'|'verify_unavailable',
17
+ * reason: string,
18
+ * downgradeTo: 'pending'|'needs_review'|null }
19
+ *
20
+ * Downgrade policy:
21
+ * clean → null (caller stamps 'completed')
22
+ * halt → 'pending' (re-fires automatically next slot)
23
+ * deps_unmet → 'pending' (re-fires when deps clear)
24
+ * transcript_errors → 'needs_review' (requires human flip)
25
+ * verify_unavailable → 'needs_review'
26
+ *
27
+ * Time complexity: O(N·L) where N = log line count, L = avg content line count.
28
+ * The log is read once with readFileSync (700 KB typical; well within Node.js
29
+ * heap). Line-by-line JSON parsing keeps working set small.
30
+ *
31
+ * Non-goals: semantic understanding of agent output, retroactive re-verification
32
+ * of already-completed jobs.
33
+ */
34
+
35
+ 'use strict';
36
+
37
+ const fs = require('node:fs');
38
+ const path = require('node:path');
39
+
40
+ const VERDICTS_SCHEMA_VERSION = 1;
41
+
42
+ // ─── content pattern detectors ───────────────────────────────────────────────
43
+
44
+ /**
45
+ * Detect high-signal error patterns in a single tool_result content string.
46
+ * Returns { verdict, pattern } on match, null if clean.
47
+ *
48
+ * Checked in priority order (cheapest first):
49
+ * 1. FAIL/FATAL at line start
50
+ * 2. Traceback + Error within 10 lines (Python exception)
51
+ * 3. ModuleNotFoundError / ImportError (missing venv / broken deps)
52
+ */
53
+ function detectPattern(content) {
54
+ if (typeof content !== 'string' || !content) return null;
55
+
56
+ // (1) FAIL/FATAL at the start of a line (case-sensitive, multiline).
57
+ if (/^FAIL\b/m.test(content) || /^FATAL\b/m.test(content)) {
58
+ return { verdict: 'transcript_errors', pattern: 'FAIL/FATAL at line start' };
59
+ }
60
+
61
+ // (2) Python Traceback + Error line within next 10 lines.
62
+ const lines = content.split('\n');
63
+ for (let i = 0; i < lines.length; i++) {
64
+ if (lines[i].includes('Traceback (most recent call last):')) {
65
+ for (let j = i + 1; j < Math.min(i + 11, lines.length); j++) {
66
+ if (lines[j].includes('Error:')) {
67
+ return { verdict: 'transcript_errors', pattern: 'Traceback + Error within 10 lines' };
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ // (3) Import / module errors (verification was skipped).
74
+ if (content.includes('ModuleNotFoundError') || content.includes('ImportError')) {
75
+ return { verdict: 'verify_unavailable', pattern: 'ModuleNotFoundError/ImportError' };
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ // ─── log parser ───────────────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Coerce a tool_result content field to a plain string.
85
+ * Claude's stream-json may send content as either a string or an array of
86
+ * { type: 'text', text: '...' } blocks.
87
+ */
88
+ function extractContent(item) {
89
+ if (typeof item.content === 'string') return item.content;
90
+ if (Array.isArray(item.content)) {
91
+ return item.content
92
+ .filter((b) => b && b.type === 'text')
93
+ .map((b) => b.text ?? '')
94
+ .join('');
95
+ }
96
+ return '';
97
+ }
98
+
99
+ /**
100
+ * Parse the run log into a flat sequence of extracted events.
101
+ *
102
+ * Event shapes:
103
+ * { kind:'tool_use', seq, toolUseId, toolName, description, command }
104
+ * { kind:'tool_result', seq, toolUseId, content, isError }
105
+ * { kind:'result', seq, subtype, resultText }
106
+ *
107
+ * Non-JSON lines (scheduler metadata "[scheduler] ...") are silently skipped.
108
+ * Malformed JSON lines are also skipped.
109
+ *
110
+ * O(N) where N = log line count.
111
+ *
112
+ * @returns {{ events: object[], resultEvent: object|null, error: string|null }}
113
+ */
114
+ function parseLog(logPath) {
115
+ let text;
116
+ try {
117
+ text = fs.readFileSync(logPath, 'utf8');
118
+ } catch (e) {
119
+ return { events: [], resultEvent: null, error: e.message };
120
+ }
121
+
122
+ const events = [];
123
+ let resultEvent = null;
124
+ let seq = 0;
125
+
126
+ for (const line of text.split('\n')) {
127
+ const trimmed = line.trim();
128
+ if (!trimmed || trimmed.startsWith('[scheduler]')) continue;
129
+
130
+ let obj;
131
+ try { obj = JSON.parse(trimmed); } catch { continue; }
132
+ if (!obj || typeof obj !== 'object') continue;
133
+
134
+ // Final result event.
135
+ if (obj.type === 'result') {
136
+ const ev = {
137
+ kind: 'result',
138
+ seq: seq++,
139
+ subtype: obj.subtype ?? '',
140
+ resultText: typeof obj.result === 'string' ? obj.result : '',
141
+ };
142
+ resultEvent = ev;
143
+ events.push(ev);
144
+ continue;
145
+ }
146
+
147
+ // Assistant turn: extract tool_use items.
148
+ if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
149
+ for (const item of obj.message.content) {
150
+ if (item?.type === 'tool_use') {
151
+ events.push({
152
+ kind: 'tool_use',
153
+ seq: seq++,
154
+ toolUseId: item.id ?? '',
155
+ toolName: item.name ?? '',
156
+ description: item.input?.description ?? '',
157
+ command: item.input?.command ?? '',
158
+ });
159
+ }
160
+ }
161
+ continue;
162
+ }
163
+
164
+ // User turn: extract tool_result items.
165
+ if (obj.type === 'user' && Array.isArray(obj.message?.content)) {
166
+ for (const item of obj.message.content) {
167
+ if (item?.type === 'tool_result') {
168
+ events.push({
169
+ kind: 'tool_result',
170
+ seq: seq++,
171
+ toolUseId: item.tool_use_id ?? '',
172
+ content: extractContent(item),
173
+ isError: item.is_error === true,
174
+ });
175
+ }
176
+ }
177
+ continue;
178
+ }
179
+ }
180
+
181
+ return { events, resultEvent, error: null };
182
+ }
183
+
184
+ // ─── self-recovery helpers ────────────────────────────────────────────────────
185
+
186
+ /**
187
+ * Return the description of the tool_use that produced a given tool_result
188
+ * (matched by tool_use_id). Returns '' if not found.
189
+ */
190
+ function toolUseDesc(events, toolUseId) {
191
+ if (!toolUseId) return '';
192
+ for (const ev of events) {
193
+ if (ev.kind === 'tool_use' && ev.toolUseId === toolUseId) return ev.description ?? '';
194
+ }
195
+ return '';
196
+ }
197
+
198
+ /**
199
+ * Check whether the next ≤5 tool_use calls after `fromSeq` include a package
200
+ * install command (pip install, pip3 install, uv sync, uv pip install).
201
+ */
202
+ function hasInstallRecovery(events, fromSeq) {
203
+ let count = 0;
204
+ for (const ev of events) {
205
+ if (ev.seq <= fromSeq) continue;
206
+ if (ev.kind !== 'tool_use') continue;
207
+ if (++count > 5) break;
208
+ const combined = `${ev.command ?? ''} ${ev.description ?? ''}`;
209
+ if (/pip\d*\s+install/i.test(combined) || /uv\s+(?:sync|pip\s+install)/i.test(combined)) {
210
+ return true;
211
+ }
212
+ }
213
+ return false;
214
+ }
215
+
216
+ /**
217
+ * Check whether the error at `errorSeq` is self-recovered within the next 30
218
+ * events.
219
+ *
220
+ * Recovery heuristic: another tool_use with the same non-empty `description`
221
+ * appears within 30 events of the error, AND its corresponding tool_result is
222
+ * free of error patterns AND is not is_error=true.
223
+ */
224
+ function isSelfRecovered(events, errorSeq, desc) {
225
+ if (!desc) return false;
226
+
227
+ // Build a quick lookup from tool_use_id → tool_result event.
228
+ const resultByUseId = new Map();
229
+ for (const ev of events) {
230
+ if (ev.kind === 'tool_result') resultByUseId.set(ev.toolUseId, ev);
231
+ }
232
+
233
+ let seen = 0;
234
+ for (const ev of events) {
235
+ if (ev.seq <= errorSeq) continue;
236
+ if (++seen > 30) break;
237
+ if (ev.kind !== 'tool_use' || ev.description !== desc) continue;
238
+ const result = resultByUseId.get(ev.toolUseId);
239
+ if (result && !result.isError && !detectPattern(result.content)) return true;
240
+ }
241
+ return false;
242
+ }
243
+
244
+ // ─── soak / deps parsing ─────────────────────────────────────────────────────
245
+
246
+ /**
247
+ * Extract soak days from a text string.
248
+ *
249
+ * Accepted forms (case-insensitive):
250
+ * "30+ days", "30 days", "≥ 180 days", "180-day soak",
251
+ * "N days of soak", "N days each", "soak of N days"
252
+ *
253
+ * Returns { soakDays, soakPhrase } or null.
254
+ * Refuses to parse if the unit is not days (surfaces as verify_unavailable
255
+ * at the dep-check call site).
256
+ */
257
+ function extractSoakFromBody(body) {
258
+ const PATTERNS = [
259
+ /(\d+)\+?\s*-?day\s+soak/i,
260
+ /soak\s+of\s+(\d+)\+?\s*days?/i,
261
+ /≥\s*(\d+)\s+days?/i,
262
+ /(\d+)\+?\s*days?\s+(?:of\s+)?soak/i,
263
+ /(\d+)\+?\s*days?\s+each/i,
264
+ /only\s+after\s+\S+.*?(?:run\s+)?(\d+)\+?\s*days?/i,
265
+ ];
266
+ for (const re of PATTERNS) {
267
+ const m = body.match(re);
268
+ if (m && m[1]) {
269
+ const n = parseInt(m[1], 10);
270
+ if (!isNaN(n) && n > 0 && n <= 3650) return { soakDays: n, soakPhrase: m[0].trim() };
271
+ }
272
+ }
273
+ return null;
274
+ }
275
+
276
+ /**
277
+ * Parse dependency slug fragments from a PRD body:
278
+ * - Lines under "# Dependencies", "# Depends on", "# Pre-condition", "# Blocked on"
279
+ * - Inline "This PRD is blocked on: - M2 — ..."
280
+ *
281
+ * Returns a deduplicated array of lowercase fragment strings.
282
+ */
283
+ function parsePrdBodyDepFragments(body) {
284
+ const frags = [];
285
+
286
+ const SECTION_RE = /^#+\s*(?:depends?\s+on|dependencies|pre-?conditions?|blocked\s+on)[^\n]*\n([\s\S]*?)(?=\n#|$)/im;
287
+ const INLINE_RE = /(?:this\s+prd\s+is\s+blocked\s+on|depends?\s+on)\s*:?\s+([\s\S]*?)(?=\n\n|$)/i;
288
+
289
+ const sections = [];
290
+ const sm = body.match(SECTION_RE);
291
+ if (sm) sections.push(sm[1]);
292
+ const im = body.match(INLINE_RE);
293
+ if (im) sections.push(im[1]);
294
+
295
+ for (const section of sections) {
296
+ for (const m of section.matchAll(/^\s*[-*]\s*([A-Za-z][A-Za-z0-9._-]+)/gm)) {
297
+ const frag = m[1].toLowerCase();
298
+ if (frag.length >= 2) frags.push(frag);
299
+ }
300
+ }
301
+ return [...new Set(frags)];
302
+ }
303
+
304
+ /**
305
+ * Verify that all declared dependencies for `queueEntry` are satisfied.
306
+ *
307
+ * Two layers:
308
+ * 1. queue.json's `dependsOn` array (explicit slug list, often empty).
309
+ * 2. PRD body deps section (prose slug fragments + optional soak period).
310
+ *
311
+ * @returns {{ ok: boolean, reason?: string, floorDate?: string }}
312
+ */
313
+ function checkDeps(queueEntry, allJobs, prdBody) {
314
+ const bySlug = new Map(allJobs.map((j) => [j.slug, j]));
315
+
316
+ // 1. Queue-level dependsOn (explicit).
317
+ const queueDeps = Array.isArray(queueEntry.dependsOn) ? queueEntry.dependsOn : [];
318
+ for (const depSlug of queueDeps) {
319
+ const dep = bySlug.get(depSlug);
320
+ if (!dep) {
321
+ return { ok: false, reason: `dependsOn: "${depSlug}" not found in queue` };
322
+ }
323
+ if (dep.status !== 'completed') {
324
+ return { ok: false, reason: `dependsOn: "${depSlug}" is ${dep.status} (need completed)` };
325
+ }
326
+ }
327
+
328
+ // 2. PRD body deps (prose).
329
+ if (prdBody) {
330
+ const frags = parsePrdBodyDepFragments(prdBody);
331
+ const soak = extractSoakFromBody(prdBody);
332
+ let latestFinishedMs = null;
333
+
334
+ for (const frag of frags) {
335
+ // Substring match: "m7" matches "44-signal-builder-m7-final-cutover".
336
+ const matches = allJobs.filter(
337
+ (j) => j.slug !== queueEntry.slug && j.slug.toLowerCase().includes(frag),
338
+ );
339
+ if (matches.length === 0) continue; // unresolvable fragment — skip
340
+
341
+ for (const dep of matches) {
342
+ if (dep.status !== 'completed') {
343
+ return {
344
+ ok: false,
345
+ reason: `PRD body dep "${frag}" → "${dep.slug}" is ${dep.status} (need completed)`,
346
+ };
347
+ }
348
+ if (dep.finishedAt) {
349
+ const t = new Date(dep.finishedAt).getTime();
350
+ if (!isNaN(t) && (latestFinishedMs === null || t > latestFinishedMs)) {
351
+ latestFinishedMs = t;
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+ // Soak period check.
358
+ if (soak && latestFinishedMs !== null) {
359
+ const floorMs = latestFinishedMs + soak.soakDays * 86_400_000;
360
+ if (Date.now() < floorMs) {
361
+ const floorDate = new Date(floorMs).toISOString().slice(0, 10);
362
+ return {
363
+ ok: false,
364
+ reason: `Soak not elapsed: "${soak.soakPhrase}" requires waiting until ${floorDate}`,
365
+ floorDate,
366
+ };
367
+ }
368
+ }
369
+ }
370
+
371
+ return { ok: true };
372
+ }
373
+
374
+ // ─── main verifier ────────────────────────────────────────────────────────────
375
+
376
+ /**
377
+ * Verify a completed run (exit=0). Returns a verdict indicating whether the
378
+ * scheduler should mark the job 'completed', downgrade to 'pending', or
379
+ * escalate to 'needs_review'.
380
+ *
381
+ * @param {object} params
382
+ * @param {string} params.runDir Absolute path to the run directory.
383
+ * @param {string} params.prdPath Absolute path to the PRD .md file.
384
+ * @param {object} params.queueEntry The queue.json entry for this job.
385
+ * @param {object[]} [params.allJobs] All entries from queue.json (dep checks).
386
+ * @returns {Promise<{verdict:string, reason:string, downgradeTo:string|null}>}
387
+ */
388
+ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [] }) {
389
+ const { slug } = queueEntry;
390
+ const logPath = path.join(runDir, `${slug}.log`);
391
+ const verdictsPath = path.join(runDir, `${slug}.verdicts.json`);
392
+
393
+ /** Write the sidecar and return the verdict object. */
394
+ function conclude(verdict, reason, downgradeTo, extras) {
395
+ const record = {
396
+ schemaVersion: VERDICTS_SCHEMA_VERSION,
397
+ verdict,
398
+ reason,
399
+ downgradeTo,
400
+ scannedAt: new Date().toISOString(),
401
+ ...(extras ?? {}),
402
+ };
403
+ try { fs.writeFileSync(verdictsPath, JSON.stringify(record, null, 2)); } catch { /* best-effort */ }
404
+ return { verdict, reason, downgradeTo };
405
+ }
406
+
407
+ try {
408
+ // ── Read PRD body (strip frontmatter) ──────────────────────────────────
409
+ let prdBody = '';
410
+ try {
411
+ const prdText = fs.readFileSync(prdPath, 'utf8');
412
+ if (prdText.startsWith('---\n')) {
413
+ const end = prdText.indexOf('\n---', 4);
414
+ prdBody = end !== -1 ? prdText.slice(end + 4).trim() : prdText;
415
+ } else {
416
+ prdBody = prdText;
417
+ }
418
+ } catch { /* PRD unreadable — skip PRD-level dep checks */ }
419
+
420
+ // ── 1. Dependency check ────────────────────────────────────────────────
421
+ // Run first: if deps are unmet we can skip the expensive log parse.
422
+ const depsResult = checkDeps(queueEntry, allJobs, prdBody);
423
+ if (!depsResult.ok) {
424
+ return conclude('deps_unmet', depsResult.reason, 'pending', {
425
+ soakFloorDate: depsResult.floorDate ?? null,
426
+ });
427
+ }
428
+
429
+ // ── 2. Parse log ───────────────────────────────────────────────────────
430
+ const { events, resultEvent, error: parseError } = parseLog(logPath);
431
+
432
+ if (parseError) {
433
+ return conclude('verify_unavailable', `log unreadable: ${parseError}`, 'needs_review');
434
+ }
435
+
436
+ // ── 3. HALT detection ─────────────────────────────────────────────────
437
+ // Primary: check the final `{"type":"result"}` event's `result` text.
438
+ if (resultEvent) {
439
+ const rt = resultEvent.resultText;
440
+ if (/\bHALT:/i.test(rt) || /^HALT\b/m.test(rt)) {
441
+ const firstLine = rt.split('\n')[0].slice(0, 200);
442
+ let reason = `Agent HALTed: ${firstLine}`;
443
+ // Embed machine-parseable floor date if present in HALT message.
444
+ const dateMatch = rt.match(/~?(\d{4}-\d{2}-\d{2})/);
445
+ if (dateMatch) reason += ` (eligible from: ${dateMatch[1]})`;
446
+ return conclude('halt', reason, 'pending');
447
+ }
448
+ }
449
+
450
+ // ── 4. Tool-result error scan ─────────────────────────────────────────
451
+ const total = events.length;
452
+ const last20pctStart = Math.floor(total * 0.8);
453
+ const issues = [];
454
+
455
+ for (let i = 0; i < events.length; i++) {
456
+ const ev = events[i];
457
+ if (ev.kind !== 'tool_result') continue;
458
+
459
+ // is_error:true in the final 20% of the transcript.
460
+ if (ev.isError && i >= last20pctStart) {
461
+ const desc = toolUseDesc(events, ev.toolUseId);
462
+ if (!isSelfRecovered(events, ev.seq, desc)) {
463
+ issues.push({
464
+ verdict: 'transcript_errors',
465
+ reason: `is_error=true in final 20% of transcript (event ${i}/${total})`,
466
+ priority: 2,
467
+ });
468
+ continue; // is_error already covers any content patterns
469
+ }
470
+ }
471
+
472
+ if (!ev.content) continue;
473
+
474
+ const hit = detectPattern(ev.content);
475
+ if (!hit) continue;
476
+
477
+ const desc = toolUseDesc(events, ev.toolUseId);
478
+
479
+ if (hit.verdict === 'verify_unavailable') {
480
+ // ModuleNotFoundError/ImportError: first check for pip/uv install in
481
+ // the next ≤5 tool_use calls (the agent may have self-healed).
482
+ if (!hasInstallRecovery(events, ev.seq) && !isSelfRecovered(events, ev.seq, desc)) {
483
+ issues.push({
484
+ verdict: 'verify_unavailable',
485
+ reason: `${hit.pattern} at event ${i}, no install recovery found`,
486
+ priority: 1,
487
+ });
488
+ }
489
+ } else {
490
+ // transcript_errors (FAIL/FATAL/Traceback): self-recovery escape hatch.
491
+ if (!isSelfRecovered(events, ev.seq, desc)) {
492
+ issues.push({
493
+ verdict: 'transcript_errors',
494
+ reason: `${hit.pattern} at event ${i} (no self-recovery)`,
495
+ priority: 2,
496
+ });
497
+ }
498
+ }
499
+ }
500
+
501
+ if (issues.length === 0) {
502
+ return conclude('clean', 'no issues detected', null);
503
+ }
504
+
505
+ // Pick highest-priority issue (transcript_errors > verify_unavailable).
506
+ issues.sort((a, b) => b.priority - a.priority);
507
+ const top = issues[0];
508
+ return conclude(top.verdict, top.reason, 'needs_review');
509
+
510
+ } catch (e) {
511
+ return conclude(
512
+ 'verify_unavailable',
513
+ `verifier threw: ${e?.message ?? String(e)}`,
514
+ 'needs_review',
515
+ );
516
+ }
517
+ }
518
+
519
+ module.exports = {
520
+ verifyRun,
521
+ // Exposed for unit tests.
522
+ detectPattern,
523
+ extractSoakFromBody,
524
+ parsePrdBodyDepFragments,
525
+ checkDeps,
526
+ parseLog,
527
+ };