ai-foreman 1.7.19 → 1.7.21

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 (205) hide show
  1. package/README.md +29 -5
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +18 -6
  4. package/dist/adapters/claude.d.ts +16 -0
  5. package/dist/adapters/claude.js +28 -3
  6. package/dist/adapters/codex.d.ts +20 -0
  7. package/dist/adapters/codex.js +99 -5
  8. package/dist/adapters/recovering.d.ts +9 -0
  9. package/dist/adapters/recovering.js +10 -0
  10. package/dist/adapters/types.d.ts +15 -0
  11. package/dist/agentRun.d.ts +7 -0
  12. package/dist/agentRun.js +24 -13
  13. package/dist/branch/currentGuard.d.ts +1 -0
  14. package/dist/branch/currentGuard.js +1 -0
  15. package/dist/branch/finalization.js +6 -1
  16. package/dist/branch/git.d.ts +1 -0
  17. package/dist/branch/git.js +15 -1
  18. package/dist/branch/runner.d.ts +2 -0
  19. package/dist/branch/runner.js +92 -5
  20. package/dist/buildAdmission.d.ts +61 -0
  21. package/dist/buildAdmission.js +286 -0
  22. package/dist/buildApproval.d.ts +2 -0
  23. package/dist/buildApproval.js +17 -0
  24. package/dist/buildAssignment.d.ts +33 -0
  25. package/dist/buildAssignment.js +215 -0
  26. package/dist/buildInterventions.d.ts +90 -0
  27. package/dist/buildInterventions.js +334 -0
  28. package/dist/buildLaunchGate.d.ts +1 -0
  29. package/dist/buildLaunchGate.js +36 -0
  30. package/dist/buildOwnershipReconciliation.d.ts +19 -0
  31. package/dist/buildOwnershipReconciliation.js +41 -0
  32. package/dist/buildOwnershipRepair.d.ts +33 -0
  33. package/dist/buildOwnershipRepair.js +111 -0
  34. package/dist/buildRuns.d.ts +24 -1
  35. package/dist/buildRuns.js +216 -70
  36. package/dist/buildSyntheticWork.d.ts +2 -0
  37. package/dist/buildSyntheticWork.js +30 -0
  38. package/dist/buildWorkAdmission.d.ts +17 -0
  39. package/dist/buildWorkAdmission.js +57 -0
  40. package/dist/buildWorkAuthorization.d.ts +2 -0
  41. package/dist/buildWorkAuthorization.js +23 -0
  42. package/dist/buildWorkContext.d.ts +3 -0
  43. package/dist/buildWorkContext.js +6 -0
  44. package/dist/buildWorkMigration.d.ts +9 -0
  45. package/dist/buildWorkMigration.js +206 -0
  46. package/dist/buildWorkReconciliation.d.ts +12 -0
  47. package/dist/buildWorkReconciliation.js +70 -0
  48. package/dist/builderGuidanceFollowup.d.ts +15 -0
  49. package/dist/builderGuidanceFollowup.js +34 -0
  50. package/dist/cli/doctor.js +3 -2
  51. package/dist/cli/graph.d.ts +2 -0
  52. package/dist/cli/graph.js +75 -0
  53. package/dist/cli/manager.d.ts +8 -0
  54. package/dist/cli/manager.js +234 -12
  55. package/dist/cli/recovery.js +3 -2
  56. package/dist/cli/runtimeAuthPrompt.d.ts +5 -0
  57. package/dist/cli/runtimeAuthPrompt.js +41 -7
  58. package/dist/cli/start.d.ts +8 -0
  59. package/dist/cli/start.js +2091 -1849
  60. package/dist/cli/status.js +20 -5
  61. package/dist/cli/tickets.js +9 -7
  62. package/dist/continuity.d.ts +16 -1
  63. package/dist/continuity.js +68 -12
  64. package/dist/foreman.d.ts +12 -4
  65. package/dist/foreman.js +397 -60
  66. package/dist/graph/boundary.d.ts +15 -0
  67. package/dist/graph/boundary.js +50 -0
  68. package/dist/graph/bridge.d.ts +9 -0
  69. package/dist/graph/bridge.js +146 -0
  70. package/dist/graph/bridge.py +205 -0
  71. package/dist/graph/config.d.ts +11 -0
  72. package/dist/graph/config.js +40 -0
  73. package/dist/graph/corpus.d.ts +16 -0
  74. package/dist/graph/corpus.js +188 -0
  75. package/dist/graph/derived.d.ts +17 -0
  76. package/dist/graph/derived.js +73 -0
  77. package/dist/graph/host.d.ts +4 -0
  78. package/dist/graph/host.js +160 -0
  79. package/dist/graph/install.d.ts +3 -0
  80. package/dist/graph/install.js +50 -0
  81. package/dist/graph/lifecycle.d.ts +11 -0
  82. package/dist/graph/lifecycle.js +124 -0
  83. package/dist/graph/maintenance.d.ts +43 -0
  84. package/dist/graph/maintenance.js +301 -0
  85. package/dist/graph/manager.d.ts +9 -0
  86. package/dist/graph/manager.js +92 -0
  87. package/dist/graph/preparation.d.ts +41 -0
  88. package/dist/graph/preparation.js +43 -0
  89. package/dist/graph/read.d.ts +27 -0
  90. package/dist/graph/read.js +152 -0
  91. package/dist/graph/semantic.d.ts +17 -0
  92. package/dist/graph/semantic.js +27 -0
  93. package/dist/graph/session.d.ts +8 -0
  94. package/dist/graph/session.js +92 -0
  95. package/dist/graph/sources.d.ts +4 -0
  96. package/dist/graph/sources.js +11 -0
  97. package/dist/graph/storage.d.ts +38 -0
  98. package/dist/graph/storage.js +159 -0
  99. package/dist/graph/turn.d.ts +17 -0
  100. package/dist/graph/turn.js +360 -0
  101. package/dist/graph/util.d.ts +5 -0
  102. package/dist/graph/util.js +51 -0
  103. package/dist/handoffs.d.ts +1 -0
  104. package/dist/handoffs.js +5 -1
  105. package/dist/humanDecision.d.ts +21 -2
  106. package/dist/humanDecision.js +169 -10
  107. package/dist/index.js +4 -1
  108. package/dist/installOwnership.d.ts +46 -0
  109. package/dist/installOwnership.js +440 -0
  110. package/dist/linuxProbeInventory.d.ts +12 -0
  111. package/dist/linuxProbeInventory.js +48 -0
  112. package/dist/log.d.ts +1 -1
  113. package/dist/log.js +7 -1
  114. package/dist/managerEvidence.d.ts +25 -0
  115. package/dist/managerEvidence.js +326 -0
  116. package/dist/managerEvidenceArtifacts.d.ts +38 -0
  117. package/dist/managerEvidenceArtifacts.js +118 -0
  118. package/dist/managerPacket.d.ts +2 -0
  119. package/dist/managerPacket.js +23 -0
  120. package/dist/markers.d.ts +1 -1
  121. package/dist/markers.js +2 -2
  122. package/dist/observability.js +1 -1
  123. package/dist/processIdentity.d.ts +21 -1
  124. package/dist/processIdentity.js +87 -5
  125. package/dist/providerPhase.d.ts +13 -0
  126. package/dist/providerPhase.js +26 -0
  127. package/dist/qaAuthorizedDisposition.d.ts +24 -0
  128. package/dist/qaAuthorizedDisposition.js +26 -0
  129. package/dist/qaBuildGate.d.ts +4 -0
  130. package/dist/qaBuildGate.js +216 -0
  131. package/dist/qaBuilderCoverage.d.ts +5 -0
  132. package/dist/qaBuilderCoverage.js +78 -0
  133. package/dist/qaContractCoverage.d.ts +10 -0
  134. package/dist/qaContractCoverage.js +55 -0
  135. package/dist/qaContractDelivery.d.ts +23 -0
  136. package/dist/qaContractDelivery.js +145 -0
  137. package/dist/qaContractFreshness.d.ts +15 -0
  138. package/dist/qaContractFreshness.js +36 -0
  139. package/dist/qaDeliveryJournal.d.ts +10 -0
  140. package/dist/qaDeliveryJournal.js +16 -1
  141. package/dist/qaEffectiveConfig.d.ts +20 -0
  142. package/dist/qaEffectiveConfig.js +40 -0
  143. package/dist/qaEvidenceReader.d.ts +14 -0
  144. package/dist/qaEvidenceReader.js +176 -0
  145. package/dist/qaFailureDelivery.d.ts +2 -0
  146. package/dist/qaFailureDelivery.js +107 -10
  147. package/dist/qaHandbackMigration.d.ts +1 -1
  148. package/dist/qaHandbackMigration.js +3 -3
  149. package/dist/qaPreparation.d.ts +26 -0
  150. package/dist/qaPreparation.js +150 -0
  151. package/dist/qaPreparationInputs.d.ts +8 -0
  152. package/dist/qaPreparationInputs.js +47 -0
  153. package/dist/qaPreparationMetrics.d.ts +145 -0
  154. package/dist/qaPreparationMetrics.js +120 -0
  155. package/dist/qaPreparationPolicy.d.ts +5 -0
  156. package/dist/qaPreparationPolicy.js +51 -0
  157. package/dist/qaPreparationStore.d.ts +157 -0
  158. package/dist/qaPreparationStore.js +412 -0
  159. package/dist/qaProtocolV2.d.ts +28 -0
  160. package/dist/qaProtocolV2.js +12 -0
  161. package/dist/qaReview.d.ts +6 -0
  162. package/dist/qaReview.js +293 -41
  163. package/dist/qaTimeline.d.ts +18 -0
  164. package/dist/qaTimeline.js +123 -0
  165. package/dist/qaVerificationContract.d.ts +17 -0
  166. package/dist/qaVerificationContract.js +236 -0
  167. package/dist/readinessCleanup.d.ts +19 -0
  168. package/dist/readinessCleanup.js +122 -0
  169. package/dist/readinessGate.d.ts +1 -0
  170. package/dist/readinessGate.js +82 -0
  171. package/dist/recoveryGuidance.d.ts +17 -0
  172. package/dist/recoveryGuidance.js +38 -0
  173. package/dist/runtimeAuth.d.ts +1 -0
  174. package/dist/runtimeAuth.js +5 -1
  175. package/dist/runtimeReadiness.d.ts +13 -0
  176. package/dist/runtimeReadiness.js +206 -10
  177. package/dist/sessionIdentity.d.ts +2 -0
  178. package/dist/sessionIdentity.js +14 -0
  179. package/dist/sessionLifecycle.js +3 -3
  180. package/dist/sources/sourceRegistry.js +7 -0
  181. package/dist/stateTransfer.js +85 -8
  182. package/dist/statusReporter.d.ts +1 -0
  183. package/dist/statusReporter.js +1 -0
  184. package/dist/supervisedStart.d.ts +6 -0
  185. package/dist/supervisedStart.js +178 -62
  186. package/dist/supervisor.d.ts +2 -0
  187. package/dist/supervisor.js +26 -2
  188. package/dist/terminalStyle.d.ts +21 -0
  189. package/dist/terminalStyle.js +50 -0
  190. package/dist/ticketPopulation.d.ts +1 -0
  191. package/dist/ticketPopulation.js +5 -1
  192. package/dist/tickets/commands.js +2 -2
  193. package/dist/tickets/queue.js +1 -1
  194. package/dist/tickets/ticketSchema.d.ts +198 -0
  195. package/dist/tickets/ticketSchema.js +2 -0
  196. package/dist/windowsProbeJob.d.ts +12 -0
  197. package/dist/windowsProbeJob.js +144 -0
  198. package/dist/workflowDb.d.ts +121 -6
  199. package/dist/workflowDb.js +874 -30
  200. package/dist/workflowReader.d.ts +116 -2
  201. package/dist/workflowReader.js +131 -4
  202. package/package.json +89 -8
  203. package/scripts/copy-graph-assets.mjs +3 -0
  204. package/scripts/graph-benchmark.mjs +20 -0
  205. package/scripts/graph-evaluation.mjs +85 -0
