@exaudeus/workrail 3.38.0 → 3.39.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,520 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseFindingsFromNotes = parseFindingsFromNotes;
4
+ exports.buildFixGoal = buildFixGoal;
5
+ exports.formatElapsed = formatElapsed;
6
+ exports.discoverConsolePort = discoverConsolePort;
7
+ exports.runPrReviewCoordinator = runPrReviewCoordinator;
8
+ const result_js_1 = require("../runtime/result.js");
9
+ const MAX_FIX_PASSES = 3;
10
+ const CHILD_SESSION_TIMEOUT_MS = 15 * 60 * 1000;
11
+ const COORDINATOR_MAX_MS = 90 * 60 * 1000;
12
+ const COORDINATOR_SPAWN_CUTOFF_MS = COORDINATOR_MAX_MS - 20 * 60 * 1000;
13
+ const REVIEW_AWAIT_TIMEOUT_MS = 20 * 60 * 1000;
14
+ function parseFindingsFromNotes(notes) {
15
+ if (notes === null || notes.trim() === '') {
16
+ return (0, result_js_1.err)('notes is null or empty');
17
+ }
18
+ const jsonBlockRe = /```json\s*\n([\s\S]*?)\n```/g;
19
+ for (const blockMatch of notes.matchAll(jsonBlockRe)) {
20
+ const blockContent = blockMatch[1];
21
+ if (!blockContent)
22
+ continue;
23
+ try {
24
+ const parsed = JSON.parse(blockContent);
25
+ if (typeof parsed['recommendation'] === 'string' &&
26
+ ['clean', 'minor', 'blocking'].includes(parsed['recommendation'])) {
27
+ const severity = parsed['recommendation'];
28
+ const findings = Array.isArray(parsed['findings'])
29
+ ? parsed['findings']
30
+ .filter((f) => typeof f === 'object' && f !== null)
31
+ .map((f) => typeof f['summary'] === 'string' ? f['summary'] : JSON.stringify(f))
32
+ : [];
33
+ return (0, result_js_1.ok)({ severity, findingSummaries: findings, raw: notes });
34
+ }
35
+ }
36
+ catch {
37
+ }
38
+ }
39
+ const upperNotes = notes.toUpperCase();
40
+ const NEGATION_BLOCKING_RE = /\b(?:not|no|without)\b.{0,30}\bblocking\b/i;
41
+ const NEGATION_CRITICAL_RE = /\b(?:not|no|without)\b.{0,30}\bcritical\b/i;
42
+ const NEGATION_REQUEST_CHANGES_RE = /\b(?:not|no|without)\b.{0,30}\brequest[\s_]changes\b/i;
43
+ const hasBlockingKeyword = (upperNotes.includes('BLOCKING') && !NEGATION_BLOCKING_RE.test(notes)) ||
44
+ (upperNotes.includes('CRITICAL') && !NEGATION_CRITICAL_RE.test(notes)) ||
45
+ (upperNotes.includes('REQUEST CHANGES') && !NEGATION_REQUEST_CHANGES_RE.test(notes));
46
+ if (hasBlockingKeyword) {
47
+ return (0, result_js_1.ok)({
48
+ severity: 'blocking',
49
+ findingSummaries: extractFindingSummaries(notes),
50
+ raw: notes,
51
+ });
52
+ }
53
+ const hasCleanKeyword = upperNotes.includes('APPROVE') ||
54
+ upperNotes.includes('LGTM') ||
55
+ upperNotes.includes('NO FINDINGS') ||
56
+ upperNotes.includes('NO ISSUES') ||
57
+ /\bCLEAN\b/.test(upperNotes);
58
+ const hasMinorKeyword = upperNotes.includes('MINOR') ||
59
+ upperNotes.includes('NIT') ||
60
+ upperNotes.includes('NITPICK') ||
61
+ upperNotes.includes('SUGGESTION');
62
+ if (hasCleanKeyword && !hasMinorKeyword) {
63
+ return (0, result_js_1.ok)({
64
+ severity: 'clean',
65
+ findingSummaries: [],
66
+ raw: notes,
67
+ });
68
+ }
69
+ if (hasMinorKeyword) {
70
+ return (0, result_js_1.ok)({
71
+ severity: 'minor',
72
+ findingSummaries: extractFindingSummaries(notes),
73
+ raw: notes,
74
+ });
75
+ }
76
+ return (0, result_js_1.ok)({
77
+ severity: 'unknown',
78
+ findingSummaries: [],
79
+ raw: notes,
80
+ });
81
+ }
82
+ function extractFindingSummaries(notes) {
83
+ const summaries = [];
84
+ const lines = notes.split('\n');
85
+ for (const line of lines) {
86
+ const trimmed = line.trim();
87
+ if (/^[-*]\s+.{10,}/.test(trimmed) || /^\d+\.\s+.{10,}/.test(trimmed)) {
88
+ const upper = trimmed.toUpperCase();
89
+ if (upper.includes('RECOMMEND') ||
90
+ upper.includes('CONFIDENCE') ||
91
+ upper.includes('COVERAGE') ||
92
+ upper.includes('SUMMARY')) {
93
+ continue;
94
+ }
95
+ const summary = trimmed.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '');
96
+ summaries.push(summary.slice(0, 120));
97
+ if (summaries.length >= 5)
98
+ break;
99
+ }
100
+ }
101
+ return summaries;
102
+ }
103
+ function buildFixGoal(prNumber, findings) {
104
+ const findingList = findings.findingSummaries.length > 0
105
+ ? ': ' + findings.findingSummaries.slice(0, 3).join('; ')
106
+ : '';
107
+ return `Fix review findings in PR #${prNumber}${findingList}`;
108
+ }
109
+ function formatElapsed(ms) {
110
+ const totalSeconds = Math.round(ms / 1000);
111
+ const minutes = Math.floor(totalSeconds / 60);
112
+ const seconds = totalSeconds % 60;
113
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`;
114
+ }
115
+ const LOCK_FILE_NAMES = ['daemon-console.lock', 'dashboard.lock'];
116
+ const DEFAULT_CONSOLE_PORT = 3456;
117
+ async function discoverConsolePort(deps, portOverride) {
118
+ if (portOverride !== undefined && portOverride > 0) {
119
+ return portOverride;
120
+ }
121
+ for (const lockFileName of LOCK_FILE_NAMES) {
122
+ const lockPath = deps.joinPath(deps.homedir(), '.workrail', lockFileName);
123
+ try {
124
+ const raw = await deps.readFile(lockPath);
125
+ const parsed = JSON.parse(raw);
126
+ if (typeof parsed.port === 'number' && parsed.port > 0) {
127
+ return parsed.port;
128
+ }
129
+ }
130
+ catch {
131
+ }
132
+ }
133
+ return DEFAULT_CONSOLE_PORT;
134
+ }
135
+ async function runPrReviewCoordinator(deps, opts) {
136
+ const coordinatorStartMs = deps.now();
137
+ const today = new Date(deps.now()).toISOString().slice(0, 10);
138
+ const reportPath = opts.workspace + `/coordinator-pr-review-${today}.md`;
139
+ const reportLines = [];
140
+ function log(line) {
141
+ deps.stderr(line);
142
+ reportLines.push(line);
143
+ }
144
+ log('[1/3] Gathering open PRs...');
145
+ const stageStart = deps.now();
146
+ let prs;
147
+ if (opts.prs && opts.prs.length > 0) {
148
+ prs = opts.prs.map((n) => ({ number: n, title: `PR #${n}`, headRef: '' }));
149
+ }
150
+ else if (opts.dryRun) {
151
+ prs = [];
152
+ log(' [dry-run] would call gh pr list');
153
+ }
154
+ else {
155
+ prs = await deps.listOpenPRs(opts.workspace);
156
+ }
157
+ log(` done (${formatElapsed(deps.now() - stageStart)}) -- ${prs.length} PR(s) found`);
158
+ if (prs.length === 0) {
159
+ const result = {
160
+ reviewed: 0,
161
+ approved: 0,
162
+ escalated: 0,
163
+ mergedPrs: [],
164
+ reportPath,
165
+ hasErrors: false,
166
+ };
167
+ await writeReport(deps, reportPath, reportLines, result);
168
+ return result;
169
+ }
170
+ log(`[2/3] Running reviews (${prs.length} parallel)...`);
171
+ const reviewStart = deps.now();
172
+ if (!opts.dryRun && deps.now() - coordinatorStartMs > COORDINATOR_SPAWN_CUTOFF_MS) {
173
+ log(' WARNING: coordinator elapsed > 70 minutes, refusing to spawn new sessions');
174
+ const result = {
175
+ reviewed: 0,
176
+ approved: 0,
177
+ escalated: prs.length,
178
+ mergedPrs: [],
179
+ reportPath,
180
+ hasErrors: true,
181
+ };
182
+ await writeReport(deps, reportPath, reportLines, result);
183
+ return result;
184
+ }
185
+ const reviewHandles = new Map();
186
+ const spawnErrors = new Map();
187
+ for (const pr of prs) {
188
+ const goal = `Review PR #${pr.number} "${pr.title}" before merge`;
189
+ if (opts.dryRun) {
190
+ log(` PR #${pr.number} [dry-run] would spawn mr-review-workflow-agentic`);
191
+ continue;
192
+ }
193
+ const spawnResult = await deps.spawnSession('mr-review-workflow-agentic', goal, opts.workspace);
194
+ if (spawnResult.kind === 'err') {
195
+ spawnErrors.set(pr.number, spawnResult.error);
196
+ log(` PR #${pr.number} spawn failed: ${spawnResult.error}`);
197
+ }
198
+ else {
199
+ const handle = spawnResult.value;
200
+ if (!handle) {
201
+ spawnErrors.set(pr.number, 'spawn returned empty session handle (zombie detection)');
202
+ log(` PR #${pr.number} spawn returned empty handle -- zombie detection triggered`);
203
+ }
204
+ else {
205
+ reviewHandles.set(pr.number, handle);
206
+ }
207
+ }
208
+ }
209
+ const outcomes = new Map();
210
+ if (!opts.dryRun && reviewHandles.size > 0) {
211
+ const awaitResult = await deps.awaitSessions([...reviewHandles.values()], REVIEW_AWAIT_TIMEOUT_MS);
212
+ const handleToPr = new Map();
213
+ for (const [prNum, handle] of reviewHandles) {
214
+ handleToPr.set(handle, prNum);
215
+ }
216
+ for (const sessionResult of awaitResult.results) {
217
+ const prNum = handleToPr.get(sessionResult.handle);
218
+ if (prNum === undefined)
219
+ continue;
220
+ const elapsedMs = sessionResult.durationMs;
221
+ const handle = sessionResult.handle;
222
+ let notes = null;
223
+ if (sessionResult.outcome === 'success') {
224
+ notes = await deps.getAgentResult(handle);
225
+ }
226
+ const findingsResult = parseFindingsFromNotes(notes);
227
+ const severity = findingsResult.kind === 'ok'
228
+ ? findingsResult.value.severity
229
+ : 'unknown';
230
+ const traceBlock = JSON.stringify({
231
+ childSessionId: handle,
232
+ outcome: sessionResult.outcome,
233
+ elapsedMs,
234
+ severity,
235
+ });
236
+ deps.stderr(`[TRACE] ${traceBlock}`);
237
+ reportLines.push(`\n<!-- TRACE: ${traceBlock} -->`);
238
+ const pr = prs.find((p) => p.number === prNum);
239
+ const severityLabel = severity.toUpperCase();
240
+ log(` PR #${pr.number} ${pr.title} done (${formatElapsed(elapsedMs)}) ${severityLabel}`);
241
+ const findings = findingsResult.kind === 'ok' ? findingsResult.value : null;
242
+ const outcome = {
243
+ prNumber: prNum,
244
+ severity,
245
+ merged: false,
246
+ escalated: sessionResult.outcome !== 'success' || severity === 'blocking' || severity === 'unknown',
247
+ escalationReason: sessionResult.outcome !== 'success'
248
+ ? `session ${sessionResult.outcome}`
249
+ : severity === 'blocking' || severity === 'unknown'
250
+ ? `severity: ${severity}`
251
+ : null,
252
+ passCount: 0,
253
+ sessionHandles: [handle],
254
+ };
255
+ if (severity === 'minor' && findings && sessionResult.outcome === 'success') {
256
+ const processedOutcome = await runFixAgentLoop(deps, opts, pr, findings, outcome, coordinatorStartMs, log);
257
+ outcomes.set(prNum, processedOutcome);
258
+ }
259
+ else {
260
+ outcomes.set(prNum, outcome);
261
+ }
262
+ }
263
+ }
264
+ for (const [prNum, errorMsg] of spawnErrors) {
265
+ outcomes.set(prNum, {
266
+ prNumber: prNum,
267
+ severity: 'unknown',
268
+ merged: false,
269
+ escalated: true,
270
+ escalationReason: `spawn error: ${errorMsg}`,
271
+ passCount: 0,
272
+ sessionHandles: [],
273
+ });
274
+ }
275
+ log('[3/3] Processing results...');
276
+ const mergeQueue = [];
277
+ const escalated = [];
278
+ for (const outcome of outcomes.values()) {
279
+ if (!outcome.escalated && outcome.severity === 'clean') {
280
+ mergeQueue.push(outcome.prNumber);
281
+ log(` PR #${outcome.prNumber} -> queued for merge`);
282
+ }
283
+ else {
284
+ escalated.push(outcome);
285
+ const reason = outcome.escalationReason ?? outcome.severity;
286
+ log(` PR #${outcome.prNumber} -> escalated (${reason})`);
287
+ }
288
+ }
289
+ const mergedPrs = [];
290
+ for (const prNum of mergeQueue) {
291
+ if (opts.dryRun) {
292
+ log(` PR #${prNum} -> [dry-run] would merge`);
293
+ mergedPrs.push(prNum);
294
+ continue;
295
+ }
296
+ const mergeResult = await deps.mergePR(prNum, opts.workspace);
297
+ if (mergeResult.kind === 'ok') {
298
+ mergedPrs.push(prNum);
299
+ log(` PR #${prNum} -> merged`);
300
+ }
301
+ else {
302
+ log(` PR #${prNum} -> merge failed: ${mergeResult.error} (escalated)`);
303
+ escalated.push({
304
+ prNumber: prNum,
305
+ severity: 'clean',
306
+ merged: false,
307
+ escalated: true,
308
+ escalationReason: `merge failed: ${mergeResult.error}`,
309
+ passCount: 0,
310
+ sessionHandles: [],
311
+ });
312
+ }
313
+ }
314
+ const result = {
315
+ reviewed: outcomes.size - spawnErrors.size,
316
+ approved: mergedPrs.length,
317
+ escalated: escalated.length,
318
+ mergedPrs,
319
+ reportPath,
320
+ hasErrors: spawnErrors.size > 0 || escalated.some((o) => o.escalationReason?.startsWith('spawn error') || o.escalationReason?.startsWith('session ') === true),
321
+ };
322
+ const mergedStr = mergedPrs.length > 0 ? `PR #${mergedPrs.join(', PR #')}` : 'none';
323
+ log('');
324
+ log(`RESULT: ${result.reviewed} PRs reviewed, ${result.approved} approved, ${result.escalated} escalated`);
325
+ log(`Merged: ${mergedStr}`);
326
+ log(`Full report: ${reportPath}`);
327
+ log(`\nTotal time: ${formatElapsed(deps.now() - reviewStart)}`);
328
+ await writeReport(deps, reportPath, reportLines, result);
329
+ return result;
330
+ }
331
+ async function runFixAgentLoop(deps, opts, pr, initialFindings, initialOutcome, coordinatorStartMs, log) {
332
+ let passCount = 0;
333
+ let currentFindings = initialFindings;
334
+ let sessionHandles = [...initialOutcome.sessionHandles];
335
+ while (passCount < MAX_FIX_PASSES) {
336
+ if (!opts.dryRun && deps.now() - coordinatorStartMs > COORDINATOR_SPAWN_CUTOFF_MS) {
337
+ log(` PR #${pr.number} -> coordinator elapsed > 70 minutes, escalating`);
338
+ return {
339
+ ...initialOutcome,
340
+ passCount,
341
+ sessionHandles,
342
+ escalated: true,
343
+ escalationReason: 'coordinator elapsed > 70 minutes',
344
+ };
345
+ }
346
+ passCount++;
347
+ const fixGoal = buildFixGoal(pr.number, currentFindings);
348
+ if (opts.dryRun) {
349
+ log(` PR #${pr.number} -> [dry-run] would spawn fix agent (pass ${passCount}): ${fixGoal}`);
350
+ return {
351
+ ...initialOutcome,
352
+ passCount,
353
+ sessionHandles,
354
+ severity: 'clean',
355
+ merged: false,
356
+ escalated: false,
357
+ escalationReason: null,
358
+ };
359
+ }
360
+ log(` PR #${pr.number} -> spawning fix agent (pass ${passCount})...`);
361
+ const fixSpawnResult = await deps.spawnSession('coding-task-workflow-agentic', fixGoal, opts.workspace);
362
+ if (fixSpawnResult.kind === 'err') {
363
+ log(` PR #${pr.number} -> fix agent spawn failed: ${fixSpawnResult.error}`);
364
+ return {
365
+ ...initialOutcome,
366
+ passCount,
367
+ sessionHandles,
368
+ escalated: true,
369
+ escalationReason: `fix agent spawn error (pass ${passCount}): ${fixSpawnResult.error}`,
370
+ };
371
+ }
372
+ const fixHandle = fixSpawnResult.value;
373
+ if (!fixHandle) {
374
+ return {
375
+ ...initialOutcome,
376
+ passCount,
377
+ sessionHandles,
378
+ escalated: true,
379
+ escalationReason: `fix agent returned empty handle (zombie, pass ${passCount})`,
380
+ };
381
+ }
382
+ sessionHandles = [...sessionHandles, fixHandle];
383
+ const fixAwait = await deps.awaitSessions([fixHandle], CHILD_SESSION_TIMEOUT_MS);
384
+ const fixResult = fixAwait.results[0];
385
+ if (!fixResult || fixResult.outcome !== 'success') {
386
+ const outcome = fixResult?.outcome ?? 'not_found';
387
+ log(` PR #${pr.number} -> fix agent ${outcome} (pass ${passCount})`);
388
+ return {
389
+ ...initialOutcome,
390
+ passCount,
391
+ sessionHandles,
392
+ escalated: true,
393
+ escalationReason: `fix agent ${outcome} (pass ${passCount})`,
394
+ };
395
+ }
396
+ log(` PR #${pr.number} -> fix done (pass ${passCount}), re-reviewing...`);
397
+ if (!opts.dryRun && deps.now() - coordinatorStartMs > COORDINATOR_SPAWN_CUTOFF_MS) {
398
+ log(` PR #${pr.number} -> coordinator elapsed > 70 minutes, skipping re-review spawn`);
399
+ return {
400
+ ...initialOutcome,
401
+ passCount,
402
+ sessionHandles,
403
+ escalated: true,
404
+ escalationReason: 'coordinator elapsed > 70 minutes (re-review cutoff)',
405
+ };
406
+ }
407
+ const reReviewGoal = `Re-review PR #${pr.number} after fixes (pass ${passCount})`;
408
+ const reReviewSpawnResult = await deps.spawnSession('mr-review-workflow-agentic', reReviewGoal, opts.workspace);
409
+ if (reReviewSpawnResult.kind === 'err') {
410
+ log(` PR #${pr.number} -> re-review spawn failed: ${reReviewSpawnResult.error}`);
411
+ return {
412
+ ...initialOutcome,
413
+ passCount,
414
+ sessionHandles,
415
+ escalated: true,
416
+ escalationReason: `re-review spawn error (pass ${passCount}): ${reReviewSpawnResult.error}`,
417
+ };
418
+ }
419
+ const reReviewHandle = reReviewSpawnResult.value;
420
+ if (!reReviewHandle) {
421
+ return {
422
+ ...initialOutcome,
423
+ passCount,
424
+ sessionHandles,
425
+ escalated: true,
426
+ escalationReason: `re-review returned empty handle (zombie, pass ${passCount})`,
427
+ };
428
+ }
429
+ sessionHandles = [...sessionHandles, reReviewHandle];
430
+ const reReviewAwait = await deps.awaitSessions([reReviewHandle], REVIEW_AWAIT_TIMEOUT_MS);
431
+ const reReviewResult = reReviewAwait.results[0];
432
+ if (!reReviewResult || reReviewResult.outcome !== 'success') {
433
+ const outcome = reReviewResult?.outcome ?? 'not_found';
434
+ log(` PR #${pr.number} -> re-review ${outcome} (pass ${passCount})`);
435
+ return {
436
+ ...initialOutcome,
437
+ passCount,
438
+ sessionHandles,
439
+ escalated: true,
440
+ escalationReason: `re-review ${outcome} (pass ${passCount})`,
441
+ };
442
+ }
443
+ const reNotes = await deps.getAgentResult(reReviewHandle);
444
+ const reFindingsResult = parseFindingsFromNotes(reNotes);
445
+ const reSeverity = reFindingsResult.kind === 'ok'
446
+ ? reFindingsResult.value.severity
447
+ : 'unknown';
448
+ const traceBlock = JSON.stringify({
449
+ childSessionId: reReviewHandle,
450
+ outcome: reReviewResult.outcome,
451
+ elapsedMs: reReviewResult.durationMs,
452
+ severity: reSeverity,
453
+ });
454
+ deps.stderr(`[TRACE] ${traceBlock}`);
455
+ log(` PR #${pr.number} -> re-review result: ${reSeverity.toUpperCase()} (pass ${passCount})`);
456
+ if (reSeverity === 'clean') {
457
+ return {
458
+ prNumber: pr.number,
459
+ severity: 'clean',
460
+ merged: false,
461
+ escalated: false,
462
+ escalationReason: null,
463
+ passCount,
464
+ sessionHandles,
465
+ };
466
+ }
467
+ if (reSeverity === 'blocking' || reSeverity === 'unknown') {
468
+ return {
469
+ prNumber: pr.number,
470
+ severity: reSeverity,
471
+ merged: false,
472
+ escalated: true,
473
+ escalationReason: `severity: ${reSeverity} after fix (pass ${passCount})`,
474
+ passCount,
475
+ sessionHandles,
476
+ };
477
+ }
478
+ currentFindings = reFindingsResult.kind === 'ok'
479
+ ? reFindingsResult.value
480
+ : { severity: 'minor', findingSummaries: [], raw: reNotes ?? '' };
481
+ }
482
+ log(` PR #${pr.number} -> ${MAX_FIX_PASSES} fix passes exhausted, escalating`);
483
+ return {
484
+ prNumber: pr.number,
485
+ severity: 'minor',
486
+ merged: false,
487
+ escalated: true,
488
+ escalationReason: `${MAX_FIX_PASSES} fix passes exhausted`,
489
+ passCount: MAX_FIX_PASSES,
490
+ sessionHandles,
491
+ };
492
+ }
493
+ async function writeReport(deps, reportPath, logLines, result) {
494
+ const today = new Date(deps.now()).toISOString().slice(0, 19).replace('T', ' ');
495
+ const content = [
496
+ `# PR Review Coordinator Report`,
497
+ ``,
498
+ `Generated: ${today}`,
499
+ ``,
500
+ `## Summary`,
501
+ ``,
502
+ `- PRs reviewed: ${result.reviewed}`,
503
+ `- Approved and merged: ${result.approved}`,
504
+ `- Escalated: ${result.escalated}`,
505
+ result.mergedPrs.length > 0 ? `- Merged PRs: ${result.mergedPrs.map((n) => `#${n}`).join(', ')}` : `- Merged PRs: none`,
506
+ ``,
507
+ `## Run Log`,
508
+ ``,
509
+ `\`\`\``,
510
+ ...logLines,
511
+ `\`\`\``,
512
+ ``,
513
+ ].join('\n');
514
+ try {
515
+ await deps.writeFile(reportPath, content);
516
+ }
517
+ catch {
518
+ deps.stderr(`Warning: could not write report to ${reportPath}`);
519
+ }
520
+ }
@@ -238,8 +238,8 @@
238
238
  "bytes": 31
