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
@@ -13,6 +13,10 @@ const path = require('node:path');
13
13
  const { execFileSync } = require('node:child_process');
14
14
  const { resolveIssueBackend } = require('../issue-backend');
15
15
  const { runIssueOperation } = require('../forge-issues');
16
+ const {
17
+ findPlanWorkFolder,
18
+ reconcilePlanAuthority,
19
+ } = require('../workflow/plan-authority');
16
20
 
17
21
  // Constants for security
18
22
  // Note: cwd is resolved at call-time via getExecOptions() to avoid stale require-time snapshots
@@ -483,7 +487,7 @@ async function registerBranchIssueLinkage(options, branch, issueId) {
483
487
  branch,
484
488
  actor: null,
485
489
  issue_id: issueId,
486
- work_folder: null,
490
+ work_folder: options.workFolder || null,
487
491
  registered_at: new Date().toISOString(),
488
492
  state: 'active',
489
493
  });
@@ -976,11 +980,38 @@ async function executePlan(featureName, options = {}) { // NOSONAR S3776
976
980
  };
977
981
  }
978
982
 
983
+ const workFolder = options.workFolder || findPlanWorkFolder(options.projectRoot || process.cwd(), featureSlug);
984
+
979
985
  // F1: persist the branch->issue linkage so a plan-created branch resolves
980
986
  // to its issue for kernel-authoritative stage state (dev/validate/ship).
981
987
  // Kernel-only, best-effort.
982
988
  if (issueBackend === 'kernel') {
983
- await registerBranchIssueLinkage({ ...options, issueBackend }, branch.branchName, issue.issueId);
989
+ await registerBranchIssueLinkage({ ...options, issueBackend, workFolder }, branch.branchName, issue.issueId);
990
+ await withPlanDriver(options, driver => {
991
+ if (!driver || typeof driver.loadPlanSnapshot !== 'function') return null;
992
+ let existing;
993
+ try {
994
+ existing = driver.loadPlanSnapshot({ issue_id: issue.issueId }, {});
995
+ } catch (error) {
996
+ // Injectable issue runners used by embedding callers may not persist into
997
+ // this driver's store. With no repository artifacts there is nothing to
998
+ // reconcile; preserve that legacy embedding contract.
999
+ if (/^Issue .* not found in the kernel$/i.test(error.message)) {
1000
+ if (!workFolder) return null;
1001
+ throw new Error(`Issue ${issue.issueId} is not available for plan authority persistence`);
1002
+ }
1003
+ throw error;
1004
+ }
1005
+ if (!existing && !workFolder) return null;
1006
+ return reconcilePlanAuthority({
1007
+ driver,
1008
+ issueId: issue.issueId,
1009
+ projectRoot: options.projectRoot || process.cwd(),
1010
+ workFolder,
1011
+ mode: 'plan',
1012
+ repairCommand: `forge plan ${JSON.stringify(featureName)} --issue ${issue.issueId}`,
1013
+ });
1014
+ });
984
1015
  }
985
1016
 
986
1017
  // Build result summary
