@bridge_gpt/mcp-server 0.2.41 → 0.2.43

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 (88) hide show
  1. package/README.md +330 -191
  2. package/build/agent-capabilities/cli.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  4. package/build/agents.generated.js +2 -2
  5. package/build/claude-review-workflow.js +510 -45
  6. package/build/claude-user-config-doctor.js +42 -11
  7. package/build/cli-release.js +2 -1
  8. package/build/commands.generated.js +6 -5
  9. package/build/conduct-epic/bridge-client.js +354 -113
  10. package/build/conduct-epic/checkpoint-store.js +17 -0
  11. package/build/conduct-epic/cli.js +947 -99
  12. package/build/conduct-epic/cut-protocol.js +327 -0
  13. package/build/conduct-epic/spawn.js +14 -2
  14. package/build/conductor/bridge-api-client.js +148 -1
  15. package/build/conductor/cli.js +109 -1
  16. package/build/conductor/doctor.js +101 -16
  17. package/build/conductor/epic-reconcile.js +72 -19
  18. package/build/conductor/epic-runtime.js +15 -3
  19. package/build/conductor/errors.js +47 -0
  20. package/build/conductor/git-hooks.js +205 -11
  21. package/build/conductor/install-doctor.js +230 -1
  22. package/build/conductor/local-merge.js +130 -28
  23. package/build/conductor/recovery-cli.js +313 -0
  24. package/build/conductor/recovery-operations.js +219 -0
  25. package/build/conductor/tools.js +32 -3
  26. package/build/conductor/worker-ledger-cli.js +27 -1
  27. package/build/conductor-bin.js +20 -16
  28. package/build/credentials-cli.js +3 -2
  29. package/build/docs.generated.js +2 -1
  30. package/build/doctor.js +120 -44
  31. package/build/drive-epic.js +375 -0
  32. package/build/executor/cli.js +48 -1
  33. package/build/executor/env.js +21 -0
  34. package/build/executor/http-client.js +71 -3
  35. package/build/executor/index-scope.js +39 -0
  36. package/build/executor/job-errors.js +9 -0
  37. package/build/executor/job-log-registry.js +69 -0
  38. package/build/executor/job-runner.js +198 -29
  39. package/build/executor/live-worker-registry.js +83 -0
  40. package/build/executor/observation.js +259 -6
  41. package/build/executor/platform.js +147 -3
  42. package/build/executor/process.js +58 -14
  43. package/build/executor/runner.js +454 -48
  44. package/build/executor/test-clock.js +3 -2
  45. package/build/executor/worker-finalization.js +233 -56
  46. package/build/executor/worktree.js +8 -1
  47. package/build/index-scope-contract.js +96 -0
  48. package/build/index.js +2277 -270
  49. package/build/init.js +83 -22
  50. package/build/install-bridge-conductor.js +323 -14
  51. package/build/install-bridge.js +225 -47
  52. package/build/install-doctor.js +23 -9
  53. package/build/install-reexec.js +2 -1
  54. package/build/launcher-config-inspection.js +83 -22
  55. package/build/mcp-host-config.js +331 -67
  56. package/build/mcp-host-targets.js +45 -21
  57. package/build/mcp-identity.js +92 -0
  58. package/build/mcp-install-state.js +94 -1
  59. package/build/mcp-invoke.js +2 -1
  60. package/build/mcp-provisioning.js +45 -12
  61. package/build/mcp-registration-doctor.js +35 -13
  62. package/build/mcp-server-invocation.js +4 -2
  63. package/build/merge-pull-request.js +208 -9
  64. package/build/pipelines.generated.js +305 -15
  65. package/build/plane/cli.js +73 -7
  66. package/build/plane/defaults.js +18 -5
  67. package/build/plane/manifest.js +90 -0
  68. package/build/plane/preflight.js +100 -10
  69. package/build/plane/shutdown.js +71 -3
  70. package/build/plane/test-fakes.js +9 -1
  71. package/build/readme.generated.js +1 -1
  72. package/build/regression-check.js +3 -2
  73. package/build/review-tickets.js +8 -7
  74. package/build/run-unit-tests-launcher.js +149 -6
  75. package/build/schedule-run.js +3 -2
  76. package/build/setup-epic.js +531 -82
  77. package/build/sfcc/tool-wrapper.js +15 -0
  78. package/build/start-tickets-prereqs.js +11 -6
  79. package/build/start-tickets.js +91 -85
  80. package/build/update-check.js +3 -2
  81. package/build/upgrade-advice.js +2 -1
  82. package/build/upgrade-cli.js +50 -18
  83. package/build/version.generated.js +2 -1
  84. package/build/worktree-core.js +31 -17
  85. package/docs/CONDUCTOR.md +22 -0
  86. package/docs/install/mcp-tool-integrations.md +19 -3
  87. package/package.json +2 -2
  88. package/pipelines/greenfield-setup.json +286 -0
