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
@@ -3,11 +3,11 @@
3
3
  /**
4
4
  * shepherd command — one bounded monitor pass over a pull request.
5
5
  *
6
- * `forge shepherd <pr> [--auto-rebase]` reads PR/CI state, takes at most one
7
- * idempotent Tier-A action (rerun a flaky required check), and exits with a
8
- * terminal state. It NEVER merges and NEVER resolves review threads. A
9
- * `--watch` loop, if desired, lives in an external scheduler that re-invokes
10
- * this command on an interval — there is no in-process polling loop here.
6
+ * `forge shepherd <pr> [--auto-rebase]` runs one local review preflight, reads
7
+ * PR/CI state, takes at most one idempotent Tier-A action (rerun a flaky required
8
+ * check), persists bounded deltas/receipts, and exits. It NEVER merges and NEVER
9
+ * resolves review threads. Event-driven waiting belongs to the singleton/watch
10
+ * process; this command does not model-poll or loop in-process.
11
11
  *
12
12
  * `forge shepherd <pr> --bundle --json` instead prints the COMPLETE read-only
13
13
  * PR-state bundle (all unresolved threads, merge state, CI, divergence,
@@ -21,12 +21,13 @@
21
21
  * done IN CODE so an agent gets one payload instead of running those by hand. It
22
22
  * still NEVER merges and NEVER resolves threads.
23
23
  *
24
- * State persists via GitHub PR comments/labels and git only.
24
+ * Public Memory is durable authority; the local journal is compatibility delivery.
25
25
  *
26
26
  * @module commands/shepherd
27
27
  */
28
28
 
29
29
  const { execFileSync } = require('node:child_process');
30
+ const crypto = require('node:crypto');
30
31
 
31
32
  const { runShepherdPass } = require('../pr-shepherd');
32
33
  const { gatherPrBundle } = require('../pr-bundle');
@@ -34,15 +35,26 @@ const { gatherPullSignal, renderPullSummary } = require('../pr-pull');
34
35
  const { PrStateAdapter } = require('../adapters/pr-state-adapter');
35
36
  const { validatePrStateAdapter } = require('../pr-state-validator');
36
37
  const { gatherMonitorSnapshot } = require('../pr-monitor/gather');
38
+ const { privacySafeIdentity } = require('../pr-monitor/flow-monitor');
37
39
  const { pollEvents } = require('../pr-monitor/monitor');
38
40
  const { watchLoop } = require('../pr-monitor/watch');
39
- const { startPrWatcherDetached } = require('../pr-monitor/watch-lifecycle');
41
+ const { startPrWatcherDetached, launchGateComplete } = require('../pr-monitor/watch-lifecycle');
42
+ const watchOwner = require('../pr-monitor/watch-owner');
40
43
  const reconcileExecutor = require('../pr-monitor/reconcile-executor');
41
44
  const monitorJournal = require('../pr-monitor/journal');
45
+ const brokerMod = require('../kernel/broker');
42
46
  const { EVENT_TYPES: T } = require('../pr-monitor/events');
43
47
  const { autoShepherdRailEnabled } = require('./ship');
48
+ const { runLocalReviewPreflight } = require('../pr-monitor/review-preflight');
44
49
 
45
50
  const DEFAULT_RERUN_BUDGET = 3;
51
+ const MAX_MONITOR_ID_LENGTH = 128;
52
+
53
+ function rootMonitorId(owner, repo, pr) {
54
+ const raw = privacySafeIdentity(`pr:${privacySafeIdentity(`${owner}/${repo}`)}:${pr}`);
55
+ if (raw.length <= MAX_MONITOR_ID_LENGTH) return raw;
56
+ return `pr:${crypto.createHash('sha256').update(raw).digest('hex')}`;
57
+ }
46
58
 
47
59
  // windowsHide: true on EVERY spawn here is load-bearing, not cosmetic. The
48
60
  // shepherd watcher runs detached in the background and re-polls every ~60s; on
@@ -51,14 +63,35 @@ const DEFAULT_RERUN_BUDGET = 3;
51
63
  // preferred home is the harness's managed shell (hidden + reaped); a
52
64
  // Forge-spawned detached watcher is the no-session fallback and must be
53
65
  // COMPLETELY silent — no console window, ever. See kernel issue 931e7924.
54
- const defaultGhRunner = (cmd, a) => execFileSync(cmd, a, { encoding: 'utf8', timeout: 30000, windowsHide: true });
66
+ const defaultGhRunner = (cmd, a, options = {}) => execFileSync(cmd, a, {
67
+ encoding: 'utf8', timeout: 30000, windowsHide: true, ...options,
68
+ });
69
+
70
+ function githubRunner(deps, projectRoot) {
71
+ return deps.githubContext?.bound
72
+ ? (_cmd, args, options = {}) => deps.githubContext.runGh(args, {
73
+ encoding: 'utf8', timeout: 30000, windowsHide: true, cwd: projectRoot, ...options,
74
+ })
75
+ : (deps.gh || defaultGhRunner);
76
+ }
77
+
78
+ function resolveTargetRepository(gh = defaultGhRunner) {
79
+ const repoJson = gh('gh', ['repo', 'view', '--json', 'nameWithOwner,parent']);
80
+ const repoInfo = JSON.parse(repoJson || '{}');
81
+ const repository = repoInfo.parent?.nameWithOwner || repoInfo.nameWithOwner;
82
+ if (typeof repository !== 'string'
83
+ || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
84
+ throw new Error('PR base repository is unavailable from the checkout');
85
+ }
86
+ return repository.toLowerCase();
87
+ }
55
88
 