@@ -81,6 +81,8 @@ async function handler(args, flags, projectRoot, opts) {
81
81
 
82
82
  module.exports = {
83
83
  name: 'pr',
84
+ githubAuth: (args = []) => ['ship', 'merge', 'shepherd']
85
+ .includes(stripGlobalFlags(args).find(arg => !arg.startsWith('-'))),
84
86
  description:
85
87
  'Unified pull-request surface: forge pr ship|preflight|shepherd|merge (wraps ship/preflight/shepherd/merge)',
86
88
  usage,
@@ -88,11 +88,19 @@ function resolveBaseRef(exec) {
88
88
  * @param {{ runAll?: boolean }} [opts]
89
89
  * @returns {{ resolved: boolean, changedFiles: string[], reason?: string, baseRef?: string }}
90
90
  */
91
- function resolveChangeSet(exec = execFileSync, { runAll = false } = {}) {
91
+ function resolveChangeSet(exec = execFileSync, { runAll = false, baseRef: explicitBaseRef = null } = {}) {
92
92
  if (runAll) {
93
93
  return { resolved: true, changedFiles: [], reason: 'whole-tree scope (--all)' };
94
94
  }
95
- const baseRef = resolveBaseRef(exec);
95
+ const baseRef = explicitBaseRef || resolveBaseRef(exec);
96
+ if (explicitBaseRef && !gitVerifyRef(exec, explicitBaseRef)) {
97
+ return {
98
+ resolved: false,
99
+ baseRef: explicitBaseRef,
100
+ changedFiles: [],
101
+ reason: `explicit base branch ${explicitBaseRef} is unavailable`,
102
+ };
103
+ }
96
104
  if (!baseRef) {
97
105
  return {
98
106
  resolved: false,
@@ -4,8 +4,9 @@ const { execFileSync, spawnSync } = require('node:child_process');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const forgeToken = require('../../scripts/check-forge-token');
7
- const { QUICK_LANE_ENV_VAR, QUICK_LANE_VALUE } = require('../../scripts/test');
7
+ const { QUICK_LANE_ENV_VAR, QUICK_LANE_VALUE, resolveFullSuiteTimeoutMs } = require('../../scripts/test');
8
8
  const { fireAndForget } = require('../pr-monitor/reconcile-executor');
9
+ const { verifyValidationReceipt } = require('../validation-receipt');
9
10
 
10
11
  const isWindows = process.platform === 'win32';
11
12
 
@@ -127,8 +128,8 @@ function maybeTriggerShepherdAfterPush({
127
128
  try {
128
129
  trigger({ projectRoot });
129
130
  return { armed: true };
130
- } catch (err) {
131
- return { armed: false, reason: err.message };
131
+ } catch {
132
+ return { armed: false, reason: 'shepherd-launch-failed' };
132
133
  }
133
134
  }
134
135
 
@@ -191,6 +192,94 @@ function runLint(spawnFn, pkgManager, projectRoot) {
191
192
  return lintResult.status === 0;
192
193
  }
193
194
 
195
+ /**
196
+ * Reports the kill signal a timed-out test run is terminated with. Shared by
197
+ * the spawn options and the timeout detector so the two cannot drift.
198
+ */
199
+ const TEST_RUN_KILL_SIGNAL = 'SIGKILL';
200
+
201
+ /**
202
+ * Reads the terminating signal under either field name.
203
+ *
204
+ * `node:child_process.spawnSync` reports `signal`; native `Bun.spawnSync`
205
+ * reports `signalCode`. Read both rather than assuming one.
206
+ *
207
+ * @param {Object} testResult - spawnSync result object
208
+ * @returns {string|null} Signal name, or null when none was reported
209
+ */
210
+ function terminationSignalOf(testResult) {
211
+ return testResult.signal || testResult.signalCode || null;
212
+ }
213
+
214
+ /**
215
+ * Reports whether a test run was killed by its own wall-clock budget.
216
+ *
217
+ * Requires AFFIRMATIVE evidence. Verified against pinned Bun 1.3.12 on Windows:
218
+ * a `node:child_process` spawnSync timeout returns `status: null`,
219
+ * `signal: <killSignal>`, AND an error whose `code` is `ETIMEDOUT`. Native
220
+ * `Bun.spawnSync` instead sets `exitedDueToTimeout: true` with `signalCode`.
221
+ * Those two markers are the only proof of a timeout, and they are checked
222
+ * BEFORE the generic error branch — testing `error` first swallowed the timeout
223
+ * diagnostic in exactly the case it was written for.
224
+ *
225
+ * A bare kill signal with no marker is deliberately NOT treated as a timeout:
226
+ * an OOM kill or an operator `kill -9` produces the identical status/signal
227
+ * shape, and since a genuine budget kill always carries a marker, inferring
228
+ * from the signal alone buys nothing and costs a misdiagnosis (it would claim
229
+ * the budget elapsed and send the user to raise FORGE_TEST_TIMEOUT_MS).
230
+ *
231
+ * @param {Object} testResult - spawnSync result object
232
+ * @returns {boolean} True when the run hit its budget
233
+ */
234
+ function isTimeoutTermination(testResult) {
235
+ if (testResult.exitedDueToTimeout === true) return true;
236
+ return Boolean(testResult.error) && testResult.error.code === 'ETIMEDOUT';
237
+ }
238
+
239
+ /**
240
+ * Explain a test run that never produced an exit status.
241
+ *
242
+ * `spawnSync` reports `status: null` when the child was killed by a signal
243
+ * (timeout kill included) or never started at all. Without this the push
244
+ * aborted with no summary at all, which is indistinguishable from a genuine
245
+ * test failure.
246
+ *
247
+ * @param {Object} testResult - spawnSync result object
248
+ * @param {number} timeoutMs - Wall-clock budget applied to the run
249
+ * @param {function} log - Logger function
250
+ */
251
+ function reportTestRunTermination(testResult, timeoutMs, log) {
252
+ const signal = terminationSignalOf(testResult);
253
+
254
+ if (isTimeoutTermination(testResult)) {
255
+ log(
256
+ `Test run timed out after ${Math.round(timeoutMs / 1000)}s `
257
+ + `(killed by ${signal || 'timeout'}) — push aborted.`,
258
+ );
259
+ log('Raise the budget with FORGE_TEST_TIMEOUT_MS if this machine is slower than the default.');
260
+ return;
261
+ }
262
+
263
+ if (testResult.error) {
264
+ log(`Test run could not complete: ${testResult.error.message}`);
265
+ return;
266
+ }
267
+
268
+ if (signal) {
269
+ // Signalled without timeout evidence: report the fact and the likely
270
+ // causes. Do NOT blame the budget — that sends the user after the wrong
271
+ // lever and hides the real failure.
272
+ log(
273
+ `Test run was terminated by ${signal} before finishing — push aborted. `
274
+ + `The run had a ${Math.round(timeoutMs / 1000)}s budget but reported no timeout, `
275
+ + 'so this is most likely an external kill or an out-of-memory kill.',
276
+ );
277
+ return;
278
+ }
279
+
280
+ log('Test run ended without an exit status — push aborted.');
281
+ }
282
+
194
283
  /**
195
284
  * Run tests unless in quick mode, logging warnings for first push.
196
285
  * @param {function} spawnFn - spawnSync or mock
@@ -199,9 +288,10 @@ function runLint(spawnFn, pkgManager, projectRoot) {
199
288
  * @param {string} projectRoot - Absolute path to project root
200
289
  * @param {boolean} quickMode - Whether to skip tests
201
290
  * @param {function} log - Logger function
291
+ * @param {NodeJS.ProcessEnv} [env] - Environment used to resolve the test budget
202
292
  * @returns {boolean|undefined} True if tests passed, undefined if skipped
203
293
  */
204
- function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log) {
294
+ function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, env = process.env) {
205
295
  if (quickMode) {
206
296
  log('Tests skipped (--quick) — CI will run full suite on GitHub');
207
297
  const branch = getCurrentBranch(execFn);
@@ -211,12 +301,25 @@ function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log) {
211
301
  return undefined;
212
302
  }
213
303
 
304
+ // `forge push` runs the package-level `test` script, i.e. the whole suite.
305
+ // Budget it from the shared full-suite budget in scripts/test.js — which is
306
+ // sized as ~2x the measured healthy full-suite runtime and overridable with
307
+ // FORGE_TEST_TIMEOUT_MS — instead of an arbitrary local cap: the old fixed
308
+ // 120s killed a healthy full run mid-suite and pushed nothing. Never
309
+ // reintroduce a local constant here; change the shared budget with a fresh
310
+ // measurement instead.
311
+ const timeoutMs = resolveFullSuiteTimeoutMs(env);
214
312
  const testResult = spawnFn(pkgManager, ['run', 'test'], {
215
313
  stdio: 'inherit',
216
314
  shell: isWindows,
217
315
  cwd: projectRoot,
218
- timeout: 120000,
316
+ timeout: timeoutMs,
317
+ killSignal: TEST_RUN_KILL_SIGNAL,
219
318
  });
319
+ if (testResult.status === null || testResult.status === undefined) {
320
+ reportTestRunTermination(testResult, timeoutMs, log);
321
+ return false;
322
+ }
220
323
  return testResult.status === 0;
221
324
  }
222
325
 
@@ -244,6 +347,7 @@ module.exports = {
244
347
  const existsFn = deps?.existsSync || fs.existsSync;
245
348
  const log = deps?.log || console.log;
246
349
  const tokenWrite = deps?.writeForgeToken || forgeToken.write;
350
+ const verifyReceipt = deps?.verifyValidationReceipt || verifyValidationReceipt;
247
351
 
248
352
  const quickMode = !!(flags && (flags['--quick'] || flags.quick));
249
353
  const pkgManager = detectPackageManager(projectRoot, existsFn);
@@ -266,9 +370,16 @@ module.exports = {
266
370
  }
267
371
 
268
372
  // Step 3: Tests (skip in quick mode)
269
- const testsPassed = runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log);
373
+ const receipt = quickMode
374
+ ? { valid: false }
375
+ : verifyReceipt(resolveGitToplevel(execFn, projectRoot));
376
+ const validationReused = receipt.valid === true;
377
+ if (validationReused) log(`Tests reused from full validation at ${receipt.head || 'current HEAD'}`);
378
+ const testsPassed = validationReused
379
+ ? true
380
+ : runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, deps?.env || process.env);
270
381
  if (!quickMode && !testsPassed) {
271
- return { success: false, quickMode, lintPassed: true, testsPassed: false, pushed: false };
382
+ return { success: false, quickMode, lintPassed: true, testsPassed: false, validationReused, pushed: false };
272
383
  }
273
384
 
274
385
  // Step 4: Write nonce token so lefthook skips pre-push hooks.
@@ -290,7 +401,7 @@ module.exports = {
290
401
  success: false,
291
402
  quickMode,
292
403
  lintPassed: true,
293
- ...(quickMode ? {} : { testsPassed: true }),
404
+ ...(quickMode ? {} : { testsPassed: true, validationReused }),
294
405
  pushed: false,
295
406
  };
296
407
  }
@@ -305,7 +416,7 @@ module.exports = {
305
416
  success: true,
306
417
  quickMode,
307
418
  lintPassed: true,
308
- ...(quickMode ? {} : { testsPassed: true }),
419
+ ...(quickMode ? {} : { testsPassed: true, validationReused }),
309
420
  pushed: true,
310
421
  };
311
422
  },
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const memoryRouter = require('../memory/router');
4
+ const projectMemory = require('../project-memory');
5
+ const { types: { isProxy } } = require('node:util');
4
6
  const { stripGlobalFlags } = require('../global-flags');
5
7
  const { fenceUntrusted } = require('../untrusted-content');
6
8
  const { applyBudget, buildSection, estimateTokens } = require('../orientation');
@@ -15,10 +17,8 @@ const RECALL_CONTENT_BUDGET = 1100;
15
17
  // `--type`): `--type` is a reserved GLOBAL flag hard-validated to workflow classifications.
16
18
  const TYPE_TAG_PREFIX = 'type:';
17
19
 
18
- // When a `--kind` filter is active the tag filter runs in the command layer (the store is
19
- // not reimplemented), so scan a generous window of recent notes before filtering rather than
20
+ // Kind filtering is pushed into the Kernel read so limits and capped metadata stay truthful.
20
21
  // the small default page — otherwise the type match could fall outside the default limit.
21
- const TYPE_FILTER_SCAN = 1000;
22
22
 
23
23
  /**
24
24
  * Separate the optional positional query from `--kind <type>`, `--limit N`, and `--json`.
@@ -80,6 +80,13 @@ function withType(entry) {
80
80
  return type ? { ...entry, type } : entry;
81
81
  }
82
82
 
83
+ function privateCommandOption(options, field) {
84
+ if (!options || typeof options !== 'object' || isProxy(options)) return undefined;
85
+ let descriptor;
86
+ try { descriptor = Object.getOwnPropertyDescriptor(options, field); } catch { return undefined; }
87
+ return descriptor && Object.hasOwn(descriptor, 'value') ? descriptor.value : undefined;
88
+ }
89
+
83
90
  function formatEntry(entry) {
84
91
  const date = entry.timestamp ? entry.timestamp.slice(0, 10) : '';
85
92
  const trust = memoryTrustStatus({
@@ -106,48 +113,32 @@ function formatEntry(entry) {
106
113
  return `- ${label}${marker}${typeMarker}${note}${tagSuffix}`;
107
114
  }
108
115
 
109
- async function handler(args, flags, projectRoot) {
110
- const { query, limit, type, json } = parseArgs(args);
111
- // `--all` is a GLOBAL boolean flag: in production bin/forge.js strips it from
112
- // args and sets flags.all; on a direct handler call it may still be in args.
113
- const all = Boolean(flags && flags.all) || args.includes('--all');
114
-
115
- // A `--type` filter scans a generous recent window, then keeps only matching notes — the
116
- // read stays entirely in the existing store (no schema change). `--limit` is re-applied
117
- // AFTER filtering so it caps the typed result set, not the pre-filter scan.
118
- const recallLimit = type ? Math.max(limit ?? 0, TYPE_FILTER_SCAN) : limit;
119
- const result = memoryRouter.recall(projectRoot, { query, limit: recallLimit, all });
120
- let notes = result.notes.map(withType);
121
- let { total, capped } = result;
122
- const { scope } = result;
123
- if (type) {
124
- notes = notes.filter(entry => entry.type === type);
125
- total = notes.length;
126
- if (limit && notes.length > limit) {
127
- notes = notes.slice(0, limit);
128
- capped = true;
129
- } else {
130
- capped = false;
116
+ function createUsageRecorder(projectRoot, recallStore, commandOpts) {
117
+ const invocationId = privateCommandOption(commandOpts, 'invocationId');
118
+ const usageStore = privateCommandOption(commandOpts, 'usageStore');
119
+ const onUsageEvidence = privateCommandOption(commandOpts, 'onUsageEvidence');
120
+ const invocationStartedAt = privateCommandOption(commandOpts, 'invocationStartedAt')
121
+ || privateCommandOption(commandOpts, 'now')
122
+ || new Date().toISOString();
123
+ const evidenceCallback = typeof onUsageEvidence === 'function' && !isProxy(onUsageEvidence)
124
+ ? onUsageEvidence
125
+ : undefined;
126
+ return selected => {
127
+ const observation = projectMemory.recordRecallUsage(projectRoot, selected, {
128
+ invocationId,
129
+ usageStore: usageStore || recallStore,
130
+ invocationStartedAt,
131
+ });
132
+ if (evidenceCallback) {
133
+ // Observability is deliberately aggregate-only: callers never receive query, note,
134
+ // path, secret, or raw durable identifiers.
135
+ try { evidenceCallback(observation); } catch { /* advisory test seam */ }
131
136
  }
132
- }
133
-
134
- if (json) {
135
- // Object (not a bare array) so programmatic consumers see the total and whether the
136
- // result was truncated (raise --limit to page further).
137
- return {
138
- success: true,
139
- output: `${JSON.stringify({ notes, total, capped, scope }, null, 2)}\n`,
140
- };
141
- }
142
-
143
- if (notes.length === 0) {
144
- const reason = query
145
- ? `No notes match "${query}".`
146
- : 'No notes remembered yet. Use "forge remember <note>" to add one.';
147
- return { success: true, output: reason };
148
- }
137
+ };
138
+ }
149
139
 
