@exaudeus/workrail 3.38.0 → 3.40.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 (38) hide show
  1. package/dist/cli-worktrain.js +231 -0
  2. package/dist/console-ui/assets/{index-BtOJj6Xy.js → index-CXWCAonr.js} +1 -1
  3. package/dist/console-ui/index.html +1 -1
  4. package/dist/coordinators/pr-review.d.ts +62 -0
  5. package/dist/coordinators/pr-review.js +575 -0
  6. package/dist/daemon/workflow-runner.d.ts +3 -2
  7. package/dist/daemon/workflow-runner.js +6 -3
  8. package/dist/manifest.json +58 -34
  9. package/dist/mcp/output-schemas.d.ts +10 -10
  10. package/dist/mcp/tools.d.ts +12 -12
  11. package/dist/trigger/trigger-router.js +9 -2
  12. package/dist/types/workflow-source.d.ts +0 -1
  13. package/dist/types/workflow-source.js +3 -6
  14. package/dist/types/workflow.d.ts +1 -1
  15. package/dist/types/workflow.js +1 -2
  16. package/dist/v2/durable-core/domain/artifact-contract-validator.js +66 -0
  17. package/dist/v2/durable-core/schemas/artifacts/coordinator-signal.d.ts +25 -0
  18. package/dist/v2/durable-core/schemas/artifacts/coordinator-signal.js +31 -0
  19. package/dist/v2/durable-core/schemas/artifacts/index.d.ts +3 -1
  20. package/dist/v2/durable-core/schemas/artifacts/index.js +14 -1
  21. package/dist/v2/durable-core/schemas/artifacts/review-verdict.d.ts +41 -0
  22. package/dist/v2/durable-core/schemas/artifacts/review-verdict.js +30 -0
  23. package/dist/v2/durable-core/schemas/export-bundle/index.d.ts +236 -236
  24. package/dist/v2/durable-core/schemas/session/events.d.ts +50 -50
  25. package/dist/v2/durable-core/schemas/session/gaps.d.ts +2 -2
  26. package/dist/v2/durable-core/schemas/session/manifest.d.ts +4 -4
  27. package/dist/v2/durable-core/schemas/session/outputs.d.ts +8 -8
  28. package/dist/v2/usecases/console-routes.js +178 -0
  29. package/docs/design/coordinator-artifact-protocol-design-candidates.md +155 -0
  30. package/docs/design/coordinator-artifact-protocol-design-review.md +103 -0
  31. package/docs/design/coordinator-artifact-protocol-implementation-plan.md +259 -0
  32. package/docs/discovery/coordinator-design-review.md +73 -0
  33. package/docs/discovery/coordinator-script-design.md +96 -679
  34. package/docs/discovery/hypothesis-challenge-report.md +44 -0
  35. package/docs/discovery/simulation-report.md +85 -0
  36. package/docs/ideas/backlog.md +158 -100
  37. package/package.json +1 -1
  38. package/workflows/mr-review-workflow.agentic.v2.json +5 -1