56
89
  /**
57
90
  * Resolve owner/repo and base branch for the shepherd pass.
58
91
  *
59
- * The base branch is read from the PR itself (`gh pr view <pr> --json
60
- * baseRefName`) rather than the current checkout's default branch, so PRs
61
- * targeting `release/*`/`develop` are evaluated against the correct branch.
92
+ * The exact base commit is read from the PR itself (`gh pr view <pr> --json
93
+ * baseRefOid`) rather than a local remote, so fork checkouts and PRs targeting
94
+ * `release/*`/`develop` are evaluated against the provider's reviewed base.
62
95
  * `owner`/`name` come from the repository the PR is queried in — that IS the
63
96
  * base repository. `cwd` (the worktree root) is threaded through so divergence
64
97
  * is computed against the right checkout.
@@ -66,21 +99,30 @@ const defaultGhRunner = (cmd, a) => execFileSync(cmd, a, { encoding: 'utf8', tim
66
99
  * @param {object} deps
67
100
  * @returns {Promise<{ pr: string, owner: string, repo: string, base: string, baseRef: string, cwd?: string }>}
68
101
  */
69
- async function defaultBuildContext({ pr, gh, git, projectRoot }) {
70
- const prJson = gh('gh', ['pr', 'view', String(pr), '--json', 'baseRefName']);
102
+ async function defaultBuildContext({ pr, gh, git, projectRoot, repository }) {
103
+ const targetRepository = repository || resolveTargetRepository(gh);
104
+ const prJson = gh('gh', [
105
+ 'pr', 'view', String(pr), '--repo', targetRepository,
106
+ '--json', 'baseRefName,baseRefOid,headRefOid,state',
107
+ ]);
71
108
  const prInfo = JSON.parse(prJson || '{}');
72
109
  const base = prInfo.baseRefName || 'master';
110
+ if (typeof prInfo.baseRefOid !== 'string' || !/^[0-9a-f]{40}$/i.test(prInfo.baseRefOid)) {
111
+ throw new Error('PR base commit is unavailable from the provider');
112
+ }
113
+ const baseRef = prInfo.baseRefOid.toLowerCase();
114
+ const headSha = typeof prInfo.headRefOid === 'string' && /^[0-9a-f]{40}$/i.test(prInfo.headRefOid)
115
+ ? prInfo.headRefOid.toLowerCase()
116
+ : null;
73
117
 
74
- const repoJson = gh('gh', ['repo', 'view', '--json', 'owner,name']);
75
- const repo = JSON.parse(repoJson || '{}');
76
- const owner = repo.owner?.login || '';
77
- const name = repo.name || '';
118
+ const [owner, name] = targetRepository.split('/');
78
119
 
79
- let baseRemote;
120
+ let localHead = null;
80
121
  try {
81
- baseRemote = git('git', ['remote']).split(/\s+/).filter(Boolean)[0] || 'origin';
82
- } catch (_err) {
83
- baseRemote = 'origin';
122
+ const candidate = git('git', ['rev-parse', 'HEAD']).trim();
123
+ if (/^[0-9a-f]{40}$/i.test(candidate)) localHead = candidate.toLowerCase();
124
+ } catch {
125
+ /* a missing local checkout makes local review preflight not applicable */
84
126
  }
85
127
 
86
128
  return {
@@ -88,7 +130,10 @@ async function defaultBuildContext({ pr, gh, git, projectRoot }) {
88
130
  owner,
89
131
  repo: name,
90
132
  base,
91
- baseRef: `${baseRemote}/${base}`,
133
+ baseRef,
134
+ headSha,
135
+ prState: typeof prInfo.state === 'string' ? prInfo.state.toUpperCase() : null,
136
+ localHead,
92
137
  ...(projectRoot ? { cwd: projectRoot } : {}),
93
138
  };
94
139
  }
@@ -121,6 +166,14 @@ function formatAction(action) {
121
166
  }
122
167
  }
123
168
 
169
+ function writePassSummary(pr, state, reason, actions) {
170
+ const passActions = Array.isArray(actions) ? actions : [];
171
+ const reasonSuffix = reason ? ` — ${reason}` : '';
172
+ process.stdout.write(`Shepherd pass — PR #${pr}: ${state}${reasonSuffix}\n`);
173
+ for (const action of passActions) process.stdout.write(` • ${formatAction(action)}\n`);
174
+ if (!passActions.length) process.stdout.write(' • no actions this pass\n');
175
+ }
176
+
124
177
  /**
125
178
  * Build the DEFAULT `check.failed` enrichment hook for the events pull surface.
126
179
  *
@@ -138,6 +191,14 @@ function formatAction(action) {
138
191
  * @param {object} pullCtx - ctx forwarded to `gatherPull` (owner/repo/base/adapter/runGh/self).
139
192
  * @returns {(records: object[]) => Promise<void>}
140
193
  */
194
+ const UNSAFE_MONITOR_EXCERPT = /(?:gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|sk_(?:live|test)_[a-z0-9]{16,}|sk-[a-z0-9]{16,}|AKIA[0-9A-Z]{16}|(?:api[_-]?key|token|secret|password)\s*[:=]\s*\S{8,}|(?:^|[\\/])(?:users|home|root)[\\/]\S+)/i;
195
+
196
+ function safeMonitorExcerpt(value) {
197
+ return typeof value === 'string' && value.length <= 16_384 && !UNSAFE_MONITOR_EXCERPT.test(value)
198
+ ? value
199
+ : null;
200
+ }
201
+
141
202
  function makeCheckFailureEnricher(pullCtx) {
142
203
  const gatherPull = pullCtx.gatherPull || gatherPullSignal;
143
204
  return async (records) => {
@@ -156,7 +217,8 @@ function makeCheckFailureEnricher(pullCtx) {
156
217
  if (r.type !== T.CHECK_FAILED) continue;
157
218
  const f = byName.get(r.data?.name);
158
219
  if (!f) continue;
159
- if (f.excerpt) r.data.excerpt = f.excerpt;
220
+ const excerpt = safeMonitorExcerpt(f.excerpt);
221
+ if (excerpt) r.data.excerpt = excerpt;
160
222
  if (f.jobUrl) r.data.jobUrl = f.jobUrl;
161
223
  }
162
224
  };
@@ -169,6 +231,20 @@ function parseSince(args) {
169
231
  return 0;
170
232
  }
171
233
 
234
+ function booleanOwnerRunningProbe(probe) {
235
+ if (typeof probe !== 'function') return undefined;
236
+ return async () => {
237
+ const result = await probe();
238
+ if (result && typeof result === 'object' && result.ok === false) {
239
+ const error = new Error('Watcher authority is unavailable');
240
+ error.code = typeof result.reason === 'string' ? result.reason : 'authority_unavailable';
241
+ error.cause = result;
242
+ throw error;
243
+ }
244
+ return result === true;
245
+ };
246
+ }
247
+
172
248
  /**
173
249
  * Build the shared monitor context — journal `dir`, bounded `gather`, and the
174
250
  * default `check.failed` enricher — that BOTH the `events` pull surface and the
@@ -183,21 +259,48 @@ function parseSince(args) {
183
259
  async function buildMonitorContext(pr, projectRoot, deps) {
184
260
  let dir = deps.dir;
185
261
  let gather = deps.gather;
262
+ let repository = typeof deps.repository === 'string' ? deps.repository.toLowerCase() : null;
263
+ let prState = deps.prState || null;
264
+ let ownerAuthority = {
265
+ owner: deps.owner || watchOwner,
266
+ ownerOptions: deps.ownerOptions,
267
+ };
268
+ let authority = deps.store ? {
269
+ store: deps.store,
270
+ monitorId: deps.monitorId,
271
+ ownerRunId: deps.ownerRunId,
272
+ packetId: deps.packetId,
273
+ subjectId: deps.subjectId,
274
+ close: deps.close || (async () => {}),
275
+ } : {};
186
276
  // enrich decorates newly-failed checks with log excerpts before the journal
187
277
  // append. The caller MUST supply the default (not just forward an injected
188
278
  // one), or a plain `forge shepherd events`/`watch` emits bare check.failed events.
189
279
  let enrich = deps.enrich;
190
280
  if (!gather || !dir) {
191
- const gh = deps.gh || defaultGhRunner;
192
- const git = deps.git || gh;
281
+ const gh = githubRunner(deps, projectRoot);
282
+ const git = deps.git || defaultGhRunner;
193
283
  const buildContext = deps.buildContext || defaultBuildContext;
194
- const ctx = await buildContext({ pr, gh, git, projectRoot });
195
- const adapter = deps.adapter || new PrStateAdapter({ gh, git });
284
+ const ctx = await buildContext({ pr, gh, git, projectRoot, repository });
285
+ repository = `${ctx.owner}/${ctx.repo}`.toLowerCase();
286
+ prState = ctx.prState;
287
+ const adapter = deps.adapter || new PrStateAdapter({ gh, git, repository });
196
288
  const validation = validatePrStateAdapter(adapter);
197
289
  if (!validation.valid) {
198
290
  return { error: `Invalid pr-state adapter: ${validation.errors.join('; ')}` };
199
291
  }
200
- dir = dir || monitorJournal.journalDir({ root: projectRoot || process.cwd(), repo: ctx.repo, pr: ctx.pr });
292
+ let gitCommonDir = deps.gitCommonDir;
293
+ if (!gitCommonDir) {
294
+ try {
295
+ const resolveGitCommonDir = deps.resolveGitCommonDir || brokerMod.resolveGitCommonDir;
296
+ gitCommonDir = resolveGitCommonDir(projectRoot || process.cwd(), { warn: () => {} });
297
+ } catch {
298
+ /* unavailable common-dir keeps the legacy per-root journal fallback */
299
+ }
300
+ }
301
+ dir = dir || monitorJournal.journalDir({
302
+ root: projectRoot || process.cwd(), gitCommonDir, repo: ctx.repo, pr: ctx.pr,
303
+ });
201
304
  gather = gather || (() => gatherMonitorSnapshot({ ...ctx, adapter, self: deps.self }));