150
- const sections = notes
140
+ function buildRecallSections(notes) {
141
+ return notes
151
142
  .map((entry, index) => {
152
143
  const content = formatEntry(entry);
153
144
  if (estimateTokens(content) > RECALL_CONTENT_BUDGET) return null;
@@ -162,29 +153,35 @@ async function handler(args, flags, projectRoot) {
162
153
  content,
163
154
  priority: index,
164
155
  preserve: false,
165
- data: { trust },
156
+ data: { trust, memoryId: entry.id },
166
157
  });
167
158
  })
168
159
  .filter(Boolean);
160
+ }
161
+
162
+ function humanRecallHeader(query, capped, rendered, noteCount, total, scope) {
163
+ const noun = scope === 'all' && !query ? 'stored memory record(s)' : 'remembered note(s)';
164
+ if (query) {
165
+ return capped || rendered < noteCount
166
+ ? `Top ${rendered} note(s) matching "${query}" (raise --limit for more):`
167
+ : `${rendered} note(s) matching "${query}":`;
168
+ }
169
+ if (capped || rendered < noteCount) {
170
+ return `Showing ${rendered} of ${total} ${noun} (newest first):`;
171
+ }
172
+ return `${rendered} ${noun}:`;
173
+ }
174
+
175
+ function renderHumanRecall(notes, result, recordUsage) {
176
+ const sections = buildRecallSections(notes);
169
177
  const budgeted = applyBudget(sections, RECALL_CONTENT_BUDGET).sections
170
178
  .filter(section => section.content);
171
179
  for (const section of budgeted) {
172
180
  section.content = fenceUntrusted(section.content, { source: 'memory' });
173
181
  }
182
+ recordUsage(budgeted.map(section => ({ id: section.data.memoryId })));
174
183
  const rendered = budgeted.length;
175
- const noun = scope === 'all' && !query ? 'stored memory record(s)' : 'remembered note(s)';
176
- let header;
177
- if (query) {
178
- // BM25 returns at most `limit`; when full, signal it is the TOP-N, not the whole set.
179
- header = capped || rendered < notes.length
180
- ? `Top ${rendered} note(s) matching "${query}" (raise --limit for more):`
181
- : `${rendered} note(s) matching "${query}":`;
182
- } else if (capped || rendered < notes.length) {
183
- // Never a bare full dump: show the rendered count and the true total.
184
- header = `Showing ${rendered} of ${total} ${noun} (newest first):`;
185
- } else {
186
- header = `${rendered} ${noun}:`;
187
- }
184
+ const header = humanRecallHeader(result.query, result.capped, rendered, notes.length, result.total, result.scope);
188
185
  const confirmed = budgeted.filter(section => section.data.trust === 'confirmed');
189
186
  const suggested = budgeted.filter(section => section.data.trust === 'suggested');
190
187
  const groups = [
@@ -195,10 +192,47 @@ async function handler(args, flags, projectRoot) {
195
192
  title,
196
193
  ...entries.map(entry => entry.content),
197
194
  ]);
198
- return {
199
- success: true,
200
- output: [header, ...body].join('\n'),
201
- };
195
+ return { success: true, output: [header, ...body].join('\n') };
196
+ }
197
+
198
+ function emptyRecall(query) {
199
+ const reason = query
200
+ ? `No notes match "${query}".`
201
+ : 'No notes remembered yet. Use "forge remember <note>" to add one.';
202
+ return { success: true, output: reason };
203
+ }
204
+
205
+ async function handler(args, flags, projectRoot, commandOpts = {}) {
206
+ const { query, limit, type, json } = parseArgs(args);
207
+ // `--all` is a GLOBAL boolean flag: in production bin/forge.js strips it from
208
+ // args and sets flags.all; on a direct handler call it may still be in args.
209
+ const all = Boolean(flags && flags.all) || args.includes('--all');
210
+
211
+ // A `--type` filter scans a generous recent window, then keeps only matching notes — the
212
+ // read stays entirely in the existing store (no schema change). `--limit` is re-applied
213
+ // AFTER filtering so it caps the typed result set, not the pre-filter scan.
214
+ // Resolve once for the complete recall: selection, count, and advisory evidence share
215
+ // one cached driver/connection rather than each opening or migrating independently.
216
+ const recallStore = projectMemory.resolveStore(projectRoot);
217
+ const result = memoryRouter.recall(projectRoot, { query, limit, all, kind: type }, { store: recallStore });
218
+ const notes = result.notes.map(withType);
219
+ const recordUsage = createUsageRecorder(projectRoot, recallStore, commandOpts);
220
+
221
+ if (json) {
222
+ // Object (not a bare array) so programmatic consumers see the total and whether the
223
+ // result was truncated (raise --limit to page further).
224
+ recordUsage(notes);
225
+ return {
226
+ success: true,
227
+ output: `${JSON.stringify({ notes, total: result.total, capped: result.capped, scope: result.scope }, null, 2)}\n`,
228
+ };
229
+ }
230
+
231
+ if (notes.length === 0) {
232
+ return emptyRecall(query);
233
+ }
234
+
235
+ return renderHumanRecall(notes, result, recordUsage);
202
236
  }