@@ -0,0 +1,575 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseFindingsFromNotes = parseFindingsFromNotes;
4
+ exports.readVerdictArtifact = readVerdictArtifact;
5
+ exports.buildFixGoal = buildFixGoal;
6
+ exports.formatElapsed = formatElapsed;
7
+ exports.discoverConsolePort = discoverConsolePort;
8
+ exports.runPrReviewCoordinator = runPrReviewCoordinator;
9
+ const result_js_1 = require("../runtime/result.js");
10
+ const review_verdict_js_1 = require("../v2/durable-core/schemas/artifacts/review-verdict.js");
11
+ const MAX_FIX_PASSES = 3;
12
+ const CHILD_SESSION_TIMEOUT_MS = 15 * 60 * 1000;
13
+ const COORDINATOR_MAX_MS = 90 * 60 * 1000;
14
+ const COORDINATOR_SPAWN_CUTOFF_MS = COORDINATOR_MAX_MS - 20 * 60 * 1000;
15
+ const REVIEW_AWAIT_TIMEOUT_MS = 20 * 60 * 1000;
16
+ function parseFindingsFromNotes(notes) {
17
+ if (notes === null || notes.trim() === '') {
18
+ return (0, result_js_1.err)('notes is null or empty');
19
+ }
20
+ const jsonBlockRe = /```json\s*\n([\s\S]*?)\n```/g;
21
+ for (const blockMatch of notes.matchAll(jsonBlockRe)) {
22
+ const blockContent = blockMatch[1];
23
+ if (!blockContent)
24
+ continue;
25
+ try {
26
+ const parsed = JSON.parse(blockContent);
27
+ if (typeof parsed['recommendation'] === 'string' &&
28
+ ['clean', 'minor', 'blocking'].includes(parsed['recommendation'])) {
29
+ const severity = parsed['recommendation'];
30
+ const findings = Array.isArray(parsed['findings'])
31
+ ? parsed['findings']
32
+ .filter((f) => typeof f === 'object' && f !== null)
33
+ .map((f) => typeof f['summary'] === 'string' ? f['summary'] : JSON.stringify(f))
34
+ : [];
35
+ return (0, result_js_1.ok)({ severity, findingSummaries: findings, raw: notes });
36
+ }
37
+ }
38
+ catch {
39
+ }
40
+ }
41
+ const upperNotes = notes.toUpperCase();
42
+ const NEGATION_BLOCKING_RE = /\b(?:not|no|without)\b.{0,30}\bblocking\b/i;
43
+ const NEGATION_CRITICAL_RE = /\b(?:not|no|without)\b.{0,30}\bcritical\b/i;
44
+ const NEGATION_REQUEST_CHANGES_RE = /\b(?:not|no|without)\b.{0,30}\brequest[\s_]changes\b/i;
45
+ const hasBlockingKeyword = (upperNotes.includes('BLOCKING') && !NEGATION_BLOCKING_RE.test(notes)) ||
46
+ (upperNotes.includes('CRITICAL') && !NEGATION_CRITICAL_RE.test(notes)) ||
47
+ (upperNotes.includes('REQUEST CHANGES') && !NEGATION_REQUEST_CHANGES_RE.test(notes));
48
+ if (hasBlockingKeyword) {
49
+ return (0, result_js_1.ok)({
50
+ severity: 'blocking',
51
+ findingSummaries: extractFindingSummaries(notes),
52
+ raw: notes,
53
+ source: 'keyword_scan',
54
+ });
55
+ }
56
+ const hasCleanKeyword = upperNotes.includes('APPROVE') ||
57
+ upperNotes.includes('LGTM') ||
58
+ upperNotes.includes('NO FINDINGS') ||
59
+ upperNotes.includes('NO ISSUES') ||
60
+ /\bCLEAN\b/.test(upperNotes);
61
+ const hasMinorKeyword = upperNotes.includes('MINOR') ||
62
+ upperNotes.includes('NIT') ||
63
+ upperNotes.includes('NITPICK') ||
64
+ upperNotes.includes('SUGGESTION');
65
+ if (hasCleanKeyword && !hasMinorKeyword) {
66
+ return (0, result_js_1.ok)({
67
+ severity: 'clean',
68
+ findingSummaries: [],
69
+ raw: notes,
70
+ source: 'keyword_scan',
71
+ });
72
+ }
73
+ if (hasMinorKeyword) {
74
+ return (0, result_js_1.ok)({
75
+ severity: 'minor',
76
+ findingSummaries: extractFindingSummaries(notes),
77
+ raw: notes,
78
+ source: 'keyword_scan',
79
+ });
80
+ }
81
+ return (0, result_js_1.ok)({
82
+ severity: 'unknown',
83
+ findingSummaries: [],
84
+ raw: notes,
85
+ source: 'keyword_scan',
86
+ });
87
+ }
88
+ function readVerdictArtifact(artifacts, sessionHandle) {
89
+ const handlePrefix = sessionHandle ? sessionHandle.slice(0, 16) : 'unknown';
90
+ for (const raw of artifacts) {
91
+ if (!(0, review_verdict_js_1.isReviewVerdictArtifact)(raw))
92
+ continue;
93
+ const result = review_verdict_js_1.ReviewVerdictArtifactV1Schema.safeParse(raw);
94
+ if (!result.success) {
95
+ const issues = result.error.issues
96
+ .map((i) => `${i.path.join('.')}: ${i.message}`)
97
+ .join('; ');
98
+ process.stderr.write(`[WARN coord:reason=artifact_parse_failed handle=${handlePrefix}] readVerdictArtifact: wr.review_verdict schema validation failed: ${issues}\n`);
99
+ continue;
100
+ }
101
+ const v = result.data;
102
+ return {
103
+ severity: v.verdict,
104
+ findingSummaries: v.findings.map((f) => f.summary),
105
+ raw: JSON.stringify(v),
106
+ source: 'artifact',
107
+ };
108
+ }
109
+ return null;
110
+ }
111
+ function extractFindingSummaries(notes) {
112
+ const summaries = [];
113
+ const lines = notes.split('\n');
114
+ for (const line of lines) {
115
+ const trimmed = line.trim();
116
+ if (/^[-*]\s+.{10,}/.test(trimmed) || /^\d+\.\s+.{10,}/.test(trimmed)) {
117
+ const upper = trimmed.toUpperCase();
118
+ if (upper.includes('RECOMMEND') ||
119
+ upper.includes('CONFIDENCE') ||
120
+ upper.includes('COVERAGE') ||
121
+ upper.includes('SUMMARY')) {
122
+ continue;
123
+ }
124
+ const summary = trimmed.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '');
125
+ summaries.push(summary.slice(0, 120));
126
+ if (summaries.length >= 5)
127
+ break;
128
+ }
129
+ }
130
+ return summaries;
131
+ }
132
+ function buildFixGoal(prNumber, findings) {
133
+ const findingList = findings.findingSummaries.length > 0
134
+ ? ': ' + findings.findingSummaries.slice(0, 3).join('; ')
135
+ : '';
136
+ return `Fix review findings in PR #${prNumber}${findingList}`;
137
+ }
138
+ function formatElapsed(ms) {
139
+ const totalSeconds = Math.round(ms / 1000);
140
+ const minutes = Math.floor(totalSeconds / 60);
141
+ const seconds = totalSeconds % 60;
142
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`;
143
+ }
144
+ const LOCK_FILE_NAMES = ['daemon-console.lock', 'dashboard.lock'];
145
+ const DEFAULT_CONSOLE_PORT = 3456;
146
+ async function discoverConsolePort(deps, portOverride) {
147
+ if (portOverride !== undefined && portOverride > 0) {
148
+ return portOverride;
149
+ }
150
+ for (const lockFileName of LOCK_FILE_NAMES) {
151
+ const lockPath = deps.joinPath(deps.homedir(), '.workrail', lockFileName);
152
+ try {
153
+ const raw = await deps.readFile(lockPath);
154
+ const parsed = JSON.parse(raw);
155
+ if (typeof parsed.port === 'number' && parsed.port > 0) {
156
+ return parsed.port;
157
+ }
158
+ }
159
+ catch {
160
+ }
161
+ }
162
+ return DEFAULT_CONSOLE_PORT;
163
+ }
164
+ async function runPrReviewCoordinator(deps, opts) {
165
+ const coordinatorStartMs = deps.now();
166
+ const today = new Date(deps.now()).toISOString().slice(0, 10);
167
+ const reportPath = opts.workspace + `/coordinator-pr-review-${today}.md`;
168
+ const reportLines = [];
169
+ function log(line) {
170
+ deps.stderr(line);
171
+ reportLines.push(line);
172
+ }
173
+ log('[1/3] Gathering open PRs...');
174
+ const stageStart = deps.now();
175
+ let prs;
176
+ if (opts.prs && opts.prs.length > 0) {
177
+ prs = opts.prs.map((n) => ({ number: n, title: `PR #${n}`, headRef: '' }));
178
+ }
179
+ else if (opts.dryRun) {
180
+ prs = [];
181
+ log(' [dry-run] would call gh pr list');
182
+ }
183
+ else {
184
+ prs = await deps.listOpenPRs(opts.workspace);
185
+ }
186
+ log(` done (${formatElapsed(deps.now() - stageStart)}) -- ${prs.length} PR(s) found`);
187
+ if (prs.length === 0) {
188
+ const result = {
189
+ reviewed: 0,
190
+ approved: 0,
191
+ escalated: 0,
192
+ mergedPrs: [],
193
+ reportPath,
194
+ hasErrors: false,
195
+ };
196
+ await writeReport(deps, reportPath, reportLines, result);
197
+ return result;
198
+ }
199
+ log(`[2/3] Running reviews (${prs.length} parallel)...`);
200
+ const reviewStart = deps.now();
201
+ if (!opts.dryRun && deps.now() - coordinatorStartMs > COORDINATOR_SPAWN_CUTOFF_MS) {
202
+ log(' WARNING: coordinator elapsed > 70 minutes, refusing to spawn new sessions');
203
+ const result = {
204
+ reviewed: 0,
205
+ approved: 0,
206
+ escalated: prs.length,
207
+ mergedPrs: [],
208
+ reportPath,
209
+ hasErrors: true,
210
+ };
211
+ await writeReport(deps, reportPath, reportLines, result);
212
+ return result;
213
+ }
214
+ const reviewHandles = new Map();
215
+ const spawnErrors = new Map();
216
+ for (const pr of prs) {
217
+ const goal = `Review PR #${pr.number} "${pr.title}" before merge`;
218
+ if (opts.dryRun) {
219
+ log(` PR #${pr.number} [dry-run] would spawn mr-review-workflow-agentic`);
220
+ continue;
221
+ }
222
+ const spawnResult = await deps.spawnSession('mr-review-workflow-agentic', goal, opts.workspace);
223
+ if (spawnResult.kind === 'err') {
224
+ spawnErrors.set(pr.number, spawnResult.error);
225
+ log(` PR #${pr.number} spawn failed: ${spawnResult.error}`);
226
+ }
227
+ else {
228
+ const handle = spawnResult.value;
229
+ if (!handle) {
230
+ spawnErrors.set(pr.number, 'spawn returned empty session handle (zombie detection)');
231
+ log(` PR #${pr.number} spawn returned empty handle -- zombie detection triggered`);
232
+ }
233
+ else {
234
+ reviewHandles.set(pr.number, handle);
235
+ }
236
+ }
237
+ }
238
+ const outcomes = new Map();
239
+ if (!opts.dryRun && reviewHandles.size > 0) {
240
+ const awaitResult = await deps.awaitSessions([...reviewHandles.values()], REVIEW_AWAIT_TIMEOUT_MS);
241
+ const handleToPr = new Map();
242
+ for (const [prNum, handle] of reviewHandles) {
243
+ handleToPr.set(handle, prNum);
244
+ }
245
+ for (const sessionResult of awaitResult.results) {
246
+ const prNum = handleToPr.get(sessionResult.handle);
247
+ if (prNum === undefined)
248
+ continue;
249
+ const elapsedMs = sessionResult.durationMs;
250
+ const handle = sessionResult.handle;
251
+ let notes = null;
252
+ let artifacts = [];
253
+ if (sessionResult.outcome === 'success') {
254
+ const agentResult = await deps.getAgentResult(handle);
255
+ notes = agentResult.recapMarkdown;
256
+ artifacts = agentResult.artifacts;
257
+ }
258
+ const verdictFromArtifact = readVerdictArtifact(artifacts, handle);
259
+ const findingsResult = verdictFromArtifact !== null
260
+ ? (() => {
261
+ deps.stderr(`[INFO coord:source=artifact handle=${handle.slice(0, 16)}] readVerdictArtifact succeeded`);
262
+ return (0, result_js_1.ok)(verdictFromArtifact);
263
+ })()
264
+ : (() => {
265
+ const keywordResult = parseFindingsFromNotes(notes);
266
+ if (keywordResult.kind === 'ok') {
267
+ const reason = artifacts.length > 0 ? 'no_valid_artifact' : 'no_artifacts';
268
+ deps.stderr(`[INFO coord:source=keyword_scan reason=${reason} artifactCount=${artifacts.length} handle=${handle.slice(0, 16)}]`);
269
+ }
270
+ return keywordResult;
271
+ })();
272
+ const severity = findingsResult.kind === 'ok'
273
+ ? findingsResult.value.severity
274
+ : 'unknown';
275
+ const traceBlock = JSON.stringify({
276
+ childSessionId: handle,
277
+ outcome: sessionResult.outcome,
278
+ elapsedMs,
279
+ severity,
280
+ });
281
+ deps.stderr(`[TRACE] ${traceBlock}`);
282
+ reportLines.push(`\n<!-- TRACE: ${traceBlock} -->`);
283
+ const pr = prs.find((p) => p.number === prNum);
284
+ const severityLabel = severity.toUpperCase();
285
+ log(` PR #${pr.number} ${pr.title} done (${formatElapsed(elapsedMs)}) ${severityLabel}`);
286
+ const findings = findingsResult.kind === 'ok' ? findingsResult.value : null;
287
+ const outcome = {
288
+ prNumber: prNum,
289
+ severity,
290
+ merged: false,
291
+ escalated: sessionResult.outcome !== 'success' || severity === 'blocking' || severity === 'unknown',
292
+ escalationReason: sessionResult.outcome !== 'success'
293
+ ? `session ${sessionResult.outcome}`
294
+ : severity === 'blocking' || severity === 'unknown'
295
+ ? `severity: ${severity}`
296
+ : null,
297
+ passCount: 0,
298
+ sessionHandles: [handle],
299
+ };
300
+ if (severity === 'minor' && findings && sessionResult.outcome === 'success') {
301
+ const processedOutcome = await runFixAgentLoop(deps, opts, pr, findings, outcome, coordinatorStartMs, log);
302
+ outcomes.set(prNum, processedOutcome);
303
+ }
304
+ else {
305
+ outcomes.set(prNum, outcome);
306
+ }
307
+ }
308
+ }
309
+ for (const [prNum, errorMsg] of spawnErrors) {
310
+ outcomes.set(prNum, {
311
+ prNumber: prNum,
312
+ severity: 'unknown',
313
+ merged: false,
314
+ escalated: true,
315
+ escalationReason: `spawn error: ${errorMsg}`,
316
+ passCount: 0,
317
+ sessionHandles: [],
318
+ });
319
+ }
320
+ log('[3/3] Processing results...');
321
+ const mergeQueue = [];
322
+ const escalated = [];
323
+ for (const outcome of outcomes.values()) {
324
+ if (!outcome.escalated && outcome.severity === 'clean') {
325
+ mergeQueue.push(outcome.prNumber);
326
+ log(` PR #${outcome.prNumber} -> queued for merge`);
327
+ }
328
+ else {
329
+ escalated.push(outcome);
330
+ const reason = outcome.escalationReason ?? outcome.severity;
331
+ log(` PR #${outcome.prNumber} -> escalated (${reason})`);
332
+ }
333
+ }
334
+ const mergedPrs = [];
335
+ for (const prNum of mergeQueue) {
336
+ if (opts.dryRun) {
337
+ log(` PR #${prNum} -> [dry-run] would merge`);
338
+ mergedPrs.push(prNum);
339
+ continue;
340
+ }
341
+ const mergeResult = await deps.mergePR(prNum, opts.workspace);
342
+ if (mergeResult.kind === 'ok') {
343
+ mergedPrs.push(prNum);
344
+ log(` PR #${prNum} -> merged`);
345
+ }
346
+ else {
347
+ log(` PR #${prNum} -> merge failed: ${mergeResult.error} (escalated)`);
348
+ escalated.push({
349
+ prNumber: prNum,
350
+ severity: 'clean',
351
+ merged: false,
352
+ escalated: true,
353
+ escalationReason: `merge failed: ${mergeResult.error}`,
354
+ passCount: 0,
355
+ sessionHandles: [],
356
+ });
357
+ }
358
+ }
359
+ const result = {
360
+ reviewed: outcomes.size - spawnErrors.size,
361
+ approved: mergedPrs.length,
362
+ escalated: escalated.length,
363
+ mergedPrs,
364
+ reportPath,
365
+ hasErrors: spawnErrors.size > 0 || escalated.some((o) => o.escalationReason?.startsWith('spawn error') || o.escalationReason?.startsWith('session ') === true),
366
+ };
367
+ const mergedStr = mergedPrs.length > 0 ? `PR #${mergedPrs.join(', PR #')}` : 'none';
368
+ log('');
369
+ log(`RESULT: ${result.reviewed} PRs reviewed, ${result.approved} approved, ${result.escalated} escalated`);
370
+ log(`Merged: ${mergedStr}`);
371
+ log(`Full report: ${reportPath}`);
372
+ log(`\nTotal time: ${formatElapsed(deps.now() - reviewStart)}`);
373
+ await writeReport(deps, reportPath, reportLines, result);
374
+ return result;
375
+ }
376
+ async function runFixAgentLoop(deps, opts, pr, initialFindings, initialOutcome, coordinatorStartMs, log) {
377
+ let passCount = 0;
378
+ let currentFindings = initialFindings;
379
+ let sessionHandles = [...initialOutcome.sessionHandles];
380
+ while (passCount < MAX_FIX_PASSES) {
381
+ if (!opts.dryRun && deps.now() - coordinatorStartMs > COORDINATOR_SPAWN_CUTOFF_MS) {
382
+ log(` PR #${pr.number} -> coordinator elapsed > 70 minutes, escalating`);
383
+ return {
384
+ ...initialOutcome,
385
+ passCount,
386
+ sessionHandles,
387
+ escalated: true,
388
+ escalationReason: 'coordinator elapsed > 70 minutes',
389
+ };
390
+ }
391
+ passCount++;
392
+ const fixGoal = buildFixGoal(pr.number, currentFindings);
393
+ if (opts.dryRun) {
394
+ log(` PR #${pr.number} -> [dry-run] would spawn fix agent (pass ${passCount}): ${fixGoal}`);
395
+ return {
396
+ ...initialOutcome,
397
+ passCount,
398
+ sessionHandles,
399
+ severity: 'clean',
400
+ merged: false,
401
+ escalated: false,
402
+ escalationReason: null,
403
+ };
404
+ }
405
+ log(` PR #${pr.number} -> spawning fix agent (pass ${passCount})...`);
406
+ const fixSpawnResult = await deps.spawnSession('coding-task-workflow-agentic', fixGoal, opts.workspace);
407
+ if (fixSpawnResult.kind === 'err') {
408
+ log(` PR #${pr.number} -> fix agent spawn failed: ${fixSpawnResult.error}`);
409
+ return {
410
+ ...initialOutcome,
411
+ passCount,
412
+ sessionHandles,
413
+ escalated: true,
414
+ escalationReason: `fix agent spawn error (pass ${passCount}): ${fixSpawnResult.error}`,
415
+ };
416
+ }
417
+ const fixHandle = fixSpawnResult.value;
418
+ if (!fixHandle) {
419
+ return {
420
+ ...initialOutcome,
421
+ passCount,
422
+ sessionHandles,
423
+ escalated: true,
424
+ escalationReason: `fix agent returned empty handle (zombie, pass ${passCount})`,
425
+ };
426
+ }
427
+ sessionHandles = [...sessionHandles, fixHandle];
428
+ const fixAwait = await deps.awaitSessions([fixHandle], CHILD_SESSION_TIMEOUT_MS);
429
+ const fixResult = fixAwait.results[0];
430
+ if (!fixResult || fixResult.outcome !== 'success') {
431
+ const outcome = fixResult?.outcome ?? 'not_found';
432
+ log(` PR #${pr.number} -> fix agent ${outcome} (pass ${passCount})`);
433
+ return {
434
+ ...initialOutcome,
435
+ passCount,
436
+ sessionHandles,
437
+ escalated: true,
438
+ escalationReason: `fix agent ${outcome} (pass ${passCount})`,
439
+ };
440
+ }
441
+ log(` PR #${pr.number} -> fix done (pass ${passCount}), re-reviewing...`);
442
+ if (!opts.dryRun && deps.now() - coordinatorStartMs > COORDINATOR_SPAWN_CUTOFF_MS) {
443
+ log(` PR #${pr.number} -> coordinator elapsed > 70 minutes, skipping re-review spawn`);
444
+ return {
445
+ ...initialOutcome,
446
+ passCount,
447
+ sessionHandles,
448
+ escalated: true,
449
+ escalationReason: 'coordinator elapsed > 70 minutes (re-review cutoff)',
450
+ };
451
+ }
452
+ const reReviewGoal = `Re-review PR #${pr.number} after fixes (pass ${passCount})`;
453
+ const reReviewSpawnResult = await deps.spawnSession('mr-review-workflow-agentic', reReviewGoal, opts.workspace);
454
+ if (reReviewSpawnResult.kind === 'err') {
455
+ log(` PR #${pr.number} -> re-review spawn failed: ${reReviewSpawnResult.error}`);
456
+ return {
457
+ ...initialOutcome,
458
+ passCount,
459
+ sessionHandles,
460
+ escalated: true,
461
+ escalationReason: `re-review spawn error (pass ${passCount}): ${reReviewSpawnResult.error}`,
462
+ };
463
+ }
464
+ const reReviewHandle = reReviewSpawnResult.value;
465
+ if (!reReviewHandle) {
466
+ return {
467
+ ...initialOutcome,
468
+ passCount,
469
+ sessionHandles,
470
+ escalated: true,
471
+ escalationReason: `re-review returned empty handle (zombie, pass ${passCount})`,
472
+ };
473
+ }
474
+ sessionHandles = [...sessionHandles, reReviewHandle];
475
+ const reReviewAwait = await deps.awaitSessions([reReviewHandle], REVIEW_AWAIT_TIMEOUT_MS);
476
+ const reReviewResult = reReviewAwait.results[0];
477
+ if (!reReviewResult || reReviewResult.outcome !== 'success') {
478
+ const outcome = reReviewResult?.outcome ?? 'not_found';
479
+ log(` PR #${pr.number} -> re-review ${outcome} (pass ${passCount})`);
480
+ return {
481
+ ...initialOutcome,
482
+ passCount,
483
+ sessionHandles,
484
+ escalated: true,
485
+ escalationReason: `re-review ${outcome} (pass ${passCount})`,
486
+ };
487
+ }
488
+ const reAgentResult = await deps.getAgentResult(reReviewHandle);
489
+ const reVerdictFromArtifact = readVerdictArtifact(reAgentResult.artifacts, reReviewHandle);
490
+ const reFindingsResult = reVerdictFromArtifact !== null
491
+ ? (() => {
492
+ deps.stderr(`[INFO coord:source=artifact handle=${reReviewHandle.slice(0, 16)}] readVerdictArtifact succeeded (re-review pass ${passCount})`);
493
+ return (0, result_js_1.ok)(reVerdictFromArtifact);
494
+ })()
495
+ : (() => {
496
+ const reason = reAgentResult.artifacts.length > 0 ? 'no_valid_artifact' : 'no_artifacts';
497
+ deps.stderr(`[INFO coord:source=keyword_scan reason=${reason} artifactCount=${reAgentResult.artifacts.length} handle=${reReviewHandle.slice(0, 16)}]`);
498
+ return parseFindingsFromNotes(reAgentResult.recapMarkdown);
499
+ })();
500
+ const reSeverity = reFindingsResult.kind === 'ok'
501
+ ? reFindingsResult.value.severity
502
+ : 'unknown';
503
+ const traceBlock = JSON.stringify({
504
+ childSessionId: reReviewHandle,
505
+ outcome: reReviewResult.outcome,
506
+ elapsedMs: reReviewResult.durationMs,
507
+ severity: reSeverity,
508
+ });
509
+ deps.stderr(`[TRACE] ${traceBlock}`);
510
+ log(` PR #${pr.number} -> re-review result: ${reSeverity.toUpperCase()} (pass ${passCount})`);
511
+ if (reSeverity === 'clean') {
512
+ return {
513
+ prNumber: pr.number,
514
+ severity: 'clean',
515
+ merged: false,
516
+ escalated: false,
517
+ escalationReason: null,
518
+ passCount,
519
+ sessionHandles,
520
+ };
521
+ }
522
+ if (reSeverity === 'blocking' || reSeverity === 'unknown') {
523
+ return {
524
+ prNumber: pr.number,
525
+ severity: reSeverity,
526
+ merged: false,
527
+ escalated: true,
528
+ escalationReason: `severity: ${reSeverity} after fix (pass ${passCount})`,
529
+ passCount,
530
+ sessionHandles,
531
+ };
532
+ }
533
+ currentFindings = reFindingsResult.kind === 'ok'
534
+ ? reFindingsResult.value
535
+ : { severity: 'minor', findingSummaries: [], raw: reAgentResult.recapMarkdown ?? '' };
536
+ }
537
+ log(` PR #${pr.number} -> ${MAX_FIX_PASSES} fix passes exhausted, escalating`);
538
+ return {
539
+ prNumber: pr.number,
540
+ severity: 'minor',
541
+ merged: false,
542
+ escalated: true,
543
+ escalationReason: `${MAX_FIX_PASSES} fix passes exhausted`,
544
+ passCount: MAX_FIX_PASSES,
545
+ sessionHandles,
546
+ };
547
+ }
548
+ async function writeReport(deps, reportPath, logLines, result) {
549
+ const today = new Date(deps.now()).toISOString().slice(0, 19).replace('T', ' ');
550
+ const content = [
551
+ `# PR Review Coordinator Report`,
552
+ ``,
553
+ `Generated: ${today}`,
554
+ ``,
555
+ `## Summary`,
556
+ ``,
557
+ `- PRs reviewed: ${result.reviewed}`,
558
+ `- Approved and merged: ${result.approved}`,
559
+ `- Escalated: ${result.escalated}`,
560
+ result.mergedPrs.length > 0 ? `- Merged PRs: ${result.mergedPrs.map((n) => `#${n}`).join(', ')}` : `- Merged PRs: none`,
561
+ ``,
562
+ `## Run Log`,
563
+ ``,
564
+ `\`\`\``,
565
+ ...logLines,
566
+ `\`\`\``,
567
+ ``,
568
+ ].join('\n');
569
+ try {
570
+ await deps.writeFile(reportPath, content);
571
+ }
572
+ catch {
573
+ deps.stderr(`Warning: could not write report to ${reportPath}`);
574
+ }
575
+ }
@@ -29,6 +29,7 @@ export interface WorkflowRunSuccess {
29
29
  readonly workflowId: string;
30
30
  readonly stopReason: string;
31
31
  readonly lastStepNotes?: string;
32
+ readonly lastStepArtifacts?: readonly unknown[];
32
33
  }
