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

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 (268) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +48 -0
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/bin/forge.js +12 -1
  6. package/docs/guides/MIGRATION.md +3 -3
  7. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  8. package/docs/reference/INSTALL.md +4 -0
  9. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  10. package/docs/reference/RELEASE.md +4 -4
  11. package/docs/reference/github-accounts.md +134 -0
  12. package/docs/reference/shepherd.md +63 -13
  13. package/lib/adapters/pr-state-adapter.js +15 -2
  14. package/lib/base-remote.js +138 -0
  15. package/lib/beta5-compatibility-evidence.js +1093 -0
  16. package/lib/bun-lockfile-proof.js +413 -0
  17. package/lib/bun-workflow-pins.js +461 -0
  18. package/lib/capabilities/index.js +9 -0
  19. package/lib/capabilities/model.js +141 -0
  20. package/lib/capabilities/probes.js +347 -0
  21. package/lib/codex-skills.js +2 -2
  22. package/lib/commands/_manifest.js +1 -0
  23. package/lib/commands/_registry.js +48 -18
  24. package/lib/commands/clean.js +57 -1
  25. package/lib/commands/doctor.js +37 -6
  26. package/lib/commands/gate.js +197 -27
  27. package/lib/commands/github.js +215 -0
  28. package/lib/commands/hooks.js +54 -6
  29. package/lib/commands/memory.js +66 -2
  30. package/lib/commands/merge.js +720 -73
  31. package/lib/commands/plan.js +33 -2
  32. package/lib/commands/pr.js +2 -0
  33. package/lib/commands/preflight.js +10 -2
  34. package/lib/commands/push.js +120 -9
  35. package/lib/commands/recall.js +95 -61
  36. package/lib/commands/release.js +23 -2
  37. package/lib/commands/remember.js +28 -4
  38. package/lib/commands/serve.js +26 -9
  39. package/lib/commands/setup.js +132 -4
  40. package/lib/commands/shepherd.js +578 -72
  41. package/lib/commands/ship.js +15 -69
  42. package/lib/commands/skill.js +8 -0
  43. package/lib/commands/team.js +47 -8
  44. package/lib/commands/test.js +163 -4
  45. package/lib/commands/validate.js +78 -22
  46. package/lib/commands/worktree.js +155 -19
  47. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  48. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  49. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  50. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  51. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  52. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  53. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  54. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  55. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  56. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  57. package/lib/forge-issues.js +78 -0
  58. package/lib/gate-events.js +98 -10
  59. package/lib/github-context.js +308 -0
  60. package/lib/global-flags.js +1 -0
  61. package/lib/hook-renderer.js +29 -1
  62. package/lib/issue-render.js +19 -0
  63. package/lib/kernel/broker.js +723 -31
  64. package/lib/kernel/claim-reconciler.js +238 -0
  65. package/lib/kernel/lease-enforcer.js +9 -4
  66. package/lib/kernel/legacy-claim-repair.js +442 -0
  67. package/lib/kernel/live-claim-projection.js +26 -0
  68. package/lib/kernel/migrations.js +118 -3
  69. package/lib/kernel/readiness-model.js +184 -12
  70. package/lib/kernel/schema.js +49 -1
  71. package/lib/kernel/sqlite-driver.js +3322 -183
  72. package/lib/kernel/taxonomy-validator.js +4 -1
  73. package/lib/kernel/windows-private-acl.js +239 -0
  74. package/lib/memory/hygiene.js +191 -0
  75. package/lib/memory/router.js +94 -27
  76. package/lib/memory/usage-evidence.js +4 -0
  77. package/lib/memory-digest.js +59 -0
  78. package/lib/merge-rules.js +135 -17
  79. package/lib/npm-publish-workflow.js +233 -40
  80. package/lib/package-root.js +2 -0
  81. package/lib/pr-monitor/auto-actions.js +169 -28
  82. package/lib/pr-monitor/differ.js +110 -4
  83. package/lib/pr-monitor/events.js +0 -0
  84. package/lib/pr-monitor/flow-monitor.js +1424 -0
  85. package/lib/pr-monitor/gather.js +251 -44
  86. package/lib/pr-monitor/journal.js +0 -37
  87. package/lib/pr-monitor/monitor.js +117 -10
  88. package/lib/pr-monitor/process-identity.js +117 -0
  89. package/lib/pr-monitor/reconcile-executor.js +1101 -625
  90. package/lib/pr-monitor/reconcile.js +0 -0
  91. package/lib/pr-monitor/render-summary.js +121 -24
  92. package/lib/pr-monitor/review-preflight.js +269 -0
  93. package/lib/pr-monitor/shepherd-lease.js +28 -19
  94. package/lib/pr-monitor/verdict.js +438 -0
  95. package/lib/pr-monitor/watch-lifecycle.js +144 -38
  96. package/lib/pr-monitor/watch-owner.js +1414 -0
  97. package/lib/pr-monitor/watch.js +129 -58
  98. package/lib/pr-shepherd.js +17 -3
  99. package/lib/project-memory.js +145 -3
  100. package/lib/protected-state-authority.js +799 -4
  101. package/lib/protected-state-surfaces.js +181 -3
  102. package/lib/release-readiness.js +2 -3
  103. package/lib/review-adapter.js +65 -0
  104. package/lib/skills-sync.js +65 -32
  105. package/lib/validation/risk-manifest.js +339 -0
  106. package/lib/validation-receipt.js +190 -0
  107. package/lib/workflow/enforce-stage.js +44 -0
  108. package/lib/workflow/plan-authority.js +225 -0
  109. package/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  110. package/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  111. package/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  112. package/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  113. package/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  114. package/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  115. package/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  116. package/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  117. package/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  118. package/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  119. package/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  120. package/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  121. package/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  122. package/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  123. package/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  124. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  125. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  126. package/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  127. package/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  128. package/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  129. package/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  130. package/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  131. package/node_modules/@forge/contracts/index.js +32 -0
  132. package/node_modules/@forge/contracts/package.json +35 -0
  133. package/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  134. package/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  135. package/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  136. package/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  137. package/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  138. package/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  139. package/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  140. package/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  141. package/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  142. package/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  143. package/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  144. package/node_modules/@forge/contracts/src/baseline.js +23 -0
  145. package/node_modules/@forge/contracts/src/canonical.js +151 -0
  146. package/node_modules/@forge/contracts/src/definitions.js +176 -0
  147. package/node_modules/@forge/contracts/src/identity.js +42 -0
  148. package/node_modules/@forge/contracts/src/schema.js +72 -0
  149. package/node_modules/@forge/contracts/src/validate.js +305 -0
  150. package/node_modules/@forge/flow/index.js +114 -0
  151. package/node_modules/@forge/flow/package.json +34 -0
  152. package/node_modules/@forge/flow/src/bounded-loop.js +415 -0
  153. package/node_modules/@forge/flow/src/efficiency-supervisor.js +89 -0
  154. package/node_modules/@forge/flow/src/executor.js +279 -0
  155. package/node_modules/@forge/flow/src/monitor-durability.js +419 -0
  156. package/node_modules/@forge/flow/src/monitor-runtime.js +460 -0
  157. package/node_modules/@forge/flow/src/process-lifecycle.js +469 -0
  158. package/node_modules/@forge/flow/src/skill-runtime.js +343 -0
  159. package/node_modules/@forge/memory/index.js +331 -0
  160. package/node_modules/@forge/memory/package.json +34 -0
  161. package/node_modules/@forge/memory/src/authority-provider.js +67 -0
  162. package/node_modules/@forge/memory/src/backend-registry.js +166 -0
  163. package/node_modules/@forge/memory/src/feedback-intake.js +243 -0
  164. package/node_modules/@forge/memory/src/pr-lifecycle-authority.js +946 -0
  165. package/node_modules/@forge/memory/src/usage-evidence.js +205 -0
  166. package/package.json +16 -4
  167. package/packages/flow/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  168. package/packages/flow/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  169. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  170. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  171. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  172. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  173. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  174. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  175. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  176. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  177. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  178. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  179. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  180. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  181. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  182. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  183. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  184. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  185. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  186. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  187. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  188. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  189. package/packages/flow/node_modules/@forge/contracts/index.js +32 -0
  190. package/packages/flow/node_modules/@forge/contracts/package.json +35 -0
  191. package/packages/flow/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  192. package/packages/flow/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  193. package/packages/flow/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  194. package/packages/flow/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  195. package/packages/flow/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  196. package/packages/flow/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  197. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  198. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  199. package/packages/flow/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  200. package/packages/flow/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  201. package/packages/flow/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  202. package/packages/flow/node_modules/@forge/contracts/src/baseline.js +23 -0
  203. package/packages/flow/node_modules/@forge/contracts/src/canonical.js +151 -0
  204. package/packages/flow/node_modules/@forge/contracts/src/definitions.js +176 -0
  205. package/packages/flow/node_modules/@forge/contracts/src/identity.js +42 -0
  206. package/packages/flow/node_modules/@forge/contracts/src/schema.js +72 -0
  207. package/packages/flow/node_modules/@forge/contracts/src/validate.js +305 -0
  208. package/packages/memory/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  209. package/packages/memory/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  210. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  211. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  212. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  213. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  214. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  215. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  216. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  217. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  218. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  219. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  220. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  221. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  222. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  223. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  224. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  225. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  226. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  227. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  228. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  229. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  230. package/packages/memory/node_modules/@forge/contracts/index.js +32 -0
  231. package/packages/memory/node_modules/@forge/contracts/package.json +35 -0
  232. package/packages/memory/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  233. package/packages/memory/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  234. package/packages/memory/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  235. package/packages/memory/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  236. package/packages/memory/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  237. package/packages/memory/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  238. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  239. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  240. package/packages/memory/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  241. package/packages/memory/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  242. package/packages/memory/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  243. package/packages/memory/node_modules/@forge/contracts/src/baseline.js +23 -0
  244. package/packages/memory/node_modules/@forge/contracts/src/canonical.js +151 -0
  245. package/packages/memory/node_modules/@forge/contracts/src/definitions.js +176 -0
  246. package/packages/memory/node_modules/@forge/contracts/src/identity.js +42 -0
  247. package/packages/memory/node_modules/@forge/contracts/src/schema.js +72 -0
  248. package/packages/memory/node_modules/@forge/contracts/src/validate.js +305 -0
  249. package/scripts/commitlint.js +13 -15
  250. package/scripts/generate-risk-manifest.js +91 -0
  251. package/scripts/github-context-bridge.sh +10 -0
  252. package/scripts/legacy-claim-repair.js +145 -0
  253. package/scripts/lib/behavioral-eval-runtime.js +3 -2
  254. package/scripts/process-tree.js +14 -2
  255. package/scripts/protected-state-check.js +440 -17
  256. package/scripts/sync-agent-skills.js +333 -34
  257. package/scripts/test-full-suite.js +704 -18
  258. package/scripts/test-profile.js +13 -3
  259. package/scripts/test.js +96 -15
  260. package/skills/coverage.json +1 -0
  261. package/skills/review/SKILL.md +2 -0
  262. package/skills/review/evals/scorecard.json +2 -2
  263. package/skills/setup/SKILL.md +18 -0
  264. package/skills/setup/evals/scorecard.json +3 -3
  265. package/skills/shepherd/SKILL.md +19 -2
  266. package/skills/shepherd/evals/scorecard.json +3 -3
  267. package/skills/validate/SKILL.md +3 -0
  268. package/skills/validate/evals/scorecard.json +1 -1