203
237
 
204
238
  module.exports = {
@@ -9,12 +9,13 @@ const {
9
9
  const { runIssueOperation: defaultRunIssueOperation } = require('../forge-issues');
10
10
  const { normalizeArgs, normalizeIssueResult, withResolvedIssueBackend } = require('./_issue');
11
11
  const { generateNpmPublishWorkflow } = require('../npm-publish-workflow');
12
+ const { updateBunWorkflowPins } = require('../bun-workflow-pins');
12
13
 
13
14
  // `forge release <id>` releases a claimed issue; `forge release check` runs the
14
15
  // release-readiness gate. The two share the top-level verb, so this command
15
16
  // dispatches `check` to the gate and routes everything else through the shared
16
17
  // issue dispatch (resolve backend → runIssueOperation('release') → normalize).
17
- const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit | forge release generate-npm-workflow';
18
+ const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit | forge release generate-npm-workflow --expect-head <full-sha> | forge release update-bun-pins --expect-head <full-sha>';
18
19
 
19
20
  async function runReleaseIssue(args, projectRoot, opts = {}) {
20
21
  const resolved = withResolvedIssueBackend(projectRoot, opts);
@@ -45,23 +46,42 @@ function parseReleaseArgs(args = []) {
45
46
  return false;
46
47
  }
47
48
  const previous = args[index - 1];
48
- return previous !== '--target';
49
+ return previous !== '--target' && previous !== '--expect-head';
49
50
  });
50
51
 
51
52
  return {
52
53
  subcommand,
53
54
  target: readOption(args, '--target', SUPPORTED_TARGET),
55
+ expectedHead: readOption(args, '--expect-head', null),
54
56
  json: args.includes('--json'),
55
57
  };
56
58
  }