@@ -103,6 +103,172 @@ function yamlSingleQuoted(value) {
103
103
  function yamlFlowList(values) {
104
104
  return `[${values.map(yamlSingleQuoted).join(", ")}]`;
105
105
  }
106
+ /**
107
+ * The verdict check, as an `actions/github-script` body.
108
+ *
109
+ * A faithful implementation of `docs/claude/claude-review-verdict-contract.md`
110
+ * §1-§4 — the same grammar, candidate eligibility, run-id/recency winner
111
+ * selection, conflict rule, and head binding the Conductor's merge gate applies.
112
+ * A check STRICTER than the gate turns mergeable pull requests red; a check
113
+ * LOOSER than the gate reproduces the silent green this whole state machine
114
+ * exists to remove. Neither side may relax a rule alone: the contract document is
115
+ * the authority both cite.
116
+ *
117
+ * Outputs, read by the retry conditions and the aggregate step:
118
+ *
119
+ * - `status` — `checked` when comments were read and the check completed,
120
+ * `unavailable` when the read itself failed. Only `checked` may trigger another
121
+ * paid attempt; another review cannot fix a check that could not read its input.
122
+ * - `valid` — `true` only for a verdict bound to the exact expected head.
123
+ * - `reason` — a stable code from the contract's fail-closed vocabulary.
124
+ *
125
+ * Read-only throughout: it never posts, edits, or deletes a comment. Diagnostics
126
+ * carry reason codes and commit SHAs only — never a comment body, a user login,
127
+ * or a token, because an Actions log is world-readable on a public repository.
128
+ */
129
+ const VERDICT_CHECK_SCRIPT = [
130
+ " // Contract: docs/claude/claude-review-verdict-contract.md §1-§4.",
131
+ " const VERDICT = /claude-review-verdict:\\s*(approved|changes_requested)/i;",
132
+ " const RUN_ID = /run[-_]id:\\s*(\\d+)/i;",
133
+ " const REVIEWED_SHA = /Reviewed-SHA:\\s*([0-9A-Za-z._-]+)/i;",
134
+ " const FULL_SHA = /^[0-9a-f]{40}$/;",
135
+ " const APPROVED_WORD = /\\bapproved\\b/i;",
136
+ " const CHANGES_WORD = /\\bchanges.requested\\b/i;",
137
+ " const expected = String(process.env.EXPECTED_HEAD || '').trim().toLowerCase();",
138
+ "",
139
+ " // Ambiguity (both verdict words) or absence (neither) yields",
140
+ " // 'unknown', never 'approved'.",
141
+ " const verdictOf = (body) => {",
142
+ " const token = VERDICT.exec(body);",
143
+ " if (token) return token[1].toLowerCase();",
144
+ " const a = APPROVED_WORD.test(body);",
145
+ " const c = CHANGES_WORD.test(body);",
146
+ " if (a && !c) return 'approved';",
147
+ " if (c && !a) return 'changes_requested';",
148
+ " return 'unknown';",
149
+ " };",
150
+ " const declaredSha = (body) => {",
151
+ " const m = REVIEWED_SHA.exec(body);",
152
+ " return m ? m[1].trim().toLowerCase() : null;",
153
+ " };",
154
+ "",
155
+ " // §2 eligibility: the structured token, or the loose marker.",
156
+ " // Malformed entries are skipped rather than throwing — one odd",
157
+ " // comment must not fail the job for a reason unrelated to review.",
158
+ " const build = (comments) => comments.reduce((acc, c, index) => {",
159
+ " if (!c || typeof c !== 'object') return acc;",
160
+ " const body = typeof c.body === 'string' ? c.body : '';",
161
+ " const hasToken = VERDICT.test(body);",
162
+ " if (!hasToken && body.indexOf('claude-review') === -1) return acc;",
163
+ " const run = RUN_ID.exec(body);",
164
+ " const raw = c.updated_at || c.created_at;",
165
+ " const ts = typeof raw === 'string' ? Date.parse(raw) : NaN;",
166
+ " acc.push({",
167
+ " runId: run ? parseInt(run[1], 10) : 0,",
168
+ " index,",
169
+ " body,",
170
+ " hasToken,",
171
+ " ts: Number.isNaN(ts) ? null : ts,",
172
+ " });",
173
+ " return acc;",
174
+ " }, []);",
175
+ "",
176
+ " // §3 legacy rank: (has a usable timestamp, that instant, carries the",
177
+ " // token). The array index is deliberately EXCLUDED so a genuine",
178
+ " // two-verdict contradiction stays detectable instead of being",
179
+ " // silently resolved as 'whichever came last'.",
180
+ " const rankOf = (c) => [c.ts !== null ? 1 : 0, c.ts === null ? 0 : c.ts, c.hasToken ? 1 : 0];",
181
+ " const cmpRank = (a, b) => {",
182
+ " const x = rankOf(a);",
183
+ " const y = rankOf(b);",
184
+ " for (let i = 0; i < x.length; i++) {",
185
+ " if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;",
186
+ " }",
187
+ " return 0;",
188
+ " };",
189
+ "",
190
+ " // §3 conflict: equal-ranked candidates that DISAGREE and share one",
191
+ " // valid Reviewed-SHA. Duplicate upserts of the same verdict are not",
192
+ " // a conflict, and candidates without shared provenance never proved",
193
+ " // they examined the same code.",
194
+ " const conflicts = (tied) => {",
195
+ " if (new Set(tied.map((c) => verdictOf(c.body))).size < 2) return false;",
196
+ " const shas = new Set(",
197
+ " tied.map((c) => declaredSha(c.body)).filter((s) => s && FULL_SHA.test(s)),",
198
+ " );",
199
+ " if (shas.size !== 1) return false;",
200
+ " const bound = tied.filter((c) => shas.has(declaredSha(c.body)));",
201
+ " return new Set(bound.map((c) => verdictOf(c.body))).size >= 2;",
202
+ " };",
203
+ "",
204
+ " const resolve = (comments) => {",
205
+ " const candidates = build(comments);",
206
+ " if (candidates.length === 0) return { valid: false, reason: 'review_verdict_absent' };",
207
+ " let tied;",
208
+ " if (candidates.some((c) => c.runId > 0)) {",
209
+ " const top = Math.max(...candidates.map((c) => c.runId));",
210
+ " tied = candidates.filter((c) => c.runId === top);",
211
+ " } else {",
212
+ " const best = candidates.reduce((a, b) => (cmpRank(a, b) >= 0 ? a : b));",
213
+ " tied = candidates.filter((c) => cmpRank(c, best) === 0);",
214
+ " }",
215
+ " if (conflicts(tied)) return { valid: false, reason: 'review_verdict_conflict' };",
216
+ " const winner = tied.reduce((a, b) => (a.index >= b.index ? a : b));",
217
+ " const declared = declaredSha(winner.body);",
218
+ " if (declared === null) return { valid: false, reason: 'review_verdict_no_head_proof' };",
219
+ " if (declared !== expected) {",
220
+ " return {",
221
+ " valid: false,",
222
+ " reason: FULL_SHA.test(declared) ? 'review_verdict_stale_head' : 'review_verdict_malformed_sha',",
223
+ " };",
224
+ " }",
225
+ " const verdict = verdictOf(winner.body);",
226
+ " if (verdict !== 'approved' && verdict !== 'changes_requested') {",
227
+ " return { valid: false, reason: 'review_verdict_unknown_token' };",
228
+ " }",
229
+ " return { valid: true, reason: 'review_verdict_resolved', verdict };",
230
+ " };",
231
+ "",
232
+ " if (!FULL_SHA.test(expected)) {",
233
+ " core.setOutput('status', 'unavailable');",
234
+ " core.setOutput('valid', 'false');",
235
+ " core.setOutput('reason', 'checker_invalid_expected_head');",
236
+ " return;",
237
+ " }",
238
+ "",
239
+ " // Bounded, read-only propagation loop: a verdict posted seconds ago",
240
+ " // may not be visible to the comments API yet, and calling that",
241
+ " // 'absent' would spend another paid review for nothing.",
242
+ " let outcome = { valid: false, reason: 'checker_not_run' };",
243
+ " let status = 'unavailable';",
244
+ " for (let attempt = 1; attempt <= 4; attempt++) {",
245
+ " let comments;",
246
+ " try {",
247
+ " comments = await github.paginate(github.rest.issues.listComments, {",
248
+ " owner: context.repo.owner,",
249
+ " repo: context.repo.repo,",
250
+ " issue_number: Number(process.env.PR_NUMBER),",
251
+ " per_page: 100,",
252
+ " });",
253
+ " } catch (err) {",
254
+ " status = 'unavailable';",
255
+ " outcome = { valid: false, reason: 'comment_fetch_failed' };",
256
+ " await new Promise((r) => setTimeout(r, 10000));",
257
+ " continue;",
258
+ " }",
259
+ " status = 'checked';",
260
+ " outcome = resolve(comments);",
261
+ " const retryable =",
262
+ " outcome.reason === 'review_verdict_absent' ||",
263
+ " outcome.reason === 'review_verdict_stale_head';",
264
+ " if (outcome.valid || !retryable || attempt === 4) break;",
265
+ " await new Promise((r) => setTimeout(r, 10000));",
266
+ " }",
267
+ " core.setOutput('status', status);",
268
+ " core.setOutput('valid', outcome.valid ? 'true' : 'false');",
269
+ " core.setOutput('reason', outcome.reason);",
270
+ " core.info('Verdict check: valid=' + outcome.valid + ' reason=' + outcome.reason);",
271
+ ].join("\n");
106
272
  /**
107
273
  * Render the workflow YAML deterministically from validated parameters.
108
274
  *
@@ -111,6 +277,22 @@ function yamlFlowList(values) {
111
277
  * `Reviewed-SHA`/`run-id` contract the Conductor's merge gate consumes — with
112
278
  * every org-specific literal replaced by a parameter.
113
279
  *
280
+ * BAPI-851 widened "structurally equivalent" to include the whole PRODUCER-SIDE
281
+ * VERDICT CONTRACT, not just the prompt: three bounded, timeout-capped review
282
+ * attempts; a fresh head guard before each one; a fail-closed verdict check after
283
+ * each one; a terminal marker whose conclusion is the aggregate check result; and
284
+ * a final step that fails the job when no head-bound verdict was published. A
285
+ * consumer repo installing this workflow otherwise inherits the exact defect
286
+ * BAPI-851 removed here — a green check with no verdict, which reads as approval
287
+ * to anyone who does not know better.
288
+ *
289
+ * The rendered workflow does the verdict check in `actions/github-script`, while
290
+ * the Bridge repo's own workflow shells out to `scripts/check_claude_review_verdict.py`.
291
+ * They are two implementations of ONE contract —
292
+ * `docs/claude/claude-review-verdict-contract.md` §1-§4 — because a generated
293
+ * workflow cannot depend on a Python file living in the Bridge repository. The
294
+ * contract document, not either implementation, is the authority.
295
+ *
114
296
  * @throws ClaudeReviewWorkflowValidationError before producing any output.
115
297
  */
116
298
  export function renderClaudeReviewWorkflow(params) {
@@ -120,8 +302,141 @@ export function renderClaudeReviewWorkflow(params) {
120
302
  // `${'$'}{{ ... }}` is avoided by building the expression marker from a constant,
121
303
  // so nothing in this template can be mistaken for a TS template substitution.
122
304
  const gh = (expr) => "${{ " + expr + " }}";
123
- return `# Generated by \`install-bridge conductor\` (Bridge API, BAPI-679).
305
+ /**
306
+ * Preflight: will the review action run at all?
307
+ *
308
+ * `anthropics/claude-code-action` refuses to run when the workflow file differs
309
+ * from the version on the repository's DEFAULT branch — its own supply-chain
310
+ * guard, so a pull request cannot rewrite the reviewer that judges it. It
311
+ * reports that as a warning and exits SUCCESS, so without this preflight the
312
+ * attempts would each publish nothing and the run would blame
313
+ * `retries_exhausted_without_verdict`, naming a cause that did not occur.
314
+ */
315
+ const preflight = () => ` - name: Preflight the review action
316
+ id: preflight
317
+ env:
318
+ DEFAULT_BRANCH: ${gh("github.event.repository.default_branch")}
319
+ uses: actions/github-script@v9
320
+ with:
321
+ script: |
322
+ const fs = require('fs');
323
+ const crypto = require('crypto');
324
+ // Reproduce git's blob hash so it can be compared with the contents
325
+ // API's own \`sha\`, which avoids downloading and decoding the file.
326
+ const raw = fs.readFileSync('.github/workflows/claude-review.yml');
327
+ const header = Buffer.from('blob ' + raw.length + '\\0', 'utf8');
328
+ const localSha = crypto.createHash('sha1')
329
+ .update(Buffer.concat([header, raw]))
330
+ .digest('hex');
331
+ let remoteSha = '';
332
+ try {
333
+ const res = await github.rest.repos.getContent({
334
+ owner: context.repo.owner,
335
+ repo: context.repo.repo,
336
+ path: '.github/workflows/claude-review.yml',
337
+ ref: String(process.env.DEFAULT_BRANCH || ''),
338
+ });
339
+ remoteSha = String(res.data.sha || '');
340
+ } catch (err) {
341
+ remoteSha = '';
342
+ }
343
+ if (!remoteSha) {
344
+ // Cannot prove a mismatch. Fail OPEN toward attempting the review: a
345
+ // suppressed review that would have worked is worse than a wasted
346
+ // attempt, and enforcement still catches a missing verdict.
347
+ core.setOutput('action_runnable', 'true');
348
+ core.setOutput('status', 'unverified');
349
+ core.info('Could not read the default branch copy of this workflow; proceeding.');
350
+ return;
351
+ }
352
+ if (localSha === remoteSha) {
353
+ core.setOutput('action_runnable', 'true');
354
+ core.setOutput('status', 'ok');
355
+ } else {
356
+ core.setOutput('action_runnable', 'false');
357
+ core.setOutput('status', 'workflow_modified');
358
+ core.warning('This pull request modifies the review workflow, so the review action will refuse to run against it. Suppressing all attempts; review this pull request by hand.');
359
+ }
360
+
361
+ `;
362
+ /** One paid review attempt: guard the head, review, then check for a verdict. */
363
+ const attempt = (n) => {
364
+ const prev = n - 1;
365
+ const guardIf = n === 1
366
+ ? " if: steps.preflight.outputs.action_runnable == 'true'\n"
367
+ : ` if: steps.check${prev}.outputs.status == 'checked' && steps.check${prev}.outputs.valid != 'true'\n`;
368
+ return ` - name: Guard head (attempt ${n})
369
+ id: guard${n}
370
+ ${guardIf} uses: actions/github-script@v9
371
+ with:
372
+ script: |
373
+ // Never spend a paid review on a head the PR has already moved past.
374
+ // A failed lookup is NOT read as "unchanged": an unprovable head is an
375
+ // infrastructure failure, not permission to review.
376
+ const expected = String(process.env.EXPECTED_HEAD || '').toLowerCase();
377
+ let actual = '';
378
+ try {
379
+ const pr = await github.rest.pulls.get({
380
+ owner: context.repo.owner,
381
+ repo: context.repo.repo,
382
+ pull_number: Number(process.env.PR_NUMBER),
383
+ });
384
+ actual = String(pr.data.head.sha || '').toLowerCase();
385
+ } catch (err) {
386
+ core.setOutput('head_ok', 'false');
387
+ core.setOutput('status', 'lookup_failed');
388
+ core.warning('Could not resolve the pull request head; refusing to start a review attempt.');
389
+ return;
390
+ }
391
+ if (!/^[0-9a-f]{40}$/.test(actual)) {
392
+ core.setOutput('head_ok', 'false');
393
+ core.setOutput('status', 'lookup_failed');
394
+ core.warning('Pull request head was not a usable commit SHA.');
395
+ return;
396
+ }
397
+ if (actual !== expected) {
398
+ core.setOutput('head_ok', 'false');
399
+ core.setOutput('status', 'drift');
400
+ core.warning('PR head moved from ' + expected + ' to ' + actual + '; suppressing remaining attempts.');
401
+ return;
402
+ }
403
+ core.setOutput('head_ok', 'true');
404
+ core.setOutput('status', 'ok');
405
+ core.info('PR head confirmed at ' + expected + '.');
406
+
407
+ - name: Claude Code Review (attempt ${n})
408
+ id: review${n}
409
+ if: steps.guard${n}.outputs.head_ok == 'true'
410
+ continue-on-error: true
411
+ timeout-minutes: 10
412
+ uses: anthropics/claude-code-action@v1
413
+ with:
414
+ claude_code_oauth_token: ${gh(`secrets.${secretName}`)}
415
+ allowed_bots: ${yamlSingleQuoted(allowedBot)}
416
+ use_sticky_comment: true
417
+ track_progress: true
418
+ prompt: ${gh("env.REVIEW_PROMPT")}
419
+ claude_args: ${gh("env.REVIEW_CLAUDE_ARGS")}
420
+
421
+ # An action failure or a ten-minute timeout does NOT skip the check: the
422
+ # agent may have published a valid verdict before the step returned
423
+ # non-success. Only the checker decides whether this attempt counted.
424
+ - name: Check verdict (attempt ${n})
425
+ id: check${n}
426
+ if: always() && steps.guard${n}.outputs.head_ok == 'true'
427
+ uses: actions/github-script@v9
428
+ with:
429
+ script: |
430
+ ${VERDICT_CHECK_SCRIPT}
431
+ `;
432
+ };
433
+ return `# Generated by \`install-bridge conductor\` (Bridge API, BAPI-679/BAPI-851).
124
434
  # Safe to edit: this file is never overwritten without explicit consent.
435
+ #
436
+ # The verdict grammar, winner selection, head binding, and every terminal shape
437
+ # this workflow can produce are specified in ONE place — Bridge API's
438
+ # docs/claude/claude-review-verdict-contract.md. The checker below and the
439
+ # Conductor's merge gate both implement that document; neither defines its own.
125
440
  name: Claude Code Review
126
441
 
127
442
  on:
@@ -133,6 +448,10 @@ on:
133
448
  # Reviewed-SHA provenance the review emits: the Conductor rejects a verdict whose
134
449
  # Reviewed-SHA does not match the current head, so cancelling a superseded review
135
450
  # only avoids wasted minutes.
451
+ #
452
+ # The bounded retry below runs as sequential steps INSIDE this job. It never
453
+ # dispatches or re-runs the workflow, so a retry never joins this group and can
454
+ # never cancel a legitimately in-flight review of a newer head.
136
455
  concurrency:
137
456
  group: claude-review-${gh("github.event.pull_request.number")}
138
457
  cancel-in-progress: true
@@ -147,58 +466,204 @@ jobs:
147
466
  pull-requests: write
148
467
  issues: write
149
468
  id-token: write
150
- steps:
151
- - uses: actions/checkout@v7
152
- with:
153
- fetch-depth: 1
469
+ env:
470
+ # The IMMUTABLE head this run was triggered for. Every attempt reviews it and
471
+ # every check demands a verdict bound to it, so no later step can quietly
472
+ # drift onto a different head.
473
+ EXPECTED_HEAD: ${gh("github.event.pull_request.head.sha")}
474
+ PR_NUMBER: ${gh("github.event.pull_request.number")}
475
+ # Held once and passed to all three attempts, so a retry cannot review under
476
+ # different instructions than the first try.
477
+ REVIEW_PROMPT: |
478
+ REPO: ${gh("github.repository")}
479
+ PR NUMBER: ${gh("github.event.pull_request.number")}
154
480
 
155
- - name: Claude Code Review
156
- id: review
157
- uses: anthropics/claude-code-action@v1
158
- with:
159
- claude_code_oauth_token: ${gh(`secrets.${secretName}`)}
160
- allowed_bots: ${yamlSingleQuoted(allowedBot)}
161
- use_sticky_comment: true
162
- track_progress: true
163
- prompt: |
164
- REPO: ${gh("github.repository")}
165
- PR NUMBER: ${gh("github.event.pull_request.number")}
481
+ Review this pull request with a focus on:
482
+ - Bugs, logic errors, and edge cases
483
+ - Security issues (injection, auth bypasses, sensitive data exposure)
484
+ - Project conventions and patterns (see CLAUDE.md)
485
+ - Data-access correctness (parameterized queries, migration safety)
486
+ - Test coverage gaps for changed code
487
+
488
+ Be specific: cite file paths and line numbers.
166
489
 
167
- Review this pull request with a focus on:
168
- - Bugs, logic errors, and edge cases
169
- - Security issues (injection, auth bypasses, sensitive data exposure)
170
- - Project conventions and patterns (see CLAUDE.md)
171
- - Data-access correctness (parameterized queries, migration safety)
172
- - Test coverage gaps for changed code
490
+ Call out significant issues; ignore minor nits with no meaningful impact.
491
+ If there are no significant issues, just say "Looks good to me!"
173
492
 
174
- Be specific: cite file paths and line numbers.
493
+ Use \`gh pr comment\` for top-level feedback.
494
+ Use \`mcp__github_inline_comment__create_inline_comment\` (with \`confirmed: true\`)
495
+ to highlight specific code issues.
496
+ Only post GitHub comments - don't submit review text as messages.
175
497
 
176
- Call out significant issues; ignore minor nits with no meaningful impact.
177
- If there are no significant issues, just say "Looks good to me!"
498
+ After your feedback, end your top-level \`gh pr comment\` with EXACTLY ONE
499
+ machine-readable verdict line, on its own line, verbatim this single line is
500
+ the source of truth consumed by automation (the Conductor merge gate):
501
+ - \`claude-review-verdict: approved\` — when the PR is safe to merge
502
+ - \`claude-review-verdict: changes_requested\` — when significant issues remain
503
+ Emit exactly one such verdict line, and do not use the words \`approved\` or
504
+ \`changes_requested\` as a verdict anywhere else in the comment.
178
505
 
179
- Use \`gh pr comment\` for top-level feedback.
180
- Use \`mcp__github_inline_comment__create_inline_comment\` (with \`confirmed: true\`)
181
- to highlight specific code issues.
182
- Only post GitHub comments - don't submit review text as messages.
506
+ Immediately BELOW the verdict line, on its own line, emit this exact
507
+ head-SHA provenance line verbatim — it binds the verdict to the exact commit
508
+ you reviewed so the Conductor can reject a stale verdict:
509
+ \`Reviewed-SHA: ${gh("github.event.pull_request.head.sha")}\`
510
+
511
+ Also include the GitHub Actions run id on its own line so overlapping
512
+ reviews resolve deterministically by run id, verbatim:
513
+ \`run-id: ${gh("github.run_id")}\`
514
+ REVIEW_CLAUDE_ARGS: |
515
+ --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Read,Glob,Grep"
516
+ steps:
517
+ - uses: actions/checkout@v7
518
+ with:
519
+ fetch-depth: 1
183
520
 
184
- After your feedback, end your top-level \`gh pr comment\` with EXACTLY ONE
185
- machine-readable verdict line, on its own line, verbatim this single line is
186
- the source of truth consumed by automation (the Conductor merge gate):
187
- - \`claude-review-verdict: approved\` when the PR is safe to merge
188
- - \`claude-review-verdict: changes_requested\` when significant issues remain
189
- Emit exactly one such verdict line, and do not use the words \`approved\` or
190
- \`changes_requested\` as a verdict anywhere else in the comment.
521
+ # ------------------------------------------------------------------
522
+ # Three bounded attempts. The review action can report success while
523
+ # having abandoned its own progress checklist and published no verdict;
524
+ # every observed occurrence recovered on a re-run at the identical head.
525
+ # So: retry up to three times in-job, then fail loudly. Each attempt bills
526
+ # a full review, which is what the cap of three is for — a deterministic
527
+ # failure (a dead OAuth token) must not bill indefinitely.
528
+ # ------------------------------------------------------------------
529
+ ${preflight()}${attempt(1)}
530
+ ${attempt(2)}
531
+ ${attempt(3)}
532
+ # ONE boolean drives both the terminal marker and the job conclusion, so the
533
+ # two can never tell contradictory stories. Aggregate success requires a
534
+ # CHECK to have confirmed a head-bound verdict — three self-reported
535
+ # successful attempts that published nothing aggregate to failure.
536
+ - name: Resolve aggregate review result
537
+ id: aggregate
538
+ if: always()
539
+ # Step outputs reach the script through \`env\`, never through a bare
540
+ # expression interpolated into JS: a skipped step's output is the EMPTY
541
+ # STRING, which would splice into a syntax error rather than a falsy value.
542
+ env:
543
+ PREFLIGHT_STATUS: ${gh("steps.preflight.outputs.status")}
544
+ CHECK1_VALID: ${gh("steps.check1.outputs.valid")}
545
+ CHECK2_VALID: ${gh("steps.check2.outputs.valid")}
546
+ CHECK3_VALID: ${gh("steps.check3.outputs.valid")}
547
+ CHECK1_STATUS: ${gh("steps.check1.outputs.status")}
548
+ CHECK2_STATUS: ${gh("steps.check2.outputs.status")}
549
+ CHECK3_STATUS: ${gh("steps.check3.outputs.status")}
550
+ GUARD1_STATUS: ${gh("steps.guard1.outputs.status")}
551
+ GUARD2_STATUS: ${gh("steps.guard2.outputs.status")}
552
+ GUARD3_STATUS: ${gh("steps.guard3.outputs.status")}
553
+ uses: actions/github-script@v9
554
+ with:
555
+ script: |
556
+ const env = process.env;
557
+ const checks = [
558
+ { valid: env.CHECK1_VALID, status: env.CHECK1_STATUS },
559
+ { valid: env.CHECK2_VALID, status: env.CHECK2_STATUS },
560
+ { valid: env.CHECK3_VALID, status: env.CHECK3_STATUS },
561
+ ].map((c) => ({ valid: String(c.valid || ''), status: String(c.status || '') }));
562
+ const guards = [env.GUARD1_STATUS, env.GUARD2_STATUS, env.GUARD3_STATUS]
563
+ .map((s) => String(s || ''));
564
+ const success = checks.some((c) => c.valid === 'true');
565
+ let reason;
566
+ if (success) {
567
+ reason = 'verdict_published';
568
+ } else if (env.PREFLIGHT_STATUS === 'workflow_modified') {
569
+ reason = 'review_action_blocked_by_workflow_modification';
570
+ } else if (guards.includes('drift')) {
571
+ reason = 'head_drift';
572
+ } else if (guards.includes('lookup_failed')) {
573
+ reason = 'head_lookup_failed';
574
+ } else if (checks.some((c) => c.status === 'unavailable')) {
575
+ reason = 'verdict_check_unavailable';
576
+ } else {
577
+ reason = 'retries_exhausted_without_verdict';
578
+ }
579
+ core.setOutput('success', success ? 'true' : 'false');
580
+ core.setOutput('conclusion', success ? 'success' : 'failure');
581
+ core.setOutput('reason', reason);
582
+ core.info('Aggregate review result: success=' + success + ' reason=' + reason);
191
583
 
192
- Immediately BELOW the verdict line, on its own line, emit this exact
193
- head-SHA provenance line verbatim it binds the verdict to the exact commit
194
- you reviewed so the Conductor can reject a stale verdict:
195
- \`Reviewed-SHA: ${gh("github.event.pull_request.head.sha")}\`
584
+ # A durable, machine-readable TERMINAL marker recording the workflow run id,
585
+ # the exact reviewed head SHA, and the run's terminal conclusion WITHOUT
586
+ # ever fabricating a review verdict. It lives in its OWN dedicated comment,
587
+ # whose grammar shares none of the substrings the sticky-verdict parser keys
588
+ # on, and is upserted per PR. \`terminal-conclusion: success\` is published on
589
+ # precisely the path that also lets this job finish green, and still never
590
+ # means "approved" — only the sticky verdict answers that.
591
+ - name: Emit terminal review marker
592
+ id: marker
593
+ if: always()
594
+ continue-on-error: true
595
+ env:
596
+ AGGREGATE_CONCLUSION: ${gh("steps.aggregate.outputs.conclusion")}
597
+ uses: actions/github-script@v9
598
+ with:
599
+ script: |
600
+ const anchor = '<!-- bapi-review-terminal -->';
601
+ const runId = String(context.runId);
602
+ const reviewedHead = context.payload.pull_request
603
+ ? String(context.payload.pull_request.head.sha)
604
+ : '';
605
+ const conclusion = String(process.env.AGGREGATE_CONCLUSION || 'unknown');
606
+ const prNumber = context.payload.pull_request
607
+ ? context.payload.pull_request.number
608
+ : (context.issue && context.issue.number);
609
+ if (!prNumber) {
610
+ core.warning('No PR number in context; skipping terminal marker.');
611
+ return;
612
+ }
613
+ const body = [
614
+ anchor,
615
+ 'bapi-review-terminal: v1',
616
+ 'terminal-workflow-run: ' + runId,
617
+ 'terminal-reviewed-head: ' + reviewedHead,
618
+ 'terminal-conclusion: ' + conclusion,
619
+ ].join('\\n');
620
+ const existing = await github.paginate(github.rest.issues.listComments, {
621
+ owner: context.repo.owner,
622
+ repo: context.repo.repo,
623
+ issue_number: prNumber,
624
+ per_page: 100,
625
+ });
626
+ const markerComment = existing.find((c) => c.body && c.body.includes(anchor));
627
+ if (markerComment) {
628
+ await github.rest.issues.updateComment({
629
+ owner: context.repo.owner,
630
+ repo: context.repo.repo,
631
+ comment_id: markerComment.id,
632
+ body,
633
+ });
634
+ } else {
635
+ await github.rest.issues.createComment({
636
+ owner: context.repo.owner,
637
+ repo: context.repo.repo,
638
+ issue_number: prNumber,
639
+ body,
640
+ });
641
+ }
642
+ core.info('Emitted terminal review marker: conclusion=' + conclusion);
196
643
 
197
- Also include the GitHub Actions run id on its own line so overlapping
198
- reviews resolve deterministically by run id, verbatim:
199
- \`run-id: ${gh("github.run_id")}\`
200
- claude_args: |
201
- --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Read,Glob,Grep"
644
+ # The loud failure. A review that published nothing used to produce a green
645
+ # check, which reads as approval to anyone who does not know better. Now the
646
+ # check goes red — but only after the retries are exhausted, so a single
647
+ # early quit never surfaces as red to a human. Marker-upsert failure also
648
+ # fails the job: a green check with no durable terminal evidence is
649
+ # indistinguishable from a review that never ran.
650
+ - name: Enforce review verdict
651
+ if: always()
652
+ env:
653
+ AGGREGATE_SUCCESS: ${gh("steps.aggregate.outputs.success")}
654
+ AGGREGATE_REASON: ${gh("steps.aggregate.outputs.reason")}
655
+ MARKER_OUTCOME: ${gh("steps.marker.outcome")}
656
+ run: |
657
+ set -uo pipefail
658
+ if [ "\${MARKER_OUTCOME}" != "success" ]; then
659
+ echo "::error::The durable terminal review marker could not be published (\${MARKER_OUTCOME})."
660
+ exit 1
661
+ fi
662
+ if [ "\${AGGREGATE_SUCCESS}" != "true" ]; then
663
+ echo "::error::claude-review published no verdict bound to \${EXPECTED_HEAD} after its bounded attempts (\${AGGREGATE_REASON})."
664
+ exit 1
665
+ fi
666
+ echo "claude-review published a verdict bound to \${EXPECTED_HEAD}."
202
667
  `;
203
668
  }
204
669
  function joinCwd(cwd, rel) {