package/dist/buildRuns.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { loadQaPreparationPolicy } from "./qaEffectiveConfig.js";
2
+ export { withBuildInvocation as withBuildRecoveryInvocation, launchDigest as launchArgumentDigest } from "./buildAdmission.js";
1
3
  import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
5
  import { hostname } from "node:os";
4
6
  import { join, resolve } from "node:path";
5
7
  import { execFileSync } from "node:child_process";
@@ -12,11 +14,27 @@ import { captureCurrentWorkflowSessionIdentity, currentWorkflowIdentityKey } fro
12
14
  import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
13
15
  import { loadTickets } from "./tickets/ticketLoader.js";
14
16
  import { loadProjectAutonomyConfig, resolveAutonomyPolicy } from "./recoveryPolicy.js";
15
- import { isLiveProcessIdentity, processStartIdentity } from "./processIdentity.js";
17
+ import { classifyProcess, processStartIdentity } from "./processIdentity.js";
18
+ import { assertBuildAssignmentReconciled } from "./buildAssignment.js";
16
19
  export const BUILD_RUN_VERSION = 3;
17
20
  export const BUILD_RUN_DIRECTORY = ".foreman/runs";
18
21
  export const BUILD_HEARTBEAT_MS = 10_000;
19
22
  export const BUILD_LEASE_STALE_MS = 45_000;
