forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6

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 (196) hide show
  1. package/AGENTS.md +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -21,7 +21,8 @@
21
21
  *
22
22
  * PR context shape (all fields optional; absence is treated fail-closed by the
23
23
  * rules that need them):
24
- * - checks: Array<{ name, conclusion }> CI check rollup
24
+ * - checks: Array<{ name, appId, status, conclusion } | { name, appId:null, state }>
25
+ * - requiredChecks: Array<{ context, appId }> protected required set
25
26
  * - requiredChecksKnown: boolean is the required set readable?
26
27
  * - unresolvedThreads: number | Array open review threads
27
28
  * - behindBase: number | boolean commits behind base
@@ -34,7 +35,7 @@
34
35
  * - state: string (OPEN | MERGED | CLOSED) read by the command pre-flight, not a rule
35
36
  *
36
37
  * Built-in rule types (compose as a list, ANDed by default):
37
- * checks_green | threads_resolved | not_behind | no_conflicts | not_draft |
38
+ * verdict_clean | checks_green | threads_resolved | not_behind | no_conflicts | not_draft |
38
39
  * min_approvals:N | settle_min:N | idle_min:N | last_comment_by:X |
39
40
  * approved_by:[..] | not_commented_by:[..]
40
41
  * `checks_green` is bare (ALL checks must be green) or scoped:
@@ -48,14 +49,21 @@
48
49
  * documented follow-up and intentionally NOT implemented here.
49
50
  *
50
51
  * Follow-ups (documented, NOT built here): an opt-in `auto_update` executor
51
- * (update-branch when behind → wait for CI → re-check → merge); required-checks
52
- * SCOPING for `checks_green` (read the branch-protection required set instead of
53
- * requiring every rollup check green); a configurable merge `method`
52
+ * (update-branch when behind → wait for CI → re-check → merge); a configurable merge `method`
54
53
  * (squash | merge | rebase); and post-merge branch deletion.
55
54
  */
56
55
 
57
- const SUCCESS_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED', 'PASS', 'PASSED']);
56
+ const SUCCESS_CONCLUSIONS = new Set(['SUCCESS']);
57
+ const SAFE_OPTIONAL_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
58
58
  const MS_PER_MIN = 60_000;
59
+ const {
60
+ normalizePrNumber,
61
+ normalizeRepository,
62
+ normalizeSha,
63
+ object,
64
+ readArrayItems,
65
+ readDataProperty,
66
+ } = require('./pr-monitor/verdict');
59
67
 
60
68
  /** Coerce an epoch-ms number, a Date, or an ISO string to epoch ms (NaN if unparseable). */
