forge-workflow 0.1.0-beta.3 → 0.1.0-beta.5

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 +14 -7
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +6 -2
  4. package/bin/forge-cmd.js +21 -1
  5. package/bin/forge.js +16 -369
  6. package/docs/INDEX.md +1 -1
  7. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  8. package/docs/guides/MIGRATION.md +4 -4
  9. package/docs/guides/SETUP.md +16 -16
  10. package/docs/reference/COMMANDS.md +9 -4
  11. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  12. package/docs/reference/RELEASE.md +5 -3
  13. package/docs/reference/TOOLCHAIN.md +8 -0
  14. package/docs/reference/protected-state-surfaces.md +4 -4
  15. package/docs/reference/shepherd.md +117 -17
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/greptile-review-adapter.js +1 -1
  19. package/lib/adapters/pr-state-adapter.js +397 -100
  20. package/lib/agents-config.js +5 -0
  21. package/lib/audit-evidence.js +71 -110
  22. package/lib/capped-jsonl-log.js +236 -0
  23. package/lib/commands/_issue.js +31 -46
  24. package/lib/commands/_manifest.js +1 -1
  25. package/lib/commands/_registry.js +2 -2
  26. package/lib/commands/_resolve-command-opts.js +36 -29
  27. package/lib/commands/claim.js +2 -4
  28. package/lib/commands/clean.js +196 -32
  29. package/lib/commands/dev.js +4 -33
  30. package/lib/commands/hooks.js +358 -13
  31. package/lib/commands/insights.js +8 -3
  32. package/lib/commands/merge.js +600 -40
  33. package/lib/commands/plan.js +23 -115
  34. package/lib/commands/pr.js +1 -1
  35. package/lib/commands/preflight.js +11 -2
  36. package/lib/commands/prime.js +23 -3
  37. package/lib/commands/push.js +41 -51
  38. package/lib/commands/recall.js +60 -16
  39. package/lib/commands/recap.js +6 -1
  40. package/lib/commands/release.js +18 -4
  41. package/lib/commands/serve.js +5 -2
  42. package/lib/commands/setup.js +191 -95
  43. package/lib/commands/shepherd.js +49 -4
  44. package/lib/commands/ship.js +22 -23
  45. package/lib/commands/skill.js +383 -0
  46. package/lib/commands/status.js +54 -33
  47. package/lib/commands/test.js +56 -34
  48. package/lib/commands/worktree.js +247 -43
  49. package/lib/core/runtime-graph.js +89 -15
  50. package/lib/doc-assertions.js +297 -0
  51. package/lib/existing-tdd-gate.js +253 -0
  52. package/lib/forge-context.js +1 -4
  53. package/lib/forge-issues.js +64 -491
  54. package/lib/git-defaults.js +56 -0
  55. package/lib/harness-capability-matrix.js +5 -5
  56. package/lib/hook-renderer.js +147 -16
  57. package/lib/insights.js +96 -80
  58. package/lib/issue-backend.js +42 -3
  59. package/lib/kernel/backing-issue.js +14 -2
  60. package/lib/kernel/broker.js +44 -0
  61. package/lib/kernel/cli-broker-factory.js +12 -1
  62. package/lib/kernel/close-on-merge.js +154 -0
  63. package/lib/kernel/fs-class.js +42 -25
  64. package/lib/kernel/migrations.js +30 -2
  65. package/lib/kernel/schema.js +35 -0
  66. package/lib/kernel/sqlite-driver.js +292 -18
  67. package/lib/lefthook-wiring.js +21 -1
  68. package/lib/memory/router.js +16 -1
  69. package/lib/memory-digest.js +47 -15
  70. package/lib/memory-recall-events.js +145 -0
  71. package/lib/memory-recall.js +212 -0
  72. package/lib/merge-rules.js +8 -4
  73. package/lib/npm-publish-workflow.js +272 -0
  74. package/lib/orientation.js +371 -49
  75. package/lib/plugin-catalog.js +14 -4
  76. package/lib/pr-bundle.js +9 -6
  77. package/lib/pr-monitor/journal.js +18 -2
  78. package/lib/pr-monitor/reconcile-executor.js +842 -0
  79. package/lib/pr-monitor/reconcile-tick.js +138 -0
  80. package/lib/pr-monitor/reconcile.js +0 -0
  81. package/lib/pr-monitor/render-summary.js +196 -0
  82. package/lib/pr-monitor/shepherd-lease.js +252 -0
  83. package/lib/pr-monitor/watch-lifecycle.js +14 -2
  84. package/lib/pr-pull.js +98 -24
  85. package/lib/pr-shepherd.js +34 -8
  86. package/lib/preflight/gates.js +65 -18
  87. package/lib/preflight/runner.js +5 -0
  88. package/lib/project-memory.js +40 -0
  89. package/lib/protected-state-authority.js +305 -0
  90. package/lib/protected-state-surfaces.js +64 -44
  91. package/lib/release-readiness.js +51 -4
  92. package/lib/rules-sync.js +4 -0
  93. package/lib/runtime-health.js +15 -46
  94. package/lib/shell-utils.js +1 -1
  95. package/lib/skill-eval.js +750 -0
  96. package/lib/skills-sync.js +6 -3
  97. package/lib/smart-merge.js +28 -4
  98. package/lib/status/identity.js +46 -0
  99. package/lib/status/presenter.js +0 -35
  100. package/lib/status/snapshot.js +11 -16
  101. package/lib/symlink-utils.js +74 -26
  102. package/lib/upgrade-safety.js +47 -9
  103. package/lib/using-forge.js +328 -0
  104. package/lib/workflow/enforce-stage.js +5 -5
  105. package/lib/workflow/state-manager.js +23 -23
  106. package/package.json +6 -7
  107. package/rules/using-forge.md +24 -0
  108. package/scripts/doc-asserting-tests.js +158 -0
  109. package/scripts/forge-team/index.sh +0 -5
  110. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  111. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  112. package/scripts/lib/behavioral-eval-runner.js +310 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  114. package/scripts/lib/eval-evidence.js +328 -0
  115. package/scripts/lib/eval-runner.js +81 -41
  116. package/scripts/lib/immutable-eval-corpus.js +309 -0
  117. package/scripts/lib/promotion-evidence-loader.js +94 -0
  118. package/scripts/lib/promotion-scorecard.js +314 -0
  119. package/scripts/npm-release-receipt.js +134 -0
  120. package/scripts/process-tree.js +761 -0
  121. package/scripts/protected-state-check.js +47 -22
  122. package/scripts/run-command-eval.js +29 -1
  123. package/scripts/sync-d20-audit.js +172 -0
  124. package/scripts/test-full-suite.js +249 -37
  125. package/scripts/test.js +184 -44
  126. package/skills/claim-safety/SKILL.md +4 -0
  127. package/skills/claim-safety/evals/scorecard.json +41 -0
  128. package/skills/coverage.json +83 -0
  129. package/skills/dev/SKILL.md +4 -0
  130. package/skills/dev/evals/scorecard.json +41 -0
  131. package/skills/gates/SKILL.md +80 -0
  132. package/skills/gates/evals/evals.json +38 -0
  133. package/skills/gates/evals/scorecard.json +41 -0
  134. package/skills/hermes-forge/SKILL.md +1 -0
  135. package/skills/hermes-forge/evals/scorecard.json +41 -0
  136. package/skills/issue-basics/SKILL.md +1 -0
  137. package/skills/issue-basics/evals/scorecard.json +41 -0
  138. package/skills/kernel/SKILL.md +38 -0
  139. package/skills/kernel/evals/scorecard.json +41 -0
  140. package/skills/memory/SKILL.md +16 -1
  141. package/skills/memory/evals/scorecard.json +41 -0
  142. package/skills/parallel-deep-research/SKILL.md +1 -0
  143. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  144. package/skills/plan/SKILL.md +6 -0
  145. package/skills/plan/evals/scorecard.json +41 -0
  146. package/skills/portability/SKILL.md +47 -0
  147. package/skills/portability/evals/evals.json +34 -0
  148. package/skills/portability/evals/scorecard.json +41 -0
  149. package/skills/research/SKILL.md +1 -0
  150. package/skills/research/evals/scorecard.json +41 -0
  151. package/skills/review/SKILL.md +10 -11
  152. package/skills/review/evals/scorecard.json +41 -0
  153. package/skills/rollback/SKILL.md +5 -11
  154. package/skills/rollback/evals/scorecard.json +41 -0
  155. package/skills/setup/SKILL.md +91 -0
  156. package/skills/setup/evals/evals.json +42 -0
  157. package/skills/setup/evals/scorecard.json +41 -0
  158. package/skills/shepherd/SKILL.md +84 -38
  159. package/skills/shepherd/evals/evals.json +21 -9
  160. package/skills/shepherd/evals/scorecard.json +41 -0
  161. package/skills/ship/SKILL.md +10 -12
  162. package/skills/ship/evals/scorecard.json +41 -0
  163. package/skills/smith/SKILL.md +8 -0
  164. package/skills/smith/evals/scorecard.json +41 -0
  165. package/skills/sonarcloud/SKILL.md +1 -0
  166. package/skills/sonarcloud/evals/scorecard.json +41 -0
  167. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  168. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  169. package/skills/status/SKILL.md +3 -0
  170. package/skills/status/evals/scorecard.json +41 -0
  171. package/skills/triage-ready/SKILL.md +2 -0
  172. package/skills/triage-ready/evals/scorecard.json +41 -0
  173. package/skills/using-forge/SKILL.md +104 -0
  174. package/skills/using-forge/evals/scorecard.json +41 -0
  175. package/skills/validate/SKILL.md +4 -0
  176. package/skills/validate/evals/scorecard.json +41 -0
  177. package/skills/verify/SKILL.md +4 -0
  178. package/skills/verify/evals/scorecard.json +41 -0
  179. package/skills/worktree/SKILL.md +92 -0
  180. package/skills/worktree/evals/evals.json +38 -0
  181. package/skills/worktree/evals/scorecard.json +41 -0
  182. package/lib/adapters/beads-issue-adapter.js +0 -127
  183. package/lib/beads-nudge.js +0 -91
  184. package/lib/beads-setup.js +0 -538
  185. package/lib/beads-sync-scaffold.js +0 -189
  186. package/lib/commands/board.js +0 -64
  187. package/lib/pat-setup.js +0 -207
  188. package/lib/pr-monitor/render-sticky.js +0 -192
  189. package/lib/pr-monitor/upsert-sticky.js +0 -169
  190. package/lib/status/beads-snapshot.js +0 -145
  191. package/scripts/beads-context.sh +0 -577
  192. package/scripts/beads-migrate-to-dolt.sh +0 -7
  193. package/scripts/beads-upgrade-smoke.sh +0 -284
  194. package/scripts/forge-team/lib/dashboard.sh +0 -316
  195. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