23
+ const buildAuthority = Symbol("buildAuthority");
24
+ export function bindBuildAuthority(run, lease) {
25
+ return { ...run, [buildAuthority]: lease };
26
+ }
27
+ function assertBuildAuthority(workflow, run) {
28
+ const token = run[buildAuthority];
29
+ if (!token)
30
+ throw new Error("build mutation requires the caller's original workflow authority");
31
+ const held = workflow.currentLease();
32
+ const authorizedSupersession = run.status === "superseded" && run.supersededBy === held?.runId;
33
+ if (token && (!held || held.owner !== token.owner || held.generation !== token.generation || held.runId !== token.runId))
34
+ throw new Error("workflow lease ownership changed");
35
+ if (held && (!token || (held.runId !== run.runId && !authorizedSupersession)))
36
+ throw new Error("build mutation requires the caller's original workflow authority");
37
+ }
20
38
  export function createBuildRun(input) {
21
39
  const now = input.now ?? new Date();
22
40
  // Complete the one-time legacy import before the new mutable build snapshot
@@ -32,6 +50,8 @@ export function createBuildRun(input) {
32
50
  runId: input.runId ?? randomUUID(),
33
51
  status: "running",
34
52
  tickets: [...input.tickets],
53
+ authorizedBatch: input.authorizedBatch ?? (input.tickets.length && (input.branchMode ?? "current") === "current" ? { tickets: [...input.tickets], requestedSteps: input.tickets.length, scopeRevision: "explicit-create-selection", startedTickets: [] } : undefined),
54
+ builderCapabilities: input.builderCapabilities,
35
55
  deliveryUnit: input.deliveryUnit,
36
56
  branchMode: input.branchMode ?? "current",
37
57
  checkpoint: "created",
@@ -61,45 +81,37 @@ export function createBuildRun(input) {
61
81
  createdAt: stamp,
62
82
  updatedAt: stamp,
63
83
  };
64
- const saved = saveBuildRun(input.repositoryRoot, run, now);
65
84
  const workflow = new WorkflowDb(input.repositoryRoot);
66
85
  try {
67
- workflow.acquireLease(saved.runId, undefined, now, BUILD_LEASE_STALE_MS);
86
+ workflow.ensureRun(run.runId, "build", now);
87
+ const authority = workflow.acquireLease(run.runId, undefined, now, BUILD_LEASE_STALE_MS);
88
+ workflow.qaPreparationStore().freezePolicy(run.runId, loadQaPreparationPolicy(input.repositoryRoot));
89
+ return saveBuildRun(input.repositoryRoot, bindBuildAuthority(run, authority), now);
68
90
  }
69
91
  finally {
70
92
  workflow.close();
71
93
  }
72
- return saved;
73
94
  }
74
95
  export function resumeBuildRun(projectDir, runId, patch, now = new Date()) {
75
96
  let existing = readBuildRuns(projectDir).find((run) => run.runId === runId);
76
97
  if (!existing)
77
98
  throw new Error(`recoverable build run not found: ${runId}`);
78
- if (existing.status === "completed" || existing.status === "superseded")
99
+ if (["completed", "cancelled", "superseded"].includes(existing.status))
79
100
  throw new Error(`build run ${runId} is ${existing.status} and cannot resume`);
80
101
  const workflow = new WorkflowDb(projectDir);
102
+ let authority;
81
103
  try {
82
- const held = workflow.currentLease();
83
- if (held && held.runId === runId && held.pid === process.pid && held.host === hostname() && held.processStart === processStartIdentity(process.pid)) {
84
- workflow.heartbeatLease(held, now);
85
- }
86
- else {
87
- workflow.acquireLease(runId, undefined, now, BUILD_LEASE_STALE_MS);
88
- }
104
+ authority = workflow.acquireLease(runId, undefined, now, BUILD_LEASE_STALE_MS);
89
105
  if (patch.expectedRecoveryDecisionDigest) {
90
106
  const decision = workflow.getRun(runId)?.state.recoveryDecision;
91
107
  if (createHash("sha256").update(JSON.stringify(decision ?? null)).digest("hex") !== patch.expectedRecoveryDecisionDigest) {
92
- const lease = workflow.currentLease();
93
- if (lease?.runId === runId && lease.pid === process.pid)
94
- workflow.releaseLease(lease, now);
108
+ workflow.releaseLease(authority, now);
95
109
  throw new Error("Recovery decision changed before the child supervisor acquired its lease; inspect and retry");
96
110
  }
97
111
  }
98
112
  existing = readBuildRuns(projectDir).find((run) => run.runId === runId);
99
- if (!existing || existing.status === "completed" || existing.status === "superseded") {
100
- const lease = workflow.currentLease();
101
- if (lease?.runId === runId && lease.pid === process.pid)
102
- workflow.releaseLease(lease, now);
113
+ if (!existing || ["completed", "cancelled", "superseded"].includes(existing.status)) {
114
+ workflow.releaseLease(authority, now);
103
115
  throw new Error("Recovery run completed or disappeared before lease acquisition");
104
116
  }
105
117
  }
@@ -111,7 +123,7 @@ export function resumeBuildRun(projectDir, runId, patch, now = new Date()) {
111
123
  ? undefined
112
124
  : patch.builderSessionRef?.sessionId ?? patch.builderSessionId ?? existing.builder?.sessionId;
113
125
  return saveBuildRun(projectDir, {
114
- ...existing, status: "running", checkpoint: "recovery-resumed", completedAt: undefined,
126
+ ...bindBuildAuthority(existing, authority), status: "running", checkpoint: "recovery-resumed", completedAt: undefined,
115
127
  builder: patch.builder ? { settings: normalizeCapturedSettings(patch.builder), ...(builderSessionId ? { sessionId: builderSessionId } : {}) } : existing.builder,
116
128
  qa: patch.qa ? { settings: normalizeCapturedSettings(patch.qa), sessionId: existing.qa?.sessionId } : existing.qa,
117
129
  sessionBindings: patch.builderSessionRef ? upsertSessionBinding(existing.sessionBindings, patch.builderSessionRef) : existing.sessionBindings,
@@ -123,15 +135,28 @@ function normalizeCapturedSettings(settings) {
123
135
  ...settings,
124
136
  session_strategy: settings.session_strategy ?? (["builder", "qa", "ticket-maker"].includes(settings.role) ? "compact" : "fresh"),
125
137
  display_session_cost: settings.display_session_cost ?? false,
126
- auto_compact_threshold_percent: settings.auto_compact_threshold_percent ?? 50,
138
+ auto_compact_threshold_percent: settings.auto_compact_threshold_percent ?? 65,
127
139
  compact_maximum: settings.compact_maximum ?? 10,
128
140
  settings_revision: settings.settings_revision ?? 0,
129
141
  };
130
142
  }
131
143
  export function saveBuildRun(projectDir, run, now = new Date()) {
144
+ if (!run[buildAuthority])
145
+ throw new Error("build mutation requires the caller's original workflow authority");
146
+ if (canonicalSessionPath(projectDir) !== canonicalSessionPath(run.repository.root))
147
+ throw new Error("Build authority belongs to another project");
148
+ const original = run[buildAuthority];
149
+ const current = readCurrentWorkflowLease(projectDir);
150
+ if (!current || current.owner !== original.owner || current.generation !== original.generation || current.runId !== original.runId)
151
+ throw new Error("workflow lease ownership changed");
132
152
  validateBuildRun(run);
133
153
  const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
134
- mkdirSync(directory, { recursive: true });
154
+ const progress = ["recoverable", "interrupted", "blocked", "failed"].includes(run.status) ? currentBranchTicketProgress(projectDir, run) : undefined;
155
+ if (progress)
156
+ run = { ...run,
157
+ currentTicket: progress.pendingQa.find(ticket => run.tickets.includes(ticket)) ?? (run.currentTicket && progress.remaining.includes(run.currentTicket) ? run.currentTicket : progress.remaining[0]),
158
+ progress: { ...run.progress, completedTickets: progress.completed, remainingTickets: progress.remaining },
159
+ };
135
160
  const upgraded = upgradeBuildRun(run, projectDir, now);
136
161
  const completedOperations = Object.keys(upgraded.receipts).sort();
137
162
  let next = {
@@ -142,13 +167,17 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
142
167
  const workflow = new WorkflowDb(projectDir);
143
168
  const target = join(directory, `${run.runId}.json`);
144
169
  let publicationId;
170
+ let temporary;
145
171
  try {
146
172
  workflow.atomic(() => {
173
+ assertBuildAuthority(workflow, run);
147
174
  const frozenPolicy = workflow.freezeAutonomyPolicy(next.runId, next.frozenPolicy, now);
148
175
  next = { ...next, frozenPolicy, recoveryAttempts: workflow.recoveryAttempts(next.runId), pendingDecisions: workflow.pendingHumanDecisions(next.runId), supervisor: workflow.supervisorState(next.runId) ?? next.supervisor };
149
176
  const existing = workflow.getRun(next.runId);
150
- if (existing?.status === "superseded" && next.status !== "superseded")
151
- throw new Error("cannot rewrite a superseded build run as active");
177
+ const priorProgress = existing?.state.progress;
178
+ next = { ...next, progress: { ...next.progress, completedTickets: [...new Set([...next.progress.completedTickets, ...(priorProgress?.completedTickets ?? [])])] } };
179
+ if (existing && ["completed", "cancelled", "superseded"].includes(existing.status) && workflowStatus(next.status) !== existing.status)
180
+ throw new Error(`cannot rewrite a ${existing.status} build run`);
152
181
  if (!existing)
153
182
  workflow.createRun({
154
183
  runId: next.runId, kind: "build", checkpoint: next.checkpoint,
@@ -167,9 +196,17 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
167
196
  publicationId = workflow.beginPublication(next.runId, { operation: "build-run-projection", target,
168
197
  revision: createHash("sha256").update(JSON.stringify(next)).digest("hex"), updatedAt: next.updatedAt }, {}, now).transactionId;
169
198
  });
199
+ mkdirSync(directory, { recursive: true });
170
200
  const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
201
+ temporary = temp;
171
202
  writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
172
- renameSync(temp, target);
203
+ workflow.atomic(() => {
204
+ assertBuildAuthority(workflow, run);
205
+ if (JSON.stringify(workflow.getRun(run.runId)?.state) !== JSON.stringify(next))
206
+ throw new Error("Build projection changed before publication");
207
+ renameSync(temp, target);
208
+ temporary = undefined;
209
+ });
173
210
  workflow.atomic(() => {
174
211
  workflow.updatePublication(publicationId, "committed", now);
175
212
  // Publishing the latest authoritative revision also reconciles any older
@@ -182,6 +219,8 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
182
219
  });
183
220
  }
184
221
  finally {
222
+ if (temporary)
223
+ rmSync(temporary, { force: true });
185
224
  workflow.close();
186
225
  }
187
226
  return next;
@@ -243,7 +282,7 @@ export function recordBuildReceipt(projectDir, run, operationId, detail) {
243
282
  return next;
244
283
  }
245
284
  export function heartbeatBuildRun(projectDir, run, now = new Date()) {
246
- const lease = readCurrentWorkflowLease(projectDir);
285
+ const lease = run[buildAuthority];
247
286
  if (!lease || lease.runId !== run.runId)
248
287
  throw new Error("workflow lease ownership changed");
249
288
  const next = heartbeatCurrentWorkflowLease(projectDir, lease, now);
@@ -251,21 +290,45 @@ export function heartbeatBuildRun(projectDir, run, now = new Date()) {
251
290
  }
252
291
  export function releaseBuildLease(projectDir, run, status = "interrupted") {
253
292
  const saved = saveBuildRun(projectDir, { ...run, status, lease: undefined });
254
- releaseWorkflowLease(projectDir, run.runId);
293
+ releaseWorkflowLease(projectDir, run);
255
294
  return saved;
256
295
  }
257
296
  export function completeBuildRun(projectDir, run, now = new Date()) {
297
+ assertBuildAssignmentReconciled(projectDir, run.runId);
298
+ if (run.status === "completed") {
299
+ const committed = readBuildRuns(projectDir).find(item => item.runId === run.runId);
300
+ if (committed?.status === "completed" && JSON.stringify(committed) === JSON.stringify(run))
301
+ return committed;
302
+ }
258
303
  const workflow = new WorkflowDb(projectDir);
259
304
  try {
260
305
  workflow.assertQaRunFinalizable(run.runId);
306
+ if (workflow.pendingHumanDecisions(run.runId).length)
307
+ throw new Error("build run has unanswered decisions");
308
+ if (["builder", "qa"].some(role => workflow.unresolvedRoleDispatches(run.runId, role).length))
309
+ throw new Error("build run has unresolved provider dispatches");
261
310
  }
262
311
  finally {
263
312
  workflow.close();
264
313
  }
265
314
  const saved = saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString(), progress: { ...run.progress, completedTickets: [...run.tickets], remainingTickets: [], nextAction: "None; build complete" } }, now);
266
- releaseWorkflowLease(projectDir, run.runId, now);
315
+ releaseWorkflowLease(projectDir, run, now);
267
316
  return saved;
268
317
  }
318
+ /** A recovered ticket boundary is complete only when the saved run scope is done. */
319
+ export function finishRecoveredTicketScope(projectDir, run, checkpoint) {
320
+ if (run.branchMode !== "current" || !run.tickets.length || !isTicketsInitialized(projectDir))
321
+ throw new Error("Ticket recovery completion requires an initialized current-branch run scope");
322
+ const { completed, remaining } = currentBranchTicketProgress(projectDir, run);
323
+ if (!remaining.length && !run.authorizedBatch)
324
+ return releaseBuildLease(projectDir, checkpointBuildRun(projectDir, run, "legacy-scope-reconciliation-required", { progress: { ...run.progress, completedTickets: completed, remainingTickets: [], nextAction: "Confirm the original authorized batch scope before declaring the build complete" } }), "recoverable");
325
+ if (!remaining.length)
326
+ return completeBuildRun(projectDir, run);
327
+ return releaseBuildLease(projectDir, checkpointBuildRun(projectDir, run, checkpoint, {
328
+ currentTicket: remaining[0],
329
+ progress: { ...run.progress, completedTickets: completed, remainingTickets: remaining, nextAction: `Resume ${remaining[0]}` },
330
+ }), "recoverable");
331
+ }
269
332
  export function readBuildRuns(projectDir) {
270
333
  const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
271
334
  const workflow = new WorkflowReader(projectDir);
@@ -282,6 +345,17 @@ export function readBuildRuns(projectDir) {
282
345
  const value = stored.state;
283
346
  if (value?.runId)
284
347
  candidates.set(value.runId, value);
348
+ else if (!["completed", "superseded", "cancelled"].includes(stored.status) && !candidates.has(stored.runId)) {
349
+ // Supervision/readiness can stop before the first full build snapshot.
350
+ // Keep that attempt visible without inventing a provider session or baseline.
351
+ candidates.set(stored.runId, {
352
+ version: 1, runId: stored.runId, status: stored.status === "running" ? "running" : "recoverable",
353
+ tickets: [], branchMode: "current", checkpoint: stored.checkpoint,
354
+ repository: { root: resolve(projectDir), worktree: resolve(projectDir) }, receipts: {},
355
+ createdAt: stored.createdAt, updatedAt: stored.updatedAt,
356
+ failure: { category: "preparation-incomplete", summary: "Build preparation stopped before a complete build checkpoint", at: stored.updatedAt },
357
+ });
358
+ }
285
359
  }
286
360
  const runs = [...candidates.values()].flatMap((projection) => {
287
361
  try {
@@ -289,8 +363,9 @@ export function readBuildRuns(projectDir) {
289
363
  const authoritative = workflow?.getRun(projection.runId)?.state;
290
364
  const run = authoritative?.runId ? authoritative : projection;
291
365
  let upgraded = upgradeBuildRun(run, projectDir);
292
- if (workflow?.getRun(run.runId)?.status === "superseded")
293
- upgraded = { ...upgraded, status: "superseded" };
366
+ const dbStatus = workflow.getRun(run.runId)?.status;
367
+ if (dbStatus === "superseded" || dbStatus === "completed" || dbStatus === "cancelled")
368
+ upgraded = { ...upgraded, status: dbStatus };
294
369
  if (!["completed", "superseded", "cancelled"].includes(workflow?.getRun(run.runId)?.status ?? "")) {
295
370
  for (const role of ["builder", "qa"]) {
296
371
  const lease = workflow?.roleMutationLease(run.runId, role);
@@ -304,36 +379,53 @@ export function readBuildRuns(projectDir) {
304
379
  catch {
305
380
  return [];
306
381
  }
307
- }).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
382
+ }).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.runId.localeCompare(b.runId));
308
383
  workflow?.close();
309
384
  return runs;
310
385
  }
311
386
  export function recoverableBuildRuns(projectDir, now = new Date()) {
312
- const databaseLease = readCurrentWorkflowLease(projectDir);
313
- return readBuildRuns(projectDir)
314
- .filter((run) => run.status !== "completed")
315
- .map((run) => ({ ...inferLegacyRunTickets(projectDir, run), active: databaseLease?.runId === run.runId ? workflowLeaseLooksLive(databaseLease, now) : isLeaseActive(run, now) }));
316
- }
317
- function workflowLeaseLooksLive(lease, now) {
318
- if (now.getTime() - new Date(lease.heartbeatAt).getTime() > BUILD_LEASE_STALE_MS)
319
- return false;
320
- if (lease.host !== hostname())
321
- return true;
322
- return isLiveProcessIdentity(lease.pid, lease.processStart);
387
+ const reader = new WorkflowReader(projectDir);
388
+ const databaseLease = reader.currentLease();
389
+ const admission = reader.buildAdmission();
390
+ const unfinished = readBuildRuns(projectDir).filter(run => !["completed", "cancelled", "superseded"].includes(run.status));
391
+ const cleanupRuns = [...new Set(reader.readinessProcesses().map(row => row.run_id))].filter(id => !unfinished.some(run => run.runId === id)).map(runId => {
392
+ const run = reader.getRun(runId);
393
+ const at = run?.updatedAt ?? new Date(0).toISOString();
394
+ return { ...upgradeBuildRun({ version: 1, runId, status: "recoverable", tickets: [], branchMode: "current", checkpoint: "readiness-cleanup", repository: { root: resolve(projectDir), worktree: resolve(projectDir) }, receipts: {}, createdAt: run?.createdAt ?? at, updatedAt: at }, projectDir), cleanupOnly: true };
395
+ });
396
+ try {
397
+ return [...unfinished, ...cleanupRuns]
398
+ .map(run => {
399
+ const owners = [];
400
+ if (admission?.runId === run.runId)
401
+ owners.push(classifyProcess(admission.pid, admission.processStart, admission.host));
402
+ if (databaseLease?.runId === run.runId)
403
+ owners.push(classifyProcess(databaseLease.pid, databaseLease.processStart, databaseLease.host));
404
+ if (run.supervisor?.pid && ["running", "stopping"].includes(run.supervisor.status))
405
+ owners.push(classifyProcess(run.supervisor.pid, run.supervisor.processStart));
406
+ if (!owners.length && run.lease)
407
+ owners.push(classifyProcess(run.lease.pid, run.lease.processStart, run.lease.hostname));
408
+ let owner = owners.find(item => item.state === "live") ?? owners.find(item => item.state === "unknown") ?? { state: "dead", reason: "No live owner recorded" };
409
+ if (owner.state !== "live" && reader.pendingBuildLaunches(run.runId).some(launch => launch.state === "dispatching"))
410
+ owner = { state: "unknown", reason: "launch dispatch began but no child claim was confirmed; reconcile the launcher and child process evidence before retrying" };
411
+ return { ...run, active: owner.state === "live", ownership: owner.state, ownershipReason: owner.reason };
412
+ });
413
+ }
414
+ finally {
415
+ reader.close();
416
+ }
323
417
  }
324
418
  /**
325
- * Current-branch runs created before ticket checkpointing did not retain their
326
- * selected ticket. Recover it from Foreman's tracker events so --ticket can
327
- * still address those durable runs without rewriting the legacy record.
419
+ * Timestamp correlations are diagnostic hints, never executable membership.
328
420
  */
329
- function inferLegacyRunTickets(projectDir, run) {
330
- if (run.tickets.length > 0 || !isTicketsInitialized(projectDir))
331
- return run;
421
+ export function legacyTrackerWorkHints(projectDir, run) {
422
+ if (!isTicketsInitialized(projectDir))
423
+ return [];
332
424
  let db;
333
425
  try {
334
426
  const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
335
427
  if (!existsSync(paths.stateDb))
336
- return run;
428
+ return [];
337
429
  db = new Database(paths.stateDb, { readonly: true, fileMustExist: true });
338
430
  const rows = db.prepare(`
339
431
  SELECT ticket_id
@@ -345,26 +437,18 @@ function inferLegacyRunTickets(projectDir, run) {
345
437
  ORDER BY timestamp, id
346
438
  `).all(run.createdAt, run.updatedAt);
347
439
  const tickets = [...new Set(rows.map((row) => row.ticket_id).filter(Boolean))];
348
- if (tickets.length === 0)
349
- return run;
350
- return { ...run, tickets, currentTicket: tickets.at(-1) };
440
+ return tickets;
351
441
  }
352
442
  catch {
353
- return run;
443
+ return [];
354
444
  }
355
445
  finally {
356
446
  db?.close();
357
447
  }
358
448
  }
359
449
  export function isLeaseActive(run, now = new Date()) {
360
- if (!run.lease)
361
- return false;
362
- const age = now.getTime() - new Date(run.lease.heartbeatAt).getTime();
363
- if (age > BUILD_LEASE_STALE_MS)
364
- return false;
365
- if (run.lease.hostname !== hostname())
366
- return true;
367
- return isLiveProcessIdentity(run.lease.pid, run.lease.processStart);
450
+ void now;
451
+ return Boolean(run.lease && classifyProcess(run.lease.pid, run.lease.processStart, run.lease.hostname).state !== "dead");
368
452
  }
369
453
  export function buildRecoveryPreview(run) {
370
454
  const completed = Object.keys(run.receipts).sort();
@@ -381,6 +465,59 @@ export function buildRecoveryPreview(run) {
381
465
  `QA session ${buildRunSessionBinding(run, "qa") ? "QA session candidate requires validation" : "fresh QA session required"}`,
382
466
  ];
383
467
  }
468
+ /** Saved context may be broader than the work authorized for this invocation. */
469
+ export function recoveryExecutionTickets(run, requested, frozen) {
470
+ const scope = frozen && run.recoveryDecision?.executionTickets !== undefined
471
+ ? run.recoveryDecision.executionTickets
472
+ : requested.length ? requested : run.tickets;
473
+ if (scope.some(ticket => !run.tickets.includes(ticket)) || requested.some(ticket => !scope.includes(ticket)) || (run.tickets.length && !scope.length)) {
474
+ throw new Error("Recovery execution scope conflicts with the saved run or requested ticket");
475
+ }
476
+ return [...new Set(scope)];
477
+ }
478
+ /** Read-only reconciliation also handles crashes before the last progress checkpoint. */
479
+ export function currentBranchTicketProgress(projectDir, run) {
480
+ if (run.branchMode !== "current" || !run.tickets.length || !isTicketsInitialized(projectDir))
481
+ return undefined;
482
+ if (run.status === "completed")
483
+ return { completed: [...run.tickets], remaining: [], pendingQa: [] };
484
+ const workflow = new WorkflowReader(projectDir);
485
+ let pendingQa;
486
+ let questions;
487
+ try {
488
+ pendingQa = workflow.pendingQaTicketIds(run.runId);
489
+ questions = workflow.pendingHumanDecisions(run.runId).filter(item => item.interruptionId.startsWith("ticket:")).map(item => item.interruptionId.slice(7));
490
+ }
491
+ finally {
492
+ workflow.close();
493
+ }
494
+ let state;
495
+ let completed = [];
496
+ try {
497
+ const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
498
+ state = new Database(paths.stateDb, { readonly: true, fileMustExist: true });
499
+ const read = state.prepare("SELECT status FROM ticket_state WHERE ticket_id=?");
500
+ completed = run.tickets.filter(ticket => read.get(ticket)?.status === "done" && !pendingQa.includes(ticket) && !questions.includes(ticket));
501
+ }
502
+ catch {
503
+ // Legacy, missing or unreadable tracker evidence cannot authorize completion.
504
+ // Keep recovery inspectable; actual dispatch still requires a usable tracker.
505
+ completed = [];
506
+ }
507
+ finally {
508
+ state?.close();
509
+ }
510
+ return { completed, remaining: run.tickets.filter(ticket => !completed.includes(ticket)), pendingQa };
511
+ }
512
+ /** Match the frozen preview at both the resume wrapper and direct start boundary. */
513
+ export function currentBranchRecoveryTicket(projectDir, run) {
514
+ if (run.currentTicket && !run.tickets.includes(run.currentTicket))
515
+ throw new Error(`Recovery ticket ${run.currentTicket} is outside the saved run scope`);
516
+ const ticket = projectBuildRecovery(projectDir, run).ticketId;
517
+ if (ticket && !run.tickets.includes(ticket))
518
+ throw new Error(`Recovery ticket ${ticket} is outside the saved run scope`);
519
+ return ticket;
520
+ }
384
521
  export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOverride, sessionAvailability) {
385
522
  let operationNames = [];
386
523
  let issues = [];
@@ -397,7 +534,13 @@ export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOv
397
534
  }
398
535
  }
399
536
  const stateTicket = typeof workflowState.currentTicket === "string" ? workflowState.currentTicket : undefined;
400
- const ticketId = ticketOverride ?? stateTicket ?? run.currentTicket ?? run.progress.remainingTickets[0] ?? run.tickets[0];
537
+ const progress = currentBranchTicketProgress(projectDir, run);
538
+ if (progress && stateTicket && !run.tickets.includes(stateTicket))
539
+ throw new Error(`Recovery ticket ${stateTicket} is outside the saved run scope`);
540
+ const savedTicket = stateTicket ?? run.currentTicket;
541
+ const ticketId = ticketOverride ?? (progress
542
+ ? progress.pendingQa.find(ticket => run.tickets.includes(ticket)) ?? (savedTicket && progress.remaining.includes(savedTicket) ? savedTicket : progress.remaining[0])
543
+ : savedTicket ?? run.progress.remainingTickets[0] ?? run.tickets[0]);
401
544
  let ticketTitle;
402
545
  if (ticketId && isTicketsInitialized(projectDir)) {
403
546
  try {
@@ -420,15 +563,15 @@ export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOv
420
563
  unexpectedChanges.push({ path: recoveryWorktree, risk: "preserved worktree identity changed" });
421
564
  if (branchChanged)
422
565
  unexpectedChanges.push({ path: recoveryWorktree, risk: `branch changed from ${expectedBranch} to ${branchNow}` });
423
- const completed = [...new Set([...run.progress.completedTickets, ...run.progress.completedOperations, ...operationNames])];
424
- const remaining = run.progress.remainingTickets.length ? run.progress.remainingTickets : remainingTickets(run);
566
+ const completed = [...new Set([...(progress?.completed ?? run.progress.completedTickets), ...run.progress.completedOperations, ...operationNames])];
567
+ const remaining = progress?.remaining ?? (run.progress.remainingTickets.length ? run.progress.remainingTickets : remainingTickets(run));
425
568
  const interruption = run.interruption?.lastError ?? run.interruption?.summary ?? run.failure?.summary ?? issues.at(-1) ?? run.status;
426
569
  const validation = run.progress.validation
427
570
  ? `${run.progress.validation.status}${run.progress.validation.qa ? `; QA ${run.progress.validation.qa}` : ""}`
428
571
  : run.qa?.sessionId ? "QA session checkpointed" : "no completed QA/validation checkpoint recorded";
429
572
  const nextAction = ticketOverride
430
573
  ? `Resume ${ticketOverride} from ${run.checkpoint}`
431
- : run.progress.nextAction ?? (ticketId ? `Resume ${ticketId} from ${run.checkpoint}` : `Resume from ${run.checkpoint}`);
574
+ : (progress ? undefined : run.progress.nextAction) ?? (ticketId ? `Resume ${ticketId} from ${run.checkpoint}` : `Resume from ${run.checkpoint}`);
432
575
  const primary = ticketId ? `${ticketId}${ticketTitle ? `: ${ticketTitle}` : ""}` : "legacy run (ticket unavailable)";
433
576
  const stateSessionId = typeof workflowState.sessionId === "string" ? workflowState.sessionId : undefined;
434
577
  const sessionCandidateRef = buildRunSessionBinding(run, "builder", stateSessionId ?? run.builder?.sessionId);
@@ -510,6 +653,8 @@ function validateBuildRun(run) {
510
653
  }
511
654
  }
512
655
  function workflowStatus(status) {
656
+ if (status === "cancelled")
657
+ return "cancelled";
513
658
  if (status === "completed")
514
659
  return "completed";
515
660
  if (status === "failed")
@@ -524,7 +669,7 @@ function workflowStatus(status) {
524
669
  }
525
670
  function remainingTickets(run) {
526
671
  const current = run.currentTicket ? run.tickets.indexOf(run.currentTicket) : 0;
527
- return run.status === "completed" ? [] : run.tickets.slice(Math.max(0, current));
672
+ return run.status === "completed" ? [] : (run.branchMode === "current" ? run.tickets : run.tickets.slice(Math.max(0, current))).filter(ticket => run.branchMode !== "current" || !run.progress.completedTickets.includes(ticket));
528
673
  }
529
674
  function captureGitSnapshot(worktree, input) {
530
675
  const git = (args) => {
@@ -619,11 +764,12 @@ function upgradeBuildRun(run, projectDir = run.repository.root, now = new Date()
619
764
  deferredTickets: [],
620
765
  };
621
766
  }
622
- function releaseWorkflowLease(projectDir, runId, now = new Date()) {
767
+ function releaseWorkflowLease(projectDir, run, now = new Date()) {
623
768
  const workflow = new WorkflowDb(projectDir);
624
769
  try {
625
- const lease = workflow.currentLease();
626
- if (lease?.runId === runId)
770
+ assertBuildAuthority(workflow, run);
771
+ const lease = run[buildAuthority];
772
+ if (lease)
627
773
  workflow.releaseLease(lease, now);
628
774
  }
629
775
  finally {
@@ -0,0 +1,2 @@
1
+ /** Explicit unticketed CLI mode freezes each work definition before its first dispatch. */
2
+ export declare function initializeSyntheticWork(projectDir: string, runId: string, steps: number, authorization: string, approvedPlan?: string): string[];
@@ -0,0 +1,30 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { WorkflowDb } from "./workflowDb.js";
3
+ import { buildScopeRevision } from "./buildApproval.js";
4
+ /** Explicit unticketed CLI mode freezes each work definition before its first dispatch. */
5
+ export function initializeSyntheticWork(projectDir, runId, steps, authorization, approvedPlan = "Explicit unticketed work authorized by the host") {
6
+ if (!Number.isSafeInteger(steps) || steps < 1 || steps > 10000)
7
+ throw new Error("Invalid synthetic work step limit");
8
+ if (!approvedPlan.trim() || Buffer.byteLength(approvedPlan) > 262144)
9
+ throw new Error("Synthetic work requires a bounded, frozen approved preflight plan");
10
+ const db = new WorkflowDb(projectDir);
11
+ try {
12
+ return db.atomic(() => {
13
+ db.ensureRun(runId);
14
+ const existing = db.workDefinitions(runId).filter(work => work.kind === "synthetic");
15
+ if (existing.length)
16
+ return existing.map(work => work.workId);
17
+ const ids = [];
18
+ for (let step = 1; step <= steps; step++) {
19
+ const id = `synthetic:${randomUUID()}`;
20
+ const definition = { id, order: step, title: `Authorized unticketed step ${step}`, area: "project", priority: "P2", size: "M", risk: "Low", depends_on: [], summary: `Implement step ${step} of the explicitly authorized project work`, acceptance: ["Satisfy the approved project requirements and the host preflight plan"], required_tests: ["Run the relevant project validation"], likely_files: [], notes: `Frozen approved preflight plan:\n${approvedPlan}` };
21
+ db.admitWork({ runId, kind: "synthetic", workId: id, definition, approvalId: `unticketed:${runId}`, scopeRevision: buildScopeRevision(projectDir), provenance: { userTurn: authorization, reason: "Explicit unticketed CLI invocation after approval" } });
22
+ ids.push(id);
23
+ }
24
+ return ids;
25
+ });
26
+ }
27
+ finally {
28
+ db.close();
29
+ }
30
+ }
@@ -0,0 +1,17 @@
1
+ import type Database from "better-sqlite3";
2
+ import { type BuildWorkAdmissionV1 } from "rafi-spec";
3
+ export type { BuildWorkAdmissionV1 } from "rafi-spec";
4
+ export interface AdmitWorkInput {
5
+ runId: string;
6
+ workId?: string;
7
+ kind: "ticket" | "synthetic";
8
+ ticketId?: string;
9
+ definition: unknown;
10
+ approvalId: string;
11
+ scopeRevision: string;
12
+ provenance: BuildWorkAdmissionV1["provenance"];
13
+ }
14
+ export declare function admittedWork(db: Database.Database, runId: string, workId: string): BuildWorkAdmissionV1 | undefined;
15
+ export declare function assertAdmittedWork(db: Database.Database, runId: string, workId: string): BuildWorkAdmissionV1;
16
+ /** Only host approval/reconciliation callers hold this service; reads never call it. */
17
+ export declare function admitWork(db: Database.Database, input: AdmitWorkInput, metadataRepair?: boolean): BuildWorkAdmissionV1;
@@ -0,0 +1,57 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { qaDigest } from "./qaProtocolV2.js";
3
+ import { validateBuildWorkAdmissionV1 } from "rafi-spec";
4
+ export function admittedWork(db, runId, workId) {
5
+ const row = db.prepare("SELECT a.record_json FROM build_work_admissions a JOIN build_work_scope s USING(run_id,work_id) WHERE a.run_id=? AND a.work_id=? AND s.state='admitted' ORDER BY a.sequence DESC LIMIT 1").get(runId, workId);
6
+ return row ? JSON.parse(row.record_json) : undefined;
7
+ }
8
+ export function assertAdmittedWork(db, runId, workId) {
9
+ const admission = admittedWork(db, runId, workId);
10
+ if (!admission)
11
+ throw new Error(`Work ${runId}/${workId} is not admitted; ownership reconciliation is required before dispatch`);
12
+ return admission;
13
+ }
14
+ /** Only host approval/reconciliation callers hold this service; reads never call it. */
15
+ export function admitWork(db, input, metadataRepair = false) {
16
+ if (!input.approvalId || !input.scopeRevision || !input.provenance.reason || !(input.provenance.userTurn || input.provenance.decisionId || input.provenance.approvedPlanDigest))
17
+ throw new Error("Work admission requires explicit authorization provenance and requirements revision");
18
+ if (input.kind === "ticket" && (!input.ticketId || input.workId && input.workId !== input.ticketId || input.ticketId.startsWith("synthetic:")))
19
+ throw new Error("Ticket work identity is invalid");
20
+ const workId = input.kind === "ticket" ? input.ticketId : input.workId ?? `synthetic:${randomUUID()}`;
21
+ if (input.kind === "synthetic" && !/^synthetic:[a-f0-9-]{36}$/.test(workId))
22
+ throw new Error("Synthetic work requires a persisted opaque identity");
23
+ const requirementsDigest = qaDigest("admitted-requirements", input.definition);
24
+ return db.transaction(() => {
25
+ const prior = admittedWork(db, input.runId, workId);
26
+ if (prior?.requirementsDigest === requirementsDigest && prior.scopeRevision === input.scopeRevision)
27
+ return prior;
28
+ const run = db.prepare("SELECT status FROM workflow_runs WHERE run_id=?").get(input.runId);
29
+ if (!run || !metadataRepair && ["completed", "cancelled", "superseded"].includes(run.status))
30
+ throw new Error("Admission requires a nonterminal build");
31
+ const quarantined = db.prepare("SELECT state FROM build_work_scope WHERE run_id=? AND work_id=?").get(input.runId, workId);
32
+ if (quarantined?.state === "quarantined")
33
+ throw new Error("Historical work requires audited ownership reconciliation before admission");
34
+ db.prepare("INSERT OR IGNORE INTO build_work_scope VALUES(?,?,?,?, 'admitted',?)").run(input.runId, workId, input.kind, input.ticketId ?? null, JSON.stringify(input.definition));
35
+ const definitionBytes = Buffer.from(JSON.stringify(input.definition));
36
+ const definitionDigest = createHash("sha256").update(definitionBytes).digest("hex");
37
+ db.prepare("INSERT OR IGNORE INTO content_refs VALUES(?,'qa',?,?)").run(definitionDigest, definitionBytes, new Date().toISOString());
38
+ db.prepare("UPDATE build_work_scope SET definition_json=? WHERE run_id=? AND work_id=?").run(JSON.stringify(input.definition), input.runId, workId);
39
+ const projectId = db.prepare("SELECT project_id FROM build_project_identity").get().project_id;
40
+ const record = { version: 1, projectId, runId: input.runId, workId, kind: input.kind, ...(input.ticketId ? { ticketId: input.ticketId } : {}), assignmentId: randomUUID(), approvalId: input.approvalId, scopeRevision: input.scopeRevision, requirementsDigest, definitionDigest, admittedAt: new Date().toISOString(), admittedSequence: 0, provenance: input.provenance };
41
+ // Reserve sequence explicitly so immutable JSON and its ordinal agree.
42
+ record.admittedSequence = db.prepare("SELECT COALESCE(MAX(sequence),0)+1 AS n FROM build_work_admissions").get().n;
43
+ const validation = validateBuildWorkAdmissionV1(record);
44
+ if (!validation.valid)
45
+ throw new Error(`Invalid work admission: ${validation.errors.join("; ")}`);
46
+ db.prepare("INSERT INTO build_work_admissions VALUES(?,?,?,?,?,?,?,?,?,?)").run(record.admittedSequence, randomUUID(), record.runId, workId, record.assignmentId, record.approvalId, record.scopeRevision, requirementsDigest, JSON.stringify(record), record.admittedAt);
47
+ const snapshot = db.prepare("SELECT state_json FROM workflow_runs WHERE run_id=?").get(input.runId);
48
+ const state = JSON.parse(snapshot.state_json);
49
+ if (metadataRepair && !Array.isArray(state.tickets))
50
+ state.tickets = [];
51
+ if ((metadataRepair || state.version !== undefined) && Array.isArray(state.tickets) && !state.tickets.includes(workId)) {
52
+ state.tickets.push(workId);
53
+ db.prepare("UPDATE workflow_runs SET state_json=? WHERE run_id=?").run(JSON.stringify(state), input.runId);
54
+ }
55
+ return record;
56
+ }).immediate();
57
+ }
@@ -0,0 +1,2 @@
1
+ /** Called by the CLI only after its source-bound approval gate succeeds. */
2
+ export declare function admitApprovedTicket(projectDir: string, runId: string, ticketId: string, approvedRevision: string, authorization: string): void;