@@ -1,11 +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');
8
- const { authorizeAndConsumeProtectedStateWrites } = require('../lib/protected-state-authority');
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();
9
18
 
10
19
  function parseNameStatus(output) {
11
20
  const files = [];
@@ -15,8 +24,10 @@ function parseNameStatus(output) {
15
24
  const parts = trimmed.split('\t').filter(Boolean);
16
25
  const status = parts[0] || '';
17
26
  if (/^[RC]/.test(status)) {
27
+ if (parts[1]) deletedFiles.add(parts[1]);
18
28
  files.push(...parts.slice(1, 3));
19
29
  } else {
30
+ if (status.startsWith('D') && parts[1]) deletedFiles.add(parts[1]);
20
31
  files.push(parts[1]);
21
32
  }
22
33
  }
@@ -24,26 +35,45 @@ function parseNameStatus(output) {
24
35
  }
25
36
 
26
37
  function getStagedFiles() {
38
+ let stagedFiles;
27
39
  if (process.env.FORGE_PROTECTED_STATE_STAGED_NAME_STATUS !== undefined) {
28
- return parseNameStatus(process.env.FORGE_PROTECTED_STATE_STAGED_NAME_STATUS);
29
- }
30
-
31
- if (process.env.FORGE_PROTECTED_STATE_STAGED_FILES !== undefined) {
32
- 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
33
43
  .split(/\r?\n/)
34
44
  .map(line => line.trim())
35
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);
36
53
  }
37
54
 
38
- const output = execFileSync('git', ['diff', '--cached', '--name-status', '--diff-filter=ACMRDT'], {
39
- encoding: 'utf8',
40
- stdio: ['ignore', 'pipe', 'pipe'],
41
- });
42
- return parseNameStatus(output);
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)];
43
73
  }
