@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,259 @@
1
+ # Implementation Plan: Coordinator Artifact Protocol
2
+
3
+ **Date:** 2026-04-18
4
+ **Branch:** `feat/coordinator-artifact-protocol`
5
+
6
+ ---
7
+
8
+ ## Problem Statement
9
+
10
+ The PR review coordinator (`src/coordinators/pr-review.ts`) extracts review severity from completed review sessions by running a keyword scan on free-form step notes. The coordinator ignores the `artifacts[]` field that `GET /api/v2/sessions/:id/nodes/:nodeId` already returns. This makes severity extraction brittle and unmeasurable.
11
+
12
+ The fix: define a `wr.review_verdict` artifact schema, update the final handoff step to emit it, update `getAgentResult()` to return artifacts alongside notes, and update the coordinator to try the artifact path before the keyword scan.
13
+
14
+ ---
15
+
16
+ ## Acceptance Criteria
17
+
18
+ 1. `npm run build` completes with 0 TypeScript errors
19
+ 2. `tests/unit/coordinator-pr-review.test.ts` passes (all existing tests + new `readVerdictArtifact` tests)
20
+ 3. `readVerdictArtifact([{ kind: 'wr.review_verdict', verdict: 'clean', ... }])` returns `{ severity: 'clean', source: 'artifact', ... }`
21
+ 4. `readVerdictArtifact([])` returns `null`
22
+ 5. `readVerdictArtifact([{ kind: 'wr.review_verdict', verdict: 'INVALID' }])` returns `null` and logs WARN
23
+ 6. `CoordinatorDeps.getAgentResult` return type is `Promise<{ recapMarkdown: string | null; artifacts: readonly unknown[] }>`
24
+ 7. `WorkflowRunSuccess` has optional field `lastStepArtifacts?: readonly unknown[]`
25
+ 8. `mr-review-workflow.agentic.v2.json` phase-6-final-handoff has `outputContract: { contractRef: 'wr.contracts.review_verdict', required: false }`
26
+ 9. `isValidContractRef('wr.contracts.review_verdict')` returns `true`
27
+ 10. `validateArtifactContract([{ kind: 'wr.review_verdict', verdict: 'clean', ... }], { contractRef: 'wr.contracts.review_verdict' })` returns `{ valid: true, artifact: ... }`
28
+
29
+ ---
30
+
31
+ ## Non-Goals
32
+
33
+ - Do NOT add a `/api/v2/sessions/:id/artifacts` server-side aggregation endpoint
34
+ - Do NOT change `required: false` to `required: true` (post-graduation decision)
35
+ - Do NOT remove the keyword-scan fallback from `parseFindingsFromNotes`
36
+ - Do NOT add a `coordinatorProtocol` field to the workflow JSON (deferred)
37
+ - Do NOT add artifacts to `spawn_agent` return value (post-MVP)
38
+ - Do NOT make `source` required on `ReviewFindings` (breaking change deferred)
39
+
40
+ ---
41
+
42
+ ## Philosophy Constraints
43
+
44
+ - **Make illegal states unrepresentable:** `verdict`, `source`, `confidence` use closed enums
45
+ - **Validate at boundaries:** Zod `safeParse` in `readVerdictArtifact()`; engine validation via `validateArtifactContract()`
46
+ - **Errors are data:** `readVerdictArtifact()` returns `ReviewFindings | null`, not throws
47
+ - **Functional/declarative:** `readVerdictArtifact()` is a pure function
48
+ - **Prefer fakes over mocks:** New tests use `makeFakeDeps()` pattern
49
+
50
+ ---
51
+
52
+ ## Invariants
53
+
54
+ 1. `required: false` in outputContract -- never block sessions during transition
55
+ 2. Schema registration (`ARTIFACT_CONTRACT_REFS`) MUST be done before workflow JSON update (compiler validates at load time via `isValidContractRef()`)
56
+ 3. Keyword-scan fallback MUST remain live in `parseFindingsFromNotes`
57
+ 4. All call sites of `CoordinatorDeps.getAgentResult` MUST handle `{ recapMarkdown, artifacts }` shape
58
+ 5. `readVerdictArtifact()` MUST log `[WARN coord:reason=artifact_parse_failed]` when kind matches but safeParse fails
59
+ 6. Per-node HTTP fetch failures MUST be caught individually (not by outer try/catch)
60
+ 7. `makeContinueWorkflowTool` AND `makeCompleteStepTool` MUST both pass artifacts to `onComplete`
61
+
62
+ ---
63
+
64
+ ## Selected Approach
65
+
66
+ **Candidate A:** Three ordered changes, all additive, following existing repo patterns exactly.
67
+
68
+ **Rationale:** Zero new infrastructure; follows `loop-control.ts` schema pattern; follows `WorkflowRunSuccess.lastStepNotes` conditional spread pattern; follows `makeFakeDeps()` testing pattern; backward compatible via `required: false` + keyword-scan fallback.
69
+
70
+ **Runner-up:** Tip-node only artifact read. Disqualified by task spec 'CRITICAL: must aggregate artifacts across ALL session nodes'.
71
+
72
+ ---
73
+
74
+ ## Slices
75
+
76
+ ### Slice 1: Schema registration (prerequisite for all other changes)
77
+
78
+ **Files:**
79
+ - `src/v2/durable-core/schemas/artifacts/review-verdict.ts` (NEW)
80
+ - `src/v2/durable-core/schemas/artifacts/index.ts` (update)
81
+ - `src/v2/durable-core/domain/artifact-contract-validator.ts` (update)
82
+
83
+ **Work:**
84
+ 1. Create `review-verdict.ts` following `loop-control.ts` pattern:
85
+ - `REVIEW_VERDICT_CONTRACT_REF = 'wr.contracts.review_verdict' as const`
86
+ - `ReviewVerdictArtifactV1Schema = z.object({ kind: z.literal('wr.review_verdict'), verdict: z.enum(['clean', 'minor', 'blocking']), confidence: z.enum(['high', 'medium', 'low']), findings: z.array(z.object({ severity: z.enum(['critical', 'major', 'minor', 'nit']), summary: z.string().min(1) }).strict()), summary: z.string().min(1) }).strict()`
87
+ - `isReviewVerdictArtifact()` type guard
88
+ - `parseReviewVerdictArtifact()` convenience function
89
+ 2. Update `index.ts`: export all new symbols, add `'wr.contracts.review_verdict'` to `ARTIFACT_CONTRACT_REFS`
90
+ 3. Update `artifact-contract-validator.ts`: import new symbols, add `case REVIEW_VERDICT_CONTRACT_REF:` to switch with `validateReviewVerdictContract()` helper
91
+
92
+ **Done when:** `isValidContractRef('wr.contracts.review_verdict')` returns `true`; `validateArtifactContract([{ kind: 'wr.review_verdict', ... }], { contractRef: 'wr.contracts.review_verdict' })` returns `{ valid: true, artifact: ... }`.
93
+
94
+ ---
95
+
96
+ ### Slice 2: Fix onComplete callback signature
97
+
98
+ **Files:**
99
+ - `src/daemon/workflow-runner.ts`
100
+
101
+ **Work:**
102
+ 1. Change `onComplete` closure definition (line 2096) from `(notes: string | undefined): void` to `(notes: string | undefined, artifacts?: readonly unknown[]): void`
103
+ 2. Add `let lastStepArtifacts: readonly unknown[] | undefined;` near `let lastStepNotes`
104
+ 3. Update `onComplete` body to set `lastStepArtifacts = artifacts`
105
+ 4. Add `lastStepArtifacts?: readonly unknown[]` to `WorkflowRunSuccess` interface
106
+ 5. Update `makeCompleteStepTool` call to `onComplete(notes)` -> `onComplete(notes, params.artifacts as readonly unknown[] | undefined)` (line 1249)
107
+ 6. Update `makeContinueWorkflowTool` call to `onComplete(params.notesMarkdown)` -> `onComplete(params.notesMarkdown, params.artifacts as readonly unknown[] | undefined)` (line 1046)
108
+ 7. Update the final `return` in `runWorkflow()` (line 2622) to spread `lastStepArtifacts` conditionally
109
+
110
+ **Done when:** `WorkflowRunSuccess` has `lastStepArtifacts` field; both tool factory call sites pass artifacts; `npm run build` passes.
111
+
112
+ ---
113
+
114
+ ### Slice 3: Update getAgentResult to return artifacts
115
+
116
+ **Files:**
117
+ - `src/cli-worktrain.ts`
118
+
119
+ **Work:**
120
+ 1. Change `getAgentResult: async (sessionHandle: string): Promise<string | null>` -> `Promise<{ recapMarkdown: string | null; artifacts: readonly unknown[] }>`
121
+ 2. In the implementation body:
122
+ - After reading `runs[0]`, read `runs[0].nodes` as `Array<{ nodeId: string; [key: string]: unknown }>` (with null check)
123
+ - Walk all nodes, fetch each node detail with individual `try/catch`:
124
+ ```
125
+ for (const node of nodes) {
126
+ try {
127
+ const nodeRes = await fetch(nodeUrl + '/' + node.nodeId)
128
+ // collect artifacts from nodeData['artifacts']
129
+ } catch { /* log WARN, continue */ }
130
+ }
131
+ ```
132
+ - Return `{ recapMarkdown: recap, artifacts: collectedArtifacts }` (or `{ recapMarkdown: null, artifacts: [] }` on failure)
133
+ 3. Early-return failures must also return `{ recapMarkdown: null, artifacts: [] }` instead of `null`
134
+
135
+ **Done when:** Return type is `Promise<{ recapMarkdown: string | null; artifacts: readonly unknown[] }>`; TypeScript compile-time errors at call sites force updates.
136
+
137
+ ---
138
+
139
+ ### Slice 4: Update coordinator to use artifact path
140
+
141
+ **Files:**
142
+ - `src/coordinators/pr-review.ts`
143
+
144
+ **Work:**
145
+ 1. Import `ReviewVerdictArtifactV1Schema` from artifacts schema
146
+ 2. Update `CoordinatorDeps.getAgentResult` return type to match new shape
147
+ 3. Add `source?: 'artifact' | 'keyword_scan'` to `ReviewFindings` interface
148
+ 4. Add `readVerdictArtifact(artifacts: readonly unknown[]): ReviewFindings | null` pure function:
149
+ - Walk artifacts array
150
+ - For each, check `(raw as any).kind === 'wr.review_verdict'`
151
+ - If kind matches, call `ReviewVerdictArtifactV1Schema.safeParse(raw)`
152
+ - On success: return `{ severity: v.verdict, findingSummaries: v.findings.map(f => f.summary), raw: JSON.stringify(v), source: 'artifact' }`
153
+ - On failure: log `[WARN coord:reason=artifact_parse_failed]`, continue to next artifact
154
+ - If no valid artifact found and artifacts.length > 0: log `[INFO coord:source=keyword_scan reason=no_valid_artifact artifactCount=N]`
155
+ - Return `null`
156
+ 5. Update both call sites in `runPrReviewCoordinator()`:
157
+ - `const { recapMarkdown: notes, artifacts } = await deps.getAgentResult(handle);`
158
+ - `const findingsResult = readVerdictArtifact(artifacts) ? ok(readVerdictArtifact(artifacts)!) : parseFindingsFromNotes(notes);`
159
+ - Log `[INFO coord:source=artifact]` or `[INFO coord:source=keyword_scan]`
160
+ 6. Add divergence check (O2): if artifact verdict and keyword-scan severity disagree, log WARN
161
+ 7. Update traceability JSON block to include `source` field
162
+
163
+ **Done when:** Coordinator tries artifact path first; keyword-scan fallback works; logging emits; `npm run build` passes.
164
+
165
+ ---
166
+
167
+ ### Slice 5: Update mr-review workflow
168
+
169
+ **Files:**
170
+ - `workflows/mr-review-workflow.agentic.v2.json`
171
+
172
+ **Work:**
173
+ 1. In `phase-6-final-handoff` step, add `outputContract: { "contractRef": "wr.contracts.review_verdict", "required": false }`
174
+ 2. Append to the step `prompt` field the artifact emission instruction:
175
+ ```
176
+ \n\nAfter completing your notes, emit a structured verdict via complete_step artifacts[] parameter. Use exactly this schema:\n{ "kind": "wr.review_verdict", "verdict": "clean|minor|blocking", "confidence": "high|medium|low", "findings": [{ "severity": "critical|major|minor|nit", "summary": "one-line description" }], "summary": "one-line overall summary" }\nFor a clean review with no findings, use findings: [].
177
+ ```
178
+
179
+ **Done when:** Workflow JSON validates via `npm run build`; `isValidContractRef('wr.contracts.review_verdict')` returns `true` (prerequisite: Slice 1 must be done first).
180
+
181
+ ---
182
+
183
+ ### Slice 6: Tests
184
+
185
+ **Files:**
186
+ - `tests/unit/coordinator-pr-review.test.ts`
187
+
188
+ **Work:**
189
+ 1. Update `makeFakeDeps()` to return `{ recapMarkdown: string | null; artifacts: readonly unknown[] }` from `getAgentResult` (change return type from `string | null`)
190
+ 2. Update `ReviewFindings` literal objects in `buildFixGoal` tests to add `source: 'artifact'` or `source: 'keyword_scan'` (or leave as optional -- `source?` means no update needed)
191
+ 3. Add new `describe('readVerdictArtifact')` block:
192
+ - `it('returns ReviewFindings with source artifact for valid artifact')`
193
+ - `it('returns null for invalid schema (wrong verdict enum)')`
194
+ - `it('returns null for empty artifacts array')`
195
+ - `it('returns null for artifact with different kind')`
196
+ - `it('returns first valid artifact when multiple present')`
197
+ 4. Import `readVerdictArtifact` from `pr-review.js`
198
+
199
+ **Done when:** All existing tests pass; 5 new `readVerdictArtifact` tests pass.
200
+
201
+ ---
202
+
203
+ ## Test Design
204
+
205
+ **Unit tests (pure function):**
206
+ - `readVerdictArtifact` with valid `wr.review_verdict` artifact -> returns `ReviewFindings` with `severity` mapped from `verdict`, `source: 'artifact'`
207
+ - `readVerdictArtifact` with invalid schema (wrong enum) -> returns `null`
208
+ - `readVerdictArtifact` with empty array -> returns `null`
209
+ - `readVerdictArtifact` with artifact of different `kind` -> returns `null` (no false positives)
210
+ - `readVerdictArtifact` with valid + invalid artifacts -> returns valid one (first match wins)
211
+
212
+ **Integration tests (fake deps):**
213
+ - Existing `runPrReviewCoordinator` tests must pass with updated `getAgentResult` return type
214
+ - The fake `getAgentResult` returns `{ recapMarkdown: 'APPROVE ...', artifacts: [] }` by default
215
+
216
+ ---
217
+
218
+ ## Risk Register
219
+
220
+ | Risk | Likelihood | Impact | Mitigation |
221
+ |------|-----------|--------|------------|
222
+ | Missing `makeContinueWorkflowTool` onComplete update | Low | Silent -- artifacts not forwarded from continue_workflow path | Manual verification; code comment at both call sites |
223
+ | Per-node HTTP fetch error aborting aggregation | Low | Graceful fallback to keyword scan | Per-node try/catch (Slice 3 R2) |
224
+ | LLM emits extra fields in artifact (`.strict()` reject) | Medium | Zod fail -> WARN log -> keyword scan fallback | Acceptable during `required: false` transition |
225
+ | `runs[0].nodes` undefined or empty | Low | Empty artifact array -> keyword scan fallback | Null check in Slice 3 |
226
+
227
+ ---
228
+
229
+ ## PR Packaging Strategy
230
+
231
+ Single PR: `feat/coordinator-artifact-protocol`
232
+
233
+ All 6 slices in one PR. Changes are tightly coupled (schema + validator + coordinator must be consistent). Breaking the PR into multiple would require interface stubs that add noise.
234
+
235
+ **PR description structure:**
236
+ 1. Summary: what was done and why
237
+ 2. Change 1 (schema), Change 2 (onComplete), Change 3 (coordinator + workflow)
238
+ 3. Test plan: `npm run build`, `npx vitest run tests/unit/coordinator-pr-review.test.ts`
239
+
240
+ ---
241
+
242
+ ## Philosophy Alignment
243
+
244
+ | Slice | Principle | Status |
245
+ |-------|-----------|--------|
246
+ | 1 (schema) | Make illegal states unrepresentable | Satisfied -- closed enums, kind literal |
247
+ | 1 (schema) | Validate at boundaries | Satisfied -- Zod strict schema |
248
+ | 2 (onComplete) | Immutability by default | Satisfied -- `readonly unknown[]` |
249
+ | 3 (getAgentResult) | Errors are data | Satisfied -- returns `{ recapMarkdown: null, artifacts: [] }` not null |
250
+ | 4 (coordinator) | Functional/declarative | Satisfied -- `readVerdictArtifact()` is pure |
251
+ | 4 (coordinator) | Make illegal states unrepresentable | Tension -- `source?` optional; accepted tradeoff |
252
+ | 6 (tests) | Prefer fakes over mocks | Satisfied -- `makeFakeDeps()` pattern |
253
+
254
+ ---
255
+
256
+ ## planConfidenceBand: High
257
+
258
+ - unresolvedUnknownCount: 0
259
+ - followUpTickets: Y1 (make source required post-graduation), Y2 (remove keyword scan post-graduation), spawn_agent artifacts gap (post-MVP)
@@ -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.