57
59
 
58
60
  async function handler(args, _flags, projectRoot, opts = {}) {
59
61
  const parsed = parseReleaseArgs(args);
62
+ if (parsed.subcommand === 'update-bun-pins') {
63
+ const updater = opts.updateBunWorkflowPins || updateBunWorkflowPins;
64
+ const updated = await updater(projectRoot, {
65
+ env: opts.env,
66
+ kernelDeps: opts.kernelDeps,
67
+ expectedHead: parsed.expectedHead,
68
+ resolveHead: opts.resolveHead,
69
+ });
70
+ return updated.success
71
+ ? {
72
+ success: true,
73
+ updated,
74
+ output: `Pinned ${updated.paths.length} workflows to Bun ${updated.version}.\n`,
75
+ }
76
+ : updated;
77
+ }
60
78
 
61
79
  if (parsed.subcommand === 'generate-npm-workflow') {
62
80
  const generated = await generateNpmPublishWorkflow(projectRoot, {
63
81
  env: opts.env,
64
82
  kernelDeps: opts.kernelDeps,
83
+ expectedHead: parsed.expectedHead,
84
+ resolveHead: opts.resolveHead,
65
85
  });
66
86
  return generated.success
67
87
  ? {
@@ -111,6 +131,7 @@ module.exports = {
111
131
  usage,
112
132
  flags: {
113
133
  '--target <version>': 'Release target to check',
134
+ '--expect-head <full-sha>': 'Required exact HEAD for protected workflow generation',
114
135
  '--json': 'Emit the readiness report as JSON',
115
136
  },
116
137
  handler,
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('node:crypto');
3
4
  const memoryRouter = require('../memory/router');
4
5
  const { stripGlobalFlags } = require('../global-flags');
5
6
 
@@ -22,6 +23,7 @@ const STRUCTURED_FIELDS = [
22
23
  // `--kind` (NOT `--type`): `--type` is a reserved GLOBAL flag that bin/forge.js hard-validates
23
24
  // against workflow classifications (critical|standard|…), so it can never carry a note type.
24
25
  const TYPE_TAG_PREFIX = 'type:';
26
+ const CONTENT_HASH_TAG_PREFIX = 'content-hash:';
25
27
 
26
28
  /**
27
29
  * Split positional note words from `--kind`, `--tag <label>`, the structured field flags,
@@ -105,15 +107,37 @@ async function handler(args, _flags, projectRoot) {
105
107
  }
106
108
 
107
109
  // The type rides as a reserved tag so it is stored and filterable without a schema change.
108
- const allTags = type ? [...tags, `${TYPE_TAG_PREFIX}${type}`] : tags;
109
- const entry = memoryRouter.append(projectRoot, body, { tags: allTags });
110
+ const contentHash = type === 'session-summary'
111
+ ? crypto.createHash('sha256').update(JSON.stringify({
112
+ body,
113
+ tags: [...tags].sort((left, right) => left.localeCompare(right)),
114
+ type,
115
+ }), 'utf8').digest('hex')
116
+ : null;
117
+ const metadata = contentHash ? { kind: 'session-summary', content_hash: contentHash } : undefined;
118
+ const hashTag = contentHash ? `${CONTENT_HASH_TAG_PREFIX}${contentHash}` : null;
119
+ const allTags = [
120
+ ...tags,
121
+ ...(type ? [`${TYPE_TAG_PREFIX}${type}`] : []),
122
+ ...(hashTag ? [hashTag] : []),
123
+ ];
124
+ let entry;
125
+ if (hashTag) {
126
+ const matches = memoryRouter.recall(projectRoot, {
127
+ query: contentHash,
128
+ limit: 2,
129
+ all: true,
130
+ }).notes;
131
+ entry = matches.find(note => Array.isArray(note.tags) && note.tags.includes(hashTag));
132
+ }
133
+ if (!entry) entry = memoryRouter.append(projectRoot, body, { tags: allTags });
110
134
 
111
135
  if (json) {
112
- const payload = type ? { ...entry, type } : entry;
136
+ const payload = type ? { ...entry, type, metadata } : entry;
113
137
  return { success: true, output: `${JSON.stringify(payload, null, 2)}\n` };
114
138
  }
115
139
 
116
- const userTags = entry.tags.filter(tag => !tag.startsWith(TYPE_TAG_PREFIX));
140
+ const userTags = entry.tags.filter(tag => !tag.startsWith(TYPE_TAG_PREFIX) && !tag.startsWith(CONTENT_HASH_TAG_PREFIX));
117
141
  const tagSuffix = userTags.length > 0 ? ` [${userTags.join(', ')}]` : '';
118
142
  const typePrefix = type ? `(${type}) ` : '';
119
143
  return {