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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/AGENTS.md +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { getTypeBehavior, isTerminalStatus, normalizeRank, toEpochMillis } = require('./taxonomy-validator');
4
+ const { isLiveClaim } = require('./live-claim-projection');
4
5
 
5
6
  // Readiness is a DERIVED read model (D18). `ready`/`blocked` are computed on demand
6
7
  // from dependencies, claims, quarantine/conflicts, gates, defer windows, and policy —
@@ -18,6 +19,11 @@ const READINESS_REASONS = Object.freeze({
18
19
  CLAIM: 'claimed',
19
20
  DEFERRED: 'deferred',
20
21
  POLICY: 'policy_disabled',
22
+ CONTRACT_MISSING: 'contract_missing',
23
+ CONTRACT_INVALID: 'contract_invalid',
24
+ CONTRACT_MISMATCH: 'contract_mismatch',
25
+ CONTRACT_ADOPTION_REQUIRED: 'contract_adoption_required',
26
+ CONTRACT_ADOPTION_UNVERIFIED: 'contract_adoption_unverified',
21
27
  });
22
28
 
23
29
  const READINESS_STATES = Object.freeze([
@@ -65,17 +71,11 @@ function isDeferred(issue, now) {
65
71
  return deferMillis > nowMillis;
66
72
  }
67
73
 
68
- function findConflictingClaim(issueId, claims, actor, now) {
69
- const nowMillis = toEpochMillis(now);
74
+ function findConflictingClaim(issue, claims, actor, now) {
70
75
  for (const claim of claims || []) {
71
- if (claim.issue_id !== issueId) continue;
72
- if ((claim.state || 'active') !== 'active') continue;
76
+ if (claim.issue_id !== issue.id) continue;
77
+ if (!isLiveClaim(claim, issue, now)) continue;
73
78
  if (actor && claim.actor === actor) continue; // the requesting actor's own claim does not block them
74
- // An active row whose lease has elapsed is dead (validateClaim agrees) — it must not
75
- // keep the issue out of the ready queue for other actors. If `now` is unknown we cannot
76
- // confirm expiry, so the claim still blocks (conservative).
77
- const expiresMillis = toEpochMillis(claim.expires_at);
78
- if (expiresMillis !== null && nowMillis !== null && expiresMillis <= nowMillis) continue;
79
79
  return claim;
80
80
  }
81
81
  return null;
@@ -129,6 +129,149 @@ function collectGateBlockers(context, reasons) {
129
129
  return hasGate;
130
130
  }
131
131
 
132
+ function nonEmptyText(value) {
133
+ if (typeof value === 'string') return value.trim() !== '';
134
+ return Array.isArray(value)
135
+ && value.length > 0
136
+ && value.every(item => typeof item === 'string' && item.trim() !== '');
137
+ }
138
+
139
+ function isPlainJsonObject(value) {
140
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
141
+ const prototype = Object.getPrototypeOf(value);
142
+ return prototype === Object.prototype || prototype === null;
143
+ }
144
+
145
+ function ownDataProperty(object, key) {
146
+ if (!isPlainJsonObject(object)) return undefined;
147
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
148
+ return descriptor && Object.prototype.hasOwnProperty.call(descriptor, 'value')
149
+ ? descriptor.value
150
+ : undefined;
151
+ }
152
+
153
+ function parseMetadata(issue) {
154
+ const metadata = ownDataProperty(issue, 'metadata');
155
+ if (metadata == null) return {};
156
+ if (isPlainJsonObject(metadata)) {
157
+ return metadata;
158
+ }
159
+ if (typeof metadata !== 'string' || metadata.trim() === '') return {};
160
+ try {
161
+ const parsed = JSON.parse(metadata);
162
+ return isPlainJsonObject(parsed) ? parsed : {};
163
+ } catch {
164
+ return {};
165
+ }
166
+ }
167
+
168
+ function getIssueContract(issue) {
169
+ const metadata = parseMetadata(issue);
170
+ const candidate = ownDataProperty(metadata, 'forge.contract');
171
+ const contract = isPlainJsonObject(candidate) ? candidate : {};
172
+ return {
173
+ contract: contract && typeof contract === 'object' && !Array.isArray(contract) ? contract : {},
174
+ metadata,
175
+ };
176
+ }
177
+
178
+ function normalizeIds(ids) {
179
+ return [...new Set((Array.isArray(ids) ? ids : []).map(String))]
180
+ .sort((left, right) => left.localeCompare(right));
181
+ }
182
+
183
+ function collectContractFieldReasons(issue, contract, context) {
184
+ const reasons = [];
185
+ if (!nonEmptyText(issue.body)) reasons.push({ code: READINESS_REASONS.CONTRACT_MISSING, field: 'purpose' });
186
+ if (!nonEmptyText(issue.acceptance_criteria)) {
187
+ reasons.push({ code: READINESS_REASONS.CONTRACT_MISSING, field: 'acceptance_criteria' });
188
+ }
189
+ const risk = ownDataProperty(contract, 'risk');
190
+ const dependencies = ownDataProperty(contract, 'dependencies');
191
+ const outOfScope = ownDataProperty(contract, 'out_of_scope');
192
+ const version = ownDataProperty(contract, 'version');
193
+ if (!nonEmptyText(risk)) reasons.push({ code: READINESS_REASONS.CONTRACT_MISSING, field: 'risk' });
194
+ if (!Array.isArray(dependencies)) {
195
+ reasons.push({ code: READINESS_REASONS.CONTRACT_MISSING, field: 'dependencies' });
196
+ }
197
+ if (!nonEmptyText(outOfScope)) {
198
+ reasons.push({ code: READINESS_REASONS.CONTRACT_MISSING, field: 'out_of_scope' });
199
+ }
200
+ if (version !== 1) {
201
+ reasons.push({
202
+ code: READINESS_REASONS.CONTRACT_INVALID,
203
+ field: 'version',
204
+ expected: 1,
205
+ actual: version ?? null,
206
+ });
207
+ }
208
+ if (Array.isArray(dependencies) && Array.isArray(context.dependencyIds)) {
209
+ const declared = normalizeIds(dependencies);
210
+ const expected = normalizeIds(context.dependencyIds);
211
+ if (JSON.stringify(declared) !== JSON.stringify(expected)) {
212
+ reasons.push({
213
+ code: READINESS_REASONS.CONTRACT_MISMATCH,
214
+ field: 'dependencies',
215
+ expected,
216
+ declared,
217
+ });
218
+ }
219
+ }
220
+ return reasons;
221
+ }
222
+
223
+ function isTrustedContractAdoption(issue, metadata, adoptedBy, version, context) {
224
+ const revision = ownDataProperty(issue, 'revision') ?? ownDataProperty(issue, 'entity_revision');
225
+ const issueId = ownDataProperty(issue, 'id');
226
+ if (typeof context.isTrustedAdoption !== 'function'
227
+ || !nonEmptyText(issueId)
228
+ || !Number.isInteger(revision)) return false;
229
+ try {
230
+ return context.isTrustedAdoption({
231
+ actor: adoptedBy,
232
+ issue_id: issueId,
233
+ revision,
234
+ metadata,
235
+ contract_version: version,
236
+ }) === true;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+
242
+ function collectAdoptionReasons(issue, metadata, contract, context) {
243
+ const origin = ownDataProperty(issue, 'origin')
244
+ ?? ownDataProperty(metadata, 'origin')
245
+ ?? ownDataProperty(metadata, 'source');
246
+ if (!['import', 'imported', 'fork', 'fork_pr'].includes(origin)) return [];
247
+ const adoptedBy = ownDataProperty(contract, 'adopted_by');
248
+ if (!nonEmptyText(adoptedBy)) {
249
+ return [{ code: READINESS_REASONS.CONTRACT_ADOPTION_REQUIRED }];
250
+ }
251
+ const version = ownDataProperty(contract, 'version');
252
+ return isTrustedContractAdoption(issue, metadata, adoptedBy, version, context)
253
+ ? []
254
+ : [{ code: READINESS_REASONS.CONTRACT_ADOPTION_UNVERIFIED, adopter: adoptedBy }];
255
+ }
256
+
257
+ /**
258
+ * Validate only the presence, shape, and authority agreement of an issue contract.
259
+ * The policy deliberately does not score prose, dictate reasoning, or require headings.
260
+ */
261
+ function evaluateIssueContract(issue = {}, policy = {}, context = {}) {
262
+ const workClasses = Array.isArray(policy.workClasses) ? policy.workClasses : [];
263
+ const applicable = policy.enabled === true && workClasses.includes(issue.type);
264
+ if (!applicable) return { applicable: false, valid: true, reasons: [] };
265
+
266
+ const { contract, metadata } = getIssueContract(issue);
267
+ const reasons = [
268
+ ...collectContractFieldReasons(issue, contract, context),
269
+ ...collectAdoptionReasons(issue, metadata, contract, context),
270
+ ];
271
+
272
+ return { applicable: true, valid: reasons.length === 0, reasons };
273
+ }
274
+
132
275
  // Precedence high→low. `blocked` (dependencies/quarantine/conflict) always outranks the
133
276
  // softer not-ready reasons; consumers picking next work should read the full reasons[]
134
277
  // because a claim hidden behind a defer window is not reflected in this single summary.
@@ -156,15 +299,30 @@ function deriveReadiness(issue = {}, context = {}) {
156
299
  return { id, status, ready: false, blocked: false, blocked_by: [], reasons: [], state: 'closed' };
157
300
  }
158
301
 
302
+ const contract = evaluateIssueContract(issue, context.contractPolicy, {
303
+ dependencyIds: context.dependencyIds,
304
+ isTrustedAdoption: context.isTrustedAdoption,
305
+ });
306
+
159
307
  // A `backlog` (parked) issue is a first-class lifecycle state, not workable work: it
160
308
  // reads as parked regardless of any dependencies/gates — never ready, never blocked —
161
309
  // so parked ideas do not surface in ready/blocked queues or flag stale.
162
310
  if (status === 'backlog') {
163
- return { id, status, ready: false, blocked: false, blocked_by: [], reasons: [], state: 'backlog' };
311
+ return {
312
+ id,
313
+ status,
314
+ ready: false,
315
+ blocked: false,
316
+ blocked_by: [],
317
+ reasons: contract.reasons,
318
+ state: 'backlog',
319
+ contract_applicable: contract.applicable,
320
+ };
164
321
  }
165
322
 
166
323
  const reasons = [];
167
324
  const blockedBy = [];
325
+ reasons.push(...contract.reasons);
168
326
 
169
327
  collectDependencyBlockers(context, reasons, blockedBy);
170
328
  const hasConflict = collectConflictBlockers(issue, context, reasons);
@@ -180,7 +338,7 @@ function deriveReadiness(issue = {}, context = {}) {
180
338
  reasons.push({ code: READINESS_REASONS.POLICY });
181
339
  }
182
340
 
183
- const conflictingClaim = findConflictingClaim(id, context.claims, context.actor, context.now);
341
+ const conflictingClaim = findConflictingClaim(issue, context.claims, context.actor, context.now);
184
342
  if (conflictingClaim) {
185
343
  reasons.push({ code: READINESS_REASONS.CLAIM, actor: conflictingClaim.actor });
186
344
  }
@@ -191,6 +349,7 @@ function deriveReadiness(issue = {}, context = {}) {
191
349
  && !deferred
192
350
  && !policyDisabled
193
351
  && !conflictingClaim
352
+ && contract.valid
194
353
  && isWorkableStatus(status)
195
354
  && isReadyEligibleType(issue.type);
196
355
 
@@ -203,7 +362,16 @@ function deriveReadiness(issue = {}, context = {}) {
203
362
  ready,
204
363
  });
205
364
 
206
- return { id, status, ready, blocked, blocked_by: blockedBy, reasons, state };
365
+ return {
366
+ id,
367
+ status,
368
+ ready,
369
+ blocked,
370
+ blocked_by: blockedBy,
371
+ reasons,
372
+ state,
373
+ contract_applicable: contract.applicable,
374
+ };
207
375
  }
208
376
 
209
377
  // BETA stop-gap for claim thundering-herd (kernel 369c43d7): concurrent `forge ready`
@@ -302,6 +470,9 @@ function buildReadinessIndex(input = {}) {
302
470
  claims: claimsByIssue.get(issue.id) || [],
303
471
  gates: gatesByIssue.get(issue.id) || [],
304
472
  policyDisabled: policyDisabledIds.has(issue.id),
473
+ contractPolicy: input.contractPolicy,
474
+ dependencyIds: (dependencyStatusesByIssue.get(issue.id) || []).map(dependency => dependency.id),
475
+ isTrustedAdoption: input.isTrustedAdoption,
305
476
  });
306
477
  }
307
478
 
@@ -325,5 +496,6 @@ module.exports = {
325
496
  DEFAULT_TOP_K_READY_PICK,
326
497
  buildReadinessIndex,
327
498
  deriveReadiness,
499
+ evaluateIssueContract,
328
500
  isWorkableStatus,
329
501
  };
@@ -34,6 +34,7 @@ function field(name, type, options = {}) {
34
34
  notNull: Boolean(options.notNull || options.primaryKey),
35
35
  default: options.default,
36
36
  references: options.references,
37
+ check: options.check,
37
38
  };
38
39
  }
39
40
 
@@ -45,7 +46,7 @@ function index(name, columns, options = {}) {
45
46
  };
46
47
  }
47
48
 
48
- function table(name, storageClass, fields, indexes = []) {
49
+ function table(name, storageClass, fields, indexes = [], options = {}) {
49
50
  return {
50
51
  name,
51
52
  sqlName: `kernel_${name}`,
@@ -56,6 +57,7 @@ function table(name, storageClass, fields, indexes = []) {
56
57
  storageClass: fieldDefinition.storageClass || storageClass,
57
58
  })),
58
59
  indexes,
60
+ ...(options.primaryKey === undefined ? {} : { primaryKey: options.primaryKey }),
59
61
  };
60
62
  }