package/lib/pr-pull.js CHANGED
@@ -28,7 +28,7 @@
28
28
  * @module pr-pull
29
29
  */
30
30
 
31
- const { isFailed, isGreen, runShepherdPass } = require('./pr-shepherd');
31
+ const { isFailed, isGreen } = require('./pr-shepherd');
32
32
  const { fenceUntrusted } = require('./untrusted-content');
33
33
 
34
34
  /** Token caps that keep the payload bounded regardless of PR size. */
@@ -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
  /**
@@ -680,9 +685,12 @@ function buildPullPayload({
680
685
 
681
686
  return {
682
687
  ...prField(pr),
688
+ // DEPRECATED: `state` is the legacy decision-pass ladder, kept only for
689
+ // back-compat. It is now a PROJECTION of `verdict` (via verdictToLegacyState),
690
+ // never independently computed. Consumers MUST read `verdict` — the single,
691
+ // trustworthy, fail-closed merge vocabulary (never false-clean). Full removal
692
+ // of `state` is tracked as a follow-up kernel issue.
683
693
  state,
684
- // `verdict` is the trustworthy, fail-closed merge signal (never false-clean);
685
- // `state` remains the legacy decision-pass state for back-compat.
686
694
  ...(verdict ? { verdict } : {}),
687
695
  ...(evidence ? { evidence } : {}),
688
696
  summary,
@@ -862,12 +870,14 @@ function buildVerdictContext(input) {
862
870
  /** Rank 1: UNKNOWN (fail-closed) — unreadable input/required set, missing head
863
871
  * oid, or a torn read (head moved during the gather). */
