forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/AGENTS.md +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -0,0 +1,20 @@
1
+ PRAGMA user_version = 1;
2
+ CREATE TABLE kernel_migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL);
3
+ CREATE TABLE kernel_issues (id TEXT PRIMARY KEY, title TEXT NOT NULL, status TEXT NOT NULL, entity_revision INTEGER NOT NULL);
4
+ CREATE TABLE kernel_comments (id TEXT PRIMARY KEY, issue_id TEXT NOT NULL, body TEXT NOT NULL, actor TEXT NOT NULL);
5
+ CREATE TABLE kernel_dependencies (id TEXT PRIMARY KEY, issue_id TEXT NOT NULL, blocks_issue_id TEXT NOT NULL);
6
+ CREATE TABLE kernel_claims (id TEXT PRIMARY KEY, issue_id TEXT NOT NULL, actor TEXT NOT NULL, state TEXT NOT NULL);
7
+ CREATE TABLE kernel_worktrees (id TEXT PRIMARY KEY, path TEXT NOT NULL, branch TEXT NOT NULL, state TEXT NOT NULL);
8
+ CREATE TABLE kernel_stage_runs (id TEXT PRIMARY KEY, issue_id TEXT NOT NULL, stage TEXT NOT NULL, status TEXT NOT NULL);
9
+ CREATE TABLE kernel_projections (id TEXT PRIMARY KEY, target TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, status TEXT NOT NULL);
10
+ CREATE TABLE kernel_events (id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, event_type TEXT NOT NULL);
11
+ INSERT INTO kernel_migrations VALUES ('001_initial_kernel_schema', '2026-08-09T00:00:00.000Z');
12
+ INSERT INTO kernel_issues VALUES ('synthetic-issue-1', 'Synthetic issue', 'open', 2); -- NOSONAR S1192: repeated synthetic foreign key preserves fixture readability.
13
+ INSERT INTO kernel_issues VALUES ('synthetic-issue-2', 'Synthetic dependency', 'done', 1);
14
+ INSERT INTO kernel_comments VALUES ('synthetic-comment-1', 'synthetic-issue-1', 'Synthetic comment', 'fixture');
15
+ INSERT INTO kernel_dependencies VALUES ('synthetic-dependency-1', 'synthetic-issue-1', 'synthetic-issue-2');
16
+ INSERT INTO kernel_claims VALUES ('synthetic-claim-1', 'synthetic-issue-1', 'fixture', 'active');
17
+ INSERT INTO kernel_worktrees VALUES ('synthetic-worktree-1', '/synthetic/worktree', 'fixture/branch', 'active');
18
+ INSERT INTO kernel_stage_runs VALUES ('synthetic-run-1', 'synthetic-issue-1', 'dev', 'done');
19
+ INSERT INTO kernel_projections VALUES ('synthetic-projection-1', 'jsonl', 'issue', 'synthetic-issue-1', 'delivered');
20
+ INSERT INTO kernel_events VALUES ('synthetic-event-1', 'issue', 'synthetic-issue-1', 'issue.updated');
@@ -7,8 +7,7 @@
7
7
  * injectable object, making state explicit and testable.
8
8
  *
9
9
  * Mirrors the globals: projectRoot, FORCE_MODE, VERBOSE_MODE,
10
- * NON_INTERACTIVE, SYMLINK_ONLY, SYNC_ENABLED, actionLog,
11
- * PKG_MANAGER, and packageDir.
10
+ * NON_INTERACTIVE, SYMLINK_ONLY, actionLog, PKG_MANAGER, and packageDir.
12
11
  *
13
12
  * @module forge-context
14
13
  */