202
305
  enrich = enrich || makeCheckFailureEnricher({
203
306
  ...ctx,
@@ -206,6 +309,45 @@ async function buildMonitorContext(pr, projectRoot, deps) {
206
309
  runGh: (ghArgs) => gh('gh', ghArgs),
207
310
  gatherPull: deps.gatherPull,
208
311
  });
312
+ if (!deps.store) {
313
+ const buildKernelDeps = deps.buildKernelDeps
314
+ || require('../kernel/cli-broker-factory').buildMigratedKernelIssueDeps;
315
+ const createMonitorStore = deps.createMonitorStore
316
+ || require('../../packages/memory').createMonitorStore;
317
+ let kernel;
318
+ try {
319
+ kernel = await buildKernelDeps({ projectRoot: projectRoot || process.cwd(), gitCommonDir });
320
+ const repositoryIdentity = privacySafeIdentity(`${ctx.owner}/${ctx.repo}`);
321
+ authority = {
322
+ store: createMonitorStore(kernel.kernelDriver),
323
+ monitorId: rootMonitorId(ctx.owner, ctx.repo, ctx.pr),
324
+ ownerRunId: privacySafeIdentity(`shepherd:${repositoryIdentity}:${ctx.pr}`),
325
+ packetId: privacySafeIdentity(`shepherd-packet:${repositoryIdentity}:${ctx.pr}`),
326
+ subjectId: privacySafeIdentity(`${repositoryIdentity}#${ctx.pr}`),
327
+ close: async () => kernel.kernelBroker?.close?.(),
328
+ };
329
+ ownerAuthority = {
330
+ owner: deps.owner || watchOwner,
331
+ ownerOptions: {
332
+ driver: kernel.kernelDriver,
333
+ verifyProviderEvidence: async (evidence, expected) => (
334
+ typeof evidence?.state === 'string'
335
+ && expected.states.includes(evidence.state.toLowerCase())
336
+ ),
337
+ verifyTerminalReceipt: async (receiptId) => {
338
+ const state = await authority.store.readDeliveryState(authority.monitorId);
339
+ return state?.terminal_receipt?.object_id === receiptId;
340
+ },
341
+ },
342
+ };
343
+ } catch (error) {
344
+ try { await kernel?.kernelBroker?.close?.(); } catch { /* fallback must remain available */ }
345
+ if (deps.forceAuthority) {
346
+ return { error: `Durable monitor authority is unavailable: ${error.message}` };
347
+ }
348
+ authority = {};
349
+ }
350
+ }
209
351
  } else if (!enrich && deps.gatherPull) {
210
352
  // Injected gather (tests / programmatic callers) still gets the default
211
353
  // enrichment when a pull-signal source is supplied.
@@ -213,7 +355,19 @@ async function buildMonitorContext(pr, projectRoot, deps) {
213
355
  pr, adapter: deps.adapter, self: deps.self, runGh: deps.runGh, gatherPull: deps.gatherPull,
214
356
  });
