@ashlr/hub 3.2.6 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +290 -0
  2. package/README.md +4 -1
  3. package/dist/build-identity.json +2 -2
  4. package/dist/cli/activation.d.ts +41 -0
  5. package/dist/cli/activation.js +485 -0
  6. package/dist/cli/activation.js.map +1 -0
  7. package/dist/cli/audit.js +18 -0
  8. package/dist/cli/audit.js.map +1 -1
  9. package/dist/cli/backlog.d.ts +0 -5
  10. package/dist/cli/backlog.js +18 -0
  11. package/dist/cli/backlog.js.map +1 -1
  12. package/dist/cli/comms.js +20 -0
  13. package/dist/cli/comms.js.map +1 -1
  14. package/dist/cli/completions.d.ts +6 -1
  15. package/dist/cli/completions.js +37 -11
  16. package/dist/cli/completions.js.map +1 -1
  17. package/dist/cli/conductor-permit.d.ts +1 -0
  18. package/dist/cli/conductor-permit.js +122 -0
  19. package/dist/cli/conductor-permit.js.map +1 -0
  20. package/dist/cli/daemon.js +10 -3
  21. package/dist/cli/daemon.js.map +1 -1
  22. package/dist/cli/demo.d.ts +0 -27
  23. package/dist/cli/demo.js +17 -0
  24. package/dist/cli/demo.js.map +1 -1
  25. package/dist/cli/goals.d.ts +2 -0
  26. package/dist/cli/goals.js +90 -5
  27. package/dist/cli/goals.js.map +1 -1
  28. package/dist/cli/help.js +21 -3
  29. package/dist/cli/help.js.map +1 -1
  30. package/dist/cli/inbox.d.ts +14 -2
  31. package/dist/cli/inbox.js +137 -23
  32. package/dist/cli/inbox.js.map +1 -1
  33. package/dist/cli/index.d.ts +1 -0
  34. package/dist/cli/index.js +16 -0
  35. package/dist/cli/index.js.map +1 -1
  36. package/dist/cli/loop.js +26 -5
  37. package/dist/cli/loop.js.map +1 -1
  38. package/dist/cli/onboard.d.ts +0 -13
  39. package/dist/cli/onboard.js +20 -0
  40. package/dist/cli/onboard.js.map +1 -1
  41. package/dist/cli/resources.js +15 -0
  42. package/dist/cli/resources.js.map +1 -1
  43. package/dist/cli/run.js +1 -1
  44. package/dist/cli/run.js.map +1 -1
  45. package/dist/cli/sandbox.js +43 -0
  46. package/dist/cli/sandbox.js.map +1 -1
  47. package/dist/cli/tui.d.ts +0 -6
  48. package/dist/cli/tui.js +14 -0
  49. package/dist/cli/tui.js.map +1 -1
  50. package/dist/core/comms/elon-dialogue.js +16 -7
  51. package/dist/core/comms/elon-dialogue.js.map +1 -1
  52. package/dist/core/config.js +70 -0
  53. package/dist/core/config.js.map +1 -1
  54. package/dist/core/daemon/activation-permit.d.ts +357 -33
  55. package/dist/core/daemon/activation-permit.js +1585 -183
  56. package/dist/core/daemon/activation-permit.js.map +1 -1
  57. package/dist/core/daemon/goal-conductor-permit-operator.d.ts +75 -0
  58. package/dist/core/daemon/goal-conductor-permit-operator.js +842 -0
  59. package/dist/core/daemon/goal-conductor-permit-operator.js.map +1 -0
  60. package/dist/core/daemon/loop.d.ts +13 -1
  61. package/dist/core/daemon/loop.js +294 -29
  62. package/dist/core/daemon/loop.js.map +1 -1
  63. package/dist/core/daemon/runtime-activation-resident-start-protocol.d.ts +141 -0
  64. package/dist/core/daemon/runtime-activation-resident-start-protocol.js +462 -0
  65. package/dist/core/daemon/runtime-activation-resident-start-protocol.js.map +1 -0
  66. package/dist/core/daemon/runtime-activation-resident-start-runtime.d.ts +27 -0
  67. package/dist/core/daemon/runtime-activation-resident-start-runtime.js +28 -0
  68. package/dist/core/daemon/runtime-activation-resident-start-runtime.js.map +1 -0
  69. package/dist/core/daemon/runtime-activation-stopped-consumer.d.ts +139 -0
  70. package/dist/core/daemon/runtime-activation-stopped-consumer.js +995 -0
  71. package/dist/core/daemon/runtime-activation-stopped-consumer.js.map +1 -0
  72. package/dist/core/daemon/runtime-activation-stopped-runtime.d.ts +36 -0
  73. package/dist/core/daemon/runtime-activation-stopped-runtime.js +134 -0
  74. package/dist/core/daemon/runtime-activation-stopped-runtime.js.map +1 -0
  75. package/dist/core/daemon/runtime-activation-transaction.d.ts +41 -17
  76. package/dist/core/daemon/runtime-activation-transaction.js +80 -11
  77. package/dist/core/daemon/runtime-activation-transaction.js.map +1 -1
  78. package/dist/core/daemon/runtime-release-dependency-inventory.js +1 -0
  79. package/dist/core/daemon/runtime-release-dependency-inventory.js.map +1 -1
  80. package/dist/core/daemon/service-install-authority.d.ts +0 -4
  81. package/dist/core/daemon/service-install-authority.js +18 -3
  82. package/dist/core/daemon/service-install-authority.js.map +1 -1
  83. package/dist/core/dashboard.d.ts +10 -0
  84. package/dist/core/dashboard.js +103 -28
  85. package/dist/core/dashboard.js.map +1 -1
  86. package/dist/core/effective-config.js +7 -0
  87. package/dist/core/effective-config.js.map +1 -1
  88. package/dist/core/fleet/automerge-pass.js +33 -0
  89. package/dist/core/fleet/automerge-pass.js.map +1 -1
  90. package/dist/core/fleet/decisions-ledger.js +35 -12
  91. package/dist/core/fleet/decisions-ledger.js.map +1 -1
  92. package/dist/core/fleet/external-skill-git-capture.js +9 -1
  93. package/dist/core/fleet/external-skill-git-capture.js.map +1 -1
  94. package/dist/core/fleet/judge-decision-metadata.d.ts +8 -1
  95. package/dist/core/fleet/judge-decision-metadata.js +14 -1
  96. package/dist/core/fleet/judge-decision-metadata.js.map +1 -1
  97. package/dist/core/fleet/manager.d.ts +15 -1
  98. package/dist/core/fleet/manager.js +108 -30
  99. package/dist/core/fleet/manager.js.map +1 -1
  100. package/dist/core/fleet/post-merge-credit.d.ts +134 -9
  101. package/dist/core/fleet/post-merge-credit.js +350 -10
  102. package/dist/core/fleet/post-merge-credit.js.map +1 -1
  103. package/dist/core/fleet/quota.d.ts +74 -7
  104. package/dist/core/fleet/quota.js +688 -34
  105. package/dist/core/fleet/quota.js.map +1 -1
  106. package/dist/core/fleet/self-improve.d.ts +34 -1
  107. package/dist/core/fleet/self-improve.js +106 -1
  108. package/dist/core/fleet/self-improve.js.map +1 -1
  109. package/dist/core/fleet/skill-library.d.ts +21 -2
  110. package/dist/core/fleet/skill-library.js +54 -12
  111. package/dist/core/fleet/skill-library.js.map +1 -1
  112. package/dist/core/fleet/status.js +7 -5
  113. package/dist/core/fleet/status.js.map +1 -1
  114. package/dist/core/fleet/worked-ledger.d.ts +22 -0
  115. package/dist/core/fleet/worked-ledger.js +32 -0
  116. package/dist/core/fleet/worked-ledger.js.map +1 -1
  117. package/dist/core/foundry/automerge-scope-policy.d.ts +2 -2
  118. package/dist/core/foundry/automerge-scope-policy.js +9 -2
  119. package/dist/core/foundry/automerge-scope-policy.js.map +1 -1
  120. package/dist/core/goals/advance.js +34 -5
  121. package/dist/core/goals/advance.js.map +1 -1
  122. package/dist/core/goals/conductor-quota.d.ts +38 -0
  123. package/dist/core/goals/conductor-quota.js +110 -0
  124. package/dist/core/goals/conductor-quota.js.map +1 -0
  125. package/dist/core/goals/conductor-target.d.ts +24 -0
  126. package/dist/core/goals/conductor-target.js +35 -0
  127. package/dist/core/goals/conductor-target.js.map +1 -0
  128. package/dist/core/goals/conductor.d.ts +15 -0
  129. package/dist/core/goals/conductor.js +116 -9
  130. package/dist/core/goals/conductor.js.map +1 -1
  131. package/dist/core/goals/store.d.ts +29 -5
  132. package/dist/core/goals/store.js +197 -30
  133. package/dist/core/goals/store.js.map +1 -1
  134. package/dist/core/goals/timestamp-repair.d.ts +50 -0
  135. package/dist/core/goals/timestamp-repair.js +740 -0
  136. package/dist/core/goals/timestamp-repair.js.map +1 -0
  137. package/dist/core/inbox/apply.js +5 -2
  138. package/dist/core/inbox/apply.js.map +1 -1
  139. package/dist/core/inbox/merge.d.ts +94 -1
  140. package/dist/core/inbox/merge.js +349 -16
  141. package/dist/core/inbox/merge.js.map +1 -1
  142. package/dist/core/integrations/github.d.ts +2 -0
  143. package/dist/core/integrations/github.js +2 -1
  144. package/dist/core/integrations/github.js.map +1 -1
  145. package/dist/core/integrations/pulse-sync.js +13 -4
  146. package/dist/core/integrations/pulse-sync.js.map +1 -1
  147. package/dist/core/learn/reflect.d.ts +42 -4
  148. package/dist/core/learn/reflect.js +56 -5
  149. package/dist/core/learn/reflect.js.map +1 -1
  150. package/dist/core/run/best-of-n.d.ts +18 -0
  151. package/dist/core/run/best-of-n.js +163 -18
  152. package/dist/core/run/best-of-n.js.map +1 -1
  153. package/dist/core/run/engines.d.ts +8 -0
  154. package/dist/core/run/engines.js +9 -1
  155. package/dist/core/run/engines.js.map +1 -1
  156. package/dist/core/run/learned-router.js +28 -3
  157. package/dist/core/run/learned-router.js.map +1 -1
  158. package/dist/core/run/orchestrator.d.ts +7 -2
  159. package/dist/core/run/orchestrator.js +140 -60
  160. package/dist/core/run/orchestrator.js.map +1 -1
  161. package/dist/core/strategy/goal-planner.js +7 -1
  162. package/dist/core/strategy/goal-planner.js.map +1 -1
  163. package/dist/core/swarm/planner.d.ts +2 -2
  164. package/dist/core/swarm/planner.js +52 -5
  165. package/dist/core/swarm/planner.js.map +1 -1
  166. package/dist/core/swarm/rollback.d.ts +11 -0
  167. package/dist/core/swarm/rollback.js +54 -0
  168. package/dist/core/swarm/rollback.js.map +1 -1
  169. package/dist/core/swarm/runner.js +123 -11
  170. package/dist/core/swarm/runner.js.map +1 -1
  171. package/dist/core/types.d.ts +139 -1
  172. package/dist/core/types.js.map +1 -1
  173. package/dist/core/web/api.js +105 -7
  174. package/dist/core/web/api.js.map +1 -1
  175. package/dist/core/web/control.d.ts +20 -0
  176. package/dist/core/web/control.js +49 -34
  177. package/dist/core/web/control.js.map +1 -1
  178. package/dist/core/web/fleet-status-cache.d.ts +68 -0
  179. package/dist/core/web/fleet-status-cache.js +120 -0
  180. package/dist/core/web/fleet-status-cache.js.map +1 -0
  181. package/dist/core/web/public/next/assets/index-CY0U241s.css +1 -0
  182. package/dist/core/web/public/next/assets/index-Ct0mf8n4.js +69 -0
  183. package/dist/core/web/public/next/assets/index-Ct0mf8n4.js.map +1 -0
  184. package/dist/core/web/public/next/index.html +15 -0
  185. package/dist/release-dependency-inventory.json +1 -1
  186. package/dist/tui/app.js +2 -2
  187. package/dist/tui/app.js.map +1 -1
  188. package/dist/tui/render.d.ts +2 -0
  189. package/dist/tui/render.js +162 -4
  190. package/dist/tui/render.js.map +1 -1
  191. package/docs/RUNTIME_ACTIVATION_AUTHORITY.md +126 -21
  192. package/docs/contracts/CONTRACT-M521.md +102 -0
  193. package/package.json +20 -3
  194. package/schema/config.schema.json +800 -5
  195. package/node_modules/which/CHANGELOG.md +0 -166
