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
@@ -1,10 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const { execFileSync } = require('node:child_process');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
4
6
  const {
5
7
  assertProtectedWriteAllowed,
6
8
  recordProtectedStateAuditEvent,
7
9
  } = require('../lib/protected-state-surfaces');
10
+ const {
11
+ authorizeAndConsumeProtectedStateWrites,
12
+ isValidGitObjectId,
13
+ } = require('../lib/protected-state-authority');
14
+ const { verifyBunLockfileRegeneration } = require('../lib/bun-lockfile-proof');
15
+ const { resolveBaseRemote, resolveBaseBranch } = require('../lib/base-remote');
16
+ const realStagedFiles = new Set();
17
+ const deletedFiles = new Set();
8
18
 
9
19
  function parseNameStatus(output) {
10
20
  const files = [];
@@ -14,8 +24,10 @@ function parseNameStatus(output) {
14
24
  const parts = trimmed.split('\t').filter(Boolean);
15
25
  const status = parts[0] || '';
16
26
  if (/^[RC]/.test(status)) {
27
+ if (parts[1]) deletedFiles.add(parts[1]);
17
28
  files.push(...parts.slice(1, 3));
18
29
  } else {
30
+ if (status.startsWith('D') && parts[1]) deletedFiles.add(parts[1]);
19
31
  files.push(parts[1]);
20
32
  }
21
33
  }
@@ -23,55 +35,484 @@ function parseNameStatus(output) {
23
35
  }
24
36
 
25
37
  function getStagedFiles() {
38
+ let stagedFiles;
26
39
  if (process.env.FORGE_PROTECTED_STATE_STAGED_NAME_STATUS !== undefined) {
27
- return parseNameStatus(process.env.FORGE_PROTECTED_STATE_STAGED_NAME_STATUS);
28
- }
29
-
30
- if (process.env.FORGE_PROTECTED_STATE_STAGED_FILES !== undefined) {
31
- return process.env.FORGE_PROTECTED_STATE_STAGED_FILES
40
+ stagedFiles = parseNameStatus(process.env.FORGE_PROTECTED_STATE_STAGED_NAME_STATUS);
41
+ } else if (process.env.FORGE_PROTECTED_STATE_STAGED_FILES !== undefined) {
42
+ stagedFiles = process.env.FORGE_PROTECTED_STATE_STAGED_FILES
32
43
  .split(/\r?\n/)
33
44
  .map(line => line.trim())
34
45
  .filter(Boolean);
46
+ } else {
47
+ const output = execFileSync('git', ['diff', '--cached', '--name-status', '--diff-filter=ACMRDT'], {
48
+ encoding: 'utf8',
49
+ stdio: ['ignore', 'pipe', 'pipe'],
50
+ });
51
+ stagedFiles = parseNameStatus(output);
52
+ for (const file of stagedFiles) realStagedFiles.add(file);
53
+ }
54
+
55
+ if (process.env.FORGE_PROTECTED_STATE_STAGED_NAME_STATUS !== undefined || process.env.FORGE_PROTECTED_STATE_STAGED_FILES !== undefined) {
56
+ try {
57
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
58
+ encoding: 'utf8',
59
+ stdio: ['ignore', 'pipe', 'pipe'],
60
+ });
61
+ } catch (_error) {
62
+ return [...new Set(stagedFiles)];
63
+ }
64
+ const realOutput = execFileSync('git', ['diff', '--cached', '--name-status', '--diff-filter=ACMRDT'], {
65
+ encoding: 'utf8',
66
+ stdio: ['ignore', 'pipe', 'pipe'],
67
+ });
68
+ const indexedFiles = parseNameStatus(realOutput);
69
+ for (const file of indexedFiles) realStagedFiles.add(file);
70
+ stagedFiles.push(...indexedFiles);
71
+ }
72
+ return [...new Set(stagedFiles)];
73
+ }
74
+
75
+ function getStagedContent(file) {
76
+ if (!realStagedFiles.has(file) && process.env.FORGE_PROTECTED_STATE_STAGED_CONTENTS_JSON) {
77
+ const contents = JSON.parse(process.env.FORGE_PROTECTED_STATE_STAGED_CONTENTS_JSON);
78
+ return Object.prototype.hasOwnProperty.call(contents, file) ? contents[file] : null;
79
+ }
80
+
81
+ try {
82
+ return execFileSync('git', ['show', `:${file}`], {
83
+ encoding: null,
84
+ stdio: ['ignore', 'pipe', 'pipe'],
85
+ });
86
+ } catch (_error) {
87
+ if (!deletedFiles.has(file)) return null;
88
+ try {
89
+ return execFileSync('git', ['show', `HEAD:${file}`], {
90
+ encoding: null,
91
+ stdio: ['ignore', 'pipe', 'pipe'],
92
+ });
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+ }
98
+
99
+
100
+ function getCurrentHead(execGit = execFileSync) {
101
+ let output;
102
+ try {
103
+ output = execGit('git', ['rev-parse', '--verify', 'HEAD^{commit}'], {
104
+ encoding: 'utf8',
105
+ stdio: ['ignore', 'pipe', 'pipe'],
106
+ });
107
+ } catch (headError) {
108
+ let symbolicRef;
109
+ try {
110
+ symbolicRef = execGit('git', ['symbolic-ref', '-q', 'HEAD'], {
111
+ encoding: 'utf8',
112
+ stdio: ['ignore', 'pipe', 'pipe'],
113
+ }).trim();
114
+ } catch {
115
+ throw headError;
116
+ }
117
+ if (!/^refs\/heads\/[^\0\r\n]+$/.test(symbolicRef)) throw headError;
118
+ try {
119
+ execGit('git', ['show-ref', '--verify', '--quiet', symbolicRef], {
120
+ encoding: 'utf8',
121
+ stdio: ['ignore', 'pipe', 'pipe'],
122
+ });
123
+ } catch (refError) {
124
+ if (refError.status === 1) return null;
125
+ throw headError;
126
+ }
127
+ throw headError;
35
128
  }
129
+ const head = output.trim();
130
+ if (!isValidGitObjectId(head)) throw new Error('Current HEAD did not resolve to one full Git object id.');
131
+ return head;
132
+ }
133
+
134
+ const ABSENT_ENTRY = 'absent';
135
+ const LINE_SPLIT = /\r?\n/;
36
136
 
37
- const output = execFileSync('git', ['diff', '--cached', '--name-status', '--diff-filter=ACMRDT'], {
137
+ // Pathspec magic is only honoured when Git is not already in a forced pathspec
138
+ // mode. If the hook inherits GIT_LITERAL_PATHSPECS=1, `:(literal)<file>` is read
139
+ // as a filename spelled `:(literal)<file>`, every probe matches nothing, and the
140
+ // exemption predicate would compare absent against absent. The sibling GLOB /
141
+ // NOGLOB / ICASE switches distort matching the same way, so strip all four and
142
+ // keep the explicit `:(literal)` magic (paths with glob or colon characters stay
143
+ // safe). Keys are compared case-insensitively because Windows environment names
144
+ // are case-insensitive.
145
+ const PATHSPEC_ENV_KEYS = new Set([
146
+ 'GIT_LITERAL_PATHSPECS',
147
+ 'GIT_GLOB_PATHSPECS',
148
+ 'GIT_NOGLOB_PATHSPECS',
149
+ 'GIT_ICASE_PATHSPECS',
150
+ ]);
151
+
152
+ function pathspecSafeEnv() {
153
+ const env = { ...process.env };
154
+ for (const key of Object.keys(env)) {
155
+ if (PATHSPEC_ENV_KEYS.has(key.toUpperCase())) delete env[key];
156
+ }
157
+ return env;
158
+ }
159
+
160
+ const GIT_PROBE_ENV = pathspecSafeEnv();
161
+ const REMOTE_PROBE_TIMEOUT_MS = 15_000;
162
+
163
+ function gitCapture(args) {
164
+ return execFileSync('git', args, {
38
165
  encoding: 'utf8',
39
166
  stdio: ['ignore', 'pipe', 'pipe'],
40
- });
41
- return parseNameStatus(output);
167
+ env: GIT_PROBE_ENV,
168
+ }).trim();
42
169
  }
43
170
 
44
- function getAllowedSurfaces() {
45
- return new Set(
46
- String(process.env.FORGE_PROTECTED_STATE_ALLOWED_SURFACES || '')
47
- .split(',')
48
- .map(surface => surface.trim())
49
- .filter(Boolean),
50
- );
171
+ // Resolve a commit-ish to a full object id, or null when it does not exist.
172
+ // Worktree-safe: plain `git rev-parse` already resolves per-worktree state.
173
+ function resolveCommit(revision) {
174
+ try {
175
+ const id = gitCapture(['rev-parse', '--verify', '--quiet', `${revision}^{commit}`]);
176
+ return isValidGitObjectId(id) ? id : null;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ // Best common ancestor of two commits, or null when there is none (unrelated
183
+ // histories) or the query fails. Used to tell "the merge side deleted this path"
184
+ // apart from "this path never existed on the merge side".
185
+ function resolveMergeBase(left, right) {
186
+ let base;
187
+ try {
188
+ base = gitCapture(['merge-base', left, right]).split(LINE_SPLIT)[0].trim();
189
+ } catch {
190
+ return null;
191
+ }
192
+ return isValidGitObjectId(base) ? base : null;
193
+ }
194
+
195
+ // Git prints the mode but not the type for index entries; derive it so index and
196
+ // tree entries normalise to the same shape.
197
+ function objectTypeForMode(mode) {
198
+ if (mode === '160000') return 'commit';
199
+ if (mode === '040000' || mode === '40000') return 'tree';
200
+ return 'blob';
201
+ }
202
+
203
+ // Normalised `<mode> <type> <oid>` for the staged index entry, ABSENT_ENTRY when
204
+ // the path is not in the index, or null when the answer is unknown. A conflicted
205
+ // path yields several stage lines and therefore null — fail closed.
206
+ // The mode matters: blob equality alone would exempt a real 100644 -> 100755
207
+ // index change on a protected path.
208
+ function stagedEntry(file) {
209
+ let output;
210
+ try {
211
+ output = gitCapture(['ls-files', '--stage', '--', `:(literal)${file}`]);
212
+ } catch {
213
+ return null;
214
+ }
215
+ if (output === '') return ABSENT_ENTRY;
216
+ const lines = output.split(LINE_SPLIT).filter(Boolean);
217
+ if (lines.length !== 1) return null;
218
+ const match = /^([0-7]{6}) ([0-9a-f]{40}|[0-9a-f]{64}) [0-3]\t/.exec(lines[0]);
219
+ if (!match) return null;
220
+ return `${match[1]} ${objectTypeForMode(match[1])} ${match[2]}`;
221
+ }
222
+
223
+ // Same normalised shape for `<revision>:<file>`, so the comparison covers mode
224
+ // and object type as well as content.
225
+ function revisionEntry(revision, file) {
226
+ let output;
227
+ try {
228
+ output = gitCapture(['ls-tree', revision, '--', `:(literal)${file}`]);
229
+ } catch {
230
+ return null;
231
+ }
232
+ if (output === '') return ABSENT_ENTRY;
233
+ const lines = output.split(LINE_SPLIT).filter(Boolean);
234
+ if (lines.length !== 1) return null;
235
+ const match = /^([0-7]{6}) (blob|tree|commit) ([0-9a-f]{40}|[0-9a-f]{64})\t/.exec(lines[0]);
236
+ if (!match) return null;
237
+ return `${match[1]} ${match[2]} ${match[3]}`;
51
238
  }
52
239
 
53
- function main() {
240
+ // Adapter so the shared base-remote resolver runs through this script's
241
+ // pathspec-scrubbed git probe. The resolver ignores the options object it is
242
+ // handed here; the env scrub and quiet stdio come from `gitCapture`.
243
+ function baseRemoteProbe(_command, args) {
244
+ return gitCapture(args);
245
+ }
246
+
247
+ // Resolve the repository's single canonical upstream ref, deterministically:
248
+ // the *base* remote (`upstream` preferred over `origin`, the same resolution
249
+ // `/ship` uses to pick a PR base), then that remote's default branch
250
+ // (`refs/remotes/<remote>/HEAD`, falling back to the conventional default
251
+ // names). Deliberately NOT the current branch's tracking remote: in a
252
+ // fork-style checkout the feature branch tracks the contributor-owned fork, and
253
+ // trusting it would let a change merged from the fork's default branch pass as
254
+ // "already published on the base" when it never reached the official repo.
255
+ // Fail closed when the official `upstream` remote is configured but has no
256
+ // fetched tracking refs: falling through to `origin` would change trust domains
257
+ // and let fork-only content pass as already published upstream.
258
+ // Fail closed when the base branch is ambiguous: with no authoritative HEAD
259
+ // and both conventional defaults present, candidate order would trust main
260
+ // even when master is the real integration branch, letting content published
261
+ // only to the wrong branch pass as already published on the base.
262
+ // Returns the fully-qualified local ref plus its remote/branch identity, or null
263
+ // when the canonical upstream cannot be established.
264
+ function canonicalUpstream() {
265
+ const cwd = process.cwd();
266
+ const remote = resolveBaseRemote(baseRemoteProbe, cwd);
267
+ if (remote === 'origin') {
268
+ try {
269
+ gitCapture(['remote', 'get-url', 'upstream']);
270
+ return null;
271
+ } catch {
272
+ // No upstream configured; origin fallback is legitimate.
273
+ }
274
+ }
275
+ try {
276
+ const remotes = gitCapture(['remote']).split(/\r?\n/).map(line => line.trim());
277
+ if (!remotes.includes(remote)) return null;
278
+ } catch {
279
+ return null;
280
+ }
281
+
282
+ const candidates = [];
283
+ let head = null;
284
+ try {
285
+ const resolved = gitCapture(['symbolic-ref', '--quiet', `refs/remotes/${remote}/HEAD`]);
286
+ if (resolved) head = resolved;
287
+ } catch {
288
+ // no remote HEAD recorded; fall through to the resolved default branch
289
+ }
290
+ if (head) {
291
+ candidates.push(head);
292
+ } else {
293
+ const hasMain = Boolean(resolveCommit(`refs/remotes/${remote}/main`));
294
+ const hasMaster = Boolean(resolveCommit(`refs/remotes/${remote}/master`));
295
+ if (hasMain && hasMaster) return null;
296
+ }
297
+ candidates.push(`refs/remotes/${remote}/${resolveBaseBranch(baseRemoteProbe, process.cwd(), remote)}`);
298
+ candidates.push(`refs/remotes/${remote}/main`, `refs/remotes/${remote}/master`);
299
+
300
+ const prefix = `refs/remotes/${remote}/`;
301
+ for (const ref of candidates) {
302
+ if (resolveCommit(ref) && ref.startsWith(prefix) && ref.length > prefix.length) {
303
+ return { ref, remote, branch: ref.slice(prefix.length) };
304
+ }
305
+ }
306
+ return null;
307
+ }
308
+
309
+ // A local remote-tracking ref is only cached evidence. Confirm that the remote
310
+ // still advertises both the selected default branch and its frozen commit
311
+ // without mutating refs; unavailable or malformed remote evidence fails closed.
312
+ function isCurrentCanonicalCommit(canonical, expectedCommit) {
313
+ if (!canonical || !expectedCommit) return false;
314
+ const expectedRef = `refs/heads/${canonical.branch}`;
315
+ let output;
316
+ try {
317
+ output = execFileSync('git', ['ls-remote', '--symref', '--exit-code', canonical.remote, 'HEAD'], {
318
+ encoding: 'utf8',
319
+ stdio: ['ignore', 'pipe', 'pipe'],
320
+ env: { ...GIT_PROBE_ENV, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'Never' },
321
+ timeout: REMOTE_PROBE_TIMEOUT_MS,
322
+ }).trim();
323
+ } catch {
324
+ return false;
325
+ }
326
+ const lines = output.split(LINE_SPLIT).filter(Boolean);
327
+ if (lines.length !== 2) return false;
328
+ const symref = lines.map(line => /^ref: ([^\0\r\n\t]+)\tHEAD$/.exec(line)).find(Boolean);
329
+ const head = lines.map(line => /^([0-9a-f]{40}|[0-9a-f]{64})\tHEAD$/.exec(line)).find(Boolean);
330
+ return Boolean(symref && head && symref[1] === expectedRef && head[1] === expectedCommit);
331
+ }
332
+
333
+ // True when `commit` is contained in the canonical upstream line — not merely
334
+ // "some ref points at it". `--contains` over refs/remotes would accept an
335
+ // untrusted contributor remote or a hand-written `git update-ref
336
+ // refs/remotes/<anything>`; ancestry in the canonical ref is the property we
337
+ // actually want (these bytes are already published on the branch this repo
338
+ // integrates into, where the same gate ran).
339
+ //
340
+ // Honest limitation: refs under .git are locally writable, and anyone who can
341
+ // write .git can disable this hook outright. This gate defends against
342
+ // accidental and agent-authored protected edits and against merges from
343
+ // untrusted contributor remotes. It is NOT a security boundary against a local
344
+ // adversary — do not treat it as one.
345
+ function isCanonicalUpstreamAncestor(commit, canonicalCommit) {
346
+ if (!canonicalCommit) return false;
347
+ try {
348
+ execFileSync('git', ['merge-base', '--is-ancestor', commit, canonicalCommit], {
349
+ stdio: ['ignore', 'ignore', 'ignore'],
350
+ });
351
+ return true;
352
+ } catch {
353
+ return false;
354
+ }
355
+ }
356
+
357
+ // A merge is in progress when the per-worktree MERGE_HEAD exists. `--git-path`
358
+ // resolves the correct (possibly linked-worktree) git dir. Returns null unless a
359
+ // merge is genuinely in progress and every recorded id resolves; `trustedSides`
360
+ // holds only those MERGE_HEAD commits contained in the canonical upstream commit.
361
+ function readMergeProvenance() {
362
+ let mergeHeadPath;
363
+ try {
364
+ mergeHeadPath = gitCapture(['rev-parse', '--git-path', 'MERGE_HEAD']);
365
+ } catch {
366
+ return null;
367
+ }
368
+ if (!mergeHeadPath) return null;
369
+ let raw;
370
+ try {
371
+ raw = fs.readFileSync(path.resolve(process.cwd(), mergeHeadPath), 'utf8');
372
+ } catch {
373
+ return null;
374
+ }
375
+ const ids = raw
376
+ .split(/\r?\n/)
377
+ .map(line => line.trim())
378
+ .filter(Boolean);
379
+ if (ids.length === 0 || !ids.every(isValidGitObjectId)) return null;
380
+ const resolved = ids.map(id => resolveCommit(id));
381
+ if (resolved.some(commit => commit === null)) return null;
382
+ // HEAD anchors the "no net change" half of the predicate. If it cannot be
383
+ // resolved we know nothing about the commit being built, so there is no
384
+ // exemption context at all — fail closed rather than fall back to the sides.
385
+ const head = resolveCommit('HEAD');
386
+ if (!head) return null;
387
+ const canonical = canonicalUpstream();
388
+ const canonicalCommit = canonical ? resolveCommit(canonical.ref) : null;
389
+ return {
390
+ head,
391
+ canonical,
392
+ canonicalCommit,
393
+ trustedSides: resolved.filter(commit => isCanonicalUpstreamAncestor(commit, canonicalCommit)),
394
+ };
395
+ }
396
+
397
+ // Exemption predicate. While a merge is in progress, a staged protected path is
398
+ // exempt only when the committer introduced no net change AND the content has
399
+ // trusted provenance:
400
+ // - staged entry (mode + object) === the entry at HEAD: no net change versus the branch being
401
+ // committed onto, so there is no provenance question at all; or
402
+ // - staged entry === both a MERGE_HEAD contained in the canonical upstream
403
+ // commit and that current canonical commit: the bytes are still published
404
+ // on the line this repo integrates into, where the same gate ran.
405
+ // A purely local merge side earns no exemption — otherwise anyone could smuggle a
406
+ // protected edit in on a local branch and merge it. The merge base is likewise not
407
+ // a permitted side: matching only the base means both sides were reverted, an edit.
408
+ // Anything else stays blocked, and any failed git query fails closed.
409
+ function createMergeExemption() {
410
+ const merge = readMergeProvenance();
411
+ if (!merge) return () => false;
412
+ let canonicalIsCurrent;
413
+ const hasCurrentCanonicalCommit = () => {
414
+ if (canonicalIsCurrent === undefined) {
415
+ canonicalIsCurrent = isCurrentCanonicalCommit(merge.canonical, merge.canonicalCommit);
416
+ }
417
+ return canonicalIsCurrent;
418
+ };
419
+ return file => {
420
+ const staged = stagedEntry(file);
421
+ if (staged === null) return false;
422
+ if (staged !== ABSENT_ENTRY && staged === revisionEntry(merge.head, file)) return true;
423
+ if (!merge.canonicalCommit || staged !== revisionEntry(merge.canonicalCommit, file)) return false;
424
+ if (staged === ABSENT_ENTRY) {
425
+ // Git just reported this path as staged, so an absent index entry is only
426
+ // honest for a staged deletion; otherwise the probe contradicts git and
427
+ // the answer is unknown.
428
+ if (!deletedFiles.has(file)) return false;
429
+ // Absent on a trusted side is not proof that side deleted anything — a
430
+ // path that only ever existed on HEAD is absent upstream too, so a manual
431
+ // `git rm` during a merge would otherwise exempt itself. Demand that the
432
+ // merge side actually removed it: present at the merge base, gone at the
433
+ // side. Fail closed when the base or either probe is unavailable.
434
+ const trustedDeletion = merge.trustedSides.some(side => {
435
+ if (revisionEntry(side, file) !== ABSENT_ENTRY) return false;
436
+ const base = resolveMergeBase(merge.head, side);
437
+ if (!base) return false;
438
+ const baseEntry = revisionEntry(base, file);
439
+ return baseEntry !== null && baseEntry !== ABSENT_ENTRY;
440
+ });
441
+ return trustedDeletion && hasCurrentCanonicalCommit();
442
+ }
443
+ const trustedCarryOver = merge.trustedSides.some(revision => {
444
+ const entry = revisionEntry(revision, file);
445
+ return entry !== null && entry === staged;
446
+ });
447
+ return trustedCarryOver && hasCurrentCanonicalCommit();
448
+ };
449
+ }
450
+
451
+ async function main() {
54
452
  const actor =
55
453
  process.env.FORGE_PROTECTED_STATE_ACTOR ||
56
454
  process.env.FORGE_ACTOR ||
57
455
  process.env.USER ||
58
456
  process.env.USERNAME ||
59
457
  'unknown';
60
- const allowedSurfaces = getAllowedSurfaces();
61
- const decisions = getStagedFiles()
458
+ const sourceHead = getCurrentHead();
459
+ const isMergeCarryOver = createMergeExemption();
460
+ const mergeExempt = [];
461
+ const probes = getStagedFiles()
62
462
  .map(file => {
63
463
  const probe = assertProtectedWriteAllowed(file, { actor, operation: 'staged_edit' });
64
- if (probe.requiredSurface && allowedSurfaces.has(probe.requiredSurface)) {
65
- return assertProtectedWriteAllowed(file, {
464
+ if (!probe.requiredSurface) return { probe };
465
+ // Deliberately after the lockfile branch: bun.lock keeps its own
466
+ // regeneration proof, which a merge must never skip.
467
+ if (probe.path !== 'bun.lock' && isMergeCarryOver(probe.path)) {
468
+ mergeExempt.push(probe.path);
469
+ return { probe: { ...probe, allowed: true, decision: 'allowed_merge_carry_over' } };
470
+ }
471
+ if (probe.path === 'bun.lock' && probe.requiredSurface === 'lockfiles') {
472
+ return {
473
+ probe,
474
+ directDecision: {
475
+ ...probe,
476
+ ...verifyBunLockfileRegeneration(process.cwd()),
477
+ },
478
+ };
479
+ }
480
+
481
+ const content = getStagedContent(probe.path);
482
+ if (content === null) return { probe };
483
+ return {
484
+ probe,
485
+ request: {
66
486
  actor,
67
- operation: 'staged_edit',
68
- viaForgeApi: true,
69
487
  surface: probe.requiredSurface,
70
- });
71
- }
72
- return probe;
488
+ path: probe.path,
489
+ content,
490
+ operation: deletedFiles.has(probe.path) ? 'staged_delete' : 'staged_edit',
491
+ sourceHead,
492
+ },
493
+ };
494
+ });
495
+ const protectedProbes = probes.filter(entry => entry.request);
496
+ const authorization = await authorizeAndConsumeProtectedStateWrites(
497
+ process.cwd(),
498
+ protectedProbes.map(entry => entry.request),
499
+ { sourceHead },
500
+ );
501
+ let authorizationIndex = 0;
502
+ const decisions = probes
503
+ .map(entry => {
504
+ if (entry.directDecision) return entry.directDecision;
505
+ if (!entry.request) return entry.probe;
506
+ const trustedDecision = authorization.decisions[authorizationIndex++];
507
+ if (!trustedDecision && authorization.batchDecision) return { ...entry.probe, allowed: true };
508
+ return trustedDecision.allowed
509
+ ? trustedDecision
510
+ : { ...entry.probe, ...trustedDecision, repairHint: entry.probe.repairHint };
73
511
  })
74
512
  .filter(decision => !decision.allowed);
513
+ if (authorization.batchDecision && !authorization.batchDecision.allowed) {
514
+ decisions.push(authorization.batchDecision);
515
+ }
75
516
 
76
517
  for (const decision of decisions) {
77
518
  const audit = recordProtectedStateAuditEvent(decision, { cwd: process.cwd() });
@@ -80,6 +521,10 @@ function main() {
80
521
  }
81
522
  }
82
523
 
524
+ for (const file of mergeExempt) {
525
+ console.log(`OK: ${file} matches a merge side unchanged (merge in progress); no net edit introduced.`);
526
+ }
527
+
83
528
  if (decisions.length === 0) {
84
529
  console.log('OK: No protected state edits detected.');
85
530
  process.exit(0);
@@ -89,16 +534,19 @@ function main() {
89
534
  for (const decision of decisions) {
90
535
  console.error(` - ${decision.path} [${decision.requiredSurface}]`);
91
536
  console.error(` Decision: ${decision.decision}`);
537
+ console.error(` Reason: ${decision.reason}`);
92
538
  console.error(` Repair: ${decision.repairHint}`);
93
539
  }
94
540
  console.error('');
95
- console.error('Use the owning Forge or Beads API surface, then stage the generated result if that command explicitly owns it.');
541
+ console.error('Use the owning Forge API surface, then stage the generated result if that command explicitly owns it.');
96
542
  process.exit(1);
97
543
  }
98
544
 
99
- try {
100
- main();
101
- } catch (error) {
102
- console.error(`Protected state check failed: ${error.message}`);
103
- process.exit(1);
545
+ if (require.main === module) {
546
+ main().catch(error => {
547
+ console.error(`Protected state check failed: ${error.message}`);
548
+ process.exit(1);
549
+ });
104
550
  }
551
+
552
+ module.exports = { getCurrentHead };
@@ -20,6 +20,7 @@ const {
20
20
  } = require('./lib/eval-runner');
21
21
  const { gradeTranscript } = require('./lib/grading');
22
22
  const { saveEvalResult } = require('./lib/eval-storage');
23
+ const { verifyEvalReplay } = require('./lib/eval-evidence');
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // parseArgs
@@ -89,6 +90,7 @@ function runShellCommand(command, worktreePath) {
89
90
  * @param {string} [options._basePath] — eval-logs base path for testing
90
91
  * @param {boolean} [options._skipWorktree=false] — skip worktree creation for unit tests
91
92
  * @param {Function} [options._executeOverride] — injectable command executor for testing
93
+ * @param {{envelope: object, inputs: {skill: string, tool: string}}} [options.replay] — exact-SHA replay binding
92
94
  * @returns {Promise<{ command: string, results: Array, overall_score: number, passed: boolean, duration_ms: number }>}
93
95
  */
94
96
  async function runEvalPipeline(evalSetPath, options = {}) {
@@ -98,6 +100,8 @@ async function runEvalPipeline(evalSetPath, options = {}) {
98
100
  const execOverride = options._executeOverride || null;
99
101
  const invokeGrader = options._invokeGrader || null;
100
102
  const basePath = options._basePath || undefined;
103
+ const hasReplay = Object.hasOwn(options, 'replay');
104
+ const replay = options.replay;
101
105
 
102
106
  const startTime = Date.now();
103
107
 
@@ -105,10 +109,34 @@ async function runEvalPipeline(evalSetPath, options = {}) {
105
109
  const evalSet = loadEvalSet(evalSetPath);
106
110
  const { command, queries } = evalSet;
107
111
 
112
+ let replayEvidence = null;
113
+ if (hasReplay) {
114
+ if (!replay || typeof replay !== 'object' || Array.isArray(replay)) throw new Error('replay must be an object');
115
+ for (const field of Object.keys(replay)) {
116
+ if (!['envelope', 'inputs'].includes(field)) throw new Error(`Unknown field 'replay.${field}'`);
117
+ }
118
+ if (!Object.hasOwn(replay, 'envelope')) throw new Error('replay.envelope is required');
119
+ if (!replay.inputs || typeof replay.inputs !== 'object' || Array.isArray(replay.inputs)) {
120
+ throw new Error('replay.inputs must be an object');
121
+ }
122
+ for (const field of Object.keys(replay.inputs)) {
123
+ if (!['skill', 'tool'].includes(field)) throw new Error(`Unknown field 'replay.inputs.${field}'`);
124
+ }
125
+ for (const field of ['skill', 'tool']) {
126
+ if (!Object.hasOwn(replay.inputs, field)) throw new Error(`replay.inputs.${field} is required`);
127
+ }
128
+ replayEvidence = verifyEvalReplay(replay.envelope, {
129
+ evalSet,
130
+ skill: replay.inputs.skill,
131
+ tool: replay.inputs.tool,
132
+ });
133
+ if (skipWorktree) throw new Error('Replay worktree verification cannot be skipped');
134
+ }
135
+
108
136
  // 2. Create eval worktree (unless skipped for testing)
109
137
  let worktreePath = null;
110
138
  if (!skipWorktree) {
111
- const wt = await createEvalWorktree();
139
+ const wt = await createEvalWorktree(replayEvidence ? replayEvidence.head_sha : undefined);
112
140
  worktreePath = wt.path;
113
141
  }
114
142