61
63
 
@@ -313,6 +315,28 @@ const TABLE_LIST = deepFreeze([
313
315
  index('idx_pr_common_dir_state_repo_number', ['git_common_dir', 'state', 'repo', 'number']),
314
316
  index('idx_pr_common_dir_repo_number', ['git_common_dir', 'repo', 'number'], { unique: true }),
315
317
  ]),
318
+ table('pr_watch_owners', 'authority', [
319
+ field('repo', 'TEXT', { notNull: true }),
320
+ field('pr', 'INTEGER', { notNull: true }),
321
+ field('version', 'INTEGER', { notNull: true, default: '1' }),
322
+ field('generation', 'TEXT', { notNull: true }),
323
+ field('phase', 'TEXT', { notNull: true }),
324
+ field('controller_pid', 'INTEGER'),
325
+ field('watcher_pid', 'INTEGER'),
326
+ field('started_at', 'TEXT', { notNull: true }),
327
+ field('updated_at', 'TEXT', { notNull: true }),
328
+ field('heartbeat_at', 'TEXT'),
329
+ field('terminal_receipt_id', 'TEXT'),
330
+ field('block_reason', 'TEXT'),
331
+ field('legacy_evidence_hash', 'TEXT'),
332
+ ], [], { primaryKey: ['repo', 'pr'] }),
333
+ table('pr_watch_migration_gate', 'authority', [
334
+ field('singleton', 'INTEGER', { primaryKey: true, check: 'singleton = 1' }),
335
+ field('state', 'TEXT', { notNull: true }),
336
+ field('snapshot_hash', 'TEXT'),
337
+ field('conflict_code', 'TEXT'),
338
+ field('updated_at', 'TEXT', { notNull: true }),
339
+ ]),
316
340
  ]);