864
872
  function rankUnknown(v) {
865
- 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;
866
876
  v.evidence.tornRead = torn;
867
877
  if (v.requiredClass.unreadable && !v.evidence.unreadable.includes('requiredChecks')) {
868
878
  v.evidence.unreadable.push('requiredChecks');
869
879
  }
870
- if (!v.headOidStart && !v.evidence.unreadable.includes('headOid')) {
880
+ if ((!startValid || !endValid) && !v.evidence.unreadable.includes('headOid')) {
871
881
  v.evidence.unreadable.push('headOid');
872
882
  }
873
883
  return (v.evidence.unreadable.length > 0 || torn) ? 'UNKNOWN' : null;
@@ -993,6 +1003,52 @@ function verdictLabel(verdict) {
993
1003
  /** The full label reconcile set — one label per canonical verdict. */
994
1004
  const VERDICT_LABELS = MERGE_VERDICTS.map((v) => `${VERDICT_LABEL_PREFIX}${v.toLowerCase()}`);
995
1005
 
1006
+ /**
1007
+ * The ONE place that maps the canonical 7-enum `verdict` onto the DEPRECATED
1008
+ * legacy `runShepherdPass` ladder (`pr-shepherd.js:34`). `verdict` is the single
1009
+ * consumer-facing vocabulary; the payload's `state` field is a back-compat
1010
+ * PROJECTION of the verdict through this map — never independently computed — so
1011
+ * the two can never disagree. Unknown/empty input fails closed to `UNKNOWN`.
1012
+ *
1013
+ * @param {string} verdict - a canonical MERGE_VERDICTS value.
1014
+ * @returns {string} the deprecated legacy state.
1015
+ */
1016
+ function verdictToLegacyState(verdict) {
1017
+ const v = String(verdict || '').toUpperCase();
1018
+ switch (v) {
1019
+ case 'CLEAN-MERGEABLE': return 'MERGE_READY';
1020
+ case 'REVIEW-PENDING': return 'NEEDS_REVIEW';
1021
+ case 'BLOCKED-THREADS': return 'NEEDS_REVIEW';
1022
+ case 'BLOCKED-CHECKS': return 'PENDING';
1023
+ // BEHIND maps to ESCALATE, not PENDING: the legacy runShepherdPass escalated a
1024
+ // behind head (handleBehindBase with --auto-rebase OFF, the default) so a human
1025
+ // rebases or opts into auto-rebase. Mapping it to PENDING would tell a legacy
1026
+ // `state` consumer to keep waiting on a PR that actually needs action.
1027
+ case 'BEHIND': return 'ESCALATE';
1028
+ case 'BLOCKED-CONFLICT': return 'ESCALATE';
1029
+ default: return 'UNKNOWN';
1030
+ }
1031
+ }
1032
+
1033
+ /**
1034
+ * The back-compat legacy `state` for the `--pull` payload. A terminal GitHub
1035
+ * lifecycle (`MERGED`/`CLOSED`) wins over the merge verdict: the old dry-run
1036
+ * `runShepherdPass` hit `lifecycleOutcome` first (`pr-shepherd.js:403`) and
1037
+ * emitted `MERGED`/`CLOSED` so a legacy consumer stops polling / stops prompting a
1038
+ * human to merge an already-landed PR. `computeVerdict`'s 7-enum has no terminal
1039
+ * value, so we read the lifecycle (`adapter.readState().state`) here; only an open
1040
+ * PR falls through to the verdict projection.
1041
+ *
1042
+ * @param {string} verdict - a canonical MERGE_VERDICTS value.
1043
+ * @param {string} [prLifecycleState] - readState().state: OPEN | MERGED | CLOSED.
1044
+ * @returns {string} the deprecated legacy state.
1045
+ */
1046
+ function legacyStateFor(verdict, prLifecycleState) {
1047
+ const lifecycle = String(prLifecycleState || '').toUpperCase();
1048
+ if (lifecycle === 'MERGED' || lifecycle === 'CLOSED') return lifecycle;
1049
+ return verdictToLegacyState(verdict);
1050
+ }
1051
+
996
1052
  /**
997
1053
  * Run an optional read and SURFACE any failure instead of swallowing it: on
998
1054
  * throw, record `{ source, error }` into `degraded` (and optionally mark `source`
@@ -1067,7 +1123,6 @@ function gatherFailureExcerpts(runGh, checks, requiredSet, { maxFailures, maxExc
1067
1123
  * @param {string} [ctx.self] - shepherd's own login
1068
1124
  * @param {object} ctx.adapter - validated pr-state adapter
1069
1125
  * @param {(args: string[]) => string} ctx.runGh - injected `gh` runner (args → stdout)
1070
- * @param {Function} [ctx.runPass] - decision pass (default runShepherdPass), injectable for tests
1071
1126
  * @param {number} [ctx.maxFailures]
1072
1127
  * @param {number} [ctx.maxThreads]
1073
1128
  * @param {number} [ctx.maxExcerptLines]
@@ -1091,9 +1146,14 @@ async function gatherPrSnapshot(ctx) {
1091
1146
 
1092
1147
  // readState is the one REQUIRED read — if it throws, the whole gather fails.
1093
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
+
1094
1153
  const requiredSet = await safeRead(
1095
1154
  'requiredChecks',
1096
- () => adapter.readRequiredChecks({ owner, repo, base }),
1155
+ // `pr` is included for diagnostic rollup evidence when protection is unreadable.
1156
+ () => adapter.readRequiredChecks({ owner, repo, base, pr }),
1097
1157
  { degraded, fallback: null },
1098
1158
  );
1099
1159
 
@@ -1119,16 +1179,26 @@ async function gatherPrSnapshot(ctx) {
1119
1179
  // Branch divergence (behind base → needs an update/rebase).
1120
1180
  const div = await safeRead(
1121
1181
  'divergence',
1122
- () => callIfPresent(adapter, 'readDivergence', { baseRef: ctx.baseRef, cwd }, { behind: 0 }),
1123
- { 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 } },
1124
1189
  );
1125
1190
  const behind = div.behind || 0;
1126
1191
 
1127
1192
  // Predicted merge conflicts (which files) — optional adapter capability.
1128
1193
  const conflicts = await safeRead(
1129
1194
  'conflicts',
1130
- () => callIfPresent(adapter, 'detectConflicts', { baseRef: ctx.baseRef, cwd }, null),
1131
- { 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 },
1132
1202
  );
1133
1203
 
1134
1204
  // Bot STATUS COMMENTS (Sonar/Vercel/Netlify/Codecov quality-gate + deployment
@@ -1164,7 +1234,8 @@ async function gatherPrSnapshot(ctx) {
1164
1234
  const headPushKnown = headPushTimeMs != null;
1165
1235
  // Torn-read guard: re-read the head oid at the END of the gather.
1166
1236
  const endState = await safeRead('headEnd', () => adapter.readState(pr), { degraded, unreadable });
1167
- const headOidEnd = endState ? endState.headSha : state.headSha;
1237
+ const headOidEnd = normalizeHeadSha(endState && endState.headSha);
1238
+ if (!headOidEnd && !unreadable.includes('headOid')) unreadable.push('headOid');
1168
1239
 
1169
1240
  // Actionable NON-HUMAN direct comments (mechanism-detected, suppression-list
1170
1241
  // filtered) and an AUTHOR-AGNOSTIC unresolved-thread count — both independent
@@ -1174,7 +1245,7 @@ async function gatherPrSnapshot(ctx) {
1174
1245
  const unresolvedThreadCount = countUnresolvedThreads(threads);
1175
1246
 
1176
1247
  const { verdict, evidence } = computeVerdict({
1177
- headOidStart: state.headSha,
1248
+ headOidStart,
1178
1249
  headOidEnd,
1179
1250
  mergeStateStatus: state.mergeStateStatus,
1180
1251
  mergeable: state.mergeable,
@@ -1195,6 +1266,9 @@ async function gatherPrSnapshot(ctx) {
1195
1266
  unreadable,
1196
1267
  });
1197
1268
 
1269
+ // Record the authoritative required-check source (`protection` or null).
1270
+ evidence.requiredSource = adapter.lastRequiredSource || null;
1271
+
1198
1272
  return {
1199
1273
  state, requiredSet, threads, behind, conflicts, issueComments,
1200
1274
  botStatusBlockers, requiredChecks, pendingChecks, draft, reviewDecision,
@@ -1207,7 +1281,6 @@ async function gatherPrSnapshot(ctx) {
1207
1281
  async function gatherPullSignal(ctx) {
1208
1282
  const {
1209
1283
  pr, self, adapter, runGh,
1210
- runPass = runShepherdPass,
1211
1284
  maxFailures = DEFAULT_MAX_FAILURES,
1212
1285
  maxThreads = DEFAULT_MAX_THREADS,
1213
1286
  maxExcerptLines = DEFAULT_MAX_EXCERPT_LINES,
@@ -1230,12 +1303,12 @@ async function gatherPullSignal(ctx) {
1230
1303
  requiredChecks, pendingChecks, draft, reviewDecision, verdict, evidence, degraded,
1231
1304
  } = snap;
1232
1305
 
1233
- // Legacy decision pass (READ-ONLY dryRun) for back-compat `state`. Shares the
1234
- // snapshot's `degraded` so a pass-read failure is still surfaced in the payload.
1235
- const pass = await safeRead('pass', () => runPass({ ...ctx, adapter, dryRun: true }), {
1236
- degraded,
1237
- fallback: { state: 'UNKNOWN', reason: 'Decision pass could not complete — a read failed; see verdict evidence.' },
1238
- });
1306
+ // `verdict` is the SINGLE consumer-facing vocabulary. The back-compat `state`
1307
+ // field is DERIVED from it (via legacyStateFor) — the read-only `--pull` payload
1308
+ // no longer runs a second decision pass, so the two vocabularies can never
1309
+ // disagree. A terminal lifecycle (MERGED/CLOSED) still wins over the verdict so
1310
+ // legacy consumers see the landed outcome, matching the old dry-run pass.
1311
+ const legacyState = legacyStateFor(verdict, state.state);
1239
1312
 
1240
1313
  const failures = gatherFailureExcerpts(runGh, state.checks, requiredSet, { maxFailures, maxExcerptLines, degraded });
1241
1314
  const reviewThreads = buildReviewThreads(threads, self, { maxThreads });
@@ -1254,7 +1327,7 @@ async function gatherPullSignal(ctx) {
1254
1327
  });
1255
1328
 
1256
1329
  const summary = summarize({
1257
- state: pass.state,
1330
+ state: legacyState,
1258
1331
  failureCount: failures.length,
1259
1332
  threadCount: reviewThreads.length,
1260
1333
  blockers,
@@ -1262,11 +1335,10 @@ async function gatherPullSignal(ctx) {
1262
1335
 
1263
1336
  return buildPullPayload({
1264
1337
  pr,
1265
- state: pass.state,
1338
+ state: legacyState,
1266
1339
  verdict,
1267
1340
  evidence,
1268
1341
  degraded,
1269
- reason: pass.reason,
1270
1342
  summary,
1271
1343
  mergeable: state.mergeable || 'UNKNOWN',
1272
1344
  mergeStateStatus: state.mergeStateStatus || 'UNKNOWN',
@@ -1299,6 +1371,8 @@ module.exports = {
1299
1371
  renderPullSummary,
1300
1372
  buildPullPayload,
1301
1373
  computeVerdict,
1374
+ verdictToLegacyState,
1375
+ legacyStateFor,
1302
1376
  MERGE_VERDICTS,
1303
1377
  VERDICT_LABELS,
1304
1378
  VERDICT_LABEL_PREFIX,
@@ -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
  }
@@ -388,13 +391,18 @@ async function runShepherdPass(ctx) {
388
391
 
389
392
  // Every read goes through the auth guard so 401/403-scope/rate-limit map to
390
393
  // the documented PENDING/HARD_STOP states instead of escaping as a generic
391
- // failure. Non-auth errors still propagate.
394
+ // failure. Other read errors are surfaced below as ESCALATE results.
392
395
 
393
396
  // --- Read PR/CI state FIRST so a merged/closed PR is detected as terminal
394
397
  // even when the branch-protection (required-checks) read would fail with an
395
398
  // auth/scope error — the scheduler must always get the terminal signal for a
396
399
  // landed/closed PR. ---
397
- const stateRead = await guardAuth(() => adapter.readState(pr), actions);
400
+ let stateRead;
401
+ try {
402
+ stateRead = await guardAuth(() => adapter.readState(pr), actions);
403
+ } catch (err) {
404
+ return result('ESCALATE', { actions, reason: `PR provider state is unreadable: ${err.message}` });
405
+ }
398
406
  if (stateRead.outcome) return stateRead.outcome;
399
407
  const startState = stateRead.value;
400
408
  const startSha = startState.headSha;
@@ -402,13 +410,24 @@ async function runShepherdPass(ctx) {
402
410
  // --- Lifecycle: a merged/closed PR is terminal. ---
403
411
  const lifecycle = lifecycleOutcome(startState.state, actions);
404
412
  if (lifecycle) return lifecycle;
413
+ if (startState.providerEvidenceReadable === false) {
414
+ return result('ESCALATE', {
415
+ actions,
416
+ reason: 'PR lifecycle, draft, head, or check-rollup evidence is malformed or incomplete.',
417
+ });
418
+ }
405
419
 
406
420
  // --- Required-checks set (only matters for non-terminal PRs); this is where
407
421
  // auth/scope fails fast. ---
408
- const requiredRead = await guardAuth(
409
- () => adapter.readRequiredChecks({ owner, repo, base }),
410
- actions,
411
- );
422
+ let requiredRead;
423
+ try {
424
+ requiredRead = await guardAuth(
425
+ () => adapter.readRequiredChecks({ owner, repo, base }),
426
+ actions,
427
+ );
428
+ } catch (err) {
429
+ return result('ESCALATE', { actions, reason: `Required-check policy is unreadable: ${err.message}` });
430
+ }
412
431
  if (requiredRead.outcome) return requiredRead.outcome;
413
432
  const required = requiredRead.value;
414
433
 
@@ -422,7 +441,7 @@ async function runShepherdPass(ctx) {
422
441
  }
423
442
 
424
443
  const divergenceRead = await guardAuth(
425
- () => adapter.readDivergence({ baseRef, cwd }),
444
+ () => adapter.readDivergence({ baseRef, cwd, headRef: startSha }),
426
445
  actions,
427
446
  );
428
447
  if (divergenceRead.outcome) return divergenceRead.outcome;
@@ -472,6 +491,13 @@ async function runShepherdPass(ctx) {
472
491
 
473
492
  // --- Terminal: all required green + not behind → merge-ready handoff. ---
474
493
  if (allRequiredGreen) {
494
+ const providerMergeState = String(startState.mergeStateStatus || '').toUpperCase();
495
+ if (!MERGE_READY_PROVIDER_STATES.has(providerMergeState)) {
496
+ return result('ESCALATE', {
497
+ actions,
498
+ reason: `Provider merge state ${providerMergeState || 'UNKNOWN'} does not authorize merge readiness.`,
499
+ });
500
+ }
475
501
  return result('MERGE_READY', {
476
502
  actions,
477
503
  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.',
@@ -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) },
@@ -19,6 +19,7 @@
19
19
  * @typedef {Object} GateOutcome
20
20
  * @property {boolean} ok - Whether the gate passed.
21
21
  * @property {string} [summary] - Short human-readable result line.
22
+ * @property {string} [inputFingerprint] - Exact input snapshot tested by the gate.
22
23
  *
23
24
  * @typedef {Object} Gate
24
25
  * @property {string} name
@@ -30,6 +31,7 @@
30
31
  * @property {boolean} skipped
31
32
  * @property {string} summary
32
33
  * @property {number} [durationMs]
34
+ * @property {string} [inputFingerprint]
33
35
  */
34
36
 
35
37
  /**
@@ -66,6 +68,9 @@ async function executeGate(gate) {
66
68
  skipped,
67
69
  summary: outcome?.summary || '',
68
70
  durationMs,
71
+ ...(typeof outcome?.inputFingerprint === 'string'
72
+ ? { inputFingerprint: outcome.inputFingerprint }
73
+ : {}),
69
74
  };
70
75
  }
71
76
 
@@ -1,7 +1,11 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
3
5
  const { resolveKernelDatabasePath } = require('./kernel/cli-broker-factory');
6
+ const { resolveGitCommonDir } = require('./kernel/broker');
4
7
  const { createBuiltinSQLiteDriver } = require('./kernel/sqlite-driver');
8
+ const { normalizeRecallHit } = require('./memory-recall');
5
9
 
6
10
  // Project memory is a Forge read model persisted in the kernel store (kernel_memories),
7
11
  // written DIRECTLY rather than through the issue CAS/guarded-event path. The store seam
@@ -30,6 +34,24 @@ function resolveStore(projectRoot, options = {}) {
30
34
  return options.store ?? defaultStore(projectRoot, options);
31
35
  }
32
36
 
37
+ function resolveProjectId(projectRoot, options = {}) {
38
+ const platform = options.platform || process.platform;
39
+ const pathImpl = platform === 'win32' ? path.win32 : path.posix;
40
+ const commonDir = options.gitCommonDir || resolveGitCommonDir(projectRoot, options);
41
+ const absolute = pathImpl.resolve(projectRoot, commonDir);
42
+ const realpath = options.realpath || fs.realpathSync.native;
43
+ let canonical;
44
+ try {
45
+ canonical = realpath(absolute);
46
+ } catch {
47
+ canonical = absolute;
48
+ }
49
+ canonical = canonical.replaceAll('\\', '/');
50
+ return platform === 'win32'
51
+ ? canonical.toLowerCase()
52
+ : canonical;
53
+ }
54
+
33
55
  function assertEntryObject(entry) {
34
56
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
35
57
  throw new TypeError('project memory entry must be an object');
@@ -140,6 +162,22 @@ function searchRanked(projectRoot, query, limit, options = {}) {
140
162
  return resolveStore(projectRoot, options).searchMemoriesRanked(query, limit);
141
163
  }
142
164
 
165
+ // Relevance-only BM25 recall that returns the raw bm25 `score` per entry, so a caller can
166
+ // apply a relevance floor. A no-match/empty query returns [] (no recency fallback). The
167
+ // per-turn memory-recall hook uses this to inject nothing unless a note clearly matches.
168
+ function searchRankedScored(projectRoot, query, limit, options = {}) {
169
+ const projectId = resolveProjectId(projectRoot, options);
170
+ const searchOptions = {
171
+ projectId,
172
+ excludeKeys: options.excludeKeys || [],
173
+ ...(options.now ? { now: options.now } : {}),
174
+ ...(options.busyTimeoutMs !== undefined ? { busyTimeoutMs: options.busyTimeoutMs } : {}),
175
+ };
176
+ return resolveStore(projectRoot, options)
177
+ .searchMemoriesRankedScored(query, limit, searchOptions)
178
+ .map(hit => normalizeRecallHit(hit, projectId));
179
+ }
180
+
143
181
  // Close and forget every cached default store. The CLI process is short-lived (the OS
144
182
  // closes the handle on exit), so this is mainly a lifecycle helper for long-lived hosts and
145
183
  // tests — it releases the SQLite/WAL handle before a temp dir is removed.
@@ -162,5 +200,7 @@ module.exports = {
162
200
  recent,
163
201
  count,
164
202
  searchRanked,
203
+ searchRankedScored,
204
+ resolveProjectId,
165
205
  closeAll,
166
206
  };