@webpresso/agent-kit 3.1.30 → 3.2.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.

Potentially problematic release.


This version of @webpresso/agent-kit might be problematic. Click here for more details.

Files changed (257) hide show
  1. package/README.md +53 -15
  2. package/bin/_run.js +179 -19
  3. package/catalog/AGENTS.md.tpl +3 -3
  4. package/catalog/agent/agents/implementer.md +45 -0
  5. package/catalog/agent/rules/parallel-orchestration-quota.md +100 -0
  6. package/catalog/agent/rules/pre-implementation.md +28 -1
  7. package/catalog/agent/rules/supported-agent-clis.md +9 -0
  8. package/catalog/agent/rules/typescript-version.md +50 -0
  9. package/catalog/agent/skills/codex/SKILL.md +25 -1
  10. package/catalog/agent/skills/verify/SKILL.md +4 -2
  11. package/catalog/docs/templates/blueprint.md +1 -1
  12. package/dist/esm/audit/agents.js +1 -0
  13. package/dist/esm/audit/blueprint-lifecycle-sql.d.ts +12 -0
  14. package/dist/esm/audit/blueprint-lifecycle-sql.js +23 -2
  15. package/dist/esm/audit/blueprint-pr-coverage.js +4 -0
  16. package/dist/esm/audit/ci-guardrails-wiring.d.ts +22 -0
  17. package/dist/esm/audit/ci-guardrails-wiring.js +90 -0
  18. package/dist/esm/audit/ci-test-perf.js +30 -0
  19. package/dist/esm/audit/command-surface.js +4 -2
  20. package/dist/esm/audit/host-substitution-risk.d.ts +2 -0
  21. package/dist/esm/audit/host-substitution-risk.js +256 -0
  22. package/dist/esm/audit/registry.d.ts +4 -4
  23. package/dist/esm/audit/registry.js +8 -1
  24. package/dist/esm/audit/toolchain-isolation.js +1 -2
  25. package/dist/esm/audit/typescript-version.d.ts +2 -0
  26. package/dist/esm/audit/typescript-version.js +123 -0
  27. package/dist/esm/blueprint/core/parser.js +43 -1
  28. package/dist/esm/blueprint/core/validation/criteria.d.ts +14 -0
  29. package/dist/esm/blueprint/core/validation/criteria.js +41 -2
  30. package/dist/esm/blueprint/core/validation/state.js +25 -14
  31. package/dist/esm/blueprint/db/migrations/run.js +4 -2
  32. package/dist/esm/blueprint/lifecycle/audit.d.ts +20 -0
  33. package/dist/esm/blueprint/lifecycle/audit.js +65 -10
  34. package/dist/esm/blueprint/lifecycle/canonicalize.d.ts +6 -0
  35. package/dist/esm/blueprint/lifecycle/canonicalize.js +135 -0
  36. package/dist/esm/blueprint/lifecycle/local.d.ts +2 -1
  37. package/dist/esm/blueprint/lifecycle/local.js +19 -3
  38. package/dist/esm/blueprint/lifecycle/review-provenance.js +36 -11
  39. package/dist/esm/blueprint/service/BlueprintCreationService.d.ts +1 -0
  40. package/dist/esm/blueprint/service/BlueprintCreationService.js +53 -4
  41. package/dist/esm/blueprint/service/blueprint-tech-debt-links.js +16 -2
  42. package/dist/esm/blueprint/trust/command-runner.d.ts +26 -0
  43. package/dist/esm/blueprint/trust/command-runner.js +148 -0
  44. package/dist/esm/blueprint/trust/gates.d.ts +7 -0
  45. package/dist/esm/blueprint/trust/gates.js +20 -0
  46. package/dist/esm/blueprint/trust/promotion.d.ts +8 -0
  47. package/dist/esm/blueprint/trust/promotion.js +62 -112
  48. package/dist/esm/blueprint/trust/scaffold.d.ts +19 -0
  49. package/dist/esm/blueprint/trust/scaffold.js +64 -0
  50. package/dist/esm/blueprint/trust/validator.d.ts +1 -0
  51. package/dist/esm/blueprint/trust/validator.js +7 -1
  52. package/dist/esm/blueprint/utils/archive.js +27 -5
  53. package/dist/esm/blueprint/utils/document-paths.d.ts +18 -0
  54. package/dist/esm/blueprint/utils/document-paths.js +45 -12
  55. package/dist/esm/build/cli-mcp-parity.js +10 -0
  56. package/dist/esm/cli/bundle/commands/blueprint.d.ts +1 -0
  57. package/dist/esm/cli/bundle/commands/blueprint.js +1 -0
  58. package/dist/esm/cli/cli.d.ts +1 -1
  59. package/dist/esm/cli/cli.js +34 -0
  60. package/dist/esm/cli/commands/agent-launch.d.ts +6 -1
  61. package/dist/esm/cli/commands/agent-launch.js +82 -4
  62. package/dist/esm/cli/commands/audit-core.d.ts +1 -1
  63. package/dist/esm/cli/commands/audit-core.js +8 -1
  64. package/dist/esm/cli/commands/audit.js +17 -5
  65. package/dist/esm/cli/commands/blueprint/execution.js +18 -6
  66. package/dist/esm/cli/commands/blueprint/mutations.js +59 -40
  67. package/dist/esm/cli/commands/blueprint/router-dispatch.d.ts +2 -1
  68. package/dist/esm/cli/commands/blueprint/router-dispatch.js +20 -1
  69. package/dist/esm/cli/commands/blueprint/router-output.d.ts +21 -0
  70. package/dist/esm/cli/commands/blueprint/router-output.js +48 -17
  71. package/dist/esm/cli/commands/blueprint/router.d.ts +7 -0
  72. package/dist/esm/cli/commands/blueprint/router.js +13 -1
  73. package/dist/esm/cli/commands/ci-preflight.d.ts +13 -2
  74. package/dist/esm/cli/commands/ci-preflight.js +6 -3
  75. package/dist/esm/cli/commands/compile.js +5 -31
  76. package/dist/esm/cli/commands/dash/delivery-status.d.ts +1 -0
  77. package/dist/esm/cli/commands/dash/delivery-status.js +11 -4
  78. package/dist/esm/cli/commands/doctor.js +5 -0
  79. package/dist/esm/cli/commands/hook.d.ts +5 -0
  80. package/dist/esm/cli/commands/hook.js +15 -9
  81. package/dist/esm/cli/commands/init/config.js +2 -2
  82. package/dist/esm/cli/commands/init/convergence-apply.d.ts +24 -0
  83. package/dist/esm/cli/commands/init/convergence-apply.js +221 -0
  84. package/dist/esm/cli/commands/init/convergence-state.d.ts +13 -0
  85. package/dist/esm/cli/commands/init/convergence-state.js +54 -0
  86. package/dist/esm/cli/commands/init/convergence.d.ts +23 -0
  87. package/dist/esm/cli/commands/init/convergence.js +57 -0
  88. package/dist/esm/cli/commands/init/git-convergence.d.ts +19 -0
  89. package/dist/esm/cli/commands/init/git-convergence.js +168 -0
  90. package/dist/esm/cli/commands/init/gitignore-patcher.js +2 -0
  91. package/dist/esm/cli/commands/init/grok-privacy-smoke.d.ts +52 -0
  92. package/dist/esm/cli/commands/init/grok-privacy-smoke.js +91 -0
  93. package/dist/esm/cli/commands/init/host-native-proof.d.ts +18 -0
  94. package/dist/esm/cli/commands/init/host-native-proof.js +88 -0
  95. package/dist/esm/cli/commands/init/host-visibility.d.ts +2 -2
  96. package/dist/esm/cli/commands/init/host-visibility.js +94 -5
  97. package/dist/esm/cli/commands/init/index.d.ts +1 -0
  98. package/dist/esm/cli/commands/init/index.js +326 -173
  99. package/dist/esm/cli/commands/init/mcp-spec.d.ts +57 -0
  100. package/dist/esm/cli/commands/init/mcp-spec.js +273 -0
  101. package/dist/esm/cli/commands/init/merge.d.ts +1 -0
  102. package/dist/esm/cli/commands/init/merge.js +30 -1
  103. package/dist/esm/cli/commands/init/plugin-cache-prune.d.ts +0 -3
  104. package/dist/esm/cli/commands/init/plugin-cache-prune.js +0 -1
  105. package/dist/esm/cli/commands/init/scaffold-agents-md.d.ts +1 -1
  106. package/dist/esm/cli/commands/init/scaffold-agents-md.js +9 -3
  107. package/dist/esm/cli/commands/init/scaffold-base-kit.d.ts +11 -0
  108. package/dist/esm/cli/commands/init/scaffold-base-kit.js +56 -1
  109. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/grok.d.ts +14 -8
  110. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/grok.js +41 -24
  111. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.d.ts +2 -0
  112. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.js +51 -23
  113. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.d.ts +12 -31
  114. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +288 -202
  115. package/dist/esm/cli/commands/init/scaffolders/file-transaction.d.ts +7 -0
  116. package/dist/esm/cli/commands/init/scaffolders/file-transaction.js +58 -0
  117. package/dist/esm/cli/commands/init/scaffolders/grok-mcp/index.d.ts +38 -0
  118. package/dist/esm/cli/commands/init/scaffolders/grok-mcp/index.js +73 -0
  119. package/dist/esm/cli/commands/init/scaffolders/opencode-plugin/index.d.ts +21 -0
  120. package/dist/esm/cli/commands/init/scaffolders/opencode-plugin/index.js +559 -63
  121. package/dist/esm/cli/commands/init/untracked-collision.d.ts +14 -0
  122. package/dist/esm/cli/commands/init/untracked-collision.js +42 -0
  123. package/dist/esm/cli/commands/opencode-rotate.d.ts +49 -0
  124. package/dist/esm/cli/commands/opencode-rotate.js +135 -0
  125. package/dist/esm/cli/commands/public-readiness.d.ts +11 -0
  126. package/dist/esm/cli/commands/public-readiness.js +42 -0
  127. package/dist/esm/cli/commands/review.d.ts +35 -3
  128. package/dist/esm/cli/commands/review.js +729 -209
  129. package/dist/esm/cli/commands/run-wait.d.ts +2 -0
  130. package/dist/esm/cli/commands/run-wait.js +122 -0
  131. package/dist/esm/cli/commands/setup-help.js +1 -1
  132. package/dist/esm/cli/commands/setup-repair-help.js +1 -1
  133. package/dist/esm/cli/commands/worktree/router-dispatch.js +13 -8
  134. package/dist/esm/cli/direct-provider-launch.js +9 -5
  135. package/dist/esm/cli/optional-tool-freshness.d.ts +27 -0
  136. package/dist/esm/cli/optional-tool-freshness.js +219 -9
  137. package/dist/esm/daemon/domains/git-actions.d.ts +29 -0
  138. package/dist/esm/daemon/domains/git-actions.js +136 -0
  139. package/dist/esm/daemon/domains/run-registry-interop.d.ts +16 -0
  140. package/dist/esm/daemon/domains/run-registry-interop.js +45 -0
  141. package/dist/esm/daemon/domains/sessions-search.d.ts +11 -0
  142. package/dist/esm/daemon/domains/sessions-search.js +29 -0
  143. package/dist/esm/daemon/handlers/blueprints-handlers.d.ts +5 -0
  144. package/dist/esm/daemon/handlers/blueprints-handlers.js +36 -0
  145. package/dist/esm/daemon/handlers/deploy-handlers.d.ts +6 -0
  146. package/dist/esm/daemon/handlers/deploy-handlers.js +5 -0
  147. package/dist/esm/daemon/handlers/fleet-handlers.d.ts +15 -0
  148. package/dist/esm/daemon/handlers/fleet-handlers.js +91 -0
  149. package/dist/esm/daemon/handlers/git-handlers.d.ts +6 -0
  150. package/dist/esm/daemon/handlers/git-handlers.js +27 -0
  151. package/dist/esm/daemon/handlers/product.d.ts +7 -0
  152. package/dist/esm/daemon/handlers/product.js +10 -119
  153. package/dist/esm/daemon/handlers/sessions-handlers.d.ts +12 -0
  154. package/dist/esm/daemon/handlers/sessions-handlers.js +31 -0
  155. package/dist/esm/daemon/handlers/shared.d.ts +5 -0
  156. package/dist/esm/daemon/handlers/shared.js +16 -0
  157. package/dist/esm/daemon/methods.d.ts +8 -1
  158. package/dist/esm/daemon/methods.js +7 -0
  159. package/dist/esm/daemon/protocol/contract.d.ts +43 -0
  160. package/dist/esm/daemon/protocol/contract.js +26 -0
  161. package/dist/esm/daemon/protocol/index.d.ts +1 -1
  162. package/dist/esm/daemon/protocol/index.js +1 -1
  163. package/dist/esm/hooks/__conformance__/matrix.d.ts +6 -0
  164. package/dist/esm/hooks/__conformance__/matrix.js +137 -0
  165. package/dist/esm/hooks/conformance/batch.js +5 -0
  166. package/dist/esm/hooks/doctor.d.ts +3 -3
  167. package/dist/esm/hooks/doctor.js +30 -15
  168. package/dist/esm/hooks/permission-request/index.js +9 -3
  169. package/dist/esm/hooks/permission-request/policy.d.ts +9 -0
  170. package/dist/esm/hooks/permission-request/policy.js +13 -0
  171. package/dist/esm/hooks/pretool-guard/dev-routing.d.ts +9 -0
  172. package/dist/esm/hooks/pretool-guard/dev-routing.js +41 -5
  173. package/dist/esm/hooks/pretool-guard/logger.d.ts +7 -0
  174. package/dist/esm/hooks/pretool-guard/logger.js +16 -0
  175. package/dist/esm/hooks/pretool-guard/runner.d.ts +6 -0
  176. package/dist/esm/hooks/pretool-guard/runner.js +68 -16
  177. package/dist/esm/hooks/pretool-guard/validators/blueprint.js +30 -10
  178. package/dist/esm/hooks/pretool-guard/validators/forbidden-commands.js +54 -0
  179. package/dist/esm/hooks/pretool-guard/validators/index.d.ts +7 -0
  180. package/dist/esm/hooks/pretool-guard/validators/index.js +21 -0
  181. package/dist/esm/hooks/pretool-guard/validators/path-contract.d.ts +19 -0
  182. package/dist/esm/hooks/pretool-guard/validators/path-contract.js +25 -8
  183. package/dist/esm/hooks/shared/guard-outcome.d.ts +149 -0
  184. package/dist/esm/hooks/shared/guard-outcome.js +215 -0
  185. package/dist/esm/hooks/shared/hook-bootstrap.d.ts +26 -0
  186. package/dist/esm/hooks/shared/hook-bootstrap.js +64 -1
  187. package/dist/esm/hooks/shared/types.d.ts +8 -5
  188. package/dist/esm/hooks/shared/types.js +23 -8
  189. package/dist/esm/hooks/stop/qa-changed-files.d.ts +11 -0
  190. package/dist/esm/hooks/stop/qa-changed-files.js +74 -0
  191. package/dist/esm/mcp/blueprint/_shared/lifecycle.js +14 -1
  192. package/dist/esm/mcp/blueprint/handlers/document-mutations.js +78 -4
  193. package/dist/esm/mcp/blueprint/handlers/review-log.js +8 -1
  194. package/dist/esm/mcp/blueprint/handlers/task-advance.js +113 -54
  195. package/dist/esm/mcp/blueprint/handlers/task-verify.js +39 -19
  196. package/dist/esm/mcp/blueprint/registration.js +3 -2
  197. package/dist/esm/mcp/server.d.ts +1 -0
  198. package/dist/esm/mcp/server.js +27 -0
  199. package/dist/esm/mcp/tools/_names.d.ts +1 -1
  200. package/dist/esm/mcp/tools/_names.js +5 -0
  201. package/dist/esm/mcp/tools/_registry.js +10 -0
  202. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  203. package/dist/esm/mcp/tools/_shared/result.js +44 -1
  204. package/dist/esm/mcp/tools/audit.d.ts +4 -1
  205. package/dist/esm/mcp/tools/audit.js +30 -0
  206. package/dist/esm/mcp/tools/audits.d.ts +3 -0
  207. package/dist/esm/mcp/tools/ci-preflight.d.ts +50 -0
  208. package/dist/esm/mcp/tools/ci-preflight.js +183 -0
  209. package/dist/esm/mcp/tools/pr-upsert.js +39 -1
  210. package/dist/esm/mcp/tools/review-run.d.ts +28 -0
  211. package/dist/esm/mcp/tools/review-run.js +216 -0
  212. package/dist/esm/mcp/tools/run-wait.d.ts +86 -0
  213. package/dist/esm/mcp/tools/run-wait.js +593 -0
  214. package/dist/esm/mcp/tools/session-id.d.ts +3 -0
  215. package/dist/esm/mcp/tools/session-id.js +45 -0
  216. package/dist/esm/mcp/tools/session-info.d.ts +3 -0
  217. package/dist/esm/mcp/tools/session-info.js +49 -0
  218. package/dist/esm/output-transforms/audit.d.ts +23 -0
  219. package/dist/esm/output-transforms/audit.js +47 -0
  220. package/dist/esm/output-transforms/index.js +2 -0
  221. package/dist/esm/review/authority.js +13 -5
  222. package/dist/esm/review/availability.d.ts +22 -0
  223. package/dist/esm/review/availability.js +107 -20
  224. package/dist/esm/review/delivery-verifier.d.ts +27 -0
  225. package/dist/esm/review/delivery-verifier.js +115 -0
  226. package/dist/esm/review/events.d.ts +3 -2
  227. package/dist/esm/review/events.js +23 -3
  228. package/dist/esm/review/execution/adapters.js +26 -9
  229. package/dist/esm/review/execution/output-policy.d.ts +2 -0
  230. package/dist/esm/review/execution/output-policy.js +12 -0
  231. package/dist/esm/review/execution/review-checkout.js +87 -6
  232. package/dist/esm/review/execution/supervisor.js +5 -0
  233. package/dist/esm/review/execution/types.d.ts +8 -0
  234. package/dist/esm/review/opencode-account.d.ts +24 -0
  235. package/dist/esm/review/opencode-account.js +68 -0
  236. package/dist/esm/review/opencode-auth.d.ts +33 -0
  237. package/dist/esm/review/opencode-auth.js +79 -0
  238. package/dist/esm/review/subject.d.ts +12 -0
  239. package/dist/esm/review/subject.js +74 -22
  240. package/dist/esm/runtime/opencode-account-materializer.d.ts +34 -0
  241. package/dist/esm/runtime/opencode-account-materializer.js +33 -0
  242. package/dist/esm/status/snapshot.js +30 -19
  243. package/dist/esm/test/duration-sequencer.d.ts +20 -0
  244. package/dist/esm/test/duration-sequencer.js +54 -0
  245. package/dist/esm/test/shard-durations.json +922 -0
  246. package/dist/esm/test/worker-budget.d.ts +6 -0
  247. package/dist/esm/test/worker-budget.js +59 -1
  248. package/dist/esm/test-helpers/global-setup.d.ts +1 -1
  249. package/dist/esm/test-helpers/global-setup.js +68 -24
  250. package/dist/esm/ultragoal/service.d.ts +7 -0
  251. package/dist/esm/ultragoal/service.js +42 -2
  252. package/dist/esm/utils/write-json-file.js +3 -1
  253. package/dist/esm/worktrees/codex-project-trust.d.ts +15 -0
  254. package/dist/esm/worktrees/codex-project-trust.js +56 -0
  255. package/dist/esm/worktrees/registry.d.ts +8 -0
  256. package/dist/esm/worktrees/registry.js +18 -3
  257. package/package.json +16 -13
