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
@@ -0,0 +1,339 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const { stableStringify } = require('../kernel/evaluators');
6
+
7
+ const SURFACE_KINDS = new Set(['path', 'command', 'schema']);
8
+
9
+ function sha256(value) {
10
+ return `sha256:${crypto.createHash('sha256').update(value, 'utf8').digest('hex')}`;
11
+ }
12
+
13
+ function hashManifest(manifest) {
14
+ const { manifest_hash: _manifestHash, ...payload } = manifest;
15
+ return sha256(stableStringify(payload));
16
+ }
17
+
18
+ function assert(condition, message) {
19
+ if (!condition) throw new Error(`Invalid risk manifest: ${message}`);
20
+ }
21
+
22
+ function assertUniqueIds(entries, label) {
23
+ const ids = new Set();
24
+ for (const entry of entries) {
25
+ assert(entry && typeof entry.id === 'string' && entry.id.length > 0, `${label} id is required`);
26
+ assert(!ids.has(entry.id), `duplicate ${label} id "${entry.id}"`);
27
+ ids.add(entry.id);
28
+ }
29
+ return ids;
30
+ }
31
+
32
+ function normalizePathValue(value) {
33
+ return value.replaceAll('\\', '/').replace(/^\.\//, '');
34
+ }
35
+
36
+ function matchesPathRoot(value, root) {
37
+ const normalizedValue = normalizePathValue(value);
38
+ const normalizedRoot = normalizePathValue(root).replace(/\/$/, '');
39
+ return normalizedValue === normalizedRoot || normalizedValue.startsWith(`${normalizedRoot}/`);
40
+ }
41
+
42
+ function pathSelectorsOverlap(left, right) {
43
+ const leftIsDirectory = left.endsWith('/');
44
+ const rightIsDirectory = right.endsWith('/');
45
+ if (!leftIsDirectory && !rightIsDirectory) return normalizePathValue(left) === normalizePathValue(right);
46
+ if (leftIsDirectory && matchesPathRoot(right.replace(/\/$/, ''), left)) return true;
47
+ return rightIsDirectory && matchesPathRoot(left.replace(/\/$/, ''), right);
48
+ }
49
+
50
+ function selectorsOverlap(left, right) {
51
+ if (left.kind !== right.kind) return false;
52
+ if (left.kind === 'path') return pathSelectorsOverlap(left.prefix, right.prefix);
53
+ return left.prefix.startsWith(right.prefix) || right.prefix.startsWith(left.prefix);
54
+ }
55
+
56
+ function validateCommandReferences(entries, label, commandIds) {
57
+ const ids = assertUniqueIds(entries, label);
58
+ for (const entry of entries) {
59
+ assert(Array.isArray(entry.command_ids) && entry.command_ids.length > 0,
60
+ `${label} "${entry.id}" needs command_ids`);
61
+ assert(entry.command_ids.every((id) => commandIds.has(id)), `${label} "${entry.id}" references an unknown command`);
62
+ }
63
+ return ids;
64
+ }
65
+
66
+ function validateCommandRegistry(manifest) {
67
+ assert(Array.isArray(manifest.commands) && manifest.commands.length > 0, 'commands must be non-empty');
68
+ assert(Array.isArray(manifest.gates) && manifest.gates.length > 0, 'gates must be non-empty');
69
+ assert(Array.isArray(manifest.lanes) && manifest.lanes.length > 0, 'lanes must be non-empty');
70
+ const commandIds = assertUniqueIds(manifest.commands, 'command');
71
+ for (const command of manifest.commands) {
72
+ assert(typeof command.executable === 'string' && command.executable.length > 0,
73
+ `command "${command.id}" needs an executable`);
74
+ assert(Array.isArray(command.argv) && command.argv.every((argument) => typeof argument === 'string'),
75
+ `command "${command.id}" must use an argv array`);
76
+ assert(!Object.hasOwn(command, 'shell'), `command "${command.id}" cannot declare a shell string`);
77
+ }
78
+ return {
79
+ gateIds: validateCommandReferences(manifest.gates, 'gate', commandIds),
80
+ laneIds: validateCommandReferences(manifest.lanes, 'lane', commandIds),
81
+ };
82
+ }
83
+
84
+ function validateRisks(risks, gateIds) {
85
+ const riskIds = assertUniqueIds(risks, 'risk');
86
+ for (const risk of risks) {
87
+ assert(/^S[0-3]$/.test(risk.severity), `risk "${risk.id}" has invalid severity`);
88
+ assert(typeof risk.non_quarantinable === 'boolean', `risk "${risk.id}" must declare non_quarantinable`);
89
+ assert(Array.isArray(risk.gate_ids) && risk.gate_ids.length > 0,
90
+ `risk "${risk.id}" must own at least one gate`);
91
+ assert(risk.gate_ids.every((id) => gateIds.has(id)), `risk "${risk.id}" references an unknown gate`);
92
+ }
93
+ return riskIds;
94
+ }
95
+
96
+ function validateOwner(owner, riskIds, laneIds) {
97
+ assert(typeof owner.product === 'string' && owner.product.length > 0, `owner "${owner.id}" needs product`);
98
+ assert(typeof owner.package === 'string' && owner.package.length > 0, `owner "${owner.id}" needs package`);
99
+ assert(Array.isArray(owner.risk_ids) && owner.risk_ids.length > 0, `owner "${owner.id}" needs risk_ids`);
100
+ assert(owner.risk_ids.every((id) => riskIds.has(id)), `owner "${owner.id}" references an unknown risk`);
101
+ assert(Array.isArray(owner.canonical_test_ids) && owner.canonical_test_ids.length > 0,
102
+ `owner "${owner.id}" needs canonical_test_ids`);
103
+ assert(Array.isArray(owner.lanes) && owner.lanes.length > 0, `owner "${owner.id}" needs lanes`);
104
+ assert(owner.lanes.every((id) => laneIds.has(id)), `owner "${owner.id}" references an unknown lane`);
105
+ assert(Array.isArray(owner.dependent_routes), `owner "${owner.id}" needs dependent_routes`);
106
+ assert(Array.isArray(owner.platform_runtime_additions), `owner "${owner.id}" needs platform_runtime_additions`);
107
+ assert(Array.isArray(owner.selectors) && owner.selectors.length > 0, `owner "${owner.id}" needs selectors`);
108
+ assert(Array.isArray(owner.package_roots) && owner.package_roots.length > 0,
109
+ `owner "${owner.id}" needs package_roots`);
110
+ }
111
+
112
+ function collectOwnershipMappings(owners, riskIds, laneIds) {
113
+ assertUniqueIds(owners, 'owner');
114
+ const selectors = [];
115
+ const packageRoots = [];
116
+ for (const owner of owners) {
117
+ validateOwner(owner, riskIds, laneIds);
118
+ for (const selector of owner.selectors) {
119
+ assert(SURFACE_KINDS.has(selector.kind), `owner "${owner.id}" has invalid selector kind`);
120
+ assert(typeof selector.prefix === 'string' && selector.prefix.length > 0,
121
+ `owner "${owner.id}" has an empty selector prefix`);
122
+ selectors.push({ ...selector, owner_id: owner.id });
123
+ }
124
+ for (const prefix of owner.package_roots) {
125
+ assert(typeof prefix === 'string' && prefix.length > 0, `owner "${owner.id}" has an empty package root`);
126
+ packageRoots.push({ prefix, owner_id: owner.id });
127
+ }
128
+ }
129
+ return { selectors, packageRoots };
130
+ }
131
+
132
+ function assertOwnershipMappingsDoNotOverlap(selectors, packageRoots) {
133
+ for (let index = 0; index < selectors.length; index += 1) {
134
+ for (let peer = index + 1; peer < selectors.length; peer += 1) {
135
+ assert(!selectorsOverlap(selectors[index], selectors[peer]),
136
+ `selectors overlap between "${selectors[index].owner_id}" and "${selectors[peer].owner_id}"`);
137
+ }
138
+ }
139
+ for (let index = 0; index < packageRoots.length; index += 1) {
140
+ for (let peer = index + 1; peer < packageRoots.length; peer += 1) {
141
+ const left = packageRoots[index];
142
+ const right = packageRoots[peer];
143
+ assert(!(matchesPathRoot(left.prefix, right.prefix) || matchesPathRoot(right.prefix, left.prefix)),
144
+ `package roots overlap between "${left.owner_id}" and "${right.owner_id}"`);
145
+ }
146
+ }
147
+ }
148
+
149
+ const REQUIRED_CONSERVATIVE_GATES = ['G0', 'G1', 'G3', 'G6'];
150
+
151
+ function validateFallback(manifest, laneIds, gateIds) {
152
+ assert(manifest.unknown_owner_fallback?.lane === 'repository-baseline',
153
+ 'unknown_owner_fallback must select repository-baseline');
154
+ assert(laneIds.has(manifest.unknown_owner_fallback?.lane), 'unknown_owner_fallback references an unknown lane');
155
+ assert(laneIds.has('contract-baseline') && laneIds.has('affected-platform-baseline'),
156
+ 'conservative fallback lanes must be executable');
157
+ assert(Array.isArray(manifest.unknown_owner_fallback.required_gates)
158
+ && manifest.unknown_owner_fallback.required_gates.length > 0,
159
+ 'unknown_owner_fallback must declare required_gates');
160
+ for (const gateId of REQUIRED_CONSERVATIVE_GATES) {
161
+ assert(gateIds.has(gateId), `conservative fallback references unknown gate ${gateId}`);
162
+ assert(manifest.unknown_owner_fallback.required_gates.includes(gateId),
163
+ `conservative fallback must require ${gateId}`);
164
+ }
165
+ }
166
+
167
+ function validateManifest(manifest, { verifyHash = true } = {}) {
168
+ assert(manifest && typeof manifest === 'object' && !Array.isArray(manifest), 'root must be an object');
169
+ assert(manifest.schema_id === 'forge.validation.risk-manifest.v1', 'unsupported schema_id');
170
+ assert(Number.isInteger(manifest.revision) && manifest.revision > 0, 'revision must be a positive integer');
171
+ assert(/^sha256:[a-f0-9]{64}$/.test(manifest.source_hash), 'source_hash must be a SHA-256 digest');
172
+ assert(typeof manifest.generator_version === 'string' && manifest.generator_version.length > 0,
173
+ 'generator_version is required');
174
+ assert(Array.isArray(manifest.risks) && manifest.risks.length > 0, 'risks must be non-empty');
175
+ assert(Array.isArray(manifest.owners) && manifest.owners.length > 0, 'owners must be non-empty');
176
+
177
+ const { gateIds, laneIds } = validateCommandRegistry(manifest);
178
+ const riskIds = validateRisks(manifest.risks, gateIds);
179
+ const mappings = collectOwnershipMappings(manifest.owners, riskIds, laneIds);
180
+ assertOwnershipMappingsDoNotOverlap(mappings.selectors, mappings.packageRoots);
181
+ validateFallback(manifest, laneIds, gateIds);
182
+
183
+ if (verifyHash) {
184
+ assert(/^sha256:[a-f0-9]{64}$/.test(manifest.manifest_hash), 'manifest_hash must be a SHA-256 digest');
185
+ assert(manifest.manifest_hash === hashManifest(manifest), 'manifest hash mismatch');
186
+ }
187
+ return manifest;
188
+ }
189
+
190
+ function loadRiskManifest(input) {
191
+ const manifest = typeof input === 'string'
192
+ ? JSON.parse(fs.readFileSync(input, 'utf8'))
193
+ : structuredClone(input);
194
+ return validateManifest(manifest);
195
+ }
196
+
197
+ function normalizeSurface(surface) {
198
+ assert(surface && SURFACE_KINDS.has(surface.kind), 'changed surface has an invalid kind');
199
+ assert(typeof surface.value === 'string' && surface.value.trim().length > 0,
200
+ 'changed surface value is required');
201
+ const value = surface.kind === 'path'
202
+ ? normalizePathValue(surface.value.trim())
203
+ : surface.value.trim();
204
+ return { kind: surface.kind, value };
205
+ }
206
+
207
+ function uniqueSorted(values) {
208
+ return [...new Set(values)].sort(compareText);
209
+ }
210
+
211
+ function compareText(left, right) {
212
+ if (left === right) return 0;
213
+ return left < right ? -1 : 1;
214
+ }
215
+
216
+ function normalizeChangedSurfaces(changedSurfaces) {
217
+ assert(Array.isArray(changedSurfaces), 'changedSurfaces must be an array');
218
+ const keyed = new Map();
219
+ for (const surface of changedSurfaces) {
220
+ const normalized = normalizeSurface(surface);
221
+ keyed.set(`${normalized.kind}\0${normalized.value}`, normalized);
222
+ }
223
+ return [...keyed.values()].sort((left, right) => compareText(left.kind, right.kind)
224
+ || compareText(left.value, right.value));
225
+ }
226
+
227
+ function ownerForExactSurface(manifest, surface) {
228
+ for (const owner of manifest.owners) {
229
+ const selector = owner.selectors.find((candidate) => candidate.kind === surface.kind
230
+ && (surface.kind === 'path'
231
+ ? (candidate.prefix.endsWith('/')
232
+ ? matchesPathRoot(surface.value, candidate.prefix)
233
+ : surface.value === normalizePathValue(candidate.prefix))
234
+ : surface.value.startsWith(candidate.prefix)));
235
+ if (selector) return { owner, selector };
236
+ }
237
+ return undefined;
238
+ }
239
+
240
+ function ownerForKnownPackage(manifest, surface) {
241
+ if (surface.kind !== 'path') return undefined;
242
+ return manifest.owners.find((owner) => owner.package_roots.some((root) => matchesPathRoot(surface.value, root)));
243
+ }
244
+
245
+ function ownerRisks(manifest, owners) {
246
+ const ids = new Set(owners.flatMap((owner) => owner.risk_ids));
247
+ return manifest.risks.filter((risk) => ids.has(risk.id));
248
+ }
249
+
250
+ function resolveCommands(manifest, gateIds, laneIds) {
251
+ const selectedDefinitions = [
252
+ ...manifest.gates.filter((gate) => gateIds.includes(gate.id)),
253
+ ...manifest.lanes.filter((lane) => laneIds.includes(lane.id)),
254
+ ];
255
+ const commandIds = uniqueSorted(selectedDefinitions.flatMap((definition) => definition.command_ids));
256
+ const commandsById = new Map(manifest.commands.map((command) => [command.id, command]));
257
+ return commandIds.map((id) => {
258
+ const command = commandsById.get(id);
259
+ return { id: command.id, executable: command.executable, argv: [...command.argv] };
260
+ });
261
+ }
262
+
263
+ function selectValidation({ manifest, changedSurfaces }) {
264
+ validateManifest(manifest);
265
+ const surfaces = normalizeChangedSurfaces(changedSurfaces);
266
+ const selections = surfaces.map((surface) => ({
267
+ surface,
268
+ exact: ownerForExactSurface(manifest, surface),
269
+ package: ownerForKnownPackage(manifest, surface),
270
+ }));
271
+ const unowned = selections.filter((selection) => !selection.exact && !selection.package)
272
+ .map((selection) => selection.surface);
273
+ const ambiguous = selections.filter((selection) => !selection.exact && selection.package);
274
+ const exactOwners = selections.map((selection) => selection.exact?.owner).filter(Boolean);
275
+ const packageOwners = ambiguous.map((selection) => selection.package);
276
+ const owners = [...new Map([...exactOwners, ...packageOwners].map((owner) => [owner.id, owner])).values()]
277
+ .sort((left, right) => compareText(left.id, right.id));
278
+ const risks = ownerRisks(manifest, owners);
279
+
280
+ let status = 'exact';
281
+ let targetedPassAllowed = surfaces.length > 0;
282
+ let lanes = owners.flatMap((owner) => owner.lanes);
283
+ let requiredGates = risks.flatMap((risk) => risk.gate_ids);
284
+
285
+ if (unowned.length > 0 || surfaces.length === 0) {
286
+ status = 'repository-baseline';
287
+ targetedPassAllowed = false;
288
+ lanes = [manifest.unknown_owner_fallback.lane];
289
+ requiredGates = manifest.unknown_owner_fallback.required_gates;
290
+ } else if (ambiguous.length > 0) {
291
+ status = 'conservative-package';
292
+ targetedPassAllowed = false;
293
+ lanes.push('contract-baseline', 'affected-platform-baseline');
294
+ requiredGates.push('G0', 'G1', 'G3', 'G6');
295
+ }
296
+
297
+ const selectedLanes = uniqueSorted(lanes);
298
+ const selectedGates = uniqueSorted(requiredGates);
299
+
300
+ return {
301
+ schema_id: 'forge.validation.selection.v1',
302
+ manifest_revision: manifest.revision,
303
+ manifest_digest: manifest.manifest_hash,
304
+ status,
305
+ targeted_pass_allowed: targetedPassAllowed,
306
+ changed_surfaces: surfaces,
307
+ unowned_surfaces: unowned,
308
+ matched_selectors: selections.filter((selection) => selection.exact).map((selection) => ({
309
+ surface: selection.surface,
310
+ owner_id: selection.exact.owner.id,
311
+ selector: selection.exact.selector,
312
+ })),
313
+ owner_ids: owners.map((owner) => owner.id),
314
+ owner_selections: owners.map((owner) => ({
315
+ owner_id: owner.id,
316
+ product: owner.product,
317
+ package: owner.package,
318
+ risk_ids: uniqueSorted(owner.risk_ids),
319
+ lanes: uniqueSorted(owner.lanes),
320
+ canonical_test_ids: uniqueSorted(owner.canonical_test_ids),
321
+ dependent_routes: uniqueSorted(owner.dependent_routes),
322
+ platform_runtime_additions: uniqueSorted(owner.platform_runtime_additions),
323
+ })),
324
+ risk_ids: uniqueSorted(risks.map((risk) => risk.id)),
325
+ required_gates: selectedGates,
326
+ lanes: selectedLanes,
327
+ dependent_routes: uniqueSorted(owners.flatMap((owner) => owner.dependent_routes)),
328
+ platform_runtime_additions: uniqueSorted(owners.flatMap((owner) => owner.platform_runtime_additions)),
329
+ test_ids: uniqueSorted(owners.flatMap((owner) => owner.canonical_test_ids)),
330
+ commands: resolveCommands(manifest, selectedGates, selectedLanes),
331
+ };
332
+ }
333
+
334
+ module.exports = {
335
+ hashManifest,
336
+ loadRiskManifest,
337
+ selectValidation,
338
+ validateManifest,
339
+ };
@@ -0,0 +1,190 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const { execFileSync } = require('node:child_process');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const { resolveWorktreeScope } = require('./protected-state-authority');
9
+
10
+ const SCHEMA_VERSION = 1;
11
+ const RECEIPT_TTL_MS = 60 * 60 * 1000;
12
+ const FUTURE_SKEW_MS = 60 * 1000;
13
+ const RUNNER_ID = 'forge-full-suite-v1';
14
+
15
+ function git(projectRoot, args, deps = {}) {
16
+ return (deps.execFileSync || execFileSync)('git', args, {
17
+ cwd: projectRoot,
18
+ encoding: 'utf8',
19
+ stdio: ['ignore', 'pipe', 'pipe'],
20
+ }).trim();
21
+ }
22
+
23
+ function resolveGitRoot(projectRoot, deps = {}) {
24
+ return path.resolve(git(projectRoot, ['rev-parse', '--show-toplevel'], deps));
25
+ }
26
+
27
+ function resolveReceiptPath(projectRoot, deps = {}) {
28
+ const root = resolveGitRoot(projectRoot, deps);
29
+ const gitPath = git(root, ['rev-parse', '--git-path', 'forge/validation-receipt.json'], deps);
30
+ return path.resolve(root, gitPath);
31
+ }
32
+
33
+ function resolveKeyPath(deps = {}) {
34
+ return path.join(deps.homeDir || os.homedir(), '.forge', 'validation-receipt.key');
35
+ }
36
+
37
+ function runtimeIdentity(deps = {}) {
38
+ return deps.runtimeIdentity
39
+ || [process.platform, process.arch, process.versions.node, process.versions.bun || 'node'].join(':');
40
+ }
41
+
42
+ function commandIdentity(command, projectRoot, env, run) {
43
+ const version = run(command, ['--version'], {
44
+ cwd: projectRoot,
45
+ encoding: 'utf8',
46
+ env,
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ }).trim();
49
+ const located = path.isAbsolute(command)
50
+ ? fs.realpathSync.native(command)
51
+ : run(process.platform === 'win32' ? 'where.exe' : 'which', [command], {
52
+ encoding: 'utf8',
53
+ env,
54
+ stdio: ['ignore', 'pipe', 'pipe'],
55
+ }).trim().split(/\r?\n/)[0];
56
+ return `${process.platform === 'win32' ? located.toLowerCase() : located}:${version}`;
57
+ }
58
+
59
+ function testRuntimeIdentity(projectRoot, deps = {}) {
60
+ if (deps.testRuntimeIdentity) return deps.testRuntimeIdentity;
61
+ const env = deps.env || process.env;
62
+ const run = deps.runtimeExecFileSync || execFileSync;
63
+ return [env.BUN_EXE || 'bun', 'node']
64
+ .map(command => commandIdentity(command, projectRoot, env, run))
65
+ .join('|');
66
+ }
67
+
68
+ function captureState(projectRoot, deps = {}) {
69
+ const root = resolveGitRoot(projectRoot, deps);
70
+ return {
71
+ worktree: resolveWorktreeScope(root, deps),
72
+ head: git(root, ['rev-parse', 'HEAD'], deps),
73
+ clean: git(root, ['status', '--porcelain=v1', '--untracked-files=all'], deps) === '',
74
+ runtime: runtimeIdentity(deps),
75
+ testRuntime: testRuntimeIdentity(root, deps),
76
+ runner: RUNNER_ID,
77
+ };
78
+ }
79
+
80
+ function sameState(left, right) {
81
+ return left?.clean === true
82
+ && right?.clean === true
83
+ && left.worktree === right.worktree
84
+ && left.head === right.head
85
+ && left.runtime === right.runtime
86
+ && left.testRuntime === right.testRuntime
87
+ && left.runner === right.runner;
88
+ }
89
+
90
+ function validationIsComplete(result) {
91
+ const checks = result?.checks || {};
92
+ const tests = checks.tests || {};
93
+ return result?.success === true
94
+ && checks.conflictMarkers?.success === true
95
+ && checks.typeCheck?.success === true
96
+ && (checks.typeCheck.skipped !== true || checks.typeCheck.notConfigured === true)
97
+ && checks.lint?.success === true && checks.lint?.skipped !== true
98
+ && checks.security?.success === true && checks.security?.skipped !== true
99
+ && tests.success === true && tests.skipped !== true
100
+ && tests.fullSuite === true && tests.testsFound === true
101
+ && tests.total > 0 && tests.failed === 0;
102
+ }
103
+
104
+ function readOrCreateKey(deps = {}) {
105
+ const keyPath = resolveKeyPath(deps);
106
+ fs.mkdirSync(path.dirname(keyPath), { recursive: true });
107
+ try {
108
+ fs.writeFileSync(keyPath, crypto.randomBytes(32), { flag: 'wx', mode: 0o600 });
109
+ } catch (error) {
110
+ if (error.code !== 'EEXIST') throw error;
111
+ }
112
+ return fs.readFileSync(keyPath);
113
+ }
114
+
115
+ function sign(payload, deps = {}) {
116
+ return crypto.createHmac('sha256', readOrCreateKey(deps))
117
+ .update(JSON.stringify(payload))
118
+ .digest('hex');
119
+ }
120
+
121
+ function signatureMatches(payload, signature, deps = {}) {
122
+ if (!/^[a-f0-9]{64}$/.test(signature || '')) return false;
123
+ const expected = Buffer.from(sign(payload, deps), 'hex');
124
+ const actual = Buffer.from(signature, 'hex');
125
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
126
+ }
127
+
128
+ function writeAtomic(filePath, content) {
129
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
130
+ const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
131
+ try {
132
+ fs.writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 });
133
+ fs.renameSync(temporary, filePath);
134
+ } finally {
135
+ fs.rmSync(temporary, { force: true });
136
+ }
137
+ }
138
+
139
+ function beginValidation(projectRoot, deps = {}) {
140
+ try {
141
+ fs.rmSync(resolveReceiptPath(projectRoot, deps), { force: true });
142
+ const state = captureState(projectRoot, deps);
143
+ return state.clean ? state : null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function completeValidation(projectRoot, snapshot, result, deps = {}) {
150
+ try {
151
+ if (!snapshot || !validationIsComplete(result)) return false;
152
+ const current = captureState(projectRoot, deps);
153
+ if (!sameState(snapshot, current)) return false;
154
+ const issuedAt = (deps.now || Date.now)();
155
+ const payload = {
156
+ schemaVersion: SCHEMA_VERSION,
157
+ ...current,
158
+ issuedAt,
159
+ expiresAt: issuedAt + RECEIPT_TTL_MS,
160
+ gates: ['conflictMarkers', 'typeCheck', 'lint', 'security', 'tests'],
161
+ };
162
+ writeAtomic(resolveReceiptPath(projectRoot, deps), JSON.stringify({ payload, signature: sign(payload, deps) }));
163
+ return true;
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ function verifyValidationReceipt(projectRoot, deps = {}) {
170
+ try {
171
+ const receipt = JSON.parse(fs.readFileSync(resolveReceiptPath(projectRoot, deps), 'utf8'));
172
+ if (receipt?.payload?.schemaVersion !== SCHEMA_VERSION) return { valid: false, reason: 'schema' };
173
+ if (!signatureMatches(receipt.payload, receipt.signature, deps)) return { valid: false, reason: 'signature' };
174
+ const now = (deps.now || Date.now)();
175
+ if (receipt.payload.issuedAt > now + FUTURE_SKEW_MS) return { valid: false, reason: 'future' };
176
+ if (receipt.payload.expiresAt < now) return { valid: false, reason: 'expired' };
177
+ if (!sameState(receipt.payload, captureState(projectRoot, deps))) return { valid: false, reason: 'state' };
178
+ return { valid: true, head: receipt.payload.head };
179
+ } catch {
180
+ return { valid: false, reason: 'missing-or-invalid' };
181
+ }
182
+ }
183
+
184
+ module.exports = {
185
+ beginValidation,
186
+ completeValidation,
187
+ resolveReceiptPath,
188
+ validationIsComplete,
189
+ verifyValidationReceipt,
190
+ };
@@ -10,6 +10,11 @@ const {
10
10
  readWorkflowState,
11
11
  } = require('./state');
12
12
  const { loadState, WORKFLOW_STATE_FILENAME } = require('./state-manager');
13
+ const {
14
+ findPlanWorkFolder,
15
+ readPlanSnapshot,
16
+ reconcilePlanAuthority,
17
+ } = require('./plan-authority');
13
18
 
14
19
  const STATELESS_ENTRY_STAGES = new Set(['plan', 'dev', 'validate', 'verify']);
15
20
 
@@ -204,6 +209,42 @@ async function resolveKernelContext({ kernelDriver, activeIssueId, branch, proje
204
209
  return { driver, issueId };
205
210
  }
206
211
 
212
+ function linkedWorkFolder(driver, issueId, branch) {
213
+ if (!driver || typeof driver.listWorktrees !== 'function') return null;
214
+ try {
215
+ const row = (driver.listWorktrees() || []).find((entry) => entry?.issue_id === issueId
216
+ && (!branch || entry.branch === branch) && (entry.state === 'active' || entry.state == null));
217
+ return row?.work_folder || null;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ function planRepairCommand(branch, issueId) {
224
+ const normalized = String(branch || '').replace(/^[^/]+\//, '').split('-').filter(Boolean).join(' ');
225
+ return `forge plan ${JSON.stringify(normalized || 'feature')} --issue ${issueId}`;
226
+ }
227
+
228
+ function enforcePlanAuthorityForDev({ driver, issueId, projectRoot, branch }) {
229
+ if (!projectRoot || !driver || typeof driver.loadPlanSnapshot !== 'function') return;
230
+ const snapshot = readPlanSnapshot(driver, issueId);
231
+ const slug = String(branch || '').replace(/^[^/]+\//, '');
232
+ const workFolder = linkedWorkFolder(driver, issueId, branch)
233
+ || (!snapshot ? findPlanWorkFolder(projectRoot, slug) : null);
234
+ // Pre-0.1.0 issues without either a snapshot or a linked repository folder are
235
+ // intentionally unmanaged for compatibility. Once either side exists, the
236
+ // authority contract is strict and all missing/drift cases fail closed.
237
+ if (!snapshot && !workFolder) return;
238
+ reconcilePlanAuthority({
239
+ driver,
240
+ issueId,
241
+ projectRoot,
242
+ workFolder,
243
+ mode: 'dev',
244
+ repairCommand: planRepairCommand(branch, issueId),
245
+ });
246
+ }
247
+
207
248
  function getOverrideInput(flags = {}) {
208
249
  if (Object.hasOwn(flags, 'overrideStage')) {
209
250
  return flags.overrideStage;
@@ -416,6 +457,9 @@ async function enforceStageEntry({
416
457
  // gate below requires the predecessor to be done, not merely entered).
417
458
  const finish = (result) => {
418
459
  if (kernelActive) {
460
+ if (stageId === 'dev') {
461
+ enforcePlanAuthorityForDev({ driver, issueId, projectRoot, branch: branch || detectBranchName(projectRoot) });
462
+ }
419
463
  recordStageRunSafe(driver, issueId, stageId, 'start', warn);
420
464
  invalidateDownstreamStages(driver, issueId, stageId, warn);
421
465
  result.recordCompletion = () => recordStageRunSafe(driver, issueId, stageId, 'complete', warn);