215
357
  }
216
- return { dir, gather, enrich };
358
+ if (deps.forceAuthority && !authority.store) {
359
+ return { error: 'Durable monitor authority is unavailable.' };
360
+ }
361
+ return {
362
+ dir,
363
+ gather,
364
+ enrich,
365
+ repo: repository,
366
+ pr: Number(pr),
367
+ prState,
368
+ ...authority,
369
+ ...ownerAuthority,
370
+ };
217
371
  }
218
372
 
219
373
  /**
@@ -238,15 +392,31 @@ async function handleEvents(args, projectRoot, deps = {}) {
238
392
 
239
393
  const built = await buildMonitorContext(pr, projectRoot, deps);
240
394
  if (built.error) return { success: false, error: built.error };
241
- const { dir, gather, enrich } = built;
242
-
243
395
  const poll = deps.pollEvents || pollEvents;
244
- const result = await poll({ dir, gather, since, now: deps.now, watcherRunning: deps.watcherRunning, enrich });
396
+ let result;
397
+ try {
398
+ result = await poll({
399
+ ...built,
400
+ since,
401
+ now: deps.now,
402
+ isOwnerRunning: booleanOwnerRunningProbe(deps.isOwnerRunning),
403
+ });
404
+ } finally {
405
+ await built.close?.();
406
+ }
245
407
  // `output` is the agent-agnostic pull surface: NDJSON, one event per line. The
246
408
  // registry CLI dispatch prints `result.output` (same contract as --pull/--bundle),
247
409
  // so this handler does NOT write to stdout itself (that would double-print).
248
- const output = result.events.map((e) => JSON.stringify(e)).join('\n');
249
- return { success: true, events: result.events, since: result.since, output };
410
+ const overflow = result.overflow === true;
411
+ const overflowRecord = overflow ? {
412
+ type: 'monitor.overflow',
413
+ since: result.since,
414
+ firstAvailableSeq: result.events[0]?.seq ?? null,
415
+ action: 'restart-from-checkpoint',
416
+ } : null;
417
+ const outputRecords = overflowRecord ? [overflowRecord, ...result.events] : result.events;
418
+ const output = outputRecords.map((event) => JSON.stringify(event)).join('\n');
419
+ return { success: true, events: result.events, since: result.since, overflow, output };
250
420
  }
251
421
 
252
422
  /**
@@ -287,9 +457,11 @@ function wireSignals() {
287
457
  * @param {Function} [exec] - gh runner (test injection).
288
458
  * @returns {number[]}
289
459
  */
