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
@@ -15,29 +15,23 @@
15
15
  * and only pushed if the next tick did not recover/green it — the JOURNAL still
16
16
  * records everything (authority), so `events --since` replay stays complete.
17
17
  *
18
- * Every external effect is injectable (emit/sleep/rng/now/gather/watcherRunning/
19
- * writePid/removePid, plus `maxPasses`/`signal`) so tests exercise the loop with a
20
- * fake clock and fake gh, never touching live GitHub or waiting 60s.
18
+ * Every external effect is injectable (emit/sleep/rng/now/gather/owner authority,
19
+ * plus `maxPasses`/`signal`) so tests exercise the loop with a fake clock and fake
20
+ * provider, never touching live GitHub or waiting 60s.
21
21
  *
22
22
  * @module pr-monitor/watch
23
23
  */
24
24
 
25
25
  const { runMonitorPass } = require('./monitor');
26
26
  const journal = require('./journal');
27
+ const watchOwner = require('./watch-owner');
27
28
  const { EVENT_TYPES: T } = require('./events');
28
29
 
29
30
  const DEFAULT_INTERVAL_MS = 60000;
31
+ const DEFAULT_OWNER_HEARTBEAT_MS = 60000;
30
32
  const JITTER_RATIO = 0.2;
31
33
  const TERMINAL_TYPES = new Set([T.PR_MERGED, T.PR_CLOSED]);
32
34
 
33
- // In-process ownership of the watcher slot, keyed by journal dir. The journal
34
- // PID file serializes claims ACROSS processes, but it cannot make claims within
35
- // ONE process idempotent: after writePid, `watcherRunning(dir)` returns false
36
- // because the PID equals process.pid, so a second same-process watchLoop() would
37
- // also claim, run in parallel, and (on either exit) remove the shared PID while
38
- // the other is still active. This Set closes that gap for the loop lifetime.
39
- const ACTIVE_DIRS = new Set();
40
-
41
35
  /** Default push sink: one NDJSON line per event to stdout. */
