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
package/lib/pr-bundle.js CHANGED
@@ -86,12 +86,12 @@ async function gatherUnresolvedComments(adapter, { owner, repo, pr }) {
86
86
  * Predict merge conflicts. `detectConflicts` is optional on the adapter; when
87
87
  * absent or failing, report it as unsupported rather than throwing.
88
88
  */
89
- async function gatherConflicts(adapter, { baseRef, cwd }) {
89
+ async function gatherConflicts(adapter, { baseRef, cwd, headRef }) {
90
90
  if (typeof adapter.detectConflicts !== 'function') {
91
91
  return { supported: false, reason: 'adapter has no detectConflicts capability' };
92
92
  }
93
93
  try {
94
- return await adapter.detectConflicts({ baseRef, cwd });
94
+ return await adapter.detectConflicts({ baseRef, cwd, headRef });
95
95
  } catch (error) {
96
96
  return { supported: false, reason: error.message || 'conflict detection failed' };
97
97
  }
@@ -148,9 +148,9 @@ async function gatherPrBundle({ pr, owner, repo, base, baseRef, cwd, adapter })
148
148
  // silently yields a null required set. `pr` lets the adapter fall back to the
149
149
  // rollup `isRequired` set when branch protection is unreadable in CI.
150
150
  const requiredRaw = await adapter.readRequiredChecks({ owner, repo, base, pr });
151
- const divergence = await adapter.readDivergence({ baseRef, cwd });
151
+ const divergence = await adapter.readDivergence({ baseRef, cwd, headRef: state.headSha });
152
152
  const comments = await gatherUnresolvedComments(adapter, { owner, repo, pr });
153
- const conflicts = await gatherConflicts(adapter, { baseRef, cwd });
153
+ const conflicts = await gatherConflicts(adapter, { baseRef, cwd, headRef: state.headSha });
154
154
 
155
155
  const requiredSet = requiredRaw === undefined ? null : requiredRaw;
156
156
 
@@ -172,8 +172,7 @@ async function gatherPrBundle({ pr, owner, repo, base, baseRef, cwd, adapter })
172
172
  state: String(state.state || 'OPEN').toUpperCase(),
173
173
  },
174
174
  ci: buildCi(state.checks, requiredSet),
175
- // Which source answered the required-checks read (`protection` | `rollup` |
176
- // null) — `rollup` is the CI path where branch protection is unreadable.
175
+ // The authoritative required-check source (`protection` or null).
177
176
  requiredSource: adapter.lastRequiredSource || null,
178
177
  branch: {
179
178
  ahead: divergence.ahead || 0,
@@ -35,6 +35,17 @@ const INFRA_CONCLUSIONS = new Set(['CANCELLED', 'TIMED_OUT', 'STALE', 'STARTUP_F
35
35
  /** Conclusions that mean the code is genuinely broken — NEVER auto-rerun these. */
36
36
  const REAL_FAILURE_CONCLUSIONS = new Set(['FAILURE', 'ERROR', 'ACTION_REQUIRED']);
37
37
 
38
+ const AUTO_ACTION_STATUS = Object.freeze({
39
+ PASS: 'PASS',
40
+ UNCHANGED: 'UNCHANGED',
41
+ STALE: 'STALE',
42
+ INCOMPLETE: 'INCOMPLETE',
43
+ CONFLICT: 'CONFLICT',
44
+ });
45
+
46
+ const MAX_AUTO_ACTIONS = 8;
47
+ const MAX_AUTO_ATTEMPTS = 5;
48
+
38
49
  /**
39
50
  * Pull the numeric Actions run id from a job/run "details" URL
40
51
  * (`.../actions/runs/<run>/job/<job>` or `.../actions/runs/<run>`). Returns null
@@ -99,16 +110,40 @@ function decideUpdateBranch(payload, opts = {}) {
99
110
  return { should: true, reason: 'otherwise-clean-behind — only blocker is behind-base; merge base in' };
100
111
  }
101
112
 
113
+ function indexFailures(failures) {
114
+ const byName = new Map();
115
+ for (const failure of failures) {
116
+ if (!failure?.name || byName.has(failure.name)) continue;
117
+ byName.set(failure.name, {
118
+ conclusion: String(failure.conclusion || '').toUpperCase(),
119
+ url: failure.jobUrl || failure.detailsUrl || null,
120
+ });
121
+ }
122
+ return byName;
123
+ }
124
+
125
+ function selectRerunChecks(failingNames, failuresByName) {
126
+ const checks = [];
127
+ for (const name of failingNames) {
128
+ const failure = failuresByName.get(name);
129
+ if (!failure?.conclusion) {
130
+ return { reason: `required check "${name}" has no known conclusion — cannot confirm flaky, fail closed` };
131
+ }
132
+ if (REAL_FAILURE_CONCLUSIONS.has(failure.conclusion)) {
133
+ return { reason: `required check "${name}" is a real failure (${failure.conclusion}) — never rerun` };
134
+ }
135
+ if (!INFRA_CONCLUSIONS.has(failure.conclusion)) {
136
+ return { reason: `required check "${name}" conclusion ${failure.conclusion} is not classified infrastructural — fail closed` };
137
+ }
138
+ checks.push({ name, conclusion: failure.conclusion, runId: runIdFromUrl(failure.url) });
139
+ }
140
+ return { checks };
141
+ }
142
+
102
143
  /**
103
144
  * Decide whether to re-run flaky REQUIRED checks. Fires ONLY when EVERY failing
104
- * required check is infrastructural (cancelled/timed-out/stale/startup-failure)
105
- * with a derivable run id, and NONE is a real FAILURE/ERROR/ACTION_REQUIRED. A
106
- * single real failure, an unclassifiable conclusion, a required-failing check
107
- * with no matching `failures[]` entry, or a missing run id fails the WHOLE
108
- * decision closed (never rerun a genuinely-broken PR, never loop on a real bug).
109
- *
110
- * @param {object} payload - the `--pull --json` payload.
111
- * @returns {{ should: boolean, checks: Array<{name:string,conclusion:string,runId:string|null}>, runIds: string[], reason: string }}
145
+ * required check is infrastructural with a derivable run id, and NONE is a real
146
+ * failure or an unclassifiable signal.
112
147
  */
113
148
  function decideRerun(payload) {
114
149
  const empty = (reason) => ({ should: false, checks: [], runIds: [], reason });
@@ -119,28 +154,15 @@ function decideRerun(payload) {
119
154
  const failingNames = Array.isArray(rc.failing) ? rc.failing : [];
120
155
  if (failingNames.length === 0) return empty('no failing required checks');
121
156
 
122
- const failures = Array.isArray(payload.failures) ? payload.failures : [];
123
- const conclByName = new Map();
124
- const urlByName = new Map();
125
- for (const f of failures) {
126
- if (!f || !f.name) continue;
127
- if (!conclByName.has(f.name)) {
128
- conclByName.set(f.name, String(f.conclusion || '').toUpperCase());
129
- urlByName.set(f.name, f.jobUrl || f.detailsUrl || null);
130
- }
131
- }
132
-
133
- const picked = [];
134
- for (const name of failingNames) {
135
- const concl = conclByName.get(name);
136
- if (!concl) return empty(`required check "${name}" has no known conclusion — cannot confirm flaky, fail closed`);
137
- if (REAL_FAILURE_CONCLUSIONS.has(concl)) return empty(`required check "${name}" is a real failure (${concl}) — never rerun`);
138
- if (!INFRA_CONCLUSIONS.has(concl)) return empty(`required check "${name}" conclusion ${concl} is not classified infrastructural — fail closed`);
139
- picked.push({ name, conclusion: concl, runId: runIdFromUrl(urlByName.get(name)) });
140
- }
157
+ const selection = selectRerunChecks(
158
+ failingNames,
159
+ indexFailures(Array.isArray(payload.failures) ? payload.failures : []),
160
+ );
161
+ if (selection.reason) return empty(selection.reason);
141
162
 
163
+ const picked = selection.checks;
142
164
  const runIds = [...new Set(picked.map((p) => p.runId).filter(Boolean))];
143
- if (runIds.length === 0) return empty('no run id derivable from failure jobUrl — fail closed');
165
+ if (picked.some((item) => !item.runId)) return empty('no run id derivable from failure jobUrl — fail closed');
144
166
  return {
145
167
  should: true,
146
168
  checks: picked,
@@ -164,6 +186,123 @@ function decideAutoActions(payload, opts = {}) {
164
186
  };
165
187
  }
166
188
 
189
+ function boundedPolicyValue(value, fallback, maximum) {
190
+ const selected = value ?? fallback;
191
+ return Number.isSafeInteger(selected) && selected > 0 && selected <= maximum ? selected : null;
192
+ }
193
+
194
+ function normalizeActionHistory(history) {
195
+ if (!Array.isArray(history)) return { ok: false, reason: 'action history is incomplete' };
196
+ const byKey = new Map();
197
+ for (const item of history) {
198
+ if (!item || typeof item.key !== 'string' || !item.key
199
+ || !['pending', 'applied', 'failed'].includes(item.state)
200
+ || !Number.isSafeInteger(item.attempts) || item.attempts < 0) {
201
+ return { ok: false, reason: 'action history is incomplete' };
202
+ }
203
+ const prior = byKey.get(item.key);
204
+ if (prior && (prior.state !== item.state || prior.attempts !== item.attempts)) {
205
+ return { ok: false, conflict: true, reason: `action history conflicts for ${item.key}` };
206
+ }
207
+ if (!prior) byKey.set(item.key, item);
208
+ }
209
+ return { ok: true, byKey };
210
+ }
211
+
212
+ function validateAutoActionAuthority(payload) {
213
+ if (!payload?.evidence || !Array.isArray(payload.evidence.unreadable)
214
+ || typeof payload.evidence.tornRead !== 'boolean') {
215
+ return { ok: false, reason: 'verdict evidence is incomplete' };
216
+ }
217
+ if (payload.draft !== false) {
218
+ return { ok: false, reason: 'non-draft authority is incomplete' };
219
+ }
220
+
221
+ const failuresByName = new Map();
222
+ for (const failure of Array.isArray(payload.failures) ? payload.failures : []) {
223
+ if (!failure || typeof failure.name !== 'string' || !failure.name) continue;
224
+ const evidence = {
225
+ conclusion: String(failure.conclusion || '').toUpperCase(),
226
+ url: failure.jobUrl || failure.detailsUrl || null,
227
+ };
228
+ const prior = failuresByName.get(failure.name);
229
+ if (prior && (prior.conclusion !== evidence.conclusion || prior.url !== evidence.url)) {
230
+ return { ok: false, conflict: true, reason: `required check evidence conflicts for ${failure.name}` };
231
+ }
232
+ if (!prior) failuresByName.set(failure.name, evidence);
233
+ }
234
+ return { ok: true };
235
+ }
236
+
237
+ function buildAutoActionCandidates(decision, subjectRevision) {
238
+ const candidates = [];
239
+ if (decision.updateBranch.should) {
240
+ candidates.push({ type: 'updateBranch', key: `updateBranch:${subjectRevision}`, subjectRevision });
241
+ }
242
+ if (decision.rerunFlaky.should) {
243
+ const runIds = [...decision.rerunFlaky.runIds].sort((left, right) => left.localeCompare(right));
244
+ for (const runId of runIds) {
245
+ candidates.push({
246
+ type: 'rerunFlaky',
247
+ key: `rerunFlaky:${subjectRevision}:${runId}`,
248
+ subjectRevision,
249
+ runId,
250
+ });
251
+ }
252
+ }
253
+ return candidates;
254
+ }
255
+
256
+ function selectBoundedActions(candidates, history, maxActions, maxAttempts) {
257
+ const actions = [];
258
+ for (const candidate of candidates) {
259
+ const prior = history.get(candidate.key);
260
+ if (prior && (prior.state === 'pending' || prior.state === 'applied' || prior.attempts >= maxAttempts)) continue;
261
+ actions.push({ ...candidate, attempt: prior ? prior.attempts + 1 : 1 });
262
+ if (actions.length === maxActions) break;
263
+ }
264
+ return actions;
265
+ }
266
+
267
+ /** Select a deterministic, per-head, bounded action/retry batch. */
268
+ function decideBoundedAutoActions(payload, opts = {}) {
269
+ const closed = (status, reason) => ({ status, reason, actions: [] });
270
+ const subjectRevision = payload?.headSha;
271
+ if (typeof subjectRevision !== 'string' || !/^[0-9a-f]{40}$/i.test(subjectRevision)) {
272
+ return closed(AUTO_ACTION_STATUS.INCOMPLETE, 'exact head authority is incomplete');
273
+ }
274
+ if (opts.expectedRevision != null && opts.expectedRevision !== subjectRevision) {
275
+ return closed(AUTO_ACTION_STATUS.STALE, 'payload head does not match expected revision');
276
+ }
277
+ const authority = validateAutoActionAuthority(payload);
278
+ if (!authority.ok) {
279
+ return closed(authority.conflict ? AUTO_ACTION_STATUS.CONFLICT : AUTO_ACTION_STATUS.INCOMPLETE, authority.reason);
280
+ }
281
+ const maxActions = boundedPolicyValue(opts.maxActions, 1, MAX_AUTO_ACTIONS);
282
+ const maxAttempts = boundedPolicyValue(opts.maxAttempts, 2, MAX_AUTO_ATTEMPTS);
283
+ if (maxActions == null || maxAttempts == null) {
284
+ return closed(AUTO_ACTION_STATUS.INCOMPLETE, 'auto-action policy exceeds bounded limits');
285
+ }
286
+ const history = normalizeActionHistory(opts.history || []);
287
+ if (!history.ok) {
288
+ return closed(history.conflict ? AUTO_ACTION_STATUS.CONFLICT : AUTO_ACTION_STATUS.INCOMPLETE, history.reason);
289
+ }
290
+
291
+ const decision = decideAutoActions(payload, opts);
292
+ if (decision.updateBranch.should && decision.rerunFlaky.should) {
293
+ return closed(AUTO_ACTION_STATUS.CONFLICT, 'payload authorizes conflicting update and rerun actions');
294
+ }
295
+
296
+ const candidates = buildAutoActionCandidates(decision, subjectRevision);
297
+ const actions = selectBoundedActions(candidates, history.byKey, maxActions, maxAttempts);
298
+ if (actions.length === 0) {
299
+ return closed(AUTO_ACTION_STATUS.UNCHANGED, candidates.length === 0
300
+ ? 'no safe auto-action transition'
301
+ : 'identical actions are pending, applied, or retry-exhausted');
302
+ }
303
+ return { status: AUTO_ACTION_STATUS.PASS, reason: 'bounded auto-actions selected', actions };
304
+ }
305
+
167
306
  module.exports = {
168
307
  decideAutoActions,
169
308
  decideUpdateBranch,
@@ -172,4 +311,6 @@ module.exports = {
172
311
  isDegraded,
173
312
  INFRA_CONCLUSIONS,
174
313
  REAL_FAILURE_CONCLUSIONS,
314
+ AUTO_ACTION_STATUS,
315
+ decideBoundedAutoActions,
175
316
  };
@@ -14,6 +14,107 @@
14
14
  */
15
15
 
16
16
  const { EVENT_TYPES: T, makeEvent } = require('./events');
17
+ const {
18
+ VERDICT_STATES,
19
+ normalizePrNumber,
20
+ normalizeRepository,
21
+ normalizeSha,
22
+ object,
23
+ readArrayItems,
24
+ readDataProperty,
25
+ } = require('./verdict');
26
+
27
+ const CURRENT_VERDICT_STATES = new Set(Object.values(VERDICT_STATES));
28
+
29
+ function compareText(left, right) {
30
+ if (left < right) return -1;
31
+ if (left > right) return 1;
32
+ return 0;
33
+ }
34
+
35
+ function normalizedReasonCodes(verdict) {
36
+ const reasons = readArrayItems(readDataProperty(verdict, 'reasons').value);
37
+ if (!reasons) return null;
38
+ const codes = [];
39
+ for (const entry of reasons) {
40
+ const code = readDataProperty(entry, 'code').value;
41
+ if (!object(entry) || typeof code !== 'string' || !code.trim()) return null;
42
+ codes.push(code.trim());
43
+ }
44
+ return [...new Set(codes)].sort(compareText);
45
+ }
46
+
47
+ function normalizeCurrentVerdict(verdict, { absent = false } = {}) {
48
+ if (absent && verdict == null) {
49
+ return {
50
+ state: null, repository: null, prNumber: null, headSha: null, baseSha: null, reasons: [],
51
+ };
52
+ }
53
+ const record = object(verdict);
54
+ const state = readDataProperty(record, 'state').value;
55
+ const repository = normalizeRepository(readDataProperty(record, 'repository').value);
56
+ const prNumber = normalizePrNumber(readDataProperty(record, 'prNumber').value);
57
+ const headSha = normalizeSha(readDataProperty(record, 'headSha').value);
58
+ const baseSha = normalizeSha(readDataProperty(record, 'baseSha').value);
59
+ const valid = record && CURRENT_VERDICT_STATES.has(state)
60
+ && repository && prNumber && headSha && baseSha;
61
+ const reasons = valid ? normalizedReasonCodes(verdict) : null;
62
+ if (!valid || reasons === null
63
+ || (state === VERDICT_STATES.MERGE_READY && reasons.length > 0)
64
+ || (state !== VERDICT_STATES.MERGE_READY && reasons.length === 0)) {
65
+ return {
66
+ state: VERDICT_STATES.INCOMPLETE,
67
+ repository: null,
68
+ prNumber: null,
69
+ headSha: null,
70
+ baseSha: null,
71
+ reasons: ['malformed_verdict'],
72
+ };
73
+ }
74
+ return {
75
+ state,
76
+ repository,
77
+ prNumber,
78
+ headSha,
79
+ baseSha,
80
+ reasons,
81
+ };
82
+ }
83
+
84
+ function difference(left, right) {
85
+ const rightSet = new Set(right);
86
+ return left.filter((value) => !rightSet.has(value));
87
+ }
88
+
89
+ /** Deterministically describe a transition between exact-current-head verdicts. */
90
+ function diffVerdictEvidence(previous, next) {
91
+ const before = normalizeCurrentVerdict(previous, { absent: true });
92
+ const after = normalizeCurrentVerdict(next);
93
+ const addedReasons = difference(after.reasons, before.reasons);
94
+ const removedReasons = difference(before.reasons, after.reasons);
95
+ const headChanged = before.headSha !== after.headSha;
96
+ const baseChanged = before.baseSha !== after.baseSha;
97
+ const repositoryChanged = before.repository !== after.repository;
98
+ const prChanged = before.prNumber !== after.prNumber;
99
+ const stateChanged = before.state !== after.state;
100
+ return {
101
+ changed: headChanged || baseChanged || repositoryChanged || prChanged || stateChanged
102
+ || addedReasons.length > 0 || removedReasons.length > 0,
103
+ headChanged,
104
+ stateChanged,
105
+ baseChanged,
106
+ repositoryChanged,
107
+ prChanged,
108
+ from: before.state,
109
+ to: after.state,
110
+ repository: after.repository,
111
+ prNumber: after.prNumber,
112
+ headSha: after.headSha,
113
+ baseSha: after.baseSha,
114
+ addedReasons,
115
+ removedReasons,
116
+ };
117
+ }
17
118
 
18
119
  /** Index an array of objects by a key field into a Map. */
19
120
  function indexBy(list, field) {
@@ -44,9 +145,9 @@ function diffChecks(prev, next, out) {
44
145
  const before = prevByName.get(String(c.name));
45
146
  const wasFailed = before?.class === 'failed';
46
147
  if (c.class === 'failed' && !wasFailed) {
47
- out.push(makeEvent(T.CHECK_FAILED, `${c.name}:${next.headSha}`, { name: c.name }));
148
+ out.push(makeEvent(T.CHECK_FAILED, `${c.name}:${next.headSha}`, { name: c.displayName || c.name }));
48
149
  } else if (c.class === 'green' && wasFailed) {
49
- out.push(makeEvent(T.CHECK_RECOVERED, `${c.name}:${next.headSha}`, { name: c.name }));
150
+ out.push(makeEvent(T.CHECK_RECOVERED, `${c.name}:${next.headSha}`, { name: c.displayName || c.name }));
50
151
  }
51
152
  }
52
153
  if (allGreen(next.checks) && !allGreen(prev.checks)) {
@@ -92,7 +193,9 @@ function diffReviews(prev, next, out) {
92
193
  || before.commitOid !== r.commitOid;
93
194
  if (changed) {
94
195
  out.push(makeEvent(T.REVIEW_SUBMITTED, `${r.author}:${r.commitOid || r.submittedAt}`, {
95
- author: r.author, verdict: r.state, commitOid: r.commitOid || null,
196
+ author: r.displayAuthor || r.author,
197
+ verdict: r.state,
198
+ commitOid: r.displayCommitOid || r.commitOid || null,
96
199
  }));
97
200
  }
98
201
  }
@@ -139,7 +242,9 @@ function diffDegraded(prev, next, out) {
139
242
  const prevSurfaces = new Set((prev.degraded || []).map((d) => d.surface));
140
243
  for (const d of (next.degraded || [])) {
141
244
  if (!prevSurfaces.has(d.surface)) {
142
- out.push(makeEvent(T.MONITOR_DEGRADED, d.surface, { surface: d.surface, error: d.error || '' }));
245
+ out.push(makeEvent(T.MONITOR_DEGRADED, d.surface, {
246
+ surface: d.displaySurface || d.surface, error: d.error || '',
247
+ }));
143
248
  }
144
249
  }
145
250
  }
@@ -190,6 +295,7 @@ function diffSnapshots(prev, next) {
190
295
 
191
296
  module.exports = {
192
297
  diffSnapshots,
298
+ diffVerdictEvidence,
193
299
  allGreen,
194
300
  indexBy,
195
301
  };
Binary file