61
69
  function toMs(value) {
@@ -85,11 +93,15 @@ function toAccountList(arg) {
85
93
  return [String(arg).trim().toLowerCase()];
86
94
  }
87
95
 
88
- /** A CI check is green if its conclusion is a success-class conclusion. */
96
+ /** A CI check is green only when it is terminal and explicitly successful. */
89
97
  function isCheckGreen(check) {
90
98
  if (!check || typeof check !== 'object') return false;
91
- const conclusion = String(check.conclusion ?? check.state ?? check.status ?? '').toUpperCase();
92
- return SUCCESS_CONCLUSIONS.has(conclusion);
99
+ if (Object.prototype.hasOwnProperty.call(check, 'state')
100
+ && !Object.prototype.hasOwnProperty.call(check, 'status')) {
101
+ return String(check.state || '').toUpperCase() === 'SUCCESS';
102
+ }
103
+ return String(check.status || '').toUpperCase() === 'COMPLETED'
104
+ && SUCCESS_CONCLUSIONS.has(String(check.conclusion || '').toUpperCase());
93
105
  }
94
106
 
95
107
  /** The most recent comment by timestamp; ties and unparseable stamps fall back to array order. */
@@ -123,16 +135,49 @@ function checkName(check) {
123
135
  return String((check && check.name) || '');
124
136
  }
125
137
 
138
+ function isCheckObservationWellFormed(check) {
139
+ if (!check || typeof check !== 'object' || typeof check.name !== 'string' || !check.name.trim()
140
+ || !Object.prototype.hasOwnProperty.call(check, 'appId')) return false;
141
+ const hasState = Object.prototype.hasOwnProperty.call(check, 'state');
142
+ const hasStatus = Object.prototype.hasOwnProperty.call(check, 'status');
143
+ const hasConclusion = Object.prototype.hasOwnProperty.call(check, 'conclusion');
144
+ if (hasState) {
145
+ return !hasStatus && !hasConclusion && check.appId === null
146
+ && typeof check.state === 'string' && Boolean(check.state.trim());
147
+ }
148
+ return hasStatus && hasConclusion && Number.isInteger(check.appId) && check.appId > 0
149
+ && typeof check.status === 'string' && Boolean(check.status.trim())
150
+ && (check.conclusion === null || typeof check.conclusion === 'string');
151
+ }
152
+
153
+ function normalizeRequiredCheck(check) {
154
+ if (!check || typeof check !== 'object' || typeof check.context !== 'string' || !check.context.trim()
155
+ || !Object.prototype.hasOwnProperty.call(check, 'appId')
156
+ || (check.appId !== null && (!Number.isInteger(check.appId) || check.appId <= 0))) return null;
157
+ return { context: check.context, appId: check.appId };
158
+ }
159
+
160
+ function matchesRequiredCheck(check, required) {
161
+ return checkName(check) === required.context
162
+ && (required.appId === null || check.appId === required.appId);
163
+ }
164
+
165
+ function isSafeOptionalCheck(check) {
166
+ if (Object.prototype.hasOwnProperty.call(check, 'state')) return isCheckGreen(check);
167
+ return String(check.status).toUpperCase() === 'COMPLETED'
168
+ && SAFE_OPTIONAL_CONCLUSIONS.has(String(check.conclusion || '').toUpperCase());
169
+ }
170
+
126
171
  /**
127
- * `checks_green` — every CI check must be green per `isCheckGreen`. Optionally scoped:
128
- * - bare string / no arg ALL checks must be green (strict default).
129
- * - { ignore: ["A","B"] }the named checks are EXEMPT; every OTHER check
130
- * must be green. `ignore: []` is identical to the bare form.
172
+ * `checks_green` — protected checks must be strict SUCCESS; optional check runs
173
+ * may also be terminal NEUTRAL or SKIPPED. Optionally scoped:
174
+ * - bare string / no arg protected checks strict, optional checks safely terminal.
175
+ * - { ignore: ["A","B"] } the named optional checks are EXEMPT;
176
+ * `ignore: []` is identical to the bare form.
131
177
  * - { only: ["A"] } → ONLY the named checks must be green (present AND
132
- * green per `isCheckGreen`); all others are ignored.
178
+ * green per `isCheckGreen`); all optional others are ignored.
133
179
  * `ignore` + `only` together is malformed → fail-closed. Exact name match.
134
- * (`required_only` scope to the branch-protection required set is a
135
- * documented follow-up, not built here.)
180
+ * The protected required set is always enforced before optional scoping.
136
181
  */
137
182
  function checksGreen(ctx, arg) {
138
183
  let ignore = [];
@@ -164,14 +209,42 @@ function checksGreen(ctx, arg) {
164
209
  if (!Array.isArray(ctx.checks)) {
165
210
  return fail('check results are missing from the PR context (fail-closed).');
166
211
  }
212
+ if (!Array.isArray(ctx.requiredChecks)) {
213
+ return fail('required-check set is missing or malformed (fail-closed).');
214
+ }
215
+ if (ctx.checks.some((check) => !isCheckObservationWellFormed(check))) {
216
+ return fail('check results contain malformed observations (fail-closed).');
217
+ }
218
+
219
+ const required = ctx.requiredChecks.map(normalizeRequiredCheck);
220
+ if (required.some((check) => !check)) {
221
+ return fail('required-check set contains malformed entries (fail-closed).');
222
+ }
223
+ const requiredApps = new Map();
224
+ for (const check of required) {
225
+ if (!requiredApps.has(check.context)) requiredApps.set(check.context, new Set());
226
+ requiredApps.get(check.context).add(check.appId === null ? '*' : String(check.appId));
227
+ }
228
+ if ([...requiredApps.values()].some((apps) => apps.size > 1)) {
229
+ return fail('required-check set contains conflicting application identities (fail-closed).');
230
+ }
231
+
232
+ const requiredProblems = required.map((requiredCheck) => {
233
+ const matches = ctx.checks.filter((check) => matchesRequiredCheck(check, requiredCheck));
234
+ if (matches.length === 0) return `${requiredCheck.context} (missing)`;
235
+ if (matches.some((check) => !isCheckGreen(check))) return `${requiredCheck.context} (not successful)`;
236
+ return null;
237
+ }).filter(Boolean);
238
+ if (requiredProblems.length > 0) {
239
+ return fail(`protected required check(s) not green: ${requiredProblems.join(', ')}.`);
240
+ }
167
241
 
168
242
  if (only) {
169
243
  // Only the named checks matter — each must be PRESENT and green.
170
- const byName = new Map(ctx.checks.map((c) => [checkName(c), c]));
171
244
  const problems = only.map((name) => {
172
- const check = byName.get(name);
173
- if (!check) return `${name} (missing)`;
174
- if (!isCheckGreen(check)) return `${name} (${check.conclusion || 'not green'})`;
245
+ const matches = ctx.checks.filter((check) => checkName(check) === name);
246
+ if (matches.length === 0) return `${name} (missing)`;
247
+ if (matches.some((check) => !isCheckGreen(check))) return `${name} (not green)`;
175
248
  return null;
176
249
  }).filter(Boolean);
177
250
  if (problems.length > 0) return fail(`required check(s) not green: ${problems.join(', ')}.`);
@@ -180,7 +253,9 @@ function checksGreen(ctx, arg) {
180
253
 
181
254
  // Default / ignore: every check EXCEPT the exempt ones must be green.
182
255
  const exempt = new Set(ignore);
183
- const notGreen = ctx.checks.filter((c) => !exempt.has(checkName(c)) && !isCheckGreen(c));
256
+ const notGreen = ctx.checks.filter((check) => !exempt.has(checkName(check))
257
+ && !required.some((requiredCheck) => matchesRequiredCheck(check, requiredCheck))
258
+ && !isSafeOptionalCheck(check));
184
259
  if (notGreen.length > 0) {
185
260
  const names = notGreen.map((c) => (c && c.name) || '?').join(', ');
186
261
  return fail(`${notGreen.length} check(s) not green: ${names}.`);
@@ -223,6 +298,52 @@ function notDraft(ctx) {
223
298
  return fail('draft status is unknown (fail-closed).');
224
299
  }
225
300
 
301
+ /** Require the pure reducer's positive verdict to match the caller's immutable head lease. */
302
+ function verdictClean(ctx) {
303
+ const verdict = object(readDataProperty(ctx, 'verdict').value);
304
+ if (!verdict) {
305
+ return fail('current-head verdict is missing or malformed (fail-closed).');
306
+ }
307
+ const repository = normalizeRepository(readDataProperty(ctx, 'repository').value);
308
+ const expectedRepository = normalizeRepository(readDataProperty(ctx, 'expectedRepository').value);
309
+ const prNumber = normalizePrNumber(readDataProperty(ctx, 'prNumber').value);
310
+ const expectedPrNumber = normalizePrNumber(readDataProperty(ctx, 'expectedPrNumber').value);
311
+ const head = normalizeSha(readDataProperty(ctx, 'headSha').value);
312
+ const expected = normalizeSha(readDataProperty(ctx, 'expectedHeadSha').value);
313
+ const base = normalizeSha(readDataProperty(ctx, 'baseSha').value);
314
+ const expectedBase = normalizeSha(readDataProperty(ctx, 'expectedBaseSha').value);
315
+ const verdictRepository = normalizeRepository(readDataProperty(verdict, 'repository').value);
316
+ const verdictPrNumber = normalizePrNumber(readDataProperty(verdict, 'prNumber').value);
317
+ const verdictHead = normalizeSha(readDataProperty(verdict, 'headSha').value);
318
+ const verdictBase = normalizeSha(readDataProperty(verdict, 'baseSha').value);
319
+ const verdictState = readDataProperty(verdict, 'state').value;
320
+ const verdictReasons = readArrayItems(readDataProperty(verdict, 'reasons').value);
321
+ if (!head || !expected || !base || !expectedBase || !verdictHead || !verdictBase) {
322
+ return fail('exact head lease, verdict head, or verdict base is missing or malformed (fail-closed).');
323
+ }
324
+ if (!repository || !expectedRepository || !prNumber || !expectedPrNumber
325
+ || !verdictRepository || !verdictPrNumber) {
326
+ return fail('repository or PR authority is missing or malformed (fail-closed).');
327
+ }
328
+ if (repository !== expectedRepository || verdictRepository !== repository
329
+ || prNumber !== expectedPrNumber || verdictPrNumber !== prNumber) {
330
+ return fail('current-head verdict does not match the exact repository and PR authority.');
331
+ }
332
+ if (head !== expected || verdictHead !== head) {
333
+ return fail('current-head verdict does not match the exact caller head lease.');
334
+ }
335
+ if (base !== expectedBase || verdictBase !== base) {
336
+ return fail('current-head verdict does not match the exact caller base lease.');
337
+ }
338
+ if (verdictState !== 'MERGE_READY') {
339
+ return fail(`current-head verdict is ${String(verdictState || 'INCOMPLETE')}, not MERGE_READY.`);
340
+ }
341
+ if (!verdictReasons || verdictReasons.length > 0) {
342
+ return fail('MERGE_READY verdict carries incomplete or contradictory reasons (fail-closed).');
343
+ }
344
+ return pass();
345
+ }
346
+
226
347
  function minApprovals(ctx, arg) {
227
348
  const need = Number.parseInt(arg, 10);
228
349
  if (!Number.isFinite(need)) return fail('min_approvals requires a numeric argument, e.g. "min_approvals:2".');
@@ -306,6 +427,7 @@ function notWrapper(ctx, arg) {
306
427
  }
307
428
 
308
429
  const PREDICATES = {
430
+ verdict_clean: (ctx) => verdictClean(ctx),
309
431
  checks_green: (ctx, arg) => checksGreen(ctx, arg),
310
432
  threads_resolved: (ctx) => threadsResolved(ctx),
311
433
  not_behind: (ctx) => notBehind(ctx),