290
- function defaultListOpenPrs(exec = execFileSync) {
460
+ function defaultListOpenPrs(repository, exec = execFileSync) {
291
461
  try {
292
- const out = exec('gh', ['pr', 'list', '--state', 'open', '--json', 'number', '-q', '.[].number'], {
462
+ const out = exec('gh', [
463
+ 'pr', 'list', '--repo', repository, '--state', 'open', '--json', 'number', '-q', '.[].number',
464
+ ], {
293
465
  encoding: 'utf8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
294
466
  });
295
467
  return String(out)
@@ -313,7 +485,7 @@ function defaultListOpenPrs(exec = execFileSync) {
313
485
  * @param {object} [deps]
314
486
  * @returns {{ success: true, adopted: number[], total: number, reason?: string }}
315
487
  */
316
- function handleAdopt(projectRoot, deps = {}) {
488
+ async function handleAdopt(projectRoot, deps = {}) {
317
489
  const railEnabled = deps.railEnabled || autoShepherdRailEnabled;
318
490
  // Gate BEFORE listing PRs / spawning watchers: a disabled rail must not spawn
319
491
  // detached watchers. No-op result mirrors the fail-open (empty) adoption shape.
@@ -321,48 +493,145 @@ function handleAdopt(projectRoot, deps = {}) {
321
493
  return { success: true, adopted: [], total: 0, reason: 'rail.auto_shepherd disabled' };
322
494
  }
323
495
  const listOpenPrs = deps.listOpenPrs || defaultListOpenPrs;
496
+ const gh = githubRunner(deps, projectRoot);
324
497
  const startWatcher = deps.startWatcher || startPrWatcherDetached;
498
+ const resolveRepository = deps.resolveRepository
499
+ || (() => resolveTargetRepository(gh));
325
500
  let prs;
501
+ let repository;
502
+ let kernel;
503
+ let ownerOptions = deps.ownerOptions;
326
504
  try {
327
- prs = listOpenPrs();
505
+ repository = resolveRepository();
506
+ if (!ownerOptions) {
507
+ const buildKernelDeps = deps.buildKernelDeps
508
+ || require('../kernel/cli-broker-factory').buildMigratedKernelIssueDeps;
509
+ kernel = await buildKernelDeps({ projectRoot: projectRoot || process.cwd() });
510
+ ownerOptions = {
511
+ driver: kernel.kernelDriver,
512
+ verifyProviderEvidence: async (evidence, expected) => (
513
+ evidence?.state === 'OPEN' && expected.states.includes('open')
514
+ ),
515
+ };
516
+ }
517
+ prs = listOpenPrs(repository, gh);
328
518
  } catch {
329
519
  prs = [];
330
520
  }
331
521
  if (!Array.isArray(prs)) prs = [];
332
522
  const adopted = [];
333
- for (const pr of prs) {
334
- try {
335
- const res = startWatcher({ prNumber: pr, cwd: projectRoot });
336
- if (res?.started) adopted.push(pr);
337
- } catch { /* fail-open per PR: one bad arm never blocks the rest */ }
523
+ try {
524
+ for (const pr of prs) {
525
+ try {
526
+ const owner = deps.owner || watchOwner;
527
+ if (!await launchGateComplete(owner, ownerOptions)) continue;
528
+ const res = await startWatcher({
529
+ prNumber: pr,
530
+ cwd: projectRoot,
531
+ repository,
532
+ owner,
533
+ ownerOptions,
534
+ providerEvidence: { state: 'OPEN', repository, pr },
535
+ });
536
+ if (res?.started) adopted.push(pr);
537
+ } catch { /* fail-open per PR: one bad arm never blocks the rest */ }
538
+ }
539
+ } finally {
540
+ try { await kernel?.kernelBroker?.close?.(); } catch { /* best-effort handle cleanup */ }
338
541
  }
339
542
  return { success: true, adopted, total: prs.length };
340
543
  }
341
544
 
545
+ function watchOption(args, name) {
546
+ const index = args.indexOf(name);
547
+ return index < 0 ? null : args[index + 1];
548
+ }
549
+
342
550
  async function handleWatch(args, projectRoot, deps = {}) {
343
551
  const rawArgs = args || [];
344
552
  // `--adopt` (no PR arg): arm a detached watcher for every open PR.
345
553
  if (rawArgs.includes('--adopt')) {
346
554
  return handleAdopt(projectRoot, deps);
347
555
  }
348
- const pr = rawArgs.find((a) => !String(a).startsWith('--') && a !== 'watch');
556
+ const optionIndexes = new Set();
557
+ for (const name of ['--repo', '--generation', '--controller-pid', '--started-at']) {
558
+ const index = rawArgs.indexOf(name);
559
+ if (index >= 0) optionIndexes.add(index + 1);
560
+ }
561
+ const pr = rawArgs.find((a, index) => !String(a).startsWith('--') && a !== 'watch' && !optionIndexes.has(index));
349
562
  if (!pr) {
350
563
  return { success: false, error: 'Usage: forge shepherd watch <pr> | forge shepherd watch --adopt' };
351
564
  }
352
565
 
353
- const built = await buildMonitorContext(pr, projectRoot, deps);
566
+ const repository = watchOption(rawArgs, '--repo');
567
+ const suppliedGeneration = watchOption(rawArgs, '--generation');
568
+ const suppliedControllerPid = Number(watchOption(rawArgs, '--controller-pid'));
569
+ const built = await buildMonitorContext(pr, projectRoot, { ...deps, repository: repository || deps.repository });
354
570
  if (built.error) return { success: false, error: built.error };
355
- const { dir, gather, enrich } = built;
356
-
571
+ const identity = { repo: built.repo, pr: Number(pr) };
572
+ const owner = built.owner || deps.owner || watchOwner;
573
+ const ownerOptions = built.ownerOptions || deps.ownerOptions || {};
574
+ let generation = suppliedGeneration;
575
+ let controllerPid = suppliedGeneration ? suppliedControllerPid : process.pid;
576
+ let reservedHere = false;
577
+ if (suppliedGeneration && (!Number.isSafeInteger(suppliedControllerPid) || suppliedControllerPid <= 0)) {
578
+ await built.close?.();
579
+ return { success: false, error: 'Watcher controller identity is invalid' };
580
+ }
581
+ if (!generation) {
582
+ if (!await launchGateComplete(owner, ownerOptions)) {
583
+ await built.close?.();
584
+ return {
585
+ success: true, started: false, passes: 0, stopped: false,
586
+ reason: 'migration-gate-incomplete',
587
+ };
588
+ }
589
+ let reserved = await owner.reserveStarting(identity, { controllerPid }, ownerOptions);
590
+ if (!reserved?.ok && reserved?.record?.phase === 'complete' && built.prState === 'OPEN') {
591
+ reserved = await owner.reserveReopened(identity, {
592
+ generation: reserved.record.generation,
593
+ expectedReceiptId: reserved.record.terminalReceiptId,
594
+ controllerPid,
595
+ providerEvidence: { state: built.prState },
596
+ }, ownerOptions);
597
+ }
598
+ if (!reserved?.ok || !reserved.record) {
599
+ await built.close?.();
600
+ return {
601
+ success: true,
602
+ started: false,
603
+ passes: 0,
604
+ stopped: false,
605
+ reason: reserved?.reason || 'authority-unavailable',
606
+ };
607
+ }
608
+ generation = reserved.record.generation;
609
+ reservedHere = true;
610
+ }
611
+ if (!await launchGateComplete(owner, ownerOptions)) {
612
+ if (reservedHere) {
613
+ await owner.abortStarting(identity, { generation, controllerPid }, ownerOptions);
614
+ }
615
+ await built.close?.();
616
+ return {
617
+ success: true, started: false, passes: 0, stopped: false,
618
+ reason: 'migration-gate-incomplete',
619
+ };
620
+ }
357
621
  const loop = deps.watchLoop || watchLoop;
358
622
  // Injected signal (tests) suppresses real process handlers; otherwise wire them.
359
623
  const wired = deps.signal ? { signal: deps.signal, cleanup: () => {} } : wireSignals();
360
624
  let result;
361
625
  try {
362
626
  result = await loop({
363
- dir,
364
- gather,
365
- enrich,
627
+ ...built,
628
+ repo: identity.repo,
629
+ pr: identity.pr,
630
+ generation,
631
+ controllerPid,
632
+ pid: process.pid,
633
+ owner,
634
+ ownerOptions,
366
635
  now: deps.now,
367
636
  emit: deps.emit,
368
637
  sleep: deps.sleep,
@@ -371,12 +640,10 @@ async function handleWatch(args, projectRoot, deps = {}) {
371
640
  maxPasses: deps.maxPasses,
372
641
  lockOpts: deps.lockOpts,
373
642
  signal: wired.signal,
374
- watcherRunning: deps.watcherRunning,
375
- writePid: deps.writePid,
376
- removePid: deps.removePid,
377
643
  });
378
644
  } finally {
379
645
  wired.cleanup();
646
+ await built.close?.();
380
647
  }
381
648
 
382
649
  return {
@@ -402,7 +669,12 @@ async function handleWatch(args, projectRoot, deps = {}) {
402
669
  * @returns {Promise<object>} result envelope.
403
670
  */
404
671
  async function handleDaemon(projectRoot, deps = {}) {
405
- const res = await reconcileExecutor.runDaemon(projectRoot, { ...deps });
672
+ const res = await reconcileExecutor.runDaemon(projectRoot, {
673
+ ...deps,
674
+ ...(deps.githubContext?.bound ? {
675
+ runGh: args => deps.githubContext.runGh(args, { cwd: projectRoot, timeout: 30000 }),
676
+ } : {}),
677
+ });
406
678
  if (!res.ok) {
407
679
  // A live foreign daemon owns the lease — this invocation is a clean no-op.
408
680
  return { success: true, started: false, reason: res.reason || 'foreign-lease' };
@@ -410,6 +682,78 @@ async function handleDaemon(projectRoot, deps = {}) {
410
682
  return { success: true, started: true };
411
683
  }
412
684
 
685
+ async function collectConvergenceEvidence({ args, pr, projectRoot, context, adapter, deps }) {
686
+ const built = await buildMonitorContext(pr, projectRoot, {
687
+ ...deps,
688
+ adapter,
689
+ buildContext: async () => context,
690
+ forceAuthority: true,
691
+ });
692
+ if (built.error) throw new Error(built.error);
693
+ try {
694
+ const polled = await (deps.pollEvents || pollEvents)({
695
+ ...built,
696
+ since: parseSince(args),
697
+ now: deps.now,
698
+ isOwnerRunning: async () => false,
699
+ });
700
+ const current = await adapter.readState(pr);
701
+ const exactHead = typeof current?.headSha === 'string' && /^[0-9a-f]{40}$/i.test(current.headSha)
702
+ ? current.headSha.toLowerCase()
703
+ : null;
704
+ const expectedHead = typeof context.headSha === 'string' && /^[0-9a-f]{40}$/i.test(context.headSha)
705
+ ? context.headSha.toLowerCase()
706
+ : null;
707
+ if (!exactHead || exactHead !== expectedHead) {
708
+ throw new Error('PR head changed after the shepherd pass; rerun convergence on the current head');
709
+ }
710
+ // The inline pass is FORCED here precisely because this evidence gates a
711
+ // MERGE_READY handoff. When the migration gate is quarantined/conflicting or
712
+ // the authority is unreadable, pollEvents deliberately suppresses that pass and
713
+ // reports `ranPass: false`; the journal deltas it still returns are stale by
714
+ // construction. Treating them as convergence evidence would hand off a merge
715
+ // that was never authoritatively converged, so fail closed with the reason.
716
+ if (polled.ranPass !== true) {
717
+ throw new Error(`the authoritative convergence pass did not run (${
718
+ polled.migrationGateIncomplete === true
719
+ ? 'watcher migration gate incomplete'
720
+ : (polled.authorityUnavailable === true ? 'watcher authority unavailable' : 'pass suppressed')
721
+ })`);
722
+ }
723
+ return {
724
+ deltas: Array.isArray(polled.events) ? polled.events : [],
725
+ deltaOverflow: polled.overflow === true,
726
+ receiptIds: Array.isArray(polled.receiptIds) ? polled.receiptIds : [],
727
+ continuationPending: polled.continuationPending === true,
728
+ ...(polled.terminalReceiptId ? { terminalReceiptId: polled.terminalReceiptId } : {}),
729
+ exactHead,
730
+ };
731
+ } finally {
732
+ await built.close?.();
733
+ }
734
+ }
735
+
736
+ function convergenceHandoff(state, pr, evidence) {
737
+ if (state === 'MERGE_READY') {
738
+ return {
739
+ next: 'merge',
740
+ command: `forge merge --auto ${pr} --expect-head ${evidence.exactHead || '<exact-head>'} --issue <issue-id>`,
741
+ humanApprovalRequired: true,
742
+ };
743
+ }
744
+ if (state === 'MERGED') {
745
+ return {
746
+ next: 'verify',
747
+ command: 'forge verify',
748
+ terminalReceiptId: evidence.terminalReceiptId || null,
749
+ };
750
+ }
751
+ if (state === 'NEEDS_REVIEW') {
752
+ return { next: 'review', command: 'forge review', resolvesThreads: false };
753
+ }
754
+ return null;
755
+ }
756
+
413
757
  /**
414
758
  * Command handler.
415
759
  *
@@ -441,8 +785,8 @@ async function handler(args, _flags, projectRoot, deps = {}) {
441
785
  return { success: false, error: 'Usage: forge shepherd <pr> [--auto-rebase] [--bundle --json] [--pull --json]' };
442
786
  }
443
787
 
444
- const gh = deps.gh || ((cmd, a) => execFileSync(cmd, a, { encoding: 'utf8', timeout: 30000, windowsHide: true }));
445
- const git = deps.git || gh;
788
+ const gh = githubRunner(deps, projectRoot);
789
+ const git = deps.git || defaultGhRunner;
446
790
  const buildContext = deps.buildContext || defaultBuildContext;
447
791
  const runPass = deps.runPass || runShepherdPass;
448
792
  const gatherBundle = deps.gatherBundle || gatherPrBundle;
@@ -455,7 +799,11 @@ async function handler(args, _flags, projectRoot, deps = {}) {
455
799
 
456
800
  const ctx = await buildContext({ pr, gh, git, projectRoot });
457
801
 
458
- const adapter = deps.adapter || new PrStateAdapter({ gh, git });
802
+ const adapter = deps.adapter || new PrStateAdapter({
803
+ gh,
804
+ git,
805
+ repository: `${ctx.owner}/${ctx.repo}`,
806
+ });
459
807
  const validation = validatePrStateAdapter(adapter);
460
808
  if (!validation.valid) {
461
809
  return { success: false, error: `Invalid pr-state adapter: ${validation.errors.join('; ')}` };
@@ -488,39 +836,206 @@ async function handler(args, _flags, projectRoot, deps = {}) {
488
836
  return { success: true, bundle, output: JSON.stringify(bundle, null, 2) };
489
837
  }
490
838
 
839
+ const runPreflight = deps.runLocalPreflight || runLocalReviewPreflight;
840
+ const preflightHead = ctx.headSha;
841
+ const cleanTree = isWorkingTreeClean(git);
842
+ let localPreflight;
843
+ try {
844
+ localPreflight = await runPreflight({
845
+ projectRoot: projectRoot || process.cwd(),
846
+ base: ctx.base,
847
+ baseRef: ctx.baseRef,
848
+ pr: String(pr),
849
+ expectedHead: ctx.headSha,
850
+ localHead: ctx.localHead,
851
+ cleanTree,
852
+ });
853
+ } catch (error) {
854
+ localPreflight = {
855
+ status: 'FAIL',
856
+ blocking: true,
857
+ providers: {},
858
+ findings: [{ provider: 'local-preflight', detail: error?.message || String(error) }],
859
+ };
860
+ }
861
+
862
+ if (localPreflight.blocking !== true) {
863
+ let postPreflightHead = null;
864
+ try {
865
+ const candidate = git('git', ['rev-parse', 'HEAD']).trim();
866
+ if (/^[0-9a-f]{40}$/i.test(candidate)) postPreflightHead = candidate.toLowerCase();
867
+ } catch {
868
+ /* unreadable mutable checkout fails the fence below */
869
+ }
870
+ const expectedLocalHead = typeof ctx.localHead === 'string' ? ctx.localHead.toLowerCase() : null;
871
+ if (!postPreflightHead || postPreflightHead !== expectedLocalHead || !isWorkingTreeClean(git)) {
872
+ localPreflight = {
873
+ ...localPreflight,
874
+ status: 'INCOMPLETE',
875
+ blocking: true,
876
+ findings: [
877
+ ...(Array.isArray(localPreflight.findings) ? localPreflight.findings : []),
878
+ { provider: 'local-preflight', detail: 'Local checkout changed during local review; rerun on the exact clean PR head.' },
879
+ ],
880
+ };
881
+ }
882
+ }
883
+
491
884
  const result = await runPass({
492
885
  ...ctx,
493
886
  adapter,
494
887
  autoRebase,
495
- cleanTree: autoRebase ? isWorkingTreeClean(git) : false,
888
+ cleanTree: autoRebase ? cleanTree : false,
496
889
  rerunBudget: deps.rerunBudget || DEFAULT_RERUN_BUDGET,
497
890
  rerunsUsed: deps.rerunsUsed || 0,
891
+ dryRun: localPreflight.blocking === true,
498
892
  });
499
893
 
500
- // Surface the pass outcome so the monitor is legible when run interactively or
501
- // tailed by a scheduler (the bounded state machine is otherwise silent).
502
- const passActions = Array.isArray(result.actions) ? result.actions : [];
503
- const reasonSuffix = result.reason ? ` — ${result.reason}` : '';
504
- process.stdout.write(`Shepherd pass — PR #${pr}: ${result.state}${reasonSuffix}\n`);
505
- for (const action of passActions) {
506
- process.stdout.write(` • ${formatAction(action)}\n`);
894
+ const collectEvidence = deps.collectConvergenceEvidence || collectConvergenceEvidence;
895
+ let evidence;
896
+ try {
897
+ const evidenceContext = result.expectedHead ? { ...ctx, headSha: result.expectedHead } : ctx;
898
+ evidence = await collectEvidence({ args, pr, projectRoot, context: evidenceContext, adapter, deps });
899
+ } catch (error) {
900
+ const reason = `Durable convergence evidence is unavailable: ${error.message}`;
901
+ writePassSummary(pr, 'INCOMPLETE', reason, result.actions);
902
+ return {
903
+ success: false,
904
+ state: 'INCOMPLETE',
905
+ remoteState: result.state,
906
+ reason,
907
+ actions: result.actions || [],
908
+ localPreflight,
909
+ deltas: [],
910
+ deltaOverflow: false,
911
+ receiptIds: [],
912
+ };
507
913
  }
508
- if (!passActions.length) {
509
- process.stdout.write(' • no actions this pass\n');
914
+
915
+ const normalizedPreflightHead = typeof preflightHead === 'string' ? preflightHead.toLowerCase() : null;
916
+ const normalizedResultHead = typeof result.expectedHead === 'string' ? result.expectedHead.toLowerCase() : null;
917
+ if (localPreflight.blocking !== true && result.state === 'MERGE_READY'
918
+ && normalizedPreflightHead && normalizedResultHead && normalizedResultHead !== normalizedPreflightHead) {
919
+ return {
920
+ success: false,
921
+ state: 'INCOMPLETE',
922
+ remoteState: result.state,
923
+ reason: 'Local preflight head changed; rerun convergence on the current head',
924
+ actions: result.actions || [],
925
+ localPreflight,
926
+ deltas: evidence.deltas,
927
+ deltaOverflow: evidence.deltaOverflow,
928
+ receiptIds: evidence.receiptIds,
929
+ };
510
930
  }
511
931
 
932
+ let confirmedResult = result;
933
+ if (localPreflight.blocking !== true && result.state === 'MERGE_READY'
934
+ && evidence.continuationPending !== true) {
935
+ const confirmation = await runPass({
936
+ ...ctx,
937
+ adapter,
938
+ autoRebase: false,
939
+ cleanTree: false,
940
+ rerunBudget: deps.rerunBudget || DEFAULT_RERUN_BUDGET,
941
+ rerunsUsed: deps.rerunsUsed || 0,
942
+ dryRun: true,
943
+ });
944
+ confirmedResult = {
945
+ ...confirmation,
946
+ actions: [...(result.actions || []), ...(confirmation.actions || [])],
947
+ };
948
+ const confirmedHead = typeof confirmedResult.expectedHead === 'string'
949
+ ? confirmedResult.expectedHead.toLowerCase()
950
+ : null;
951
+ if (confirmedResult.state === 'MERGE_READY' && confirmedHead !== evidence.exactHead) {
952
+ return {
953
+ success: false,
954
+ state: 'INCOMPLETE',
955
+ remoteState: confirmedResult.state,
956
+ reason: 'PR head changed while confirming mutable merge-readiness evidence; rerun convergence on the current head',
957
+ actions: confirmedResult.actions,
958
+ localPreflight,
959
+ deltas: evidence.deltas,
960
+ deltaOverflow: evidence.deltaOverflow,
961
+ receiptIds: evidence.receiptIds,
962
+ };
963
+ }
964
+ if (confirmedResult.state !== result.state) {
965
+ try {
966
+ const evidenceContext = confirmedResult.expectedHead
967
+ ? { ...ctx, headSha: confirmedResult.expectedHead }
968
+ : ctx;
969
+ evidence = await collectEvidence({ args, pr, projectRoot, context: evidenceContext, adapter, deps });
970
+ } catch (error) {
971
+ return {
972
+ success: false,
973
+ state: 'INCOMPLETE',
974
+ remoteState: confirmedResult.state,
975
+ reason: `Confirmed convergence evidence is unavailable: ${error.message}`,
976
+ actions: confirmedResult.actions,
977
+ localPreflight,
978
+ deltas: evidence.deltas,
979
+ deltaOverflow: evidence.deltaOverflow,
980
+ receiptIds: evidence.receiptIds,
981
+ };
982
+ }
983
+ if (confirmedResult.state === 'MERGED' && !evidence.terminalReceiptId) {
984
+ return {
985
+ success: false,
986
+ state: 'INCOMPLETE',
987
+ remoteState: confirmedResult.state,
988
+ reason: 'Confirmed merged state has no durable terminal receipt',
989
+ actions: confirmedResult.actions,
990
+ localPreflight,
991
+ deltas: evidence.deltas,
992
+ deltaOverflow: evidence.deltaOverflow,
993
+ receiptIds: evidence.receiptIds,
994
+ };
995
+ }
996
+ }
997
+ }
998
+
999
+ const effectiveState = evidence.continuationPending === true
1000
+ ? 'PENDING'
1001
+ : localPreflight.blocking === true && confirmedResult.state === 'MERGE_READY'
1002
+ ? (localPreflight.status === 'INCOMPLETE' ? 'INCOMPLETE' : 'PENDING')
1003
+ : confirmedResult.state;
1004
+ const effectiveReason = effectiveState !== confirmedResult.state
1005
+ ? (evidence.continuationPending === true
1006
+ ? 'Durable convergence work remains; run another bounded shepherd pass.'
1007
+ : effectiveState === 'INCOMPLETE'
1008
+ ? 'Local review preflight is incomplete; merge readiness is not established.'
1009
+ : 'Local review preflight is blocking merge readiness.')
1010
+ : confirmedResult.reason;
1011
+
1012
+ // Surface the pass outcome so the monitor is legible when run interactively or
1013
+ // tailed by a scheduler (the bounded state machine is otherwise silent).
1014
+ writePassSummary(pr, effectiveState, effectiveReason, confirmedResult.actions);
1015
+
512
1016
  return {
513
- success: result.state !== 'HARD_STOP',
514
- state: result.state,
515
- reason: result.reason,
516
- actions: result.actions || [],
517
- ...(result.authClass ? { authClass: result.authClass } : {}),
518
- ...(result.retryAfter ? { retryAfter: result.retryAfter } : {}),
1017
+ success: effectiveState !== 'HARD_STOP' && effectiveState !== 'INCOMPLETE',
1018
+ state: effectiveState,
1019
+ ...(effectiveState !== confirmedResult.state ? { remoteState: confirmedResult.state } : {}),
1020
+ reason: effectiveReason,
1021
+ actions: confirmedResult.actions || [],
1022
+ localPreflight,
1023
+ deltas: evidence.deltas,
1024
+ deltaOverflow: evidence.deltaOverflow,
1025
+ receiptIds: evidence.receiptIds,
1026
+ continuationPending: evidence.continuationPending === true,
1027
+ ...(evidence.terminalReceiptId ? { terminalReceiptId: evidence.terminalReceiptId } : {}),
1028
+ ...(convergenceHandoff(effectiveState, pr, evidence)
1029
+ ? { handoff: convergenceHandoff(effectiveState, pr, evidence) }
1030
+ : {}),
1031
+ ...(confirmedResult.authClass ? { authClass: confirmedResult.authClass } : {}),
1032
+ ...(confirmedResult.retryAfter ? { retryAfter: confirmedResult.retryAfter } : {}),
519
1033
  };
520
1034
  }
521
1035
 
522
1036
  module.exports = {
523
1037
  name: 'shepherd',
1038
+ githubAuth: true,
524
1039
  description: 'Run one bounded monitor pass over a PR (rerun flaky checks, escalate, hand off — never merges)',
525
1040
  usage: 'Usage: forge shepherd <pr> [--auto-rebase] [--bundle --json] [--pull --json] | forge shepherd events <pr> --since <seq> [--json] | forge shepherd watch <pr> | forge shepherd watch --adopt',
526
1041
  handler,
@@ -529,6 +1044,9 @@ module.exports = {
529
1044
  buildMonitorContext,
530
1045
  makeCheckFailureEnricher,
531
1046
  parseSince,
1047
+ collectConvergenceEvidence,
1048
+ convergenceHandoff,
532
1049
  defaultBuildContext,
1050
+ defaultListOpenPrs,
533
1051
  isWorkingTreeClean,
534
1052
  };