@@ -98,7 +98,7 @@ import { execFileSync } from 'node:child_process';
98
98
  import { existsSync, mkdirSync, writeFileSync, unlinkSync, symlinkSync, readFileSync } from 'node:fs';
99
99
  import { homedir } from 'node:os';
100
100
  import { join } from 'node:path';
101
- import { randomBytes } from 'node:crypto';
101
+ import { createHash, randomBytes } from 'node:crypto';
102
102
  import { isDeepStrictEqual } from 'node:util';
103
103
  import { loadProposal, recordRealizedMerge, setStatus, updateProposalField } from './store.js';
104
104
  import { canonicalModelTag } from '../run/model-catalog.js';
@@ -128,6 +128,7 @@ import { acquireProposalMutationLock, releaseProposalMutationLock } from './prop
128
128
  import { acquireOutwardMutationFence, ownsOutwardMutationFence, releaseOutwardMutationFence, } from '../sandbox/mutation-fence.js';
129
129
  import { isAuthenticatedAwaitingRemoteHandoff, isApprovedRemoteHandoffRetryCandidate, remoteAuthorityBinding, remoteAuthorityMatchesRepo, remoteIntentAuthorizationId, REMOTE_HANDOFF_RECOVERY_MARKER, } from './remote-handoff.js';