@@ -21,7 +20,6 @@ class ForgeContext { // NOSONAR — constructor-only class is intentional: serve
21
20
  * @param {boolean} [options.verboseMode] - Verbose output (--verbose)
22
21
  * @param {boolean} [options.nonInteractive] - Skip prompts (--quick / --yes)
23
22
  * @param {boolean} [options.symlinkOnly] - Fail instead of copy fallback (--symlink)
24
- * @param {boolean} [options.syncEnabled] - Scaffold Beads GitHub sync (--sync)
25
23
  * @param {string} [options.pkgManager] - Detected package manager
26
24
  * @param {Array} [options.actionLog] - Incremental setup action log
27
25
  * @param {string} [options.packageDir] - Forge package directory
@@ -32,7 +30,6 @@ class ForgeContext { // NOSONAR — constructor-only class is intentional: serve
32
30
  this.verboseMode = options.verboseMode || false;
33
31
  this.nonInteractive = options.nonInteractive || false;
34
32
  this.symlinkOnly = options.symlinkOnly || false;
35
- this.syncEnabled = options.syncEnabled || false;
36
33
  this.pkgManager = options.pkgManager || 'npm';
37
34
  this.actionLog = options.actionLog || [];
38
35
  this.packageDir = options.packageDir || '';
@@ -7,6 +7,8 @@ const { createLocalBroker, buildLocalBrokerConfig } = require('./kernel/broker')
7
7
  const { createBuiltinSQLiteDriver } = require('./kernel/sqlite-driver');
8
8
  const { detectWorktree } = require('./detect-worktree');
9
9
  const { DEFAULT_LEASE_TTL_MS } = require('./kernel/lease-enforcer');
10
+ const { ISSUE_TYPES } = require('./kernel/issue-command-contract');
11
+ const { loadRuntimeGraphConfig } = require('./core/runtime-graph');
10
12
 
11
13
  const OPERATION_METHOD_ALIASES = {
12
14
  'dep.add': 'depAdd',
@@ -25,6 +27,7 @@ function isHelpInvocation(args = []) {
25
27
  // on a missing `exec` method before ever reaching the "no tables" case.
26
28
  function createDefaultKernelBroker(brokerContext, deps) {
27
29
  let driver = deps.kernelDriver;
30
+ const ownsDriver = !driver;
28
31
  if (!driver) {
29
32
  const config = buildLocalBrokerConfig({
30
33
  projectRoot: brokerContext.projectRoot,
@@ -35,7 +38,7 @@ function createDefaultKernelBroker(brokerContext, deps) {
35
38
  driver = createBuiltinSQLiteDriver({ databasePath: config.databasePath });
36
39
  }
37
40
 
38
- return createLocalBroker({
41
+ const broker = createLocalBroker({
39
42
  projectRoot: brokerContext.projectRoot,
40
43
  gitCommonDir: deps.gitCommonDir,
41
44
  databasePath: deps.kernelDatabasePath,
@@ -46,6 +49,10 @@ function createDefaultKernelBroker(brokerContext, deps) {
46
49
  // classifier instead of paying the real Windows drive probe per per-op broker.
47
50
  classifyFilesystem: deps.classifyFilesystem,
48
51
  });
52
+ if (ownsDriver && typeof driver.close === 'function') {
53
+ broker.close = () => driver.close();
54
+ }
55
+ return broker;
49
56
  }
50
57
 
51
58
  // Symbol marker for the memoized lazy-init promise. Stored ON the broker object
@@ -82,6 +89,11 @@ function withLazyKernelInit(broker) {
82
89
  async initialize() {
83
90
  return ensureInitialized();
84
91
  },
92
+ async close() {
93
+ if (typeof broker.close === 'function') {
94
+ await broker.close();
95
+ }
96
+ },
85
97
  async runIssueOperation(operation, args = [], context = {}) {
86
98
  await ensureInitialized();
87
99
  return broker.runIssueOperation(operation, args, context);
@@ -94,11 +106,14 @@ function createKernelIssueBackend(context = {}) {
94
106
  const createBroker = deps.createKernelBroker
95
107
  || ((brokerContext) => createDefaultKernelBroker(brokerContext, deps));
96
108
 
109
+ const ownsBroker = !deps.kernelBroker;
97
110
  const broker = deps.kernelBroker || createBroker(context);
98
-
99
- return new KernelIssueAdapter({
100
- broker: withLazyKernelInit(broker),
101
- });
111
+ const managedBroker = withLazyKernelInit(broker);
112
+ const backend = new KernelIssueAdapter({ broker: managedBroker });
113
+ if (ownsBroker && typeof managedBroker?.close === 'function') {
114
+ backend.dispose = () => managedBroker.close();
115
+ }
116
+ return backend;
102
117
  }
103
118
 
104
119
  function createIssueService({ backend } = {}) {
@@ -108,6 +123,11 @@ function createIssueService({ backend } = {}) {
108
123
  const resolvedBackend = backend || createKernelIssueBackend();
109
124
 
110
125
  return {
126
+ async dispose() {
127
+ if (typeof resolvedBackend?.dispose === 'function') {
128
+ await resolvedBackend.dispose();
129
+ }
130
+ },
111
131
  async run(operation, args = [], context = {}) {
112
132
  const methodName = operation === 'show' && typeof resolvedBackend?.show !== 'function'
113
133
  ? 'read'
@@ -181,6 +201,75 @@ function resolveLeaseTtlMs(env = {}) {
181
201
  return ttl;
182
202
  }
183
203
 
204
+ function readIssueContractValues(loaded, fail) {
205
+ const missing = Symbol('missing issue contract config');
206
+ const ownDataValue = (object, key, path) => {
207
+ if (!object || (typeof object !== 'object' && typeof object !== 'function')) return missing;
208
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
209
+ if (!descriptor) return missing;
210
+ if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
211
+ fail(`${path} must be a data property`);
212
+ }
213
+ return descriptor.value;
214
+ };
215
+ const issues = ownDataValue(loaded?.config, 'issues', 'issues');
216
+ const readiness = issues === missing ? missing : ownDataValue(issues, 'readiness', 'issues.readiness');
217
+ const configured = readiness === missing
218
+ ? missing
219
+ : ownDataValue(readiness, 'contracts', 'issues.readiness.contracts');
220
+ if (configured === missing || configured == null) return null;
221
+ if (typeof configured !== 'object'
222
+ || Array.isArray(configured)
223
+ || Object.getPrototypeOf(configured) !== Object.prototype) {
224
+ fail('issues.readiness.contracts must be a plain JSON object');
225
+ }
226
+ const allowedKeys = new Set(['enabled', 'workClasses']);
227
+ const ownKeys = Reflect.ownKeys(configured);
228
+ const unknownKeys = ownKeys.filter(key => typeof key !== 'string' || !allowedKeys.has(key));
229
+ if (unknownKeys.length > 0) {
230
+ fail(`unknown issues.readiness.contracts keys: ${unknownKeys.map(String).sort((left, right) => left.localeCompare(right)).join(', ')}`);
231
+ }
232
+ const values = Object.create(null);
233
+ for (const key of ownKeys) {
234
+ const descriptor = Object.getOwnPropertyDescriptor(configured, key);
235
+ if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
236
+ fail(`issues.readiness.contracts.${key} must be a data property`);
237
+ }
238
+ values[key] = descriptor.value;
239
+ }
240
+ return values;
241
+ }
242
+
243
+ function resolveIssueContractPolicy(projectRoot, deps = {}) {
244
+ const loadConfig = deps.loadRuntimeGraphConfig || loadRuntimeGraphConfig;
245
+ const loaded = loadConfig({ projectRoot });
246
+ const fail = message => {
247
+ const error = new Error(`FORGE_ISSUE_CONTRACT_CONFIG_INVALID: ${message}`);
248
+ error.code = 'FORGE_ISSUE_CONTRACT_CONFIG_INVALID';
249
+ throw error;
250
+ };
251
+ if (Array.isArray(loaded?.errors) && loaded.errors.length > 0) {
252
+ fail(loaded.errors.map(error => error.message || error.code || String(error)).join('; '));
253
+ }
254
+ const values = readIssueContractValues(loaded, fail);
255
+ if (values === null) return null;
256
+ if (values.enabled !== undefined && typeof values.enabled !== 'boolean') {
257
+ fail('issues.readiness.contracts.enabled must be a boolean');
258
+ }
259
+ if (values.enabled !== true) return { enabled: false, workClasses: [] };
260
+ if (!Array.isArray(values.workClasses) || values.workClasses.length === 0) {
261
+ fail('issues.readiness.contracts.workClasses must be a non-empty array');
262
+ }
263
+ const allowedClasses = new Set(ISSUE_TYPES);
264
+ if (values.workClasses.some(value => typeof value !== 'string' || !allowedClasses.has(value))) {
265
+ fail('issues.readiness.contracts.workClasses contains an unknown work class');
266
+ }
267
+ return {
268
+ enabled: true,
269
+ workClasses: [...new Set(values.workClasses)],
270
+ };
271
+ }
272
+
184
273
  async function runIssueOperation(operation, rawArgs, projectRoot, deps = {}) {
185
274
  const injectedService = deps.createService;
186
275
 
@@ -188,6 +277,7 @@ async function runIssueOperation(operation, rawArgs, projectRoot, deps = {}) {
188
277
  backend: createKernelIssueBackend({ projectRoot, deps }),
189
278
  }));
190
279
 
280
+ const contractPolicy = resolveIssueContractPolicy(projectRoot, deps);
191
281
  const service = createService();
192
282
  // Thread a distinct per-agent actor (and, when present, a stable session id) into the
193
283
  // mutation context so the Kernel's claim idempotency key is scoped per agent rather than
@@ -205,36 +295,47 @@ async function runIssueOperation(operation, rawArgs, projectRoot, deps = {}) {
205
295
  const isClaim = operation === 'claim';
206
296
  const worktreeId = isClaim ? resolveWorktreeId(projectRoot, actorEnv, deps) : undefined;
207
297
  const leaseTtlMs = isClaim ? resolveLeaseTtlMs(actorEnv) : undefined;
208
- const result = await service.run(operation, rawArgs, {
209
- projectRoot,
210
- deps,
211
- ...(actor ? { actor } : {}),
212
- ...(sessionId ? { sessionId } : {}),
213
- ...(worktreeId ? { worktreeId } : {}),
214
- ...(leaseTtlMs ? { leaseTtlMs } : {}),
215
- // Kernel mutations enqueue a projection-outbox "dirty marker" that `forge export`
216
- // (the D16 git-JSONL portability projection) drains under target 'jsonl'. The
217
- // broker's legacy primitive default is still 'beads', so without steering the target
218
- // here every Kernel-created issue would be enqueued as 'beads' and `forge export` —
219
- // which drains 'jsonl' — would find nothing, silently never emitting git-tracked
220
- // JSONL. Unconditional now that the Kernel is the only backend.
221
- projectionTarget: 'jsonl',
222
- });
298
+ const isTrustedAdoption = typeof deps.verifyIssueContractAdoption === 'function'
299
+ ? deps.verifyIssueContractAdoption
300
+ : undefined;
301
+ try {
302
+ const result = await service.run(operation, rawArgs, {
303
+ projectRoot,
304
+ deps,
305
+ ...(actor ? { actor } : {}),
306
+ ...(sessionId ? { sessionId } : {}),
307
+ ...(worktreeId ? { worktreeId } : {}),
308
+ ...(leaseTtlMs ? { leaseTtlMs } : {}),
309
+ ...(contractPolicy ? { contractPolicy } : {}),
310
+ ...(isTrustedAdoption ? { isTrustedAdoption } : {}),
311
+ // Kernel mutations enqueue a projection-outbox "dirty marker" that `forge export`
312
+ // (the D16 git-JSONL portability projection) drains under target 'jsonl'. The
313
+ // broker's legacy primitive default is still 'beads', so without steering the target
314
+ // here every Kernel-created issue would be enqueued as 'beads' and `forge export` —
315
+ // which drains 'jsonl' — would find nothing, silently never emitting git-tracked
316
+ // JSONL. Unconditional now that the Kernel is the only backend.
317
+ projectionTarget: 'jsonl',
318
+ });
223
319
 
224
- const queueGitHubProjection = deps.enqueueGitHubProjection || deps.queueGitHubProjection;
225
- if (result?.success && typeof queueGitHubProjection === 'function') {
226
- const projectionPlan = createGitHubProjectionPlan(operation, rawArgs);
227
- if (projectionPlan) {
228
- await queueGitHubProjection(projectionPlan, {
229
- operation,
230
- args: rawArgs,
231
- projectRoot,
232
- result,
233
- });
320
+ const queueGitHubProjection = deps.enqueueGitHubProjection || deps.queueGitHubProjection;
321
+ if (result?.success && typeof queueGitHubProjection === 'function') {
322
+ const projectionPlan = createGitHubProjectionPlan(operation, rawArgs);
323
+ if (projectionPlan) {
324
+ await queueGitHubProjection(projectionPlan, {
325
+ operation,
326
+ args: rawArgs,
327
+ projectRoot,
328
+ result,
329
+ });
330
+ }
234
331
  }
235
- }
236
332
 
237
- return result;
333
+ return result;
334
+ } finally {
335
+ if (!injectedService && typeof service.dispose === 'function') {
336
+ try { await service.dispose(); } catch { /* cleanup must not mask the operation outcome */ }
337
+ }
338
+ }
238
339
  }
239
340
 
240
341
  module.exports = {
@@ -243,4 +344,5 @@ module.exports = {
243
344
  runIssueOperation,
244
345
  isHelpInvocation,
245
346
  resolveIssueActor,
347
+ resolveIssueContractPolicy,
246
348
  };
@@ -49,8 +49,13 @@ const GATE_EVENT_ORIGIN = 'cli';
49
49
  * SAME-actor re-approval mints no duplicate, while a later reject (different
50
50
  * decision) or a different actor is recorded as its own event.
51
51
  */
52
- function gateIdempotencyKey(eventType, issueId, gateId, actor) {
53
- return `${eventType}:${issueId}:${gateId}:${actor}`;
52
+ function gateIdempotencyKey(eventType, issueId, gateId, actor, expiresAt) {
53
+ const base = `${eventType}:${issueId}:${gateId}:${actor}`;
54
+ return expiresAt ? `${base}:${expiresAt}` : base;
55
+ }
56
+
57
+ function gateControlId(issueId, gateId) {
58
+ return `gate:${issueId}:${gateId}`;
54
59
  }
55
60
 
56
61
  /** Shape a stored kernel_events row into the gate-event view callers consume. */
@@ -67,6 +72,18 @@ function parseGateEvent(row) {
67
72
  actor: row.actor,
68
73
  created_at: row.created_at,
69
74
  };
75
+ // Older direct callers supplied only the legacy row subset and consumed its
76
+ // exact four-field view. Stored Kernel rows always carry entity_id, so enrich
77
+ // those audit/status views without changing that legacy parse contract.
78
+ if (row.entity_id) {
79
+ view.issue_id = row.entity_id;
80
+ view.control_id = payload.control_id || gateControlId(row.entity_id, payload.gate);
81
+ view.decision = row.event_type === GATE_APPROVED_EVENT ? 'approved' : 'rejected';
82
+ view.issued_at = row.created_at;
83
+ view.expires_at = payload.expires_at ?? null;
84
+ view.request_identity = payload.request_identity ?? null;
85
+ view.generation = Number.isInteger(payload.generation) ? payload.generation : 0;
86
+ }
70
87
  if (payload.reason !== undefined) view.reason = payload.reason;
71
88
  return view;
72
89
  }
@@ -76,10 +93,43 @@ function isIdempotencyRace(error) {
76
93
  return /UNIQUE constraint failed/i.test(message) && /idempotency_key/i.test(message);
77
94
  }
78
95
 
96
+ function isActiveApproval(event, now) {
97
+ if (!event || event.event_type !== GATE_APPROVED_EVENT) return false;
98
+ if (!event.expires_at) return true;
99
+ const nowMillis = Date.parse(now);
100
+ const expiryMillis = Date.parse(event.expires_at);
101
+ return Number.isFinite(nowMillis) && Number.isFinite(expiryMillis) && nowMillis < expiryMillis;
102
+ }
103
+
104
+ function planGateGeneration(events, params) {
105
+ const { gateId, decision, actor, requestIdentity, now } = params;
106
+ const gateEvents = events.filter(event => event.gate === gateId);
107
+ const latest = gateEvents.at(-1);
108
+ const maxGeneration = gateEvents.reduce(
109
+ (max, event) => Math.max(max, Number.isInteger(event.generation) ? event.generation : 0),
110
+ -1,
111
+ );
112
+
113
+ if (decision === 'approved') {
114
+ const identicalActive = latest
115
+ && latest.actor === actor
116
+ && latest.request_identity === requestIdentity
117
+ && isActiveApproval(latest, now);
118
+ if (identicalActive) return { duplicateEvent: latest, generation: latest.generation };
119
+ return { duplicateEvent: null, generation: maxGeneration + 1 };
120
+ }
121
+
122
+ if (latest?.event_type === GATE_REJECTED_EVENT && latest.actor === actor) {
123
+ return { duplicateEvent: latest, generation: latest.generation };
124
+ }
125
+ return { duplicateEvent: null, generation: Math.max(maxGeneration, 0) };
126
+ }
127
+
79
128
  /**
80
- * Record a gate decision as a kernel event on the issue. Idempotent per
81
- * issue+gate+actor+decision. Validates the issue exists (the caller validates the
82
- * gate id against the known-gate set before calling).
129
+ * Record a gate decision as a kernel event on the issue. Active identical
130
+ * requests are idempotent; expiry/rejection advances the durable generation so
131
+ * a later approval can become authoritative. Validates the issue exists (the
132
+ * caller validates the gate id against the known-gate set before calling).
83
133
  *
84
134
  * @param {string} projectRoot
85
135
  * @param {Object} params
@@ -93,7 +143,7 @@ function isIdempotencyRace(error) {
93
143
  * @returns {Promise<{ ok: boolean, duplicate?: boolean, issueMissing?: boolean, event?: Object, actor?: string }>}
94
144
  */
95
145
  async function recordGateEvent(projectRoot, params = {}) {
96
- const { issueId, gateId, decision, reason, env, deps, now } = params;
146
+ const { issueId, gateId, decision, reason, expiresAt, requestIdentity, env, deps, now } = params;
97
147
  const eventType = GATE_EVENT_TYPES[decision];
98
148
  if (!eventType) {
99
149
  throw new Error(`Unknown gate decision '${decision}' (expected 'approved' or 'rejected').`);
@@ -109,14 +159,42 @@ async function recordGateEvent(projectRoot, params = {}) {
109
159
  return { ok: false, issueMissing: true, actor };
110
160
  }
111
161
 
112
- const idempotencyKey = gateIdempotencyKey(eventType, issueId, gateId, actor);
162
+ const createdAt = now || new Date().toISOString();
163
+ const stableRequestIdentity = requestIdentity ?? expiresAt ?? null;
164
+ const rows = await driver.listKernelEvents(ISSUE_ENTITY_TYPE, issueId, {}, config);
165
+ const generationPlan = planGateGeneration((rows || []).map(parseGateEvent), {
166
+ gateId,
167
+ decision,
168
+ actor,
169
+ requestIdentity: stableRequestIdentity,
170
+ now: createdAt,
171
+ });
172
+ if (generationPlan.duplicateEvent) {
173
+ return { ok: true, duplicate: true, event: generationPlan.duplicateEvent, actor };
174
+ }
175
+ const keyParts = [stableRequestIdentity];
176
+ if (generationPlan.generation > 0) keyParts.push(`generation=${generationPlan.generation}`);
177
+ const idempotencyKey = gateIdempotencyKey(
178
+ eventType,
179
+ issueId,
180
+ gateId,
181
+ actor,
182
+ keyParts.filter(Boolean).join(':') || undefined,
183
+ );
113
184
 
114
185
  const existing = await driver.loadKernelEventByIdempotencyKey(idempotencyKey, {}, config);
115
186
  if (existing) {
116
187
  return { ok: true, duplicate: true, event: parseGateEvent(existing), actor };
117
188
  }
118
189
 
119
- const payload = { gate: gateId, actor };
190
+ const payload = {
191
+ gate: gateId,
192
+ actor,
193
+ control_id: gateControlId(issueId, gateId),
194
+ expires_at: expiresAt ?? null,
195
+ request_identity: stableRequestIdentity,
196
+ generation: generationPlan.generation,
197
+ };
120
198
  if (typeof reason === 'string' && reason.length > 0) payload.reason = reason;
121
199
 
122
200
  const event = {
@@ -128,7 +206,7 @@ async function recordGateEvent(projectRoot, params = {}) {
128
206
  actor,
129
207
  origin: GATE_EVENT_ORIGIN,
130
208
  payload,
131
- created_at: now || new Date().toISOString(),
209
+ created_at: createdAt,
132
210
  };
133
211
 
134
212
  try {
@@ -166,6 +244,12 @@ async function listGateEvents(projectRoot, issueId, options = {}) {
166
244
  }
167
245
  }
168
246
 
247
+ function evaluateGateApproval(events, gateId, now = new Date().toISOString()) {
248
+ const decisions = events.filter(event => event.gate === gateId);
249
+ const event = decisions.at(-1);
250
+ return { approved: isActiveApproval(event, now), event };
251
+ }
252
+
169
253
  /**
170
254
  * True iff a `gate.approved` event exists for this issue + gate.
171
255
  *
@@ -177,7 +261,7 @@ async function listGateEvents(projectRoot, issueId, options = {}) {
177
261
  */
178
262
  async function isGateApproved(projectRoot, issueId, gateId, options = {}) {
179
263
  const events = await listGateEvents(projectRoot, issueId, options);
180
- return events.some(event => event.event_type === GATE_APPROVED_EVENT && event.gate === gateId);
264
+ return evaluateGateApproval(events, gateId, options.now).approved;
181
265
  }
182
266
 
183
267
  module.exports = {
@@ -185,8 +269,12 @@ module.exports = {
185
269
  GATE_REJECTED_EVENT,
186
270
  GATE_EVENT_TYPES,
187
271
  gateIdempotencyKey,
272
+ gateControlId,
273
+ isActiveApproval,
274
+ planGateGeneration,
188
275
  parseGateEvent,
189
276
  recordGateEvent,
190
277
  listGateEvents,
278
+ evaluateGateApproval,
191
279
  isGateApproved,
192
280
  };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('node:child_process');
4
+
5
+ /**
6
+ * Detect the default branch of the repository.
7
+ *
8
+ * Strategy (in order):
9
+ * 1. `git symbolic-ref refs/remotes/origin/HEAD` -> parse branch name
10
+ * 2. `git remote show origin` -> parse "HEAD branch:" line
11
+ * 3. Fall back to `'main'`
12
+ *
13
+ * @param {string} projectRoot - Absolute path to the project root.
14
+ * @param {object} [options] - Options object.
15
+ * @param {Function} [options._exec] - Injected execFileSync for testing.
16
+ * @returns {string} The default branch name.
17
+ */
18
+ function detectDefaultBranch(projectRoot, options = {}) {
19
+ const exec = options._exec || execFileSync;
20
+
21
+ // Strategy 1: symbolic-ref
22
+ try {
23
+ const out = exec('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], {
24
+ cwd: projectRoot,
25
+ stdio: ['pipe', 'pipe', 'pipe'],
26
+ });
27
+ const ref = out.toString().trim();
28
+ // refs/remotes/origin/release/2026 -> release/2026 (branch names may contain '/')
29
+ const prefix = 'refs/remotes/origin/';
30
+ if (ref.startsWith(prefix)) {
31
+ return ref.slice(prefix.length);
32
+ }
33
+ } catch (_e) { // NOSONAR S2486 — symbolic-ref fails when origin/HEAD is unset; fall through to strategy 2
34
+ }
35
+
36
+ // Strategy 2: remote show origin
37
+ try {
38
+ const out = exec('git', ['remote', 'show', 'origin'], {
39
+ cwd: projectRoot,
40
+ stdio: ['pipe', 'pipe', 'pipe'],
41
+ });
42
+ const text = out.toString();
43
+ const match = text.match(/HEAD branch:\s*(.+)/);
44
+ if (match) {
45
+ return match[1].trim();
46
+ }
47
+ } catch (_e) { // NOSONAR S2486 — 'git remote show origin' fails with no remote configured; fall through to the default
48
+ }
49
+
50
+ // Strategy 3: fallback
51
+ return 'main';
52
+ }
53
+
54
+ module.exports = {
55
+ detectDefaultBranch,
56
+ };