@@ -10,17 +10,23 @@ import { relativeBlueprintSlug, resolveBlueprintFile } from "#lifecycle/local.js
10
10
  import { readBlueprintOverviewAtRef } from "#lifecycle/review-provenance.js";
11
11
  import { scanBlueprintDirectory } from "#service/scanner.js";
12
12
  import { resolveBlueprintRoot } from "#utils/blueprint-root.js";
13
+ import { assertFolderBlueprintIdentity } from "#utils/document-paths.js";
13
14
  import { CLAUDE_DEFAULT_REVIEW_MODEL, CODEX_DEFAULT_REVIEW_MODEL, GROK_DEFAULT_REVIEW_MODEL, DEFAULT_REVIEW_MAX_PROGRESS_EVENTS, runReviewExecution, } from "#review/execution/index.js";
14
15
  import { REVIEW_OUTPUT_MAX_BYTES, REVIEW_OUTPUT_TOO_LARGE, applyReviewOutputPolicy, } from "#review/execution/output-policy.js";
15
16
  import { removeOwnedRunningReviewState } from "#review/execution/artifacts.js";
16
17
  import { createReviewTargetCheckout, } from "#review/execution/review-checkout.js";
17
18
  import { loadReviewProcessRecords, readReviewProcessIdentity, removeReviewProcessRecord, } from "#review/execution/process-registry.js";
18
- import { isProviderSuppressed, loadReviewAvailability, pruneExpiredAvailabilitySuppressions, recordReviewAvailability, recordReviewAvailabilityWithLock, reviewAvailabilityPath, saveReviewAvailability, suppressedReviewers, } from "#review/availability.js";
19
+ import { isProviderSuppressed, loadOpencodeAccountAvailability, loadReviewAvailability, pruneExpiredAvailabilitySuppressions, pruneExpiredOpencodeAccountSuppressions, recordOpencodeAccountAvailabilityWithLock, recordReviewAvailability, recordReviewAvailabilityWithLock, reviewAvailabilityPath, saveReviewAvailability, suppressedReviewers, } from "#review/availability.js";
20
+ import { opencodeAccountCanonicalId, opencodeAccountDataDir, selectActiveOpencodeAccount, } from "#review/opencode-account.js";
21
+ import { materializeOpencodeGoAuth } from "#review/opencode-auth.js";
22
+ import { resolveOpencodeAccountsGracefully } from "#runtime/opencode-account-materializer.js";
23
+ import { resolveAgentSecretProfile } from "#cli/commands/agent-launch.js";
19
24
  import { evaluateApprovalGate, evaluateApprovalReviewers, formatApprovalGateRequirement, } from "#lifecycle/audit";