33
34
  export interface WorkflowRunError {
34
35
  readonly _tag: 'error';
@@ -64,8 +65,8 @@ export declare function readDaemonSessionState(sessionId: string): Promise<{
64
65
  } | null>;
65
66
  export declare function readAllDaemonSessions(sessionsDir?: string): Promise<OrphanedSession[]>;
66
67
  export declare function runStartupRecovery(sessionsDir?: string): Promise<void>;
67
- export declare function makeContinueWorkflowTool(sessionId: string, ctx: V2ToolContext, onAdvance: (nextStepText: string, continueToken: string) => void, onComplete: (notes: string | undefined) => void, schemas: Record<string, any>, _executeContinueWorkflowFn?: typeof executeContinueWorkflow, emitter?: DaemonEventEmitter, workrailSessionId?: string | null): AgentTool;
68
- export declare function makeCompleteStepTool(sessionId: string, ctx: V2ToolContext, getCurrentToken: () => string, onAdvance: (nextStepText: string, continueToken: string) => void, onComplete: (notes: string | undefined) => void, onTokenUpdate: (t: string) => void, schemas: Record<string, any>, _executeContinueWorkflowFn?: typeof executeContinueWorkflow, emitter?: DaemonEventEmitter, workrailSessionId?: string | null): AgentTool;
68
+ export declare function makeContinueWorkflowTool(sessionId: string, ctx: V2ToolContext, onAdvance: (nextStepText: string, continueToken: string) => void, onComplete: (notes: string | undefined, artifacts?: readonly unknown[]) => void, schemas: Record<string, any>, _executeContinueWorkflowFn?: typeof executeContinueWorkflow, emitter?: DaemonEventEmitter, workrailSessionId?: string | null): AgentTool;
69
+ export declare function makeCompleteStepTool(sessionId: string, ctx: V2ToolContext, getCurrentToken: () => string, onAdvance: (nextStepText: string, continueToken: string) => void, onComplete: (notes: string | undefined, artifacts?: readonly unknown[]) => void, onTokenUpdate: (t: string) => void, schemas: Record<string, any>, _executeContinueWorkflowFn?: typeof executeContinueWorkflow, emitter?: DaemonEventEmitter, workrailSessionId?: string | null): AgentTool;
69
70
  export declare function makeBashTool(workspacePath: string, schemas: Record<string, any>, sessionId?: string, emitter?: DaemonEventEmitter, workrailSessionId?: string | null): AgentTool;
70
71
  export declare function makeSpawnAgentTool(sessionId: string, ctx: V2ToolContext, apiKey: string, thisWorkrailSessionId: string, currentDepth: number, maxDepth: number, runWorkflowFn: typeof runWorkflow, schemas: Record<string, any>, emitter?: DaemonEventEmitter): AgentTool;
71
72
  export declare function makeReportIssueTool(sessionId: string, emitter?: DaemonEventEmitter, workrailSessionId?: string | null, issuesDirOverride?: string, onIssueSummary?: (summary: string) => void): AgentTool;
@@ -479,7 +479,7 @@ function makeContinueWorkflowTool(sessionId, ctx, onAdvance, onComplete, schemas
479
479
  };
480
480
  }