42
36
  function defaultEmit(event) {
43
37
  process.stdout.write(`${JSON.stringify(event)}\n`);
@@ -78,30 +72,6 @@ function isAborted(signal) {
78
72
  return Boolean(signal && signal.aborted);
79
73
  }
80
74
 
81
- /**
82
- * Atomically claim the watcher slot for this PR. The `watcherRunning` check and
83
- * the `writePid` write run TOGETHER inside the cross-process journal lock (so two
84
- * starts in DIFFERENT processes can never both pass), AND the in-process
85
- * `ACTIVE_DIRS` guard rejects a SECOND concurrent `watchLoop()` in the SAME
86
- * process (which the PID file alone misses, since our own PID reads as "not
87
- * running"). Returns true only when the slot was newly claimed; the caller must
88
- * release with `releaseClaim(dir)` on loop exit. The (possibly injected)
89
- * `watcherRunning`/`writePid` primitives are honored, so test injection works.
90
- */
91
- function defaultClaim(dir, { watcherRunning, writePid, lockOpts }) {
92
- return journal.withJournalLock(dir, () => {
93
- if (ACTIVE_DIRS.has(dir) || watcherRunning(dir)) return false;
94
- writePid(dir);
95
- ACTIVE_DIRS.add(dir);
96
- return true;
97
- }, lockOpts);
98
- }
99
-
100
- /** Release the in-process watcher claim for `dir` (idempotent). */
101
- function releaseClaim(dir) {
102
- ACTIVE_DIRS.delete(dir);
103
- }
104
-
105
75
  /**
106
76
  * Split a pass's events into: held-candidate failures (by check name), terminal
107
77
  * events (pr.merged/closed), and everything else (emitted immediately).
@@ -144,6 +114,20 @@ function confirmHeld(pending, contra, emit) {
144
114
  }
145
115
  }
146
116
 
117
+ function emitTerminalPass(state, events, emit) {
118
+ const { failures, terminal, others } = partition(events);
119
+ const contra = contradictions(events);
120
+ const heldNames = new Set(state.pending.keys());
121
+ confirmHeld(state.pending, contra, emit);
122
+ state.pending = new Map();
123
+ for (const event of others) {
124
+ if (event.type === T.CHECK_RECOVERED && heldNames.has(checkName(event))) continue;
125
+ emit(event);
126
+ }
127
+ for (const failure of failures.values()) emit(failure);
128
+ for (const event of terminal) emit(event);
129
+ }
130
+
147
131
  /**
148
132
  * Run ONE watch tick: a bounded monitor pass, then stream its events with the
149
133
  * 2-pass flap debounce applied. Mutates `state.pending` (the held failures).
@@ -151,10 +135,18 @@ function confirmHeld(pending, contra, emit) {
151
135
  * @returns {Promise<{ terminal: boolean }>} terminal=true → stop the loop.
152
136
  */
153
137
  async function runWatchPass(state, ctx, emit) {
154
- const { events } = await runMonitorPass({
155
- dir: ctx.dir, gather: ctx.gather, now: ctx.now, enrich: ctx.enrich, lockOpts: ctx.lockOpts,
138
+ const monitorPass = ctx.runMonitorPass || runMonitorPass;
139
+ const { events: rawEvents, terminalReceiptId, journalCursor } = await monitorPass({
140
+ ...ctx, dir: ctx.dir, gather: ctx.gather, now: ctx.now, enrich: ctx.enrich, lockOpts: ctx.lockOpts,
156
141
  });
142
+ const events = ctx.dir && Number.isSafeInteger(journalCursor)
143
+ ? journal.readEventsSince(ctx.dir, journalCursor)
144
+ : rawEvents;
157
145
  const { failures, terminal, others } = partition(events);
146
+ if (terminalReceiptId) {
147
+ emitTerminalPass(state, events, emit);
148
+ return { terminal: true, terminalReceiptId };
149
+ }
158
150
  const contra = contradictions(events);
159
151
  const heldNames = new Set(state.pending.keys());
160
152
 
@@ -168,7 +160,7 @@ async function runWatchPass(state, ctx, emit) {
168
160
  if (terminal.length) {
169
161
  for (const failure of failures.values()) emit(failure);
170
162
  for (const t of terminal) emit(t);
171
- return { terminal: true };
163
+ return { terminal: false, receiptUnavailable: true };
172
164
  }
173
165
  state.pending = failures;
174
166
  return { terminal: false };
@@ -192,51 +184,130 @@ async function runWatchPass(state, ctx, emit) {
192
184
  * @returns {Promise<{ started: boolean, passes: number, stopped: boolean, reason?: string }>}
193
185
  */
194
186
  async function watchLoop(ctx) {
195
- const { dir } = ctx;
196
187
  const emit = ctx.emit || defaultEmit;
197
188
  const sleep = ctx.sleep || defaultSleep;
198
189
  const rng = ctx.rng || Math.random;
199
190
  const intervalMs = ctx.intervalMs ?? DEFAULT_INTERVAL_MS;
191
+ const ownerHeartbeatMs = ctx.ownerHeartbeatMs ?? DEFAULT_OWNER_HEARTBEAT_MS;
200
192
  const maxPasses = ctx.maxPasses ?? Infinity;
201
- const watcherRunning = ctx.watcherRunning || journal.watcherRunning;
202
- const writePid = ctx.writePid || journal.writePid;
203
- const removePid = ctx.removePid || journal.removePid;
204
- const claim = ctx.claim || (() => defaultClaim(dir, { watcherRunning, writePid, lockOpts: ctx.lockOpts }));
205
-
206
- // Atomic idempotent claim: a live OTHER watcher already owns this PR → no-op.
207
- // The check+write are serialized under the journal lock so concurrent starts
208
- // cannot both succeed (see defaultClaim).
209
- const claimed = await claim(dir);
210
- if (!claimed) {
211
- return { started: false, passes: 0, stopped: false, reason: 'watcher-already-running' };
193
+ const owner = ctx.owner || watchOwner;
194
+ const ownerOptions = ctx.ownerOptions || {};
195
+ const identity = { repo: String(ctx.repo || '').toLowerCase(), pr: Number(ctx.pr) };
196
+ const generation = ctx.generation;
197
+ const controllerPid = Number(ctx.controllerPid);
198
+ const pid = Number(ctx.pid ?? process.pid);
199
+
200
+ // Exact generation binding makes a duplicate or stale child a clean no-op.
201
+ if (!Number.isSafeInteger(identity.pr) || identity.pr <= 0 || !identity.repo
202
+ || !generation || !Number.isSafeInteger(controllerPid) || controllerPid <= 0
203
+ || !Number.isSafeInteger(pid) || pid <= 0 || typeof owner.bindRunning !== 'function') {
204
+ return { started: false, passes: 0, stopped: false, reason: 'authority-unavailable' };
205
+ }
206
+ const bound = await owner.bindRunning(identity, { generation, controllerPid, pid }, ownerOptions);
207
+ if (!bound?.ok) {
208
+ return { started: false, passes: 0, stopped: false, reason: bound?.reason || 'bind-failed' };
212
209
  }
213
210
 
214
211
  const state = { pending: new Map() };
215
212
  let passes = 0;
216
213
  let stopped = false;
214
+ let terminalPending = false;
215
+ let reason;
216
+ let heartbeatFailure;
217
+ let heartbeatInFlight = null;
218
+ const checkpointOwner = () => {
219
+ if (heartbeatInFlight) return heartbeatInFlight;
220
+ heartbeatInFlight = (async () => {
221
+ try {
222
+ const checkpoint = await owner.heartbeat(identity, { generation, pid }, ownerOptions);
223
+ if (!checkpoint?.ok) heartbeatFailure = checkpoint?.reason || 'owner-lost';
224
+ } catch {
225
+ heartbeatFailure = 'owner-lost';
226
+ } finally {
227
+ heartbeatInFlight = null;
228
+ }
229
+ })();
230
+ return heartbeatInFlight;
231
+ };
232
+ const scheduleHeartbeat = ctx.setInterval || setInterval;
233
+ const cancelHeartbeat = ctx.clearInterval || clearInterval;
234
+ const heartbeatTimer = scheduleHeartbeat(() => { void checkpointOwner(); }, ownerHeartbeatMs);
235
+ heartbeatTimer?.unref?.();
217
236
  try {
218
237
  while (passes < maxPasses && !isAborted(ctx.signal)) {
238
+ if (heartbeatFailure) {
239
+ reason = heartbeatFailure;
240
+ break;
241
+ }
242
+ const current = await owner.readOwner(identity, ownerOptions);
243
+ if (!current?.ok || current.record?.generation !== generation || current.record?.watcherPid !== pid) {
244
+ reason = current?.reason || 'owner-lost';
245
+ break;
246
+ }
247
+ if (current.record.phase === 'stop_requested') {
248
+ stopped = true;
249
+ reason = 'stop-requested';
250
+ break;
251
+ }
252
+ if (current.record.phase === 'terminal_pending') {
253
+ stopped = true;
254
+ terminalPending = true;
255
+ reason = 'terminal-pending';
256
+ break;
257
+ }
258
+ if (current.record.phase !== 'running') {
259
+ reason = 'owner-not-running';
260
+ break;
261
+ }
219
262
  const result = await runWatchPass(state, ctx, emit);
220
263
  passes += 1;
221
- if (result.terminal) { stopped = true; break; }
264
+ if (result.terminal) {
265
+ // Keep the exact running row for dead-owner receipt recovery if this write fails.
266
+ terminalPending = true;
267
+ const recorded = await owner.recordTerminal(identity, {
268
+ generation, pid, terminalReceiptId: result.terminalReceiptId,
269
+ }, ownerOptions);
270
+ if (recorded?.ok) {
271
+ stopped = true;
272
+ terminalPending = true;
273
+ reason = 'terminal-pending';
274
+ break;
275
+ }
276
+ reason = recorded?.reason || 'receipt-unavailable';
277
+ }
278
+ await checkpointOwner();
279
+ if (heartbeatFailure) {
280
+ reason = heartbeatFailure;
281
+ break;
282
+ }
222
283
  if (passes >= maxPasses || isAborted(ctx.signal)) break;
223
284
  await sleep(jitter(intervalMs, rng), ctx.signal);
224
285
  }
225
286
  } finally {
226
- // Release BOTH claims: the in-process slot (for a same-process restart) and
227
- // the cross-process PID file. Ordered so the in-process guard clears first.
228
- releaseClaim(dir);
229
- removePid(dir);
287
+ cancelHeartbeat(heartbeatTimer);
288
+ await heartbeatInFlight;
289
+ // Terminal ownership remains until a later dead-PID completion transaction;
290
+ // every other process exit releases its exact generation cooperatively.
291
+ if (!terminalPending) {
292
+ try {
293
+ const stopRequested = typeof owner.requestStop === 'function'
294
+ ? await owner.requestStop(identity, { generation, pid }, ownerOptions)
295
+ : { ok: true };
296
+ if (stopRequested?.ok) {
297
+ await owner.releaseNonterminal(identity, { generation, pid }, ownerOptions);
298
+ }
299
+ } catch {
300
+ // Preserve the owner row for a later dead-watcher recovery transaction.
301
+ }
302
+ }
230
303
  }
231
- return { started: true, passes, stopped };
304
+ return { started: true, passes, stopped, ...(reason ? { reason } : {}) };
232
305
  }
233
306
 
234
307
  module.exports = {
235
308
  watchLoop,
236
309
  runWatchPass,
237
310
  defaultSleep,
238
- defaultClaim,
239
- releaseClaim,
240
311
  jitter,
241
312
  partition,
242
313
  contradictions,
package/lib/pr-pull.js CHANGED
@@ -43,6 +43,11 @@ const LOG_FETCH_MULTIPLIER = 3;
43
43
  // re-review or a late reviewer comment inside the window means the PR is still
44
44
  // in flight. Mirrors the merge-rules `settle_min` idea (default 10 minutes).
45
45
  const DEFAULT_SETTLE_WINDOW_MS = 600000;
46
+ const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
47
+
48
+ function normalizeHeadSha(value) {
49
+ return typeof value === 'string' && FULL_HEAD_SHA.test(value) ? value.toLowerCase() : null;
50
+ }
46
51
 
47
52
  /**
48
53
  * Bot logins whose review threads ARE actionable fixes (their comments tell you
@@ -320,7 +325,7 @@ function isSkipped(check) {
320
325
  /** A check still in flight (not green, not failed) — e.g. IN_PROGRESS/QUEUED or
321
326
  * a status context with no conclusion yet. */
322
327
  function isPending(check) {
323
- return !isGreen(check) && !isFailed(check);
328
+ return !isSkipped(check) && !isGreen(check) && !isFailed(check);
324
329
  }
325
330
 
326
331
  /**
@@ -865,12 +870,14 @@ function buildVerdictContext(input) {
865
870
  /** Rank 1: UNKNOWN (fail-closed) — unreadable input/required set, missing head
866
871
  * oid, or a torn read (head moved during the gather). */
867
872
  function rankUnknown(v) {
868
- const torn = Boolean(v.headOidStart) && Boolean(v.headOidEnd) && v.headOidStart !== v.headOidEnd;
873
+ const startValid = normalizeHeadSha(v.headOidStart) !== null;
874
+ const endValid = normalizeHeadSha(v.headOidEnd) !== null;
875
+ const torn = startValid && endValid && v.headOidStart !== v.headOidEnd;
869
876
  v.evidence.tornRead = torn;
870
877
  if (v.requiredClass.unreadable && !v.evidence.unreadable.includes('requiredChecks')) {
871
878
  v.evidence.unreadable.push('requiredChecks');
872
879
  }
873
- if (!v.headOidStart && !v.evidence.unreadable.includes('headOid')) {
880
+ if ((!startValid || !endValid) && !v.evidence.unreadable.includes('headOid')) {
874
881
  v.evidence.unreadable.push('headOid');
875
882
  }
876
883
  return (v.evidence.unreadable.length > 0 || torn) ? 'UNKNOWN' : null;
@@ -1139,10 +1146,13 @@ async function gatherPrSnapshot(ctx) {
1139
1146
 
1140
1147
  // readState is the one REQUIRED read — if it throws, the whole gather fails.
1141
1148
  const state = await adapter.readState(pr);
1149
+ const headOidStart = normalizeHeadSha(state.headSha);
1150
+ if (!headOidStart) unreadable.push('headOid');
1151
+ if (state.providerEvidenceReadable === false) unreadable.push('providerEvidence');
1152
+
1142
1153
  const requiredSet = await safeRead(
1143
1154
  'requiredChecks',
1144
- // `pr` lets the adapter fall back to the rollup `isRequired` set when branch
1145
- // protection is unreadable (the Actions token can't read protection).
1155
+ // `pr` is included for diagnostic rollup evidence when protection is unreadable.
1146
1156
  () => adapter.readRequiredChecks({ owner, repo, base, pr }),
1147
1157
  { degraded, fallback: null },
1148
1158
  );
@@ -1169,16 +1179,26 @@ async function gatherPrSnapshot(ctx) {
1169
1179
  // Branch divergence (behind base → needs an update/rebase).
1170
1180
  const div = await safeRead(
1171
1181
  'divergence',
1172
- () => callIfPresent(adapter, 'readDivergence', { baseRef: ctx.baseRef, cwd }, { behind: 0 }),
1173
- { degraded, fallback: { behind: 0 } },
1182
+ () => callIfPresent(
1183
+ adapter,
1184
+ 'readDivergence',
1185
+ { baseRef: ctx.baseRef, cwd, headRef: state.headSha },
1186
+ { behind: 0 },
1187
+ ),
1188
+ { degraded, unreadable, fallback: { behind: 0 } },
1174
1189
  );
1175
1190
  const behind = div.behind || 0;
1176
1191
 
1177
1192
  // Predicted merge conflicts (which files) — optional adapter capability.
1178
1193
  const conflicts = await safeRead(
1179
1194
  'conflicts',
1180
- () => callIfPresent(adapter, 'detectConflicts', { baseRef: ctx.baseRef, cwd }, null),
1181
- { degraded, fallback: null },
1195
+ () => callIfPresent(
1196
+ adapter,
1197
+ 'detectConflicts',
1198
+ { baseRef: ctx.baseRef, cwd, headRef: state.headSha },
1199
+ null,
1200
+ ),
1201
+ { degraded, unreadable, fallback: null },
1182
1202
  );
1183
1203
 
1184
1204
  // Bot STATUS COMMENTS (Sonar/Vercel/Netlify/Codecov quality-gate + deployment
@@ -1214,7 +1234,8 @@ async function gatherPrSnapshot(ctx) {
1214
1234
  const headPushKnown = headPushTimeMs != null;
1215
1235
  // Torn-read guard: re-read the head oid at the END of the gather.
1216
1236
  const endState = await safeRead('headEnd', () => adapter.readState(pr), { degraded, unreadable });
1217
- const headOidEnd = endState ? endState.headSha : state.headSha;
1237
+ const headOidEnd = normalizeHeadSha(endState && endState.headSha);
1238
+ if (!headOidEnd && !unreadable.includes('headOid')) unreadable.push('headOid');
1218
1239
 
1219
1240
  // Actionable NON-HUMAN direct comments (mechanism-detected, suppression-list
1220
1241
  // filtered) and an AUTHOR-AGNOSTIC unresolved-thread count — both independent
@@ -1224,7 +1245,7 @@ async function gatherPrSnapshot(ctx) {
1224
1245
  const unresolvedThreadCount = countUnresolvedThreads(threads);
1225
1246
 
1226
1247
  const { verdict, evidence } = computeVerdict({
1227
- headOidStart: state.headSha,
1248
+ headOidStart,
1228
1249
  headOidEnd,
1229
1250
  mergeStateStatus: state.mergeStateStatus,
1230
1251
  mergeable: state.mergeable,
@@ -1245,9 +1266,7 @@ async function gatherPrSnapshot(ctx) {
1245
1266
  unreadable,
1246
1267
  });
1247
1268
 
1248
- // Record which source answered the required-checks read (`protection` |
1249
- // `rollup` | null) so the source is visible in the verdict evidence — the
1250
- // rollup source is the CI path where branch protection is unreadable.
1269
+ // Record the authoritative required-check source (`protection` or null).
1251
1270
  evidence.requiredSource = adapter.lastRequiredSource || null;
1252
1271
 
1253
1272
  return {
@@ -32,6 +32,7 @@ const { fenceUntrusted } = require('./untrusted-content');
32
32
 
33
33
  /** Non-erroring terminal states a pass can settle into. */
34
34
  const TERMINAL_STATES = ['MERGE_READY', 'ESCALATE', 'PENDING', 'MERGED', 'CLOSED', 'NEEDS_REVIEW'];
35
+ const MERGE_READY_PROVIDER_STATES = new Set(['CLEAN', 'HAS_HOOKS', 'UNSTABLE']);
35
36
 
36
37
  // Review threads are classified BY MECHANISM, not by a bot-name list: a GitHub
37
38
  // review THREAD is opened by a reviewer (a human OR any bot) and stays open until
@@ -72,9 +73,11 @@ function actionableComments(threads, _self) {
72
73
  );
73
74
  }
74
75
 
75
- const SUCCESS_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
76
+ const SUCCESS_CONCLUSIONS = new Set(['SUCCESS']);
76
77
 
77
78
  function isGreen(check) {
79
+ if (!Object.prototype.hasOwnProperty.call(check || {}, 'status')
80
+ || String(check.status || '').toUpperCase() !== 'COMPLETED') return false;
78
81
  const c = String(check.conclusion || '').toUpperCase();
79
82
  return SUCCESS_CONCLUSIONS.has(c);
80
83
  }
@@ -208,7 +211,7 @@ async function handleFailedRequired({
208
211
  * @returns {Promise<object>} decision envelope.
209
212
  */
210
213
  async function handleBehindBase({
211
- behind, autoRebase, cleanTree, adapter, baseRef, headUnchanged, actions,
214
+ behind, autoRebase, cleanTree, adapter, baseRef, expectedHead, headUnchanged, actions,
212
215
  }) {
213
216
  if (!autoRebase) {
214
217
  return result('ESCALATE', {
@@ -237,10 +240,22 @@ async function handleBehindBase({
237
240
  });
238
241
  }
239
242
  try {
240
- await adapter.rebaseOntoBase({ baseRef });
243
+ const rebaseResult = await adapter.rebaseOntoBase({ baseRef, expectedHead });
244
+ const verifiedHead = rebaseResult?.previousHead === expectedHead
245
+ && typeof rebaseResult?.headSha === 'string'
246
+ && /^[0-9a-f]{40}$/i.test(rebaseResult.headSha)
247
+ ? rebaseResult.headSha.toLowerCase()
248
+ : null;
249
+ if (!verifiedHead) {
250
+ return result('ESCALATE', {
251
+ actions,
252
+ reason: 'Rebase completed without a lease-bound post-action head; refusing convergence handoff.',
253
+ });
254
+ }
241
255
  actions.push({ type: 'rebase', baseRef });
242
256
  return result('PENDING', {
243
257
  actions,
258
+ expectedHead: verifiedHead,
244
259
  reason: 'Rebased onto base and force-pushed with lease. Awaiting CI on the next scheduled pass.',
245
260
  });
246
261
  } catch (error) {
@@ -388,13 +403,18 @@ async function runShepherdPass(ctx) {
388
403
 
389
404
  // Every read goes through the auth guard so 401/403-scope/rate-limit map to
390
405
  // the documented PENDING/HARD_STOP states instead of escaping as a generic
391
- // failure. Non-auth errors still propagate.
406
+ // failure. Other read errors are surfaced below as ESCALATE results.
392
407
 
393
408
  // --- Read PR/CI state FIRST so a merged/closed PR is detected as terminal
394
409
  // even when the branch-protection (required-checks) read would fail with an
395
410
  // auth/scope error — the scheduler must always get the terminal signal for a
396
411
  // landed/closed PR. ---
397
- const stateRead = await guardAuth(() => adapter.readState(pr), actions);
412
+ let stateRead;
413
+ try {
414
+ stateRead = await guardAuth(() => adapter.readState(pr), actions);
415
+ } catch (err) {
416
+ return result('ESCALATE', { actions, reason: `PR provider state is unreadable: ${err.message}` });
417
+ }
398
418
  if (stateRead.outcome) return stateRead.outcome;
399
419
  const startState = stateRead.value;
400
420
  const startSha = startState.headSha;
@@ -402,13 +422,24 @@ async function runShepherdPass(ctx) {
402
422
  // --- Lifecycle: a merged/closed PR is terminal. ---
403
423
  const lifecycle = lifecycleOutcome(startState.state, actions);
404
424
  if (lifecycle) return lifecycle;
425
+ if (startState.providerEvidenceReadable === false) {
426
+ return result('ESCALATE', {
427
+ actions,
428
+ reason: 'PR lifecycle, draft, head, or check-rollup evidence is malformed or incomplete.',
429
+ });
430
+ }
405
431
 
406
432
  // --- Required-checks set (only matters for non-terminal PRs); this is where
407
433
  // auth/scope fails fast. ---
408
- const requiredRead = await guardAuth(
409
- () => adapter.readRequiredChecks({ owner, repo, base }),
410
- actions,
411
- );
434
+ let requiredRead;
435
+ try {
436
+ requiredRead = await guardAuth(
437
+ () => adapter.readRequiredChecks({ owner, repo, base }),
438
+ actions,
439
+ );
440
+ } catch (err) {
441
+ return result('ESCALATE', { actions, reason: `Required-check policy is unreadable: ${err.message}` });
442
+ }
412
443
  if (requiredRead.outcome) return requiredRead.outcome;
413
444
  const required = requiredRead.value;
414
445
 
@@ -422,7 +453,7 @@ async function runShepherdPass(ctx) {
422
453
  }
423
454
 
424
455
  const divergenceRead = await guardAuth(
425
- () => adapter.readDivergence({ baseRef, cwd }),
456
+ () => adapter.readDivergence({ baseRef, cwd, headRef: startSha }),
426
457
  actions,
427
458
  );
428
459
  if (divergenceRead.outcome) return divergenceRead.outcome;
@@ -459,7 +490,8 @@ async function runShepherdPass(ctx) {
459
490
  // never rebase — force autoRebase off so the branch-behind path only escalates. ---
460
491
  if (behind > 0) {
461
492
  return handleBehindBase({
462
- behind, autoRebase: dryRun ? false : autoRebase, cleanTree, adapter, baseRef, headUnchanged, actions,
493
+ behind, autoRebase: dryRun ? false : autoRebase, cleanTree, adapter, baseRef,
494
+ expectedHead: startSha, headUnchanged, actions,
463
495
  });
464
496
  }
465
497
 
@@ -472,8 +504,16 @@ async function runShepherdPass(ctx) {
472
504
 
473
505
  // --- Terminal: all required green + not behind → merge-ready handoff. ---
474
506
  if (allRequiredGreen) {
507
+ const providerMergeState = String(startState.mergeStateStatus || '').toUpperCase();
508
+ if (!MERGE_READY_PROVIDER_STATES.has(providerMergeState)) {
509
+ return result('ESCALATE', {
510
+ actions,
511
+ reason: `Provider merge state ${providerMergeState || 'UNKNOWN'} does not authorize merge readiness.`,
512
+ });
513
+ }
475
514
  return result('MERGE_READY', {
476
515
  actions,
516
+ expectedHead: startSha,
477
517
  reason: 'All required checks are green and the branch is up to date. Handing off to the human to merge in the GitHub UI — the shepherd never merges.',
478
518
  });
479
519
  }
@@ -17,10 +17,11 @@
17
17
  */
18
18
 
19
19
  const path = require('node:path');
20
+ const crypto = require('node:crypto');
20
21
  const { spawnSync } = require('node:child_process');
21
- const { execFileSync } = require('node:child_process');
22
22
  const nodeFs = require('node:fs');
23
- const { getAffectedTestFiles } = require('../commands/test');
23
+ const { getTestCandidatesForChangedFile } = require('../commands/test');
24
+ const { selectDocAssertingTests } = require('../doc-assertions');
24
25
 
25
26
  const IS_WINDOWS = process.platform === 'win32';
26
27
 
@@ -37,6 +38,36 @@ function statusSummary(result, okMsg, failMsg) {
37
38
  : { ok: false, summary: failMsg };
38
39
  }
39
40
 
41
+ function compareStringsByCodePoint(left, right) {
42
+ if (left < right) return -1;
43
+ if (left > right) return 1;
44
+ return 0;
45
+ }
46
+
47
+ function normalizeChangedFiles(files) {
48
+ if (!Array.isArray(files)) return Object.freeze([]);
49
+ return Object.freeze([...new Set(
50
+ files.map((file) => String(file).replaceAll('\\', '/')).filter((file) => file !== ''),
51
+ )].sort(compareStringsByCodePoint));
52
+ }
53
+
54
+ function fingerprintChangedFiles(files) {
55
+ return crypto.createHash('sha256').update(JSON.stringify(files)).digest('hex');
56
+ }
57
+
58
+ function selectAffectedTests(projectRoot, changedFiles, fs = nodeFs) {
59
+ const tests = new Set();
60
+ for (const file of changedFiles) {
61
+ for (const candidate of getTestCandidatesForChangedFile(file)) {
62
+ if (fs.existsSync(path.join(projectRoot, candidate))) tests.add(candidate);
63
+ }
64
+ }
65
+ for (const candidate of selectDocAssertingTests(changedFiles, projectRoot, fs)) {
66
+ tests.add(candidate);
67
+ }
68
+ return [...tests].sort(compareStringsByCodePoint);
69
+ }
70
+
40
71
  /**
41
72
  * Gate 1 — ESLint on the change blast radius (or the whole tree with --all).
42
73
  * `files === null` means "lint everything" (`eslint .`).
@@ -152,38 +183,50 @@ function runSonar(files, { projectRoot, spawn = spawnSync } = {}) {
152
183
  */
153
184
  function runAffectedTests({
154
185
  projectRoot,
155
- changedFiles: _changedFiles,
186
+ changedFiles,
156
187
  spawn = spawnSync,
157
188
  resolveTests,
158
189
  } = {}) {
190
+ const inputs = Array.isArray(changedFiles) && Object.isFrozen(changedFiles)
191
+ ? changedFiles
192
+ : normalizeChangedFiles(changedFiles);
193
+ const inputFingerprint = fingerprintChangedFiles(inputs);
159
194
  const resolver = typeof resolveTests === 'function'
160
195
  ? resolveTests
161
- // strict:true so a failed `git diff` THROWS here instead of returning [] —
162
- // otherwise a git error would masquerade as "no affected tests" (fast-lane
163
- // green), a fail-OPEN. The catch below turns that throw into a closed gate.
164
- : () => getAffectedTestFiles(projectRoot, execFileSync, nodeFs, { strict: true });
196
+ : (files) => selectAffectedTests(projectRoot, files);
165
197
  let targets;
166
198
  try {
167
- targets = resolver() || [];
199
+ targets = resolver(inputs) || [];
168
200
  } catch (err) {
169
201
  // A resolver ERROR must NOT masquerade as "no affected tests" (green).
170
202
  // We could not determine what to run, so fail closed — never a vacuous pass.
171
203
  const reason = err && err.message ? err.message : String(err);
172
- return { ok: false, summary: `affected-test resolution failed — fail-closed (${reason})` };
204
+ return {
205
+ ok: false,
206
+ summary: `affected-test resolution failed — fail-closed (${reason})`,
207
+ inputFingerprint,
208
+ };
173
209
  }
174
210
  if (targets.length === 0) {
175
- return { ok: true, summary: 'no affected tests resolved (fast lane)' };
211
+ return {
212
+ ok: true,
213
+ summary: 'no affected tests resolved (fast lane)',
214
+ inputFingerprint,
215
+ };
176
216
  }
177
217
  const result = spawn(
178
218
  'bun',
179
219
  ['test', '--timeout', '15000', ...targets],
180
220
  { cwd: projectRoot, stdio: 'inherit', shell: IS_WINDOWS },
181
221
  );
182
- return statusSummary(
183
- result,
184
- `${targets.length} affected test file(s) passed`,
185
- 'affected tests failed',
186
- );
222
+ return {
223
+ ...statusSummary(
224
+ result,
225
+ `${targets.length} affected test file(s) passed`,
226
+ 'affected tests failed',
227
+ ),
228
+ inputFingerprint,
229
+ };
187
230
  }
188
231
 
189
232
  /**
@@ -198,15 +241,18 @@ function runAffectedTests({
198
241
  * @returns {{ name: string, run: () => Promise<{ok:boolean, summary?:string}> }[]}
199
242
  */
200
243
  function buildGates({ projectRoot, changedFiles = [], runAll = false, deps = {} }) {
244
+ const normalizedChangedFiles = normalizeChangedFiles(changedFiles);
245
+ const inputFingerprint = fingerprintChangedFiles(normalizedChangedFiles);
201
246
  const eslint = deps.eslint || ((files) => runEslint(files, { projectRoot }));
202
247
  const structural = deps.structural || (() => runStructural({ projectRoot }));
203
248
  const sonar = deps.sonar || ((files) => runSonar(files, { projectRoot }));
204
- const affected = deps.affected || (() => runAffectedTests({ projectRoot, changedFiles }));
249
+ const affected = deps.affected
250
+ || ((files) => runAffectedTests({ projectRoot, changedFiles: files }));
205
251
 
206
252
  // Under --all, scope BOTH lint and sonar to the whole tree (null). Otherwise
207
253
  // sonar would receive changedFiles=[] and report a vacuous "no changed files"
208
254
  // pass while lint scanned everything — a fail-open hole on the remedy path.
209
- const scanTargets = runAll ? null : changedFiles;
255
+ const scanTargets = runAll ? null : normalizedChangedFiles;
210
256
 
211
257
  // Affected-tests maps from the change set, which is empty under --all. Running
212
258
  // the WHOLE suite here defeats preflight's fast purpose (and hangs on Windows),
@@ -216,8 +262,9 @@ function buildGates({ projectRoot, changedFiles = [], runAll = false, deps = {}
216
262
  ok: true,
217
263
  skipped: true,
218
264
  summary: 'whole-tree mode (--all): affected-test mapping N/A — run the full suite (CI does)',
265
+ inputFingerprint,
219
266
  })
220
- : async () => affected();
267
+ : async () => affected(normalizedChangedFiles);
221
268
 
222
269
  return [
223
270
  { name: 'lint', run: async () => eslint(scanTargets) },