239
239
  },
240
240
  "cli-worktrain.js": {
241
- "sha256": "984167ed2dd411c409971fbfcd35847c368a205f3d147af9b6310a90377c47f9",
242
- "bytes": 32342
241
+ "sha256": "72187f7993fdad038cd3e164372a3e7fbbe630bc56d1b40bdea0062e2431e089",
242
+ "bytes": 43548
243
243
  },
244
244
  "cli.d.ts": {
245
245
  "sha256": "43e818adf60173644896298637f47b01d5819b17eda46eaa32d0c7d64724d012",
@@ -449,16 +449,16 @@
449
449
  "sha256": "5fe866e54f796975dec5d8ba9983aefd86074db212d3fccd64eed04bc9f0b3da",
450
450
  "bytes": 8011
451
451
  },
452
+ "console-ui/assets/index-3oXZ_A9m.js": {
453
+ "sha256": "b4d27f6107d5100ca44bb257e724beaf88df2ab235a21bdc88d5c9bfb89009ff",
454
+ "bytes": 754955
455
+ },
452
456
  "console-ui/assets/index-8dh0Psu-.css": {
453
457
  "sha256": "cf9d09641f1c31fffe6c7835b30bbbad52572befec1acab7fb9a0c188431af36",
454
458
  "bytes": 60355
455
459
  },