130
130
  import { findLocalRealizedMergeDescendant } from './realized-merge.js';
131
+ import { prepareHostMergeRevocation, transitionHostMergeRevocation, readHostMergeRevocationState, } from '../autonomy/host-merge-revocation-protocol.js';
131
132
  function auditAutoMergeCanaryFailure(repo, sandboxId) {
132
133
  try {
133
134
  audit({
@@ -159,6 +160,20 @@ function decisionReadIsHealthy(decisions) {
159
160
  const MERGE_BRANCH_PREFIX = 'ashlr/merge/';
160
161
  /** Timeout for git operations (ms). */
161
162
  const GIT_TIMEOUT = 30_000;
163
+ /**
164
+ * Gate 6.5 (self-eval parity, M54/M86): how many times to run test:invariants
165
+ * per flag state before declaring it red. >1 tolerates a one-off transient
166
+ * blip (lock timing, momentary contention) without weakening the gate — a
167
+ * genuinely broken invariant still fails every attempt. Kept small (one
168
+ * retry, short delay): sustained multi-minute contention from a concurrent
169
+ * test run can't be "waited out" here anyway — cross-tick retry (the
170
+ * proposal simply stays pending and is re-evaluated next pass, see
171
+ * automerge-pass.ts) is what actually absorbs that, and this must stay
172
+ * short enough to never turn a fast fail-closed refusal into a slow one.
173
+ */
174
+ const SELF_EVAL_PARITY_RETRY_ATTEMPTS = 2;
175
+ /** Delay between self-eval parity retry attempts (ms). */
176
+ const SELF_EVAL_PARITY_RETRY_DELAY_MS = 500;
162
177
  // ---------------------------------------------------------------------------
163
178
  // git helpers — arg arrays, no shell
164
179
  // ---------------------------------------------------------------------------
@@ -612,7 +627,23 @@ export function evaluateAutoMergeReadinessPreflight(proposal, cfg) {
612
627
  return block('proposal has no repo');
613
628
  if (proposal.verifyResult?.passed === false) {
614
629
  const failed = proposal.verifyResult.failed?.filter(Boolean).join('; ');
615
- return block(`known verification failure: proposal.verifyResult.passed is false${failed ? ` (${failed})` : ''}`);
630
+ const category = proposal.verifyResult.failureCategory;
631
+ // A verifier that could NOT be executed (missing binary, exit 127, spawn
632
+ // ENOENT) or that hit a bounded timeout/infra hiccup is a DIFFERENT
633
+ // outcome from a verifier that ran and found the diff broken. Both stay
634
+ // fail-closed (ready:false — this NEVER makes an unverified change
635
+ // eligible to merge), but only a real 'code' failure (or an
636
+ // unclassified/legacy record) may accrue toward stuckPassCount/
637
+ // autoArchive. An infra-flavored failure must not permanently kill a
638
+ // proposal whose only crime was that the verifier was momentarily
639
+ // unavailable — it stays pending and is retried next pass.
640
+ const infraLike = category === 'tool' || category === 'timeout' || category === 'infra';
641
+ const advisories = infraLike
642
+ ? [
643
+ `verifier-unavailable: verification could not run to completion (${category}) — not counted toward auto-archive; will retry`,
644
+ ]
645
+ : [];
646
+ return block(`known verification failure: proposal.verifyResult.passed is false${failed ? ` (${failed})` : ''}`, advisories, !infraLike);
616
647
  }
617
648
  const trustBasis = cfg.foundry?.['autoMerge']
618
649
  ? cfg.foundry['autoMerge']?.['trustBasis'] ?? 'tier'
@@ -1417,6 +1448,7 @@ export function verifyResultFromProposalResult(result, source = 'auto-merge', ve
1417
1448
  return {
1418
1449
  passed: result.ok,
1419
1450
  ...(result.ok ? {} : { failed: [result.detail] }),
1451
+ ...(result.ok || result.failureCategory === undefined ? {} : { failureCategory: result.failureCategory }),
1420
1452
  detail: result.detail,
1421
1453
  ran: [...result.ran],
1422
1454
  ...(result.browser ? { browser: result.browser } : {}),
@@ -1534,6 +1566,7 @@ function verifyResultFromStored(result) {
1534
1566
  ...(result.browser ? { browser: result.browser } : {}),
1535
1567
  ...(result.baseBranch ? { baseBranch: result.baseBranch } : {}),
1536
1568
  ...(result.baseHead ? { baseHead: result.baseHead } : {}),
1569
+ ...(result.failureCategory ? { failureCategory: result.failureCategory } : {}),
1537
1570
  };
1538
1571
  }
1539
1572
  function truncateEvidenceText(text, limit = 500) {
@@ -1685,15 +1718,24 @@ export async function verifyProposal(proposal, cfg) {
1685
1718
  }
1686
1719
  // Apply the diff to the worktree ONLY AFTER capturing the base-derived
1687
1720
  // command list, then run those (immutable) commands against the patched tree.
1721
+ // M??? (merge-pipeline reliability): --3way lets git fall back to a
1722
+ // three-way merge (using the blob shas already embedded in `git diff`'s
1723
+ // extended headers) when the diff's ORIGINAL base has since moved but the
1724
+ // proposal's edits don't actually conflict with what changed. Plain
1725
+ // `apply` fails on any context drift even for benign staleness; `--3way`
1726
+ // only fails closed on a REAL textual conflict, which is the outcome we
1727
+ // actually want to distinguish from "diff is corrupt". Safe: this
1728
+ // worktree is always removed in the finally block below regardless of
1729
+ // outcome, so a conflicted intermediate state is never observed.
1688
1730
  patchFile = writeTmpFile(diff);
1689
1731
  try {
1690
- gitRun(tmpDir, ['apply', '--index', patchFile]);
1732
+ gitRun(tmpDir, ['apply', '--3way', '--index', patchFile]);
1691
1733
  }
1692
1734
  catch (err) {
1693
1735
  return {
1694
1736
  ok: false,
1695
1737
  ran: [],
1696
- detail: `git apply failed in verify worktree: ${err instanceof Error ? err.message : String(err)}`,
1738
+ detail: `git apply failed in verify worktree (3-way fallback also failed — likely a real conflict, not just a stale base): ${err instanceof Error ? err.message : String(err)}`,
1697
1739
  baseBranch: base,
1698
1740
  baseHead,
1699
1741
  };
@@ -1711,6 +1753,7 @@ export async function verifyProposal(proposal, cfg) {
1711
1753
  detail: `required verify '${vc.kind}' failed (exit ${res.exitCode}): ${res.command}`,
1712
1754
  baseBranch: base,
1713
1755
  baseHead,
1756
+ ...(res.failureCategory ? { failureCategory: res.failureCategory } : {}),
1714
1757
  };
1715
1758
  }
1716
1759
  // M281: for test commands, only block if NEW failures were introduced.
@@ -1788,6 +1831,7 @@ export async function verifyProposal(proposal, cfg) {
1788
1831
  detail: `verify '${vc.kind}' failed (exit ${res.exitCode}): ${res.command}`,
1789
1832
  baseBranch: base,
1790
1833
  baseHead,
1834
+ ...(res.failureCategory ? { failureCategory: res.failureCategory } : {}),
1791
1835
  };
1792
1836
  }
1793
1837
  }
@@ -1968,6 +2012,223 @@ function reconcileLocalMergeIntent(id, cfg) {
1968
2012
  });
1969
2013
  return { ok: true, merged: true, reason };
1970
2014
  }
2015
+ // ---------------------------------------------------------------------------
2016
+ // Host merge — the last hop of the remote handoff path: the actual
2017
+ // `gh pr merge`. Gated by durable per-authority revocation
2018
+ // (autonomy/host-merge-revocation-protocol.ts) on top of every existing
2019
+ // mechanical/judge/self-target gate already run above. Default DISABLED via
2020
+ // cfg.foundry.autoMerge.hostAutoMerge (separate opt-in from pushToRemote —
2021
+ // opening a PR and actually merging it are different trust levels).
2022
+ // ---------------------------------------------------------------------------
2023
+ /** Bounded lifetime of a single host-merge authority window (< the protocol's 15m cap). */
2024
+ export const HOST_MERGE_AUTHORITY_TTL_MS = 10 * 60 * 1000;
2025
+ /** Timeout for the `gh pr merge` invocation itself (network op). */
2026
+ const HOST_MERGE_TIMEOUT_MS = 30_000;
2027
+ /**
2028
+ * Placeholder kill-switch epoch. ~/.ashlr/KILL has no versioning of its own
2029
+ * today; this binds every host-merge authority to a fixed protocol
2030
+ * generation so the identity schema is forward-compatible if a real epoch
2031
+ * counter is added later. It is NOT a substitute for the direct
2032
+ * killSwitchOn() checks below — those remain the live, load-bearing gate.
2033
+ */
2034
+ export const HOST_MERGE_KILL_EPOCH_DIGEST = createHash('sha256')
2035
+ .update('ashlr.host-merge-revocation.kill-epoch.v1', 'utf8')
2036
+ .digest('hex');
2037
+ export function hostMergeOperationId(id, suffix) {
2038
+ const candidate = `${id}-${suffix}`;
2039
+ return /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(candidate) ? candidate : null;
2040
+ }
2041
+ /**
2042
+ * Digest binding the currently-configured auto-merge policy surface.
2043
+ * Changing any of these values between prepare and arm changes the
2044
+ * authority's identity (and therefore its authorityId), so a stale grant
2045
+ * prepared under an old policy can never silently carry forward under a
2046
+ * different one — transitionHostMergeRevocation would refuse it as
2047
+ * 'authority-missing' instead.
2048
+ */
2049
+ export function hostMergePolicyEpoch(input) {
2050
+ return createHash('sha256').update(JSON.stringify(input), 'utf8').digest('hex');
2051
+ }
2052
+ export function buildHostMergeRevocationIdentity(input) {
2053
+ return {
2054
+ nameWithOwner: input.nameWithOwner,
2055
+ repositoryId: input.repositoryId,
2056
+ baseRef: input.base,
2057
+ baseOid: input.baseOid,
2058
+ headRef: input.branch,
2059
+ headOid: input.headOid,
2060
+ pullRequestId: input.pullRequestId,
2061
+ pullRequestNumber: input.pullRequestNumber,
2062
+ evidencePackDigest: input.evidencePackDigest,
2063
+ verifierManifestDigest: input.verifierManifestDigest,
2064
+ protectionPolicyDigest: input.protectionPolicyDigest,
2065
+ killEpoch: HOST_MERGE_KILL_EPOCH_DIGEST,
2066
+ policyEpoch: input.policyEpoch,
2067
+ expiresAt: new Date(input.now.getTime() + HOST_MERGE_AUTHORITY_TTL_MS).toISOString(),
2068
+ };
2069
+ }
2070
+ /** Run `gh pr merge` directly (never throws). */
2071
+ function hostMergeGhPrMerge(cwd, args) {
2072
+ try {
2073
+ const stdout = execFileSync('gh', args, {
2074
+ cwd,
2075
+ timeout: HOST_MERGE_TIMEOUT_MS,
2076
+ stdio: 'pipe',
2077
+ encoding: 'utf8',
2078
+ env: {
2079
+ ...process.env,
2080
+ GH_HOST: 'github.com',
2081
+ GH_NO_UPDATE_NOTIFIER: '1',
2082
+ NO_COLOR: '1',
2083
+ },
2084
+ });
2085
+ return { ok: true, detail: (typeof stdout === 'string' ? stdout.trim() : '') || 'gh pr merge exited 0' };
2086
+ }
2087
+ catch (err) {
2088
+ const e = err;
2089
+ const stderr = typeof e.stderr === 'string'
2090
+ ? e.stderr
2091
+ : e.stderr instanceof Buffer
2092
+ ? e.stderr.toString('utf8')
2093
+ : undefined;
2094
+ return { ok: false, detail: (stderr || e.message || 'gh pr merge failed').trim().slice(0, 2000) };
2095
+ }
2096
+ }
2097
+ /**
2098
+ * Attempt the last hop of the remote handoff: the actual `gh pr merge`.
2099
+ * Returns a human-readable mergeNote describing the outcome; NEVER throws.
2100
+ * Fails closed on every ambiguity (kill switch, unreadable/degraded/missing
2101
+ * revocation state, missing identity inputs). The proposal always ends up in
2102
+ * the existing awaiting-host-merge state regardless of this function's
2103
+ * outcome — the pre-existing reconcileRemoteHandoffs() sweep is the durable
2104
+ * source of truth for whatever actually happened on the host, whether we
2105
+ * merged it just now or a human merges it later.
2106
+ *
2107
+ * Exported (with its identity-building helpers) so tests can drive it
2108
+ * directly, and so a test can independently prepare/arm/revoke the EXACT
2109
+ * authority this function will look up (same identity, same clock) to
2110
+ * exercise durable revocation without needing to fabricate an entire
2111
+ * end-to-end autoMergeProposal() gate-chain pass.
2112
+ */
2113
+ export async function attemptHostAutoMerge(input) {
2114
+ const { repo, id, cfg, nameWithOwner, repositoryId, url, base, baseOid, branch, headOid, observedPr, evidencePackDigest, verifierManifestDigest, protectionPolicyDigest, maxRisk, managerGateEnabled, allowSelfMerge, maxFiles, maxLines, } = input;
2115
+ const hostAutoMergeEnabled = autoMergeConfigValue(cfg, 'hostAutoMerge') === true;
2116
+ if (!hostAutoMergeEnabled) {
2117
+ return 'PR opened; host auto-merge is disabled (cfg.foundry.autoMerge.hostAutoMerge !== true)';
2118
+ }
2119
+ if (!verifierManifestDigest) {
2120
+ return 'PR opened; host auto-merge refused because no required-verifier manifest digest is bound to this attempt';
2121
+ }
2122
+ if (killSwitchOn()) {
2123
+ return 'PR opened; host auto-merge refused because the autonomy kill switch is ON';
2124
+ }
2125
+ // Re-derive PR identity fresh — never trust an object handed in from an
2126
+ // earlier step of this same invocation. This is also what makes an
2127
+ // interrupted host merge reconcilable: if a prior, crashed attempt already
2128
+ // got the PR merged, we observe that here and never re-invoke gh pr merge.
2129
+ const freshBeforeAttempt = viewPr(repo, url, { repo: nameWithOwner });
2130
+ if (!freshBeforeAttempt) {
2131
+ return 'PR opened; host auto-merge refused because the PR could not be freshly re-read before merging';
2132
+ }
2133
+ if (freshBeforeAttempt.state === 'MERGED' || Boolean(freshBeforeAttempt.mergedAt)) {
2134
+ return 'PR opened; already merged on the host (observed before any merge attempt this pass)';
2135
+ }
2136
+ if (freshBeforeAttempt.state !== 'OPEN' ||
2137
+ !freshBeforeAttempt.headRefOid || freshBeforeAttempt.headRefOid.toLowerCase() !== headOid.toLowerCase() ||
2138
+ freshBeforeAttempt.baseRefName !== base) {
2139
+ return `PR opened; host auto-merge refused because the live PR state changed (state=${freshBeforeAttempt.state ?? 'unknown'})`;
2140
+ }
2141
+ const pullRequestId = observedPr?.id ?? freshBeforeAttempt.id;
2142
+ const pullRequestNumber = observedPr?.number ?? freshBeforeAttempt.number;
2143
+ if (!pullRequestId || typeof pullRequestNumber !== 'number' || !Number.isSafeInteger(pullRequestNumber)) {
2144
+ return 'PR opened; host auto-merge refused because the PR node id/number could not be resolved';
2145
+ }
2146
+ const policyEpoch = hostMergePolicyEpoch({
2147
+ maxRisk, managerGateEnabled, allowSelfMerge, hostAutoMergeEnabled, maxFiles, maxLines,
2148
+ });
2149
+ const now = input.now ?? new Date();
2150
+ const identity = buildHostMergeRevocationIdentity({
2151
+ nameWithOwner, repositoryId, base, baseOid, branch, headOid,
2152
+ pullRequestId, pullRequestNumber, evidencePackDigest, verifierManifestDigest,
2153
+ protectionPolicyDigest, policyEpoch, now,
2154
+ });
2155
+ const prepareOpId = hostMergeOperationId(id, 'host-merge-prepare');
2156
+ const armOpId = hostMergeOperationId(id, 'host-merge-arm');
2157
+ const consumeOpId = hostMergeOperationId(id, 'host-merge-consume');
2158
+ if (!prepareOpId || !armOpId || !consumeOpId) {
2159
+ return 'PR opened; host auto-merge refused because a durable operation id could not be derived';
2160
+ }
2161
+ const prepared = prepareHostMergeRevocation({ identity, operationId: prepareOpId, now });
2162
+ if (!('receipt' in prepared)) {
2163
+ return `PR opened; host auto-merge refused: revocation prepare ${prepared.status} (${prepared.reason})`;
2164
+ }
2165
+ const armed = transitionHostMergeRevocation({
2166
+ identity,
2167
+ action: 'arm',
2168
+ operationId: armOpId,
2169
+ expectedSequence: prepared.receipt.sequence,
2170
+ expectedReceiptDigest: prepared.receipt.receiptDigest,
2171
+ now,
2172
+ });
2173
+ if (!('receipt' in armed)) {
2174
+ return `PR opened; host auto-merge refused: revocation arm ${armed.status} (${armed.reason})`;
2175
+ }
2176
+ // Immediately before the irreversible act: re-check the kill switch AND do
2177
+ // a FRESH authenticated read of the durable revocation store — never trust
2178
+ // the in-memory `armed` result, since a concurrent revoker could have acted
2179
+ // in between.
2180
+ if (killSwitchOn()) {
2181
+ const killRevokeOpId = hostMergeOperationId(id, 'host-merge-revoke-kill');
2182
+ if (killRevokeOpId) {
2183
+ // Best-effort durable revoke — gives the kill switch real teeth against
2184
+ // this specific authority, even though we abort THIS invocation either way.
2185
+ transitionHostMergeRevocation({
2186
+ identity,
2187
+ action: 'revoke',
2188
+ operationId: killRevokeOpId,
2189
+ expectedSequence: armed.receipt.sequence,
2190
+ expectedReceiptDigest: armed.receipt.receiptDigest,
2191
+ now,
2192
+ });
2193
+ }
2194
+ return 'PR opened; host auto-merge refused because the autonomy kill switch is ON';
2195
+ }
2196
+ const preMergeState = readHostMergeRevocationState(identity);
2197
+ if (preMergeState.state !== 'healthy' || preMergeState.record.phase !== 'armed') {
2198
+ const detail = preMergeState.state === 'healthy'
2199
+ ? `phase=${preMergeState.record.phase}`
2200
+ : preMergeState.state === 'degraded'
2201
+ ? preMergeState.reason
2202
+ : 'authority missing';
2203
+ return `PR opened; host auto-merge refused: revocation authority is not armed (${detail})`;
2204
+ }
2205
+ const mergeMethodCfg = autoMergeConfigValue(cfg, 'mergeMethod');
2206
+ const methodFlag = mergeMethodCfg === 'merge' ? '--merge' : mergeMethodCfg === 'rebase' ? '--rebase' : '--squash';
2207
+ const attempt = hostMergeGhPrMerge(repo, ['pr', 'merge', url, '--repo', nameWithOwner, methodFlag]);
2208
+ const freshAfterAttempt = viewPr(repo, url, { repo: nameWithOwner });
2209
+ const confirmedMerged = Boolean(freshAfterAttempt &&
2210
+ (freshAfterAttempt.state === 'MERGED' || freshAfterAttempt.mergedAt) &&
2211
+ freshAfterAttempt.headRefOid?.toLowerCase() === headOid.toLowerCase());
2212
+ if (confirmedMerged) {
2213
+ transitionHostMergeRevocation({
2214
+ identity,
2215
+ action: 'consume',
2216
+ operationId: consumeOpId,
2217
+ expectedSequence: armed.receipt.sequence,
2218
+ expectedReceiptDigest: armed.receipt.receiptDigest,
2219
+ now,
2220
+ });
2221
+ return `PR opened and merged via host auto-merge (gh pr merge)${attempt.ok ? '' : `; gh reported: ${attempt.detail}`}`;
2222
+ }
2223
+ // Not confirmed merged — leave the authority 'armed' (never consumed, never
2224
+ // revoked) so a later retry via the existing remoteHandoff recovery path
2225
+ // replays the SAME authority instead of minting a new one, and so
2226
+ // reconcileRemoteHandoffs() remains the single source of truth if the host
2227
+ // actually completes the merge after our read.
2228
+ return attempt.ok
2229
+ ? 'PR opened; host auto-merge invoked gh pr merge but the merge was not confirmed on re-read; will reconcile'
2230
+ : `PR opened; host auto-merge refused: ${attempt.detail}`;
2231
+ }
1971
2232
  /** Build the staging branch holding the proposal's diff, off the default branch.
1972
2233
  * Returns the branch name on success, or null + detail on failure. */
1973
2234
  function buildMergeBranch(repo, id, diff, base, expectedBaseHead, finalAuthority) {
@@ -2021,7 +2282,12 @@ function buildMergeBranch(repo, id, diff, base, expectedBaseHead, finalAuthority
2021
2282
  const applyAuthorityFailure = finalAuthority?.();
2022
2283
  if (applyAuthorityFailure)
2023
2284
  throw new Error(applyAuthorityFailure);
2024
- gitRun(tmpDir, ['apply', '--index', patchFile]);
2285
+ // --3way: see the matching comment in verifyProposal()'s apply site —
2286
+ // same rationale, harmless here since expectedBaseHead (when supplied)
2287
+ // already guarantees this worktree's base is byte-identical to what was
2288
+ // verified, so 3-way and plain apply agree; it only helps callers that
2289
+ // omit expectedBaseHead.
2290
+ gitRun(tmpDir, ['apply', '--3way', '--index', patchFile]);
2025
2291
  const commitAuthorityFailure = finalAuthority?.();
2026
2292
  if (commitAuthorityFailure)
2027
2293
  throw new Error(commitAuthorityFailure);
@@ -2467,11 +2733,12 @@ export async function autoMergeProposal(id, cfg, invocation = {}) {
2467
2733
  let maxLinesForEvidence = 0;
2468
2734
  // ── Gate 5.5 (M86): scope cap — only small, fully-bounded diffs auto-merge
2469
2735
  // to main. Defaults are conservative; bounded config keys allow opt-in relaxation.
2470
- // We require risk==='low' here (Gate 5 already enforces ≤maxRisk, but
2471
- // maxRisk could be raised to 'medium' — scope cap applies ONLY when risk is
2472
- // strictly 'low', so even a cfg.maxRisk='medium' run is scoped to low-risk
2473
- // diffs for the size check). File + line counts reuse the same diff parser
2474
- // as classifyRisk so the two gates are consistent.
2736
+ // NOTE (was stale): this used to hard-require risk==='low' regardless of
2737
+ // cfg.maxRisk, silently overriding an operator's higher maxRisk for the
2738
+ // scope check even though Gate 5 above had already cleared it. M295 (below)
2739
+ // fixed that — the size cap now applies up to the CONFIGURED maxRisk, so
2740
+ // the two gates agree. File + line counts reuse the same diff parser as
2741
+ // classifyRisk so the two gates are consistent.
2475
2742
  {
2476
2743
  const autoMergeCfg = cfg.foundry
2477
2744
  ?.autoMerge;
@@ -2593,12 +2860,32 @@ export async function autoMergeProposal(id, cfg, invocation = {}) {
2593
2860
  : 'npm';
2594
2861
  if (scriptsForParity['test:invariants']) {
2595
2862
  // Fast path: run only the invariant suite.
2863
+ //
2864
+ // Bounded retry (merge-pipeline reliability): test:invariants
2865
+ // includes concurrency/timing/disk-cap tests (h3/h5) that are
2866
+ // legitimately flaky under host contention — a second live daemon
2867
+ // tick, another sandboxed agent's test run, or transient disk
2868
+ // pressure can make ONE pass momentarily red for reasons that have
2869
+ // nothing to do with the proposal's diff. Unlike the main verify
2870
+ // path (Gate 6, M281/M293) there is no per-proposal baseline to
2871
+ // diff against here — this check runs against the UNPATCHED repo
2872
+ // tree, so a single flaky run previously meant 3 non-parity
2873
+ // refusals permanently auto-archived an otherwise-good proposal.
2874
+ // A genuinely broken invariant fails EVERY attempt and is still
2875
+ // refused — this only tolerates transient noise, never a real break.
2596
2876
  const invariantCmd = {
2597
2877
  kind: 'test',
2598
2878
  cmd: [pm, 'run', 'test:invariants'],
2599
2879
  };
2600
- const res = await runVerifyCommandAsync(invariantCmd, repo, parityCfg);
2601
- return res.ok;
2880
+ for (let attempt = 1; attempt <= SELF_EVAL_PARITY_RETRY_ATTEMPTS; attempt++) {
2881
+ const res = await runVerifyCommandAsync(invariantCmd, repo, parityCfg);
2882
+ if (res.ok)
2883
+ return true;
2884
+ if (attempt < SELF_EVAL_PARITY_RETRY_ATTEMPTS) {
2885
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, SELF_EVAL_PARITY_RETRY_DELAY_MS));
2886
+ }
2887
+ }
2888
+ return false;
2602
2889
  }
2603
2890
  // Fallback: no targeted script — run all detected verify commands.
2604
2891
  const cmds = detectVerifyCommands(repo, 'merge');
@@ -2608,8 +2895,18 @@ export async function autoMergeProposal(id, cfg, invocation = {}) {
2608
2895
  return true;
2609
2896
  }
2610
2897
  for (const vc of cmds) {
2611
- const res = await runVerifyCommandAsync(vc, repo, parityCfg);
2612
- if (!res.ok && vc.required !== false)
2898
+ const attempts = vc.kind === 'test' ? SELF_EVAL_PARITY_RETRY_ATTEMPTS : 1;
2899
+ let ok = false;
2900
+ for (let attempt = 1; attempt <= attempts; attempt++) {
2901
+ const res = await runVerifyCommandAsync(vc, repo, parityCfg);
2902
+ ok = res.ok;
2903
+ if (ok)
2904
+ break;
2905
+ if (attempt < attempts) {
2906
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, SELF_EVAL_PARITY_RETRY_DELAY_MS));
2907
+ }
2908
+ }
2909
+ if (!ok && vc.required !== false)
2613
2910
  return false;
2614
2911
  }
2615
2912
  return true;
@@ -2773,7 +3070,15 @@ export async function autoMergeProposal(id, cfg, invocation = {}) {
2773
3070
  engine: inlineJudgeEngine,
2774
3071
  model: inlineJudgeEngine,
2775
3072
  verdict: verdict.verdict,
2776
- detail: verdict.wouldMerge && inlineReviewerIndependence.independent ? 'would-merge' : '',
3073
+ // M507: a judgeFailure verdict is a synthetic fail-closed fallback, not
3074
+ // a considered judgment. It must be distinguishable in the ledger from a
3075
+ // real 'review' — conflating them is what made ~22% of judge calls look
3076
+ // like deliberate rejections. Mirrors automerge-pass.ts:243 so BOTH judge
3077
+ // paths record the same way. `wouldMerge` is always false on a failure,
3078
+ // so this never displaces the 'would-merge' marker.
3079
+ detail: verdict.judgeFailure
3080
+ ? (verdict.judgeFailure === 'parse' ? 'judge-parse-failure' : 'judge-network-failure')
3081
+ : (verdict.wouldMerge && inlineReviewerIndependence.independent ? 'would-merge' : ''),
2777
3082
  ...(verdict.semanticEvents ? { semanticEvents: verdict.semanticEvents } : {}),
2778
3083
  ...(inlineAttestation !== undefined ? { judgeAttestation: inlineAttestation } : {}),
2779
3084
  ...(inlineAttestation !== undefined
@@ -3384,9 +3689,37 @@ export async function autoMergeProposal(id, cfg, invocation = {}) {
3384
3689
  else if (!originStable) {
3385
3690
  mergeNote = 'PR opened; host auto-merge refused because canonical GitHub origin changed';
3386
3691
  }
3692
+ else if (!url) {
3693
+ mergeNote = 'PR opened; host auto-merge refused because no canonical PR URL is available';
3694
+ }
3387
3695
  else {
3696
+ // Reaching this branch means every protected-handoff precondition was
3697
+ // confirmed (PR identity, base OID, live protection, canonical origin,
3698
+ // canonical URL). Record that BEFORE attempting the host merge, so the
3699
+ // strictHandoffUncertain reconciliation path below stays correct
3700
+ // regardless of what the merge attempt itself returns.
3388
3701
  finalProtectedHandoffConfirmed = true;
3389
- mergeNote = 'PR opened; host auto-merge is disabled until durable revocation is available';
3702
+ mergeNote = await attemptHostAutoMerge({
3703
+ repo,
3704
+ id,
3705
+ cfg,
3706
+ nameWithOwner: githubOrigin.nameWithOwner,
3707
+ repositoryId: latestProtection.evidence.repositoryId,
3708
+ url,
3709
+ base,
3710
+ baseOid: boundBaseHead,
3711
+ branch,
3712
+ headOid: stagedHead,
3713
+ observedPr,
3714
+ evidencePackDigest,
3715
+ verifierManifestDigest: requiredVerifierManifest?.digest ?? null,
3716
+ protectionPolicyDigest: latestProtection.evidence.policyHash,
3717
+ maxRisk,
3718
+ managerGateEnabled,
3719
+ allowSelfMerge,
3720
+ maxFiles: maxFilesForEvidence,
3721
+ maxLines: maxLinesForEvidence,
3722
+ });
3390
3723
  }
3391
3724
  }
3392
3725
  const durableReason = `${mergeNote}${url ? `: ${url}` : ''} (remote handoff; awaiting host merge)`;