481
481
  if (out.isComplete) {
482
- onComplete(params.notesMarkdown);
482
+ onComplete(params.notesMarkdown, Array.isArray(params.artifacts) ? params.artifacts : undefined);
483
483
  return {
484
484
  content: [{ type: 'text', text: 'Workflow complete. All steps have been executed.' }],
485
485
  details: out,
@@ -581,7 +581,7 @@ function makeCompleteStepTool(sessionId, ctx, getCurrentToken, onAdvance, onComp
581
581
  };
582
582
  }
583
583
  if (out.isComplete) {
584
- onComplete(notes);
584
+ onComplete(notes, Array.isArray(params.artifacts) ? params.artifacts : undefined);
585
585
  return {
586
586
  content: [{ type: 'text', text: JSON.stringify({ status: 'complete' }) }],
587
587
  details: out,
@@ -1004,6 +1004,7 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter) {
1004
1004
  let isComplete = false;
1005
1005
  let pendingSteerText = null;
1006
1006
  let lastStepNotes;
1007
+ let lastStepArtifacts;
1007
1008
  let stepAdvanceCount = 0;
1008
1009
  const lastNToolCalls = [];
1009
1010
  const STUCK_REPEAT_THRESHOLD = 3;
@@ -1017,9 +1018,10 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter) {
1017
1018
  daemonRegistry?.heartbeat(workrailSessionId);
1018
1019
  emitter?.emit({ kind: 'step_advanced', sessionId, ...withWorkrailSession(workrailSessionId) });
1019
1020
  };
1020
- const onComplete = (notes) => {
1021
+ const onComplete = (notes, artifacts) => {
1021
1022
  isComplete = true;
1022
1023
  lastStepNotes = notes;
1024
+ lastStepArtifacts = artifacts;
1023
1025
  };
1024
1026
  let firstStep;
1025
1027
  if (trigger._preAllocatedStartResponse !== undefined) {
@@ -1286,5 +1288,6 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter) {
1286
1288
  workflowId: trigger.workflowId,
1287
1289
  stopReason,
1288
1290
  ...(lastStepNotes !== undefined ? { lastStepNotes } : {}),
1291
+ ...(lastStepArtifacts !== undefined ? { lastStepArtifacts } : {}),
1289
1292
  };
1290
1293
  }