20
25
  import { assertReviewRefUnchanged, readReviewAuthorityAtRef, readWorkingReviewEvents, } from "#review/authority.js";
21
- import { appendReviewEvent, evaluateReviewEvents } from "#review/events.js";
26
+ import { appendReviewEvent, evaluateReviewEvents, MAINTAINER_OVERRIDE_REVIEWER, parseReviewEventLog, } from "#review/events.js";
22
27
  import { isPolicyApprovalVerdict } from "#review/verdict.js";
23
- import { createDeliverySubjectAtRef, createPlanSubjectAtRef, } from "#review/subject.js";
28
+ import { verificationArtifactSection, verificationPromptSummary, verifyDeliveryPromotionGates, } from "#review/delivery-verifier.js";
29
+ import { createDeliverySubjectAtRef, createPlanSubjectAtRef, findUnreviewedWorkingTreeChanges, } from "#review/subject.js";
24
30
  import { runCommand } from "#mcp/tools/_shared/run-command.js";
25
31
  import { NotInGitRepoError, withLock } from "#paths/state-root.js";
26
32
  import { writeFileAtomic, writeJsonFileAtomic } from "#shared-utils/write-json-file.js";
@@ -149,6 +155,18 @@ function defaultReviewModel(provider) {
149
155
  return GROK_DEFAULT_REVIEW_MODEL;
150
156
  return undefined;
151
157
  }