44
74
 
45
75
  function getStagedContent(file) {
46
- if (process.env.FORGE_PROTECTED_STATE_STAGED_CONTENTS_JSON) {
76
+ if (!realStagedFiles.has(file) && process.env.FORGE_PROTECTED_STATE_STAGED_CONTENTS_JSON) {
47
77
  const contents = JSON.parse(process.env.FORGE_PROTECTED_STATE_STAGED_CONTENTS_JSON);
48
78
  return Object.prototype.hasOwnProperty.call(contents, file) ? contents[file] : null;
49
79
  }
@@ -54,10 +84,370 @@ function getStagedContent(file) {
54
84
  stdio: ['ignore', 'pipe', 'pipe'],
55
85
  });
56
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;
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/;
136
+
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, {
165
+ encoding: 'utf8',
166
+ stdio: ['ignore', 'pipe', 'pipe'],
167
+ env: GIT_PROBE_ENV,
168
+ }).trim();
169
+ }
170
+
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 {
57
178
  return null;
58
179
  }
59
180
  }
60
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]}`;
238
+ }
239
+
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
+
61
451
  async function main() {
62
452
  const actor =
63
453
  process.env.FORGE_PROTECTED_STATE_ACTOR ||
@@ -65,10 +455,28 @@ async function main() {
65
455
  process.env.USER ||
66
456
  process.env.USERNAME ||
67
457
  'unknown';
458
+ const sourceHead = getCurrentHead();
459
+ const isMergeCarryOver = createMergeExemption();
460
+ const mergeExempt = [];
68
461
  const probes = getStagedFiles()
69
462
  .map(file => {
70
463
  const probe = assertProtectedWriteAllowed(file, { actor, operation: 'staged_edit' });
71
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
+ }
72
480
 
73
481
  const content = getStagedContent(probe.path);
74
482
  if (content === null) return { probe };
@@ -79,7 +487,8 @@ async function main() {
79
487
  surface: probe.requiredSurface,
80
488
  path: probe.path,
81
489
  content,
82
- operation: 'staged_edit',
490
+ operation: deletedFiles.has(probe.path) ? 'staged_delete' : 'staged_edit',
491
+ sourceHead,
83
492
  },
84
493
  };
85
494
  });
@@ -87,17 +496,23 @@ async function main() {
87
496
  const authorization = await authorizeAndConsumeProtectedStateWrites(
88
497
  process.cwd(),
89
498
  protectedProbes.map(entry => entry.request),
499
+ { sourceHead },
90
500
  );
91
501
  let authorizationIndex = 0;
92
502
  const decisions = probes
93
503
  .map(entry => {
504
+ if (entry.directDecision) return entry.directDecision;
94
505
  if (!entry.request) return entry.probe;
95
506
  const trustedDecision = authorization.decisions[authorizationIndex++];
507
+ if (!trustedDecision && authorization.batchDecision) return { ...entry.probe, allowed: true };
96
508
  return trustedDecision.allowed
97
509
  ? trustedDecision
98
510
  : { ...entry.probe, ...trustedDecision, repairHint: entry.probe.repairHint };
99
511
  })
100
512
  .filter(decision => !decision.allowed);
513
+ if (authorization.batchDecision && !authorization.batchDecision.allowed) {
514
+ decisions.push(authorization.batchDecision);
515
+ }
101
516
 
102
517
  for (const decision of decisions) {
103
518
  const audit = recordProtectedStateAuditEvent(decision, { cwd: process.cwd() });
@@ -106,6 +521,10 @@ async function main() {
106
521
  }
107
522
  }
108
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
+
109
528
  if (decisions.length === 0) {
110
529
  console.log('OK: No protected state edits detected.');
111
530
  process.exit(0);
@@ -123,7 +542,11 @@ async function main() {
123
542
  process.exit(1);
124
543
  }
125
544
 
126
- main().catch(error => {
127
- console.error(`Protected state check failed: ${error.message}`);
128
- process.exit(1);
129
- });
545
+ if (require.main === module) {
546
+ main().catch(error => {
547
+ console.error(`Protected state check failed: ${error.message}`);
548
+ process.exit(1);
549
+ });
550
+ }
551
+
552
+ module.exports = { getCurrentHead };