456
- "console-ui/assets/index-BtOJj6Xy.js": {
457
- "sha256": "92758189eacadde70ae3288c09100f130d51bf27811b3a92b9ea355f45243836",
458
- "bytes": 754955
459
- },
460
460
  "console-ui/index.html": {
461
- "sha256": "13df1ae3bcff9a53cdfde1ab8e78db4c6e582809588d45f81096699c1c91b71c",
461
+ "sha256": "bd9b538a079608bf3cf01289202d58a81c52336e3ee0bf797c863459c66d700f",
462
462
  "bytes": 417
463
463
  },
464
464
  "console/standalone-console.d.ts": {
@@ -469,6 +469,14 @@
469
469
  "sha256": "759d8be0b9804aa2e6301f14afb2dc97d45231434058f64d1cfbec6e025a1991",
470
470
  "bytes": 6325
471
471
  },
472
+ "coordinators/pr-review.d.ts": {
473
+ "sha256": "f544a13fbfbcbac56229914270aad58a91a3081ce1d5e1e667e8eef45fd698d2",
474
+ "bytes": 2570
475
+ },
476
+ "coordinators/pr-review.js": {
477
+ "sha256": "3a9cb96ea0b4e138dac28b13abcb172b5785fdf67d752a3c8f42d746fb07feec",
478
+ "bytes": 21154
479
+ },
472
480
  "core/error-handler.d.ts": {
473
481
  "sha256": "80451f12ac8e185133ec3dc4c57285491a785f27525ed21e729db1da3f61010d",
474
482
  "bytes": 1368
@@ -0,0 +1,73 @@
1
+ # Design Review Findings: PR Review Coordinator
2
+
3
+ *Review completed: 2026-04-18*
4
+
5
+ ## Tradeoff Review
6
+
7
+ | Tradeoff | Verdict | Conditions for Failure |
8
+ |----------|---------|------------------------|
9
+ | Port discovery duplicated from worktrain-spawn.ts | Acceptable | Lock file names change (low risk, bounded) |
10
+ | Two-tier parser is heuristic | Acceptable for MVP | False escalation rate > 20% of clean PRs |
11
+ | Fix-agent loop max 3 passes | Acceptable | If 3 passes is too few for real codebases (tunable) |
12
+
13
+ ## Failure Mode Review
14
+
15
+ | Failure Mode | Coverage | Risk |
16
+ |-------------|----------|------|
17
+ | null recapMarkdown -> unknown -> escalate | Full | Low -- conservative, correct |
18
+ | Fix loop 3 passes, persistent minor -> escalate | Full | Low |
19
+ | ECONNREFUSED -> clear error exit | Partial (needs timeout) | Low |
20
+ | Keyword false positive -> false escalation | Partial (needs negation check) | Medium |
21
+ | gh pr merge failure -> escalate without retry | Full | Low |
22
+ | Zombie session (null childSessionId) | Full | Low |
23
+
24
+ ## Runner-Up / Simpler Alternative Review
25
+
26
+ Candidate A (subprocess model) has nothing worth borrowing. No beneficial hybrid exists. Candidate B is the correct shape. One naming improvement identified: `postResult` -> `writeFile(path, content)` for clarity.
27
+
28
+ ## Philosophy Alignment
29
+
30
+ - All 8 CLAUDE.md principles satisfied
31
+ - Two minor tensions: mutable loop counter (acceptable, local), two-tier parser as patch (acceptable, follow-up filed)
32
+
33
+ ---
34
+
35
+ ## Findings
36
+
37
+ ### ORANGE: Keyword false positive risk -- negation context not handled
38
+
39
+ The keyword scanner will match `BLOCKING` in contexts like 'this is not technically blocking'. This is the most likely source of false escalations in practice.
40
+
41
+ **Recommended revision:** Before returning `'blocking'`, check that the `BLOCKING` keyword is not preceded by a negation within ~30 chars: `/\b(?:not|no|without)\b.{0,30}\bblocking\b/i` -> if this matches, do not classify as blocking. Apply same check to CRITICAL.
42
+
43
+ ### ORANGE: No fetch timeout on HTTP calls
44
+
45
+ `spawnSession()` and `getAgentResult()` use bare `fetch()` with no timeout. If the daemon is running but unresponsive, the coordinator hangs indefinitely.
46
+
47
+ **Recommended revision:** Add `AbortSignal.timeout(30_000)` to all dispatch and session/node fetch calls. Catch `AbortError` and return `err('Daemon request timed out after 30s')`.
48
+
49
+ ### YELLOW: `postResult` dep name is unclear
50
+
51
+ The `postResult(notes: string)` name is ambiguous -- does it post to Slack? Write to a file? Create a GitHub comment?
52
+
53
+ **Recommended revision:** Rename to `writeFile(path: string, content: string): Promise<void>`. The coordinator decides the report file path. This matches the UX spec: `Full report: ./coordinator-pr-review-2026-04-18.md`.
54
+
55
+ ### YELLOW: Rule 3 adaptation not explicit in original 5 robustness rules spec
56
+
57
+ The original Rule 3 (go/no-go time check) was designed for daemon sessions with known `maxSessionMinutes`. The CLI coordinator has no such parameter.
58
+
59
+ **Recommended revision:** Explicit Rule 3 adaptation: `const coordinatorStartMs = deps.now()` at startup; before each `spawnSession()` call, check `deps.now() - coordinatorStartMs > 70 * 60 * 1000` (70 min = 90 min coordinator max - 20 min buffer) and refuse to spawn if exceeded.
60
+
61
+ ---
62
+
63
+ ## Recommended Revisions
64
+
65
+ 1. Add negation context check in keyword parser (`/\b(?:not|no|without)\b.{0,30}\bblocking\b/i`)
66
+ 2. Add `AbortSignal.timeout(30_000)` to fetch calls
67
+ 3. Rename `postResult` to `writeFile(path, content)`
68
+ 4. Add explicit wall-clock Rule 3 adaptation to implementation spec
69
+
70
+ ## Residual Concerns
71
+
72
+ - The two-tier parser's keyword scan is a heuristic. False escalation rate unknown until tested against real mr-review outputs. Follow-up: update mr-review-workflow to emit `## COORDINATOR_OUTPUT` JSON block.
73
+ - The coordinator's own timeout (90 minutes) is hardcoded. Should be configurable via `--max-runtime` flag if needed. Not for MVP.