158
+ /**
159
+ * Claude's CLI accepts `fable` as its moving alias and `claude-fable-N` as a
160
+ * versioned identifier. Accept the common human shorthand `fableN` too, so a
161
+ * request for a specific Fable generation is never forwarded as an invalid
162
+ * provider model id.
163
+ */
164
+ function resolveReviewModel(provider, model) {
165
+ if (provider !== "claude")
166
+ return model;
167
+ const fableVersion = /^fable[-\s]?(\d+)$/iu.exec(model)?.[1];
168
+ return fableVersion ? `claude-fable-${fableVersion}` : model;
169
+ }
152
170
  function classificationForFailureCode(code) {
153
171
  if (!code)
154
172
  return undefined;
@@ -189,9 +207,10 @@ export async function runReviewRunCommand(projectRoot, options, deps = {}) {
189
207
  if (!options.artifactRoot)
190
208
  throw new Error("Missing required --artifact-root.");
191
209
  const provider = parseReviewProvider(options.provider);
192
- const model = options.model?.trim() || defaultReviewModel(provider);
193
- if (!model)
210
+ const requestedModel = options.model?.trim() || defaultReviewModel(provider);
211
+ if (!requestedModel)
194
212
  throw new Error(`Missing required --model for ${provider}.`);
213
+ const model = resolveReviewModel(provider, requestedModel);
195
214
  const effort = parseReviewEffort(options.effort);
196
215
  const stage = parseReviewStage(options.stage);
197
216
  const idleSeconds = options.idleSeconds === undefined ? 180 : Number(options.idleSeconds);
@@ -354,6 +373,13 @@ function reviewsPathForBlueprint(blueprintPath) {
354
373
  }
355
374
  return path.join(path.dirname(blueprintPath), "reviews.md");
356
375
  }
376
+ function assertReviewEvidenceCapable(location) {
377
+ assertFolderBlueprintIdentity({
378
+ documentPath: location.path,
379
+ lifecycleSlug: location.slug,
380
+ shape: location.shape,
381
+ });
382
+ }
357
383
  function parseLegacyTableEntries(markdown, blueprintSlug, blueprintPath, reviewsPath) {
358
384
  const entries = [];
359
385
  for (const line of markdown.split("\n")) {
@@ -688,6 +714,7 @@ async function resolveReviewBlueprint(projectRoot, slug) {
688
714
  }
689
715
  export async function logReviewEntry(projectRoot, slug, input) {
690
716
  const location = await resolveReviewBlueprint(projectRoot, slug);
717
+ assertReviewEvidenceCapable(location);
691
718
  const reviewsPath = reviewsPathForBlueprint(location.path);
692
719
  const timestamp = new Date().toISOString();
693
720
  const reviewer = parseReviewerId(input.reviewer);
@@ -749,6 +776,7 @@ export async function logReviewEntry(projectRoot, slug, input) {
749
776
  }
750
777
  export async function logReviewEvent(projectRoot, slug, input) {
751
778
  const location = await resolveReviewBlueprint(projectRoot, slug);
779
+ assertReviewEvidenceCapable(location);
752
780
  const stableSlug = relativeBlueprintSlug(location.slug);
753
781
  const purpose = normalizeReviewPurpose(input.purpose);
754
782
  const targetSha = resolveImmutableSha(projectRoot, input.target);
@@ -810,6 +838,127 @@ export async function logReviewEvent(projectRoot, slug, input) {
810
838
  updateReviewCache(projectRoot, scanAllReviewLedgers(projectRoot));
811
839
  return event;
812
840
  }
841
+ function renderMaintainerOverrideArtifact(input) {
842
+ return [
843
+ `# Maintainer override — ${input.blueprintSlug} (${input.purpose})`,
844
+ "",
845
+ "A maintainer has adjudicated this subject after a formal Claude/Codex/Grok/OpenCode-Go",
846
+ "reviewer rejected it and the blocking finding cannot be remediated (a physical or",
847
+ "maximalist residual). This override satisfies the promotion approval gate DISTINCTLY;",
848
+ "it is never counted as a strong-reviewer or OpenCode-Go approval.",
849
+ "",
850
+ `- Subject scheme: ${input.subjectScheme}`,
851
+ `- Subject digest: ${input.subjectDigest}`,
852
+ `- Reviewed commit: ${input.reviewedCommit}`,
853
+ `- Overridden rejection artifact: ${input.rejectionArtifactPath}`,
854
+ `- Recorded: ${input.timestamp}`,
855
+ "",
856
+ "## Maintainer reason",
857
+ "",
858
+ input.reason,
859
+ "",
860
+ ].join("\n");
861
+ }
862
+ /**
863
+ * `wp review override` — the gated, auditable human escape hatch for the
864
+ * promotion approval gate. It records a `maintainer-override` approval that
865
+ * satisfies the gate distinctly (never as a strong/OpenCode-Go approval).
866
+ *
867
+ * Trust properties:
868
+ * - REFUSES unless a formal strong-reviewer gate has already REJECTED the exact
869
+ * current subject (precondition + target binding are the same lookup).
870
+ * - Requires an explicit `--reason` and `--confirm` — no accidental invocation.
871
+ * - Reviewer id `maintainer-override` is not in the `wp review log` reviewer
872
+ * enum, so neither `wp review log` nor `wp_blueprint_review_log` can mint it;
873
+ * this command is the only writer. The read-time policy in
874
+ * `#review/events` treats it as human-like, so its manual-stage record is a
875
+ * legitimate governance approval rather than a disguised machine approval.
876
+ */
877
+ export async function runReviewOverride(projectRoot, slug, input) {
878
+ const location = await resolveReviewBlueprint(projectRoot, slug);
879
+ assertReviewEvidenceCapable(location);
880
+ if (lstatSync(location.path).isSymbolicLink()) {
881
+ throw new Error(`Blueprint overview must not be a symlink: ${location.path}`);
882
+ }
883
+ const reason = normalizeOptionalText(input.reason);
884
+ if (!reason) {
885
+ throw new Error("Missing required --reason for `review override`: a maintainer adjudication must state why an automated reviewer cannot converge.");
886
+ }
887
+ if (input.confirm !== true) {
888
+ throw new Error("`review override` requires explicit --confirm: it mints a promotion-gate-satisfying human adjudication that bypasses the strong-reviewer gate.");
889
+ }
890
+ const stableSlug = relativeBlueprintSlug(location.slug);
891
+ const purpose = normalizeReviewPurpose(input.purpose);
892
+ const targetSha = resolveImmutableSha(projectRoot, input.target);
893
+ const repositoryBlueprintPath = blueprintPathAtGitRoot(projectRoot, location.path);
894
+ const subject = purpose === "plan"
895
+ ? createPlanSubjectAtRef(projectRoot, targetSha, stableSlug, repositoryBlueprintPath)
896
+ : createDeliverySubjectAtRef(projectRoot, targetSha, stableSlug, repositoryBlueprintPath);
897
+ const ledgerPath = path.join(path.dirname(location.path), "review-events.jsonl");
898
+ const events = existsSync(ledgerPath)
899
+ ? parseReviewEventLog(readFileSync(ledgerPath, "utf8"))
900
+ : [];
901
+ // Precondition AND target binding in one lookup: the override is valid only
902
+ // when a formal strong-reviewer gate REJECTED the exact current subject. This
903
+ // refuses overriding content that was never properly reviewed, and refuses a
904
+ // stale rejection of content that has since changed.
905
+ const rejection = rejectedReviewSubjects({
906
+ events,
907
+ projectRoot,
908
+ purpose,
909
+ stableSlug,
910
+ }).find((entry) => entry.subjectDigest === subject.digest);
911
+ if (!rejection) {
912
+ throw new Error(`Refusing to record a maintainer override: no formal Claude/Codex/Grok/OpenCode-Go rejection exists for the current ${purpose} subject (${subject.digest.slice(0, 12)}) of ${stableSlug}. A maintainer override is only valid after a strong reviewer has rejected the exact content under review.`);
913
+ }
914
+ const timestamp = new Date().toISOString();
915
+ const artifactSuffix = timestamp.replace(/[^0-9]/gu, "").slice(0, 14);
916
+ const artifactPath = `review-artifacts/maintainer-override-${subject.digest.slice(0, 12)}-${artifactSuffix}.md`;
917
+ const artifactAbsolute = safeArtifactPath(location.path, artifactPath);
918
+ const artifactContent = Buffer.from(renderMaintainerOverrideArtifact({
919
+ blueprintSlug: stableSlug,
920
+ purpose,
921
+ subjectScheme: subject.scheme,
922
+ subjectDigest: subject.digest,
923
+ reviewedCommit: targetSha,
924
+ rejectionArtifactPath: rejection.artifactPath,
925
+ reason,
926
+ timestamp,
927
+ }), "utf8");
928
+ const event = {
929
+ schemaVersion: 2,
930
+ // The timestamp keeps the id unique across re-invocations. Re-running the
931
+ // override for the same subject therefore appends a second approve event
932
+ // rather than being idempotent; that is harmless (each is gated by the same
933
+ // real rejection, and evaluateReviewEvents keeps the latest per reviewer)
934
+ // and avoids a same-id/different-timestamp collision in appendReviewEvent.
935
+ id: `${purpose}:maintainer-override:${subject.digest}:${timestamp}`,
936
+ purpose,
937
+ blueprintSlug: stableSlug,
938
+ subjectScheme: subject.scheme,
939
+ subjectDigest: subject.digest,
940
+ reviewedCommit: targetSha,
941
+ gateId: "manual",
942
+ promptHash: createHash("sha256")
943
+ .update(`maintainer-override\0${purpose}\0${subject.scheme}\0${subject.digest}`)
944
+ .digest("hex"),
945
+ reviewer: MAINTAINER_OVERRIDE_REVIEWER,
946
+ // No `provider`: a maintainer override has no AI backend.
947
+ stage: "manual",
948
+ status: "complete",
949
+ verdict: "approve",
950
+ timestamp,
951
+ artifactPath,
952
+ artifactSha256: createHash("sha256").update(artifactContent).digest("hex"),
953
+ };
954
+ appendReviewEvent({
955
+ ledgerPath,
956
+ event,
957
+ artifact: { path: artifactAbsolute, content: artifactContent },
958
+ });
959
+ updateReviewCache(projectRoot, scanAllReviewLedgers(projectRoot));
960
+ return event;
961
+ }
813
962
  function resolveImmutableSha(projectRoot, target) {
814
963
  let resolved;
815
964
  try {
@@ -946,8 +1095,15 @@ function dedupeGateAttempts(attempts) {
946
1095
  return result;
947
1096
  }
948
1097
  function gateAttemptFromEvent(event) {
1098
+ // Only real gate events reach here (callers filter by the content-hash gateId
1099
+ // + promptHash); those always carry a provider. A `provider`-less event (e.g.
1100
+ // a maintainer override) is a caller bug — fail loudly rather than fabricate.
1101
+ const provider = event.provider;
1102
+ if (!provider) {
1103
+ throw new Error(`Gate attempt event ${event.id} is missing a provider.`);
1104
+ }
949
1105
  return {
950
- provider: event.provider,
1106
+ provider,
951
1107
  ...(event.model ? { model: event.model } : {}),
952
1108
  ...(event.effort ? { effort: event.effort } : {}),
953
1109
  reviewer: event.reviewer,
@@ -999,6 +1155,19 @@ const reviewGateAttemptSchema = z.object({
999
1155
  retryAfterMs: z.number().optional(),
1000
1156
  artifact: z.string().optional(),
1001
1157
  });
1158
+ const reviewGateVerificationCommandSchema = z.object({
1159
+ command: z.string(),
1160
+ outcome: z.enum(["passed", "failed", "timed-out"]),
1161
+ durationMs: z.number(),
1162
+ exitCode: z.number().nullable(),
1163
+ signal: z.string().optional(),
1164
+ });
1165
+ const reviewGateVerificationSchema = z.object({
1166
+ status: z.enum(["passed", "failed"]),
1167
+ commands: z.array(reviewGateVerificationCommandSchema),
1168
+ failureCode: z.string().optional(),
1169
+ logPath: z.string().optional(),
1170
+ });
1002
1171
  const reviewGateStateSchema = z
1003
1172
  .object({
1004
1173
  schemaVersion: z.literal(2),
@@ -1011,10 +1180,18 @@ const reviewGateStateSchema = z
1011
1180
  targetSha: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u),
1012
1181
  baseSha: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u),
1013
1182
  promptHash: z.string().length(64),
1014
- status: z.enum(["approved", "needs-review", "rejected", "recorded-untracked"]),
1183
+ status: z.enum([
1184
+ "approved",
1185
+ "needs-review",
1186
+ "rejected",
1187
+ "scope-exhausted",
1188
+ "recorded-untracked",
1189
+ "verification-failed",
1190
+ ]),
1015
1191
  approvalGate: z.object({
1016
1192
  distinct: z.number(),
1017
1193
  openCodeGoModelReviewers: z.array(z.string()),
1194
+ overrideReviewers: z.array(z.string()),
1018
1195
  satisfied: z.boolean(),
1019
1196
  strongReviewers: z.array(z.string()),
1020
1197
  }),
@@ -1036,6 +1213,7 @@ const reviewGateStateSchema = z
1036
1213
  // Optional so resuming a gate state persisted before issue tracking existed
1037
1214
  // still validates under .strict().
1038
1215
  issues: z.array(z.string()).optional(),
1216
+ verification: reviewGateVerificationSchema.optional(),
1039
1217
  message: z.string(),
1040
1218
  })
1041
1219
  .strict();
@@ -1077,6 +1255,16 @@ function buildGatePrompt(input) {
1077
1255
  `Review purpose: ${input.purpose}`,
1078
1256
  `Reviewed content subject: ${input.subject.scheme}:${input.subject.digest}`,
1079
1257
  `Immutable target SHA: ${input.targetSha}`,
1258
+ ...(input.verificationSummary ? ["", input.verificationSummary, ""] : []),
1259
+ ...(input.priorRejections && input.priorRejections.length > 0
1260
+ ? [
1261
+ "",
1262
+ "Prior formal rejection evidence (this is the final semantic remediation review):",
1263
+ ...input.priorRejections.map((entry) => `- ${entry.subjectDigest}: ${entry.artifactPath}`),
1264
+ "Inspect every prior finding and its disposition. Return all remaining blockers together; do not defer discoverable blockers to another review round.",
1265
+ "",
1266
+ ]
1267
+ : []),
1080
1268
  "Inspect the target SHA and blueprint directly. Focus on correctness, security, concurrency, recovery, tests, and scope compliance.",
1081
1269
  "End with exactly one standalone verdict line: APPROVED, APPROVE-WITH-NITS, or BLOCKED.",
1082
1270
  "APPROVE-WITH-NITS satisfies the gate only when every finding is non-blocking.",
@@ -1168,7 +1356,10 @@ function chooseOpenCodeModels(catalog, explicitModel) {
1168
1356
  return selected;
1169
1357
  }
1170
1358
  function isProviderWideOpenCodeGateResult(result) {
1171
- return result.reviewer === "opencode" || result.failureCode === "opencode-go-usage-limit";
1359
+ // A per-account usage limit (failureCode "opencode-go-usage-limit") is NO
1360
+ // LONGER provider-wide — the gate fails over to the next account first. Only a
1361
+ // systemic opencode failure (the family-agnostic "opencode" reviewer) is.
1362
+ return result.reviewer === "opencode";
1172
1363
  }
1173
1364
  function contaminatedReviewResult(result, changes) {
1174
1365
  return {
@@ -1226,12 +1417,18 @@ function gateFailureFromSummary(summary) {
1226
1417
  verdict: "no-verdict",
1227
1418
  terminalClassification,
1228
1419
  ...(summary.failureCode ? { failureCode: summary.failureCode } : {}),
1420
+ ...(summary.providerErrorDetail ? { providerErrorDetail: summary.providerErrorDetail } : {}),
1229
1421
  ...(summary.limitWindow ? { limitWindow: summary.limitWindow } : {}),
1230
1422
  ...(summary.retryAfterMs === undefined ? {} : { retryAfterMs: summary.retryAfterMs }),
1231
1423
  };
1232
1424
  }
1233
- export async function invokeProvider(input, executeReview = runReviewExecution, runCatalogCommand = runCommand, createTargetCheckout = createReviewTargetCheckout, existingCheckout) {
1425
+ export async function invokeProvider(input, executeReview = runReviewExecution, runCatalogCommand = runCommand, createTargetCheckout = createReviewTargetCheckout,
1426
+ // A thunk (not a checkout instance) so callers that never reach a real
1427
+ // provider attempt — injected fakes included — never pay the checkout's
1428
+ // git-archive/tar setup cost.
1429
+ getSharedCheckout) {
1234
1430
  let checkout;
1431
+ const existingCheckout = getSharedCheckout?.();
1235
1432
  const ownsCheckout = existingCheckout === undefined;
1236
1433
  try {
1237
1434
  checkout = existingCheckout ?? createTargetCheckout(input.projectRoot, input.targetSha);
@@ -1246,7 +1443,8 @@ export async function invokeProvider(input, executeReview = runReviewExecution,
1246
1443
  // The attempt already fails closed as an isolation failure.
1247
1444
  }
1248
1445
  }
1249
- const model = input.model ?? defaultReviewModel(input.provider) ?? "unresolved";
1446
+ const requestedModel = input.model ?? defaultReviewModel(input.provider) ?? "unresolved";
1447
+ const model = resolveReviewModel(input.provider, requestedModel);
1250
1448
  return {
1251
1449
  output: "Unable to create the disposable review checkout; review was not started.",
1252
1450
  model,
@@ -1280,6 +1478,7 @@ async function invokeProviderInCheckout(input, executeReview = runReviewExecutio
1280
1478
  cwd: input.executionCwd,
1281
1479
  registryRoot: input.projectRoot,
1282
1480
  ...(input.signal ? { signal: input.signal } : {}),
1481
+ ...(input.authDataDir ? { authDataDir: input.authDataDir } : {}),
1283
1482
  };
1284
1483
  async function probeProvider(args) {
1285
1484
  const probe = await executeReview({
@@ -1302,7 +1501,7 @@ async function invokeProviderInCheckout(input, executeReview = runReviewExecutio
1302
1501
  };
1303
1502
  }
1304
1503
  if (input.provider === "claude") {
1305
- const model = input.model ?? defaultReviewModel("claude");
1504
+ const model = resolveReviewModel("claude", input.model ?? defaultReviewModel("claude"));
1306
1505
  const probeFailure = await probeProvider({ provider: "claude", model, reviewer: "claude" });
1307
1506
  if (probeFailure)
1308
1507
  return probeFailure;
@@ -1442,6 +1641,101 @@ async function invokeProviderInCheckout(input, executeReview = runReviewExecutio
1442
1641
  : gateFailureFromSummary(result)),
1443
1642
  };
1444
1643
  }
1644
+ /**
1645
+ * Default credential preparer: materialize the account's key into its own
1646
+ * isolated `<stateRoot>/opencode-accounts/<id>/opencode/auth.json` (0600) and
1647
+ * return the `XDG_DATA_HOME` directory. Never mutates the user's global store.
1648
+ */
1649
+ function prepareOpencodeCredentialDefault(account) {
1650
+ const dataDir = opencodeAccountDataDir(account.id);
1651
+ materializeOpencodeGoAuth(account.key, { dataDir });
1652
+ return dataDir;
1653
+ }
1654
+ /**
1655
+ * Multi-account failover for the opencode-go gateway. Resolves Doppler accounts
1656
+ * once, materializes the freshest non-cooled account's isolated auth store, and
1657
+ * rotates to the next account on a per-account usage limit — never touching the
1658
+ * user's global `auth.json`. Degrades to a no-op (undefined `authDataDir`, the
1659
+ * historical global-auth behavior) when no accounts are provisioned.
1660
+ */
1661
+ function createOpencodeFailoverController(deps) {
1662
+ // Resolve through the same agent secret profile the launch path uses; without
1663
+ // an environment selector the secret manager falls back to its own ambient,
1664
+ // path-scoped config, which only resolves inside a checkout that happens to
1665
+ // sit under that scope (so account discovery silently finds nothing in a
1666
+ // managed worktree, CI clone, or fresh checkout).
1667
+ const resolution = deps.resolveAccounts({
1668
+ cwd: deps.projectRoot,
1669
+ secretProfile: resolveAgentSecretProfile(),
1670
+ });
1671
+ const accounts = resolution.ok ? resolution.accounts : [];
1672
+ const tried = new Set();
1673
+ let active;
1674
+ let authDataDir;
1675
+ function prepare(account) {
1676
+ try {
1677
+ authDataDir = deps.prepareCredential(account);
1678
+ active = account;
1679
+ tried.add(account.id);
1680
+ return true;
1681
+ }
1682
+ catch {
1683
+ return false;
1684
+ }
1685
+ }
1686
+ /**
1687
+ * @param allowCooled Accept the soonest-expiring account when every account is
1688
+ * already cooled. True for the initial pick (mirrors `wp opencode`: a known
1689
+ * account beats falling back to whatever the global auth store happens to
1690
+ * hold); false when rotating after a usage limit, where retrying a capped
1691
+ * account would only burn an attempt.
1692
+ */
1693
+ function selectNext(allowCooled) {
1694
+ const state = pruneExpiredOpencodeAccountSuppressions(loadOpencodeAccountAvailability()).state;
1695
+ const selection = selectActiveOpencodeAccount(accounts, state);
1696
+ if (!selection || tried.has(selection.account.id))
1697
+ return undefined;
1698
+ if (selection.cooled && !allowCooled)
1699
+ return undefined;
1700
+ return selection.account;
1701
+ }
1702
+ if (accounts.length > 0) {
1703
+ const first = selectNext(true);
1704
+ if (first)
1705
+ prepare(first);
1706
+ }
1707
+ return {
1708
+ authDataDir: () => authDataDir,
1709
+ async onUsageLimit(event) {
1710
+ if (active) {
1711
+ await recordOpencodeAccountAvailabilityWithLock(opencodeAccountCanonicalId(active.id), {
1712
+ failureCode: "opencode-go-usage-limit",
1713
+ terminalClassification: "provider-limit-reached",
1714
+ ...(event.limitWindow ? { limitWindow: event.limitWindow } : {}),
1715
+ ...(event.retryAfterMs === undefined ? {} : { retryAfterMs: event.retryAfterMs }),
1716
+ });
1717
+ }
1718
+ const next = selectNext(false);
1719
+ if (next && prepare(next))
1720
+ return "retry";
1721
+ // No account left: suppress the opencode PROVIDER (the per-attempt record
1722
+ // is family-scoped and does not) so the gate moves to the next provider.
1723
+ // Sync (unlocked) write — the gate already holds the worktree lock, so
1724
+ // re-locking here would self-deadlock (ELOCKED).
1725
+ recordReviewAvailability(deps.projectRoot, {
1726
+ provider: "opencode",
1727
+ reviewer: "opencode",
1728
+ effort: event.effort,
1729
+ stage: event.stage,
1730
+ terminalClassification: "provider-limit-reached",
1731
+ failureCode: "opencode-go-usage-limit",
1732
+ ...(event.limitWindow ? { limitWindow: event.limitWindow } : {}),
1733
+ ...(event.retryAfterMs === undefined ? {} : { retryAfterMs: event.retryAfterMs }),
1734
+ });
1735
+ return "suppress-provider";
1736
+ },
1737
+ };
1738
+ }
1445
1739
  function currentReviewHost(env = process.env) {
1446
1740
  const host = env["WP_REVIEW_HOST"] ?? env["WP_HOOK_HOST"];
1447
1741
  return host === "claude" || host === "codex" || host === "opencode" || host === "grok"
@@ -1524,11 +1818,52 @@ function reviewArtifactRelativePath(input) {
1524
1818
  return path.posix.join(artifactDirectory, filename);
1525
1819
  }
1526
1820
  function reviewGateEventId(input) {
1527
- return input.stage === "approval-gate"
1528
- ? input.ordinal === 1
1529
- ? `gate:${input.gateId}:${input.provider}:${input.model}`
1530
- : `gate:${input.gateId}:${input.provider}:approval-retry:${input.ordinal}:${input.model}`
1531
- : `gate:${input.gateId}:${input.provider}:${input.stage}:${input.ordinal}:${input.model}`;
1821
+ if (input.stage !== "approval-gate") {
1822
+ return `gate:${input.gateId}:${input.provider}:${input.stage}:${input.ordinal}:${input.model}`;
1823
+ }
1824
+ return input.ordinal === 1
1825
+ ? `gate:${input.gateId}:${input.provider}:${input.model}`
1826
+ : `gate:${input.gateId}:${input.provider}:approval-retry:${input.ordinal}:${input.model}`;
1827
+ }
1828
+ function isFormalGateRejection(event) {
1829
+ const provider = event.provider;
1830
+ if (event.status !== "complete" ||
1831
+ event.verdict !== "reject" ||
1832
+ event.stage !== "approval-gate" ||
1833
+ !/^[a-f0-9]{16}$/u.test(event.gateId) ||
1834
+ !provider ||
1835
+ !event.model ||
1836
+ !event.artifactPath) {
1837
+ return false;
1838
+ }
1839
+ return (event.id ===
1840
+ reviewGateEventId({
1841
+ gateId: event.gateId,
1842
+ provider,
1843
+ model: event.model,
1844
+ stage: "approval-gate",
1845
+ ordinal: event.attemptOrdinal ?? 1,
1846
+ }));
1847
+ }
1848
+ function rejectedReviewSubjects(input) {
1849
+ const bySubject = new Map();
1850
+ for (const event of input.events) {
1851
+ if (event.blueprintSlug !== input.stableSlug ||
1852
+ event.purpose !== input.purpose ||
1853
+ !isFormalGateRejection(event)) {
1854
+ continue;
1855
+ }
1856
+ const canonical = input.purpose === "plan"
1857
+ ? createPlanSubjectAtRef(input.projectRoot, event.reviewedCommit, input.stableSlug)
1858
+ : createDeliverySubjectAtRef(input.projectRoot, event.reviewedCommit, input.stableSlug);
1859
+ if (!bySubject.has(canonical.digest)) {
1860
+ bySubject.set(canonical.digest, {
1861
+ artifactPath: event.artifactPath,
1862
+ subjectDigest: canonical.digest,
1863
+ });
1864
+ }
1865
+ }
1866
+ return [...bySubject.values()];
1532
1867
  }
1533
1868
  /**
1534
1869
  * Sanctioned fallback when `wp review gate` does not reach "approved" — points
@@ -1537,13 +1872,46 @@ function reviewGateEventId(input) {
1537
1872
  * workaround. See catalog/agent/rules/pre-implementation.md.
1538
1873
  */
1539
1874
  export function reviewGateNonConvergenceHint(slug, context = {}) {
1875
+ if (context.status === "scope-exhausted") {
1876
+ return `hint: formal review budget is exhausted for ${slug}. Stop automated review retries and obtain an explicit human scope decision before creating a successor blueprint.`;
1877
+ }
1540
1878
  if (context.failureCode === "provider-event-too-large" && context.gateId) {
1541
1879
  return `hint: provider transport emitted a single provider JSON event above the bounded capture limit for ${slug}; no review verdict was produced. Retry the unchanged gate with "wp review gate ${slug} --resume ${context.gateId} --provider <provider>" and select a different provider when possible. Retrying the same provider/model is allowed for recovery but is likely to recur for deterministic oversized output. An independent "wp review run" can be recorded only as a human-adjudicated verdict through wp_blueprint_review_log; that tool cannot create a Claude/Codex/Grok/OpenCode-Go approval. The event-size and timeout bounds were not increased. See catalog/agent/rules/pre-implementation.md.`;
1542
1880
  }
1543
1881
  return `hint: wp review gate did not satisfy the approval policy for ${slug}. Retry interrupted or structural failures with --resume${context.gateId ? ` ${context.gateId}` : " <gate-id>"} and optionally switch --provider. For a separate review, wp_blueprint_review_log can record only a human-adjudicated approval; it cannot create a Claude/Codex/Grok/OpenCode-Go approval and cannot substitute for the strong-reviewer gate. It is NOT the same as this CLI's own "wp review log" subcommand, which writes only the machine ledger and requires --provider plus a human reviewer for any approval verdict. See catalog/agent/rules/pre-implementation.md.`;
1544
1882
  }
1883
+ /**
1884
+ * Guards the invariant this file must never silently violate: a
1885
+ * `wp-git-tree-v1` delivery subject digest is computed purely from the
1886
+ * committed tree at `targetSha` (see createDeliverySubjectAtRef). When that
1887
+ * target is the currently checked-out HEAD — the default and overwhelmingly
1888
+ * common case — any uncommitted non-evidence change in the working tree is
1889
+ * content nobody has reviewed yet. Reporting a "satisfied" verdict in that
1890
+ * state (whether by reusing a prior approval for `targetSha` or by running a
1891
+ * fresh review whose prompt still only sees `targetSha`'s committed content)
1892
+ * would silently ship edits that were never reviewed. Fail closed instead.
1893
+ *
1894
+ * Plan purpose is intentionally exempt: its `wp-blueprint-v1` digest is
1895
+ * scoped to a single file and already neutralizes lifecycle bookkeeping by
1896
+ * design (see normalizePlanSubjectContent), so uncommitted lifecycle moves
1897
+ * are expected to keep reusing an existing plan approval.
1898
+ */
1899
+ function assertWorkingTreeMatchesDeliveryTarget(projectRoot, purpose, targetSha, baseSha) {
1900
+ if (purpose !== "delivery" || targetSha !== baseSha)
1901
+ return;
1902
+ const gitRoot = resolveReviewGitToplevel(projectRoot);
1903
+ const divergentPaths = findUnreviewedWorkingTreeChanges(gitRoot);
1904
+ if (divergentPaths.length === 0)
1905
+ return;
1906
+ throw new Error(`Review target ${targetSha} is the current HEAD, but the working tree has uncommitted ` +
1907
+ `changes not reflected in that reviewed commit: ${divergentPaths.join(", ")}. A ` +
1908
+ `wp-git-tree-v1 approval bound to ${targetSha} does not cover these edits. Commit (or ` +
1909
+ `discard) the changes so the reviewed content matches what will actually ship, then ` +
1910
+ `re-run "wp review gate".`);
1911
+ }
1545
1912
  export async function runReviewGate(projectRoot, slug, input, dependencies = { invokeProvider }) {
1546
1913
  const location = await resolveReviewBlueprint(projectRoot, slug);
1914
+ assertReviewEvidenceCapable(location);
1547
1915
  if (lstatSync(location.path).isSymbolicLink()) {
1548
1916
  throw new Error(`Blueprint overview must not be a symlink: ${location.path}`);
1549
1917
  }
@@ -1557,6 +1925,7 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
1557
1925
  const subject = purpose === "plan"
1558
1926
  ? createPlanSubjectAtRef(projectRoot, targetSha, stableSlug, repositoryBlueprintPath)
1559
1927
  : createDeliverySubjectAtRef(projectRoot, targetSha, stableSlug, repositoryBlueprintPath);
1928
+ assertWorkingTreeMatchesDeliveryTarget(projectRoot, purpose, targetSha, baseSha);
1560
1929
  const promptHash = hashPrompt({
1561
1930
  blueprintContent,
1562
1931
  blueprintSlug: stableSlug,
@@ -1575,13 +1944,16 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
1575
1944
  }
1576
1945
  const explicitProvider = normalizeGateProvider(input.provider);
1577
1946
  const effort = normalizeGateEffort(input.effort);
1578
- const prompt = buildGatePrompt({
1947
+ const renderPrompt = (verificationSummary, priorRejections) => buildGatePrompt({
1579
1948
  blueprintPath: projectBlueprintPath,
1580
1949
  blueprintSlug: stableSlug,
1581
1950
  purpose,
1582
1951
  subject,
1583
1952
  targetSha,
1953
+ ...(verificationSummary ? { verificationSummary } : {}),
1954
+ ...(priorRejections ? { priorRejections } : {}),
1584
1955
  });
1956
+ let prompt = renderPrompt();
1585
1957
  return withLock("worktree", async () => {
1586
1958
  const resumed = loadGateState(projectRoot, gateId, {
1587
1959
  purpose,
@@ -1594,17 +1966,32 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
1594
1966
  resumed.subjectDigest !== subject.digest)) {
1595
1967
  throw new Error(`Gate ${gateId} does not match target ${targetSha} and prompt ${promptHash}.`);
1596
1968
  }
1597
- let reusedEvents = [];
1598
1969
  const gateIssues = [];
1970
+ let lineageFailure;
1971
+ let rejectedSubjects = [];
1972
+ let reusedApprovals = [];
1599
1973
  try {
1600
1974
  const reusedAuthority = readReviewAuthorityAtRef(projectRoot, targetSha, stableSlug, purpose);
1601
- reusedEvents = reusedAuthority.approvals;
1975
+ reusedApprovals = reusedAuthority.approvals;
1602
1976
  gateIssues.push(...reusedAuthority.issues);
1977
+ const formalRejectionIds = new Set(reusedAuthority.events.filter(isFormalGateRejection).map((event) => event.id));
1978
+ const compromisedRejection = reusedAuthority.issues.find((issue) => [...formalRejectionIds].some((eventId) => issue.includes(`(${eventId})`)));
1979
+ if (compromisedRejection) {
1980
+ lineageFailure = `Formal rejection evidence failed integrity verification: ${compromisedRejection}`;
1981
+ }
1982
+ else {
1983
+ rejectedSubjects = rejectedReviewSubjects({
1984
+ events: reusedAuthority.events,
1985
+ projectRoot,
1986
+ purpose,
1987
+ stableSlug,
1988
+ });
1989
+ }
1603
1990
  }
1604
- catch {
1605
- reusedEvents = [];
1991
+ catch (error) {
1992
+ lineageFailure = `Review lineage authority is unreadable: ${error instanceof Error ? error.message : String(error)}`;
1993
+ gateIssues.push(lineageFailure);
1606
1994
  }
1607
- const reusedApprovals = reusedEvents;
1608
1995
  const statePath = reviewGateStatePath(projectRoot, gateId);
1609
1996
  const reusedSummary = reusedApprovals.map((entry) => ({
1610
1997
  reviewer: entry.reviewer,
@@ -1614,7 +2001,9 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
1614
2001
  targetHash: entry.subjectDigest,
1615
2002
  rev: entry.id,
1616
2003
  }));
1617
- const workingAtStart = readWorkingReviewEvents(location.path, stableSlug);
2004
+ const workingAtStart = lineageFailure
2005
+ ? { events: [], compromisedIds: new Set(), issues: [] }
2006
+ : readWorkingReviewEvents(location.path, stableSlug);
1618
2007
  gateIssues.push(...workingAtStart.issues);
1619
2008
  const durableGateEvents = workingAtStart.events.filter((event) => event.gateId === gateId &&
1620
2009
  event.promptHash === promptHash &&
@@ -1627,6 +2016,7 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
1627
2016
  ...(resumed?.attempts ?? []),
1628
2017
  ...durableGateEvents.map(gateAttemptFromEvent),
1629
2018
  ]);
2019
+ let verification;
1630
2020
  let publicationProgress = resumed?.publicationProgress ??
1631
2021
  (durableGateEvents.length > 0
1632
2022
  ? { artifact: true, ledger: true, frontmatter: false }
@@ -1645,7 +2035,7 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
1645
2035
  }, working.compromisedIds);
1646
2036
  return evaluateApprovalReviewers(evaluation.approvals.map((entry) => entry.reviewer));
1647
2037
  };
1648
- const makeResult = (approvalGate, status) => ({
2038
+ const makeResult = (approvalGate, status, messageOverride) => ({
1649
2039
  schemaVersion: 2,
1650
2040
  gateId,
1651
2041
  purpose,
@@ -1663,222 +2053,336 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
1663
2053
  publicationProgress,
1664
2054
  reusedApprovals: reusedSummary,
1665
2055
  ...(gateIssues.length > 0 ? { issues: [...gateIssues] } : {}),
1666
- message: `${approvalGate.satisfied
1667
- ? `Approval gate satisfied (${formatApprovalGateRequirement(approvalGate)}).`
1668
- : `Approval gate not satisfied (${formatApprovalGateRequirement(approvalGate)}).`}${gateIssues.length > 0 ? ` Warnings: ${gateIssues.join("; ")}` : ""}`,
2056
+ ...(verification ? { verification } : {}),
2057
+ message: messageOverride ??
2058
+ `${approvalGate.satisfied
2059
+ ? `Approval gate satisfied (${formatApprovalGateRequirement(approvalGate)}).`
2060
+ : `Approval gate not satisfied (${formatApprovalGateRequirement(approvalGate)}).`}${gateIssues.length > 0 ? ` Warnings: ${gateIssues.join("; ")}` : ""}`,
1669
2061
  });
1670
- let approvalGate = currentApprovalGate();
1671
- if (approvalGate.satisfied) {
1672
- const result = makeResult(approvalGate, reusedApprovals.length > 0 ? "approved" : "recorded-untracked");
2062
+ if (lineageFailure) {
2063
+ const result = makeResult(evaluateApprovalReviewers([]), "verification-failed", `${lineageFailure} No review provider was invoked.`);
1673
2064
  assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
1674
2065
  persistGateState(projectRoot, result);
1675
2066
  return result;
1676
2067
  }
1677
- if (attempts.some((attempt) => attempt.status === "complete" && attempt.verdict === "reject")) {
1678
- const result = makeResult(approvalGate, "rejected");
1679
- assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
1680
- persistGateState(projectRoot, result);
1681
- return result;
1682
- }
1683
- const artifactDirectory = path.join(path.dirname(location.path), "review-artifacts");
1684
- if (existsSync(artifactDirectory) && lstatSync(artifactDirectory).isSymbolicLink()) {
1685
- throw new Error(`Review artifact directory must not be a symlink: ${artifactDirectory}`);
1686
- }
1687
- const availabilityPrune = pruneExpiredAvailabilitySuppressions(loadReviewAvailability(projectRoot));
1688
- if (availabilityPrune.pruned.length > 0) {
1689
- saveReviewAvailability(projectRoot, availabilityPrune.state);
1690
- }
1691
- const availability = availabilityPrune.state;
1692
- const providerQueue = selectGateProviders({
1693
- availability,
1694
- ...(explicitProvider ? { explicitProvider } : {}),
1695
- });
1696
- const usedReviewers = new Set([
1697
- ...attempts
1698
- .filter((attempt) => !isRetryableGateAttempt(attempt))
1699
- .map((attempt) => attempt.reviewer)
1700
- .filter((value) => Boolean(value)),
1701
- ...suppressedReviewers(availability),
1702
- ]);
1703
2068
  const checkoutFactory = dependencies.createTargetCheckout ?? createReviewTargetCheckout;
1704
2069
  let sharedCheckout;
1705
2070
  let checkoutSetupFailed = false;
1706
- if (!dependencies.invokeProvider) {
2071
+ // Deferred: a review checkout costs a full `git archive` + tar tree copy
2072
+ // plus status fingerprints, so it must not be created on gate calls that
2073
+ // never need one (plan-purpose reuse of an existing approval, terminal
2074
+ // rejections, exhausted scope). A setup failure is remembered so provider
2075
+ // attempts still surface the same isolation-failure evidence.
2076
+ const ensureSharedCheckout = () => {
2077
+ if (sharedCheckout !== undefined || checkoutSetupFailed)
2078
+ return sharedCheckout;
1707
2079
  try {
1708
2080
  sharedCheckout = checkoutFactory(projectRoot, targetSha);
1709
2081
  }
1710
2082
  catch {
1711
2083
  checkoutSetupFailed = true;
1712
2084
  }
1713
- }
1714
- const createCheckoutForAttempt = checkoutSetupFailed
1715
- ? () => {
1716
- throw new Error("review checkout setup failed");
1717
- }
1718
- : checkoutFactory;
2085
+ return sharedCheckout;
2086
+ };
2087
+ const createCheckoutForAttempt = () => {
2088
+ // Only reachable when ensureSharedCheckout() already failed — a live
2089
+ // shared checkout is always passed to the attempt as existingCheckout.
2090
+ throw new Error("review checkout setup failed");
2091
+ };
2092
+ let sharedCheckoutCleaned = false;
2093
+ const cleanupSharedCheckout = () => {
2094
+ if (sharedCheckoutCleaned)
2095
+ return;
2096
+ sharedCheckoutCleaned = true;
2097
+ sharedCheckout?.cleanup();
2098
+ };
1719
2099
  try {
1720
- reviewAttempts: for (const provider of providerQueue) {
1721
- const maxAttempts = provider === "claude" ||
1722
- provider === "codex" ||
1723
- provider === "grok" ||
1724
- (provider === explicitProvider && input.model)
1725
- ? 1
1726
- : OPENCODE_FAMILY_ORDER.length;
1727
- for (let index = 0; index < maxAttempts && !approvalGate.satisfied; index += 1) {
1728
- if (dependencies.signal?.aborted)
1729
- break reviewAttempts;
1730
- const providerResult = enforceReviewPublicationOutput(await (dependencies.invokeProvider ?? invokeProvider)({
1731
- provider,
1732
- projectRoot,
1733
- prompt,
1734
- effort,
1735
- ...(provider === explicitProvider && input.model ? { model: input.model } : {}),
1736
- excludedReviewers: usedReviewers,
1737
- targetSha,
1738
- ...(dependencies.signal ? { signal: dependencies.signal } : {}),
1739
- }, dependencies.runReviewExecution, dependencies.runCatalogCommand, createCheckoutForAttempt, sharedCheckout));
1740
- if (providerResult.reviewer !== "opencode")
1741
- usedReviewers.add(providerResult.reviewer);
1742
- const providerStage = gateAttemptStage(providerResult.stage ?? "review");
1743
- let attemptOrdinal = nextGateAttemptOrdinal(attempts, provider, providerResult.model, providerStage);
1744
- let artifactRelative;
1745
- let artifactAbsolute;
1746
- let rev;
1747
- while (true) {
1748
- artifactRelative = reviewArtifactRelativePath({
2100
+ // Only the delivery purpose runs checkout-backed gate verification
2101
+ // (verifyDeliveryPromotionGates is a no-op for every other purpose).
2102
+ const deliveryCheckout = purpose === "delivery" ? ensureSharedCheckout() : undefined;
2103
+ if (deliveryCheckout) {
2104
+ verification = verifyDeliveryPromotionGates({
2105
+ purpose,
2106
+ blueprintContent,
2107
+ projectRoot,
2108
+ gateId,
2109
+ checkout: deliveryCheckout,
2110
+ ...(dependencies.signal ? { signal: dependencies.signal } : {}),
2111
+ });
2112
+ const summary = verificationPromptSummary(verification);
2113
+ if (summary) {
2114
+ prompt = renderPrompt(summary);
2115
+ }
2116
+ if (verification?.status === "failed") {
2117
+ const result = makeResult(currentApprovalGate(), "verification-failed");
2118
+ assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
2119
+ persistGateState(projectRoot, result);
2120
+ cleanupSharedCheckout();
2121
+ return result;
2122
+ }
2123
+ }
2124
+ let approvalGate = currentApprovalGate();
2125
+ if (approvalGate.satisfied) {
2126
+ const result = makeResult(approvalGate, reusedApprovals.length > 0 ? "approved" : "recorded-untracked");
2127
+ assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
2128
+ persistGateState(projectRoot, result);
2129
+ cleanupSharedCheckout();
2130
+ return result;
2131
+ }
2132
+ if (attempts.some((attempt) => attempt.status === "complete" && attempt.verdict === "reject")) {
2133
+ const result = makeResult(approvalGate, "rejected");
2134
+ assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
2135
+ persistGateState(projectRoot, result);
2136
+ cleanupSharedCheckout();
2137
+ return result;
2138
+ }
2139
+ const currentSubjectRejection = rejectedSubjects.find((entry) => entry.subjectDigest === subject.digest);
2140
+ if (currentSubjectRejection) {
2141
+ const result = makeResult(approvalGate, "rejected", `This semantic review subject was already rejected (${currentSubjectRejection.artifactPath}). No review provider was invoked.`);
2142
+ assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
2143
+ persistGateState(projectRoot, result);
2144
+ cleanupSharedCheckout();
2145
+ return result;
2146
+ }
2147
+ if (rejectedSubjects.length >= 2) {
2148
+ const result = makeResult(approvalGate, "scope-exhausted", "Formal review budget exhausted after two rejected semantic subjects. No review provider was invoked; stop and obtain an explicit human scope decision before creating a successor blueprint.");
2149
+ assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
2150
+ persistGateState(projectRoot, result);
2151
+ cleanupSharedCheckout();
2152
+ return result;
2153
+ }
2154
+ if (rejectedSubjects.length > 0) {
2155
+ prompt = renderPrompt(verification ? verificationPromptSummary(verification) : undefined, rejectedSubjects.slice(0, 2));
2156
+ }
2157
+ const artifactDirectory = path.join(path.dirname(location.path), "review-artifacts");
2158
+ if (existsSync(artifactDirectory) && lstatSync(artifactDirectory).isSymbolicLink()) {
2159
+ throw new Error(`Review artifact directory must not be a symlink: ${artifactDirectory}`);
2160
+ }
2161
+ const availabilityPrune = pruneExpiredAvailabilitySuppressions(loadReviewAvailability(projectRoot));
2162
+ if (availabilityPrune.pruned.length > 0) {
2163
+ saveReviewAvailability(projectRoot, availabilityPrune.state);
2164
+ }
2165
+ const availability = availabilityPrune.state;
2166
+ const providerQueue = selectGateProviders({
2167
+ availability,
2168
+ ...(explicitProvider ? { explicitProvider } : {}),
2169
+ });
2170
+ const usedReviewers = new Set([
2171
+ ...attempts
2172
+ .filter((attempt) => !isRetryableGateAttempt(attempt))
2173
+ .map((attempt) => attempt.reviewer)
2174
+ .filter((value) => Boolean(value)),
2175
+ ...suppressedReviewers(availability),
2176
+ ]);
2177
+ // Prepare opencode-go multi-account failover only when opencode is
2178
+ // actually queued (avoids a needless secret fetch otherwise). Resolves
2179
+ // Doppler accounts, materializes the freshest non-cooled account's
2180
+ // isolated auth store, and hands the loop its XDG_DATA_HOME. Degrades to
2181
+ // undefined (today's global-auth behavior) when unprovisioned.
2182
+ const opencodeFailover = providerQueue.includes("opencode")
2183
+ ? createOpencodeFailoverController({
2184
+ projectRoot,
2185
+ resolveAccounts: dependencies.resolveOpencodeAccounts ?? resolveOpencodeAccountsGracefully,
2186
+ prepareCredential: dependencies.prepareOpencodeCredential ?? prepareOpencodeCredentialDefault,
2187
+ })
2188
+ : undefined;
2189
+ try {
2190
+ reviewAttempts: for (const provider of providerQueue) {
2191
+ const maxAttempts = provider === "claude" ||
2192
+ provider === "codex" ||
2193
+ provider === "grok" ||
2194
+ (provider === explicitProvider && input.model)
2195
+ ? 1
2196
+ : OPENCODE_FAMILY_ORDER.length;
2197
+ for (let index = 0; index < maxAttempts && !approvalGate.satisfied; index += 1) {
2198
+ if (dependencies.signal?.aborted)
2199
+ break reviewAttempts;
2200
+ const opencodeAuthDataDir = provider === "opencode" ? opencodeFailover?.authDataDir() : undefined;
2201
+ const providerResult = enforceReviewPublicationOutput(await (dependencies.invokeProvider ?? invokeProvider)({
2202
+ provider,
1749
2203
  projectRoot,
1750
- blueprintPath: location.path,
1751
- gateId,
2204
+ prompt,
2205
+ effort,
2206
+ ...(provider === explicitProvider && input.model ? { model: input.model } : {}),
2207
+ excludedReviewers: usedReviewers,
2208
+ targetSha,
2209
+ ...(dependencies.signal ? { signal: dependencies.signal } : {}),
2210
+ ...(opencodeAuthDataDir ? { authDataDir: opencodeAuthDataDir } : {}),
2211
+ }, dependencies.runReviewExecution, dependencies.runCatalogCommand, createCheckoutForAttempt, ensureSharedCheckout));
2212
+ if (providerResult.reviewer !== "opencode")
2213
+ usedReviewers.add(providerResult.reviewer);
2214
+ const providerStage = gateAttemptStage(providerResult.stage ?? "review");
2215
+ let attemptOrdinal = nextGateAttemptOrdinal(attempts, provider, providerResult.model, providerStage);
2216
+ let artifactRelative;
2217
+ let artifactAbsolute;
2218
+ let rev;
2219
+ while (true) {
2220
+ artifactRelative = reviewArtifactRelativePath({
2221
+ projectRoot,
2222
+ blueprintPath: location.path,
2223
+ gateId,
2224
+ provider,
2225
+ reviewer: providerResult.reviewer,
2226
+ stage: providerStage,
2227
+ ordinal: attemptOrdinal,
2228
+ });
2229
+ artifactAbsolute = safeArtifactPath(location.path, artifactRelative);
2230
+ rev = reviewGateEventId({
2231
+ gateId,
2232
+ provider,
2233
+ model: providerResult.model,
2234
+ stage: providerStage,
2235
+ ordinal: attemptOrdinal,
2236
+ });
2237
+ if (!existsSync(artifactAbsolute) &&
2238
+ !gateArtifactPaths.has(artifactRelative) &&
2239
+ !gateEventIds.has(rev)) {
2240
+ break;
2241
+ }
2242
+ attemptOrdinal += 1;
2243
+ }
2244
+ const persistedOrdinal = providerStage === "approval-gate" && attemptOrdinal === 1
2245
+ ? undefined
2246
+ : attemptOrdinal;
2247
+ const attempt = {
1752
2248
  provider,
2249
+ model: providerResult.model,
2250
+ effort,
1753
2251
  reviewer: providerResult.reviewer,
2252
+ promptHash,
2253
+ ...(persistedOrdinal === undefined ? {} : { ordinal: persistedOrdinal }),
1754
2254
  stage: providerStage,
1755
- ordinal: attemptOrdinal,
2255
+ status: providerResult.status,
2256
+ verdict: providerResult.verdict,
2257
+ terminalClassification: providerResult.terminalClassification,
2258
+ ...(providerResult.failureCode ? { failureCode: providerResult.failureCode } : {}),
2259
+ ...(providerResult.providerErrorDetail
2260
+ ? { providerErrorDetail: providerResult.providerErrorDetail }
2261
+ : {}),
2262
+ ...(providerResult.limitWindow ? { limitWindow: providerResult.limitWindow } : {}),
2263
+ ...(providerResult.retryAfterMs === undefined
2264
+ ? {}
2265
+ : { retryAfterMs: providerResult.retryAfterMs }),
2266
+ artifact: artifactRelative,
2267
+ };
2268
+ attempts = dedupeGateAttempts([...attempts, attempt]);
2269
+ recordReviewAvailability(projectRoot, {
2270
+ provider,
2271
+ reviewer: providerResult.reviewer,
2272
+ model: providerResult.model,
2273
+ effort,
2274
+ stage: providerStage,
2275
+ terminalClassification: providerResult.terminalClassification,
2276
+ ...(providerResult.failureCode ? { failureCode: providerResult.failureCode } : {}),
2277
+ ...(providerResult.limitWindow ? { limitWindow: providerResult.limitWindow } : {}),
2278
+ ...(providerResult.retryAfterMs === undefined
2279
+ ? {}
2280
+ : { retryAfterMs: providerResult.retryAfterMs }),
1756
2281
  });
1757
- artifactAbsolute = safeArtifactPath(location.path, artifactRelative);
1758
- rev = reviewGateEventId({
2282
+ const artifactContent = Buffer.from(`${verificationArtifactSection(verification)}${providerResult.output}`, "utf8");
2283
+ const event = {
2284
+ schemaVersion: 2,
2285
+ id: rev,
2286
+ purpose,
2287
+ blueprintSlug: stableSlug,
2288
+ subjectScheme: subject.scheme,
2289
+ subjectDigest: subject.digest,
2290
+ reviewedCommit: targetSha,
1759
2291
  gateId,
2292
+ promptHash,
2293
+ reviewer: providerResult.reviewer,
1760
2294
  provider,
1761
2295
  model: providerResult.model,
2296
+ effort,
1762
2297
  stage: providerStage,
1763
- ordinal: attemptOrdinal,
2298
+ ...(persistedOrdinal === undefined ? {} : { attemptOrdinal: persistedOrdinal }),
2299
+ status: providerResult.status,
2300
+ verdict: providerResult.verdict,
2301
+ terminalClassification: providerResult.terminalClassification,
2302
+ ...(providerResult.failureCode ? { failureCode: providerResult.failureCode } : {}),
2303
+ ...(providerResult.limitWindow ? { limitWindow: providerResult.limitWindow } : {}),
2304
+ ...(providerResult.retryAfterMs === undefined
2305
+ ? {}
2306
+ : { retryAfterMs: providerResult.retryAfterMs }),
2307
+ timestamp: new Date().toISOString(),
2308
+ artifactPath: artifactRelative,
2309
+ artifactSha256: createHash("sha256").update(artifactContent).digest("hex"),
2310
+ };
2311
+ appendReviewEvent({
2312
+ ledgerPath: path.join(path.dirname(location.path), "review-events.jsonl"),
2313
+ event,
2314
+ artifact: { path: artifactAbsolute, content: artifactContent },
1764
2315
  });
1765
- if (!existsSync(artifactAbsolute) &&
1766
- !gateArtifactPaths.has(artifactRelative) &&
1767
- !gateEventIds.has(rev)) {
2316
+ gateEventIds.add(rev);
2317
+ gateArtifactPaths.add(artifactRelative);
2318
+ publicationProgress = { artifact: true, ledger: true, frontmatter: false };
2319
+ approvalGate = currentApprovalGate();
2320
+ const interimStatus = providerResult.verdict === "reject"
2321
+ ? "rejected"
2322
+ : approvalGate.satisfied
2323
+ ? "recorded-untracked"
2324
+ : "needs-review";
2325
+ persistGateState(projectRoot, makeResult(approvalGate, interimStatus));
2326
+ if (providerResult.verdict === "reject") {
2327
+ return makeResult(approvalGate, "rejected");
2328
+ }
2329
+ if (providerResult.failureCode === "review-worktree-contaminated" ||
2330
+ providerResult.failureCode === "review-isolation-failure") {
2331
+ break reviewAttempts;
2332
+ }
2333
+ if (dependencies.signal?.aborted)
2334
+ break reviewAttempts;
2335
+ if (provider === "opencode" &&
2336
+ opencodeFailover &&
2337
+ providerResult.terminalClassification === "provider-limit-reached" &&
2338
+ providerResult.failureCode === "opencode-go-usage-limit") {
2339
+ const outcome = await opencodeFailover.onUsageLimit({
2340
+ effort,
2341
+ stage: providerStage,
2342
+ ...(providerResult.limitWindow
2343
+ ? { limitWindow: providerResult.limitWindow }
2344
+ : {}),
2345
+ ...(providerResult.retryAfterMs === undefined
2346
+ ? {}
2347
+ : { retryAfterMs: providerResult.retryAfterMs }),
2348
+ });
2349
+ if (outcome === "retry") {
2350
+ // The family failed only because its ACCOUNT was capped. Make it
2351
+ // eligible again and retry under the next account without
2352
+ // consuming a family-rotation slot.
2353
+ usedReviewers.delete(providerResult.reviewer);
2354
+ index -= 1;
2355
+ continue;
2356
+ }
1768
2357
  break;
1769
2358
  }
1770
- attemptOrdinal += 1;
1771
- }
1772
- const persistedOrdinal = providerStage === "approval-gate" && attemptOrdinal === 1
1773
- ? undefined
1774
- : attemptOrdinal;
1775
- const attempt = {
1776
- provider,
1777
- model: providerResult.model,
1778
- effort,
1779
- reviewer: providerResult.reviewer,
1780
- promptHash,
1781
- ...(persistedOrdinal === undefined ? {} : { ordinal: persistedOrdinal }),
1782
- stage: providerStage,
1783
- status: providerResult.status,
1784
- verdict: providerResult.verdict,
1785
- terminalClassification: providerResult.terminalClassification,
1786
- ...(providerResult.failureCode ? { failureCode: providerResult.failureCode } : {}),
1787
- ...(providerResult.limitWindow ? { limitWindow: providerResult.limitWindow } : {}),
1788
- ...(providerResult.retryAfterMs === undefined
1789
- ? {}
1790
- : { retryAfterMs: providerResult.retryAfterMs }),
1791
- artifact: artifactRelative,
1792
- };
1793
- attempts = dedupeGateAttempts([...attempts, attempt]);
1794
- recordReviewAvailability(projectRoot, {
1795
- provider,
1796
- reviewer: providerResult.reviewer,
1797
- model: providerResult.model,
1798
- effort,
1799
- stage: providerStage,
1800
- terminalClassification: providerResult.terminalClassification,
1801
- ...(providerResult.failureCode ? { failureCode: providerResult.failureCode } : {}),
1802
- ...(providerResult.limitWindow ? { limitWindow: providerResult.limitWindow } : {}),
1803
- ...(providerResult.retryAfterMs === undefined
1804
- ? {}
1805
- : { retryAfterMs: providerResult.retryAfterMs }),
1806
- });
1807
- const artifactContent = Buffer.from(providerResult.output, "utf8");
1808
- const event = {
1809
- schemaVersion: 2,
1810
- id: rev,
1811
- purpose,
1812
- blueprintSlug: stableSlug,
1813
- subjectScheme: subject.scheme,
1814
- subjectDigest: subject.digest,
1815
- reviewedCommit: targetSha,
1816
- gateId,
1817
- promptHash,
1818
- reviewer: providerResult.reviewer,
1819
- provider,
1820
- model: providerResult.model,
1821
- effort,
1822
- stage: providerStage,
1823
- ...(persistedOrdinal === undefined ? {} : { attemptOrdinal: persistedOrdinal }),
1824
- status: providerResult.status,
1825
- verdict: providerResult.verdict,
1826
- terminalClassification: providerResult.terminalClassification,
1827
- ...(providerResult.failureCode ? { failureCode: providerResult.failureCode } : {}),
1828
- ...(providerResult.limitWindow ? { limitWindow: providerResult.limitWindow } : {}),
1829
- ...(providerResult.retryAfterMs === undefined
1830
- ? {}
1831
- : { retryAfterMs: providerResult.retryAfterMs }),
1832
- timestamp: new Date().toISOString(),
1833
- artifactPath: artifactRelative,
1834
- artifactSha256: createHash("sha256").update(artifactContent).digest("hex"),
1835
- };
1836
- appendReviewEvent({
1837
- ledgerPath: path.join(path.dirname(location.path), "review-events.jsonl"),
1838
- event,
1839
- artifact: { path: artifactAbsolute, content: artifactContent },
1840
- });
1841
- gateEventIds.add(rev);
1842
- gateArtifactPaths.add(artifactRelative);
1843
- publicationProgress = { artifact: true, ledger: true, frontmatter: false };
1844
- approvalGate = currentApprovalGate();
1845
- const interimStatus = providerResult.verdict === "reject"
1846
- ? "rejected"
1847
- : approvalGate.satisfied
1848
- ? "recorded-untracked"
1849
- : "needs-review";
1850
- persistGateState(projectRoot, makeResult(approvalGate, interimStatus));
1851
- if (providerResult.verdict === "reject") {
1852
- return makeResult(approvalGate, "rejected");
1853
- }
1854
- if (providerResult.failureCode === "review-worktree-contaminated" ||
1855
- providerResult.failureCode === "review-isolation-failure") {
1856
- break reviewAttempts;
1857
- }
1858
- if (dependencies.signal?.aborted)
1859
- break reviewAttempts;
1860
- if (providerResult.terminalClassification === "provider-limit-reached" &&
1861
- (provider !== "opencode" || isProviderWideOpenCodeGateResult(providerResult))) {
1862
- break;
2359
+ if (providerResult.terminalClassification === "provider-limit-reached" &&
2360
+ (provider !== "opencode" || isProviderWideOpenCodeGateResult(providerResult))) {
2361
+ break;
2362
+ }
2363
+ if ((provider === "claude" || provider === "codex") &&
2364
+ providerResult.status === "complete")
2365
+ break;
2366
+ if (provider === "opencode" && providerResult.reviewer === "opencode")
2367
+ break;
1863
2368
  }
1864
- if ((provider === "claude" || provider === "codex") &&
1865
- providerResult.status === "complete")
1866
- break;
1867
- if (provider === "opencode" && providerResult.reviewer === "opencode")
1868
- break;
1869
2369
  }
1870
2370
  }
2371
+ finally {
2372
+ cleanupSharedCheckout();
2373
+ }
2374
+ const status = approvalGate.satisfied
2375
+ ? "recorded-untracked"
2376
+ : "needs-review";
2377
+ const result = makeResult(approvalGate, status);
2378
+ assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
2379
+ persistGateState(projectRoot, result);
2380
+ return result;
1871
2381
  }
1872
- finally {
1873
- sharedCheckout?.cleanup();
2382
+ catch (error) {
2383
+ cleanupSharedCheckout();
2384
+ throw error;
1874
2385
  }
1875
- const status = approvalGate.satisfied
1876
- ? "recorded-untracked"
1877
- : "needs-review";
1878
- const result = makeResult(approvalGate, status);
1879
- assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
1880
- persistGateState(projectRoot, result);
1881
- return result;
1882
2386
  }, projectRoot).catch((error) => {
1883
2387
  if (isStateLockHeldError(error)) {
1884
2388
  throw new Error(`Review gate ${gateId} is already active for this worktree. Wait for the owner to finish, then retry with --resume ${gateId}; the lock acquisition bound was not increased.`, { cause: error });
@@ -2240,6 +2744,8 @@ export function registerReviewCommand(cli, deps = {}) {
2240
2744
  .option("--effort <effort>", "Review effort (medium|high)")
2241
2745
  .option("--resume <gate-id>", "Resume a persisted review gate")
2242
2746
  .option("--reviewer <name>", "Reviewer id for `review log`")
2747
+ .option("--reason <text>", "Maintainer reason for `review override`")
2748
+ .option("--confirm", "Confirm a `review override` (required)")
2243
2749
  .option("--target-kind <kind>", "blueprint | pull-request")
2244
2750
  .option("--target-id <id>", "Target slug / PR number / stable review target id")
2245
2751
  .option("--artifact <path>", "Committed review transcript/artifact path relative to blueprint (required for promotion-counted approvals)")
@@ -2287,6 +2793,7 @@ export function registerReviewCommand(cli, deps = {}) {
2287
2793
  ? undefined
2288
2794
  : reviewGateNonConvergenceHint(slug, {
2289
2795
  gateId: result.gateId,
2796
+ status: result.status,
2290
2797
  ...(latestFailure?.failureCode
2291
2798
  ? { failureCode: latestFailure.failureCode }
2292
2799
  : {}),
@@ -2297,6 +2804,9 @@ export function registerReviewCommand(cli, deps = {}) {
2297
2804
  `${result.status}: ${result.message}`,
2298
2805
  `gate: ${result.gateId}`,
2299
2806
  `state: ${result.statePath}`,
2807
+ ...(latestFailure?.providerErrorDetail
2808
+ ? [`provider error: ${latestFailure.providerErrorDetail}`]
2809
+ : []),
2300
2810
  ...(hint ? [hint] : []),
2301
2811
  ].join("\n"));
2302
2812
  return;
@@ -2315,6 +2825,16 @@ export function registerReviewCommand(cli, deps = {}) {
2315
2825
  : `Logged review ${entry.id} -> ${entry.blueprintSlug}`);
2316
2826
  return;
2317
2827
  }
2828
+ case "override": {
2829
+ const slug = args[0];
2830
+ if (!slug)
2831
+ throw new Error("Usage: wp review override <blueprint-slug> --reason <text> --confirm [--purpose plan|delivery] [--target <git-ref>]");
2832
+ const entry = await runReviewOverride(projectRoot, slug, options);
2833
+ console.log(options.json
2834
+ ? JSON.stringify(entry, null, 2)
2835
+ : `Recorded maintainer override ${entry.id} -> ${entry.blueprintSlug}`);
2836
+ return;
2837
+ }
2318
2838
  case "read": {
2319
2839
  const slug = args[0];
2320
2840
  if (!slug)
@@ -2356,7 +2876,7 @@ export function registerReviewCommand(cli, deps = {}) {
2356
2876
  }
2357
2877
  }
2358
2878
  default:
2359
- throw new Error("Unknown review subcommand. Use one of: gate, run, log, read, scoreboard, availability, doctor");
2879
+ throw new Error("Unknown review subcommand. Use one of: gate, run, log, override, read, scoreboard, availability, doctor");
2360
2880
  }
2361
2881
  });
2362
2882
  }