317
341
 
318
342
  const KERNEL_TABLES = deepFreeze(Object.fromEntries(TABLE_LIST.map(candidate => [candidate.name, candidate])));
@@ -344,6 +368,29 @@ function validateKernelSchema(schema = getKernelSchema()) {
344
368
  if (!KERNEL_FIELD_AUTHORITIES.includes(tableDefinition.authority)) {
345
369
  throw new Error(`Invalid table authority for ${tableDefinition.name}: ${tableDefinition.authority}`);
346
370
  }
371
+ if (tableDefinition.primaryKey !== undefined) {
372
+ if (!Array.isArray(tableDefinition.primaryKey) || tableDefinition.primaryKey.length === 0) {
373
+ throw new Error(`Invalid table primary key for ${tableDefinition.name}: expected a non-empty column array`);
374
+ }
375
+ const primaryKeyColumns = new Set();
376
+ const fieldsByName = new Map(tableDefinition.fields.map(fieldDefinition => [fieldDefinition.name, fieldDefinition]));
377
+ if (tableDefinition.fields.some(fieldDefinition => fieldDefinition.primaryKey)) {
378
+ throw new Error(`Mixed field and table primary keys for ${tableDefinition.name}`);
379
+ }
380
+ for (const column of tableDefinition.primaryKey) {
381
+ if (primaryKeyColumns.has(column)) {
382
+ throw new Error(`Duplicate table primary key column for ${tableDefinition.name}: ${column}`);
383
+ }
384
+ primaryKeyColumns.add(column);
385
+ const primaryKeyField = fieldsByName.get(column);
386
+ if (!primaryKeyField) {
387
+ throw new Error(`Unknown table primary key column for ${tableDefinition.name}: ${column}`);
388
+ }
389
+ if (!primaryKeyField.notNull) {
390
+ throw new Error(`Nullable table primary key column for ${tableDefinition.name}: ${column}`);
391
+ }
392
+ }
393
+ }
347
394
  for (const fieldDefinition of tableDefinition.fields) {
348
395
  if (!KERNEL_STORAGE_CLASSES.includes(fieldDefinition.storageClass)) {
349
396
  throw new Error(`Invalid storage class for ${tableDefinition.name}.${fieldDefinition.name}: ${fieldDefinition.storageClass}`);
@@ -361,6 +408,7 @@ function validateKernelSchema(schema = getKernelSchema()) {
361
408
  function cloneTableList(tableList) {
362
409
  return tableList.map(tableDefinition => ({
363
410
  ...tableDefinition,
411
+ ...(Array.isArray(tableDefinition.primaryKey) ? { primaryKey: [...tableDefinition.primaryKey] } : {}),
364
412
  fields: tableDefinition.fields.map(fieldDefinition => ({ ...fieldDefinition })),
365
413
  indexes: tableDefinition.indexes.map(indexDefinition => ({
366
414
  ...indexDefinition,