ai-foreman 1.7.26 → 1.7.28

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 (98) hide show
  1. package/dist/adapters/codex.d.ts +12 -0
  2. package/dist/adapters/codex.js +50 -1
  3. package/dist/adapters/recovering.d.ts +4 -0
  4. package/dist/adapters/recovering.js +10 -0
  5. package/dist/adapters/sessionFailure.d.ts +6 -0
  6. package/dist/adapters/sessionFailure.js +19 -0
  7. package/dist/adapters/types.d.ts +6 -0
  8. package/dist/branch/currentGuard.d.ts +8 -0
  9. package/dist/branch/currentGuard.js +5 -0
  10. package/dist/branch/runner.js +6 -2
  11. package/dist/buildApproval.js +2 -1
  12. package/dist/buildRuns.d.ts +12 -0
  13. package/dist/buildRuns.js +50 -0
  14. package/dist/buildWorkContext.js +1 -1
  15. package/dist/buildWorkMigration.d.ts +10 -0
  16. package/dist/buildWorkMigration.js +18 -2
  17. package/dist/cli/start.d.ts +10 -1
  18. package/dist/cli/start.js +186 -31
  19. package/dist/cli/state.js +39 -1
  20. package/dist/cli/tickets.d.ts +37 -0
  21. package/dist/cli/tickets.js +367 -12
  22. package/dist/config.js +14 -4
  23. package/dist/continuity.d.ts +1 -0
  24. package/dist/continuity.js +5 -0
  25. package/dist/foreman.js +89 -8
  26. package/dist/graph/preparation.js +6 -3
  27. package/dist/graph/semantic.js +2 -1
  28. package/dist/graph/turn.js +8 -1
  29. package/dist/handoffProtocol.js +2 -1
  30. package/dist/handoffs.js +7 -19
  31. package/dist/hostQaComparison.d.ts +50 -0
  32. package/dist/hostQaComparison.js +195 -0
  33. package/dist/log.d.ts +1 -1
  34. package/dist/managerPacket.js +1 -1
  35. package/dist/observability.d.ts +2 -0
  36. package/dist/observability.js +2 -1
  37. package/dist/permissions/policy.js +32 -0
  38. package/dist/planChanges.js +1 -1
  39. package/dist/projectDiagnostics.js +30 -0
  40. package/dist/providerQuestions.js +4 -24
  41. package/dist/qaBuildGate.d.ts +6 -1
  42. package/dist/qaBuildGate.js +76 -16
  43. package/dist/qaBuilderCoverage.d.ts +2 -1
  44. package/dist/qaBuilderCoverage.js +58 -7
  45. package/dist/qaBuilderEvidence.d.ts +18 -0
  46. package/dist/qaBuilderEvidence.js +60 -0
  47. package/dist/qaContractCoverage.d.ts +12 -1
  48. package/dist/qaContractCoverage.js +34 -2
  49. package/dist/qaContractDelivery.d.ts +2 -2
  50. package/dist/qaContractDelivery.js +88 -10
  51. package/dist/qaEffectiveConfig.js +2 -1
  52. package/dist/qaFailureDelivery.d.ts +23 -0
  53. package/dist/qaFailureDelivery.js +87 -6
  54. package/dist/qaHostFacts.d.ts +2 -0
  55. package/dist/qaHostFacts.js +11 -0
  56. package/dist/qaPreparation.d.ts +38 -1
  57. package/dist/qaPreparation.js +372 -31
  58. package/dist/qaPreparationInputs.js +5 -1
  59. package/dist/qaPreparationPolicy.d.ts +2 -1
  60. package/dist/qaPreparationPolicy.js +39 -9
  61. package/dist/qaPreparationStore.d.ts +4 -0
  62. package/dist/qaPreparationStore.js +8 -0
  63. package/dist/qaRecovery.d.ts +7 -0
  64. package/dist/qaRecovery.js +73 -1
  65. package/dist/qaReview.js +58 -9
  66. package/dist/qaReviewEvidence.d.ts +6 -0
  67. package/dist/qaReviewEvidence.js +37 -0
  68. package/dist/qaRollbackRehearsal.d.ts +2 -0
  69. package/dist/qaRollbackRehearsal.js +78 -0
  70. package/dist/qaSnapshot.d.ts +3 -1
  71. package/dist/qaSnapshot.js +3 -2
  72. package/dist/qaVerificationContract.d.ts +16 -0
  73. package/dist/qaVerificationContract.js +183 -17
  74. package/dist/sources/sourceRegistry.js +16 -5
  75. package/dist/stateTransfer.d.ts +14 -0
  76. package/dist/stateTransfer.js +51 -0
  77. package/dist/statusReporter.d.ts +8 -0
  78. package/dist/statusReporter.js +5 -0
  79. package/dist/supervisedStart.js +23 -0
  80. package/dist/ticketPlanning.d.ts +373 -0
  81. package/dist/ticketPlanning.js +34 -3
  82. package/dist/ticketPopulation.d.ts +28 -3
  83. package/dist/ticketPopulation.js +127 -15
  84. package/dist/tickets/details.d.ts +2 -1
  85. package/dist/tickets/details.js +7 -3
  86. package/dist/tickets/eligibility.d.ts +6 -0
  87. package/dist/tickets/eligibility.js +12 -1
  88. package/dist/tickets/riskWaivers.d.ts +3 -0
  89. package/dist/tickets/riskWaivers.js +12 -0
  90. package/dist/tickets/stateDb.d.ts +10 -0
  91. package/dist/tickets/stateDb.js +23 -0
  92. package/dist/tickets/ticketLoader.d.ts +9 -0
  93. package/dist/tickets/ticketLoader.js +15 -2
  94. package/dist/tickets/ticketSchema.d.ts +14 -1
  95. package/dist/tickets/ticketSchema.js +2 -1
  96. package/dist/tickets/validate.js +11 -1
  97. package/dist/workflowDb.js +24 -3
  98. package/package.json +9 -4
@@ -4,7 +4,12 @@ const contractAjv = new Ajv({ allErrors: true });
4
4
  const candidateSchema = contractAjv.compile(qaContractCandidateV1Schema);
5
5
  const assessmentSchema = contractAjv.compile(qaSemanticAssessmentV1Schema);
6
6
  const challengeSchema = contractAjv.compile(qaChallengeReceiptV1Schema);
7
+ function schemaIssues(errors) {
8
+ return (errors ?? []).map(error => `${error.instancePath || "/"} ${error.message}${error.keyword === "additionalProperties" ? `: ${String(error.params.additionalProperty)}` : error.keyword === "required" ? `: ${String(error.params.missingProperty)}` : ""}`);
9
+ }
7
10
  import { createHash } from "node:crypto";
11
+ import { realpathSync, statSync } from "node:fs";
12
+ import { isAbsolute, relative, sep } from "node:path";
8
13
  import { canonicalJson } from "./qaProtocolV2.js";
9
14
  import { depthObligations, validateDepthDecision } from "./qaPreparationPolicy.js";
10
15
  export function canonicalContractJson(value) {
@@ -47,8 +52,24 @@ export function allocateStableCheckIds(candidate) {
47
52
  counts.set(fingerprint, occurrence + 1);
48
53
  mapping.set(check.id, `check-${fingerprint.slice(0, 24)}-${occurrence}`);
49
54
  }
50
- const replace = (id) => { const replacement = mapping.get(id); if (!replacement)
51
- throw new Error(`Unknown proposed check reference ${id}`); return replacement; };
55
+ // A provider may copy a host-assigned ID from an earlier draft into a
56
+ // baseline or repair response. Treat an already allocated ID as
57
+ // idempotently normalized; never remap it through the provider-name map.
58
+ const allocatedIds = new Set(mapping.values());
59
+ const replace = (id) => {
60
+ const replacement = mapping.get(id);
61
+ if (replacement)
62
+ return replacement;
63
+ if (allocatedIds.has(id))
64
+ return id;
65
+ throw new Error(`Unknown proposed check reference ${id}; valid proposed check IDs: ${[...proposed.keys()].join(", ")}; valid allocated check IDs: ${[...allocatedIds].join(", ")}`);
66
+ };
67
+ // Rewrite exact provider-generated check identifiers in provider-authored prose as
68
+ // well as structured references. Never rewrite approved requirements, paths,
69
+ // commands, or generic IDs such as "check" that may also be ordinary words.
70
+ const proseIds = [...mapping.keys()].filter(id => id.startsWith("check-")).sort((a, b) => b.length - a.length);
71
+ const prosePattern = proseIds.length ? new RegExp(`(^|[^A-Za-z0-9_-])(${proseIds.map(id => id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?![A-Za-z0-9_-])`, "g") : undefined;
72
+ const rewrite = (text) => prosePattern ? text.replace(prosePattern, (_match, prefix, id) => `${prefix}${replace(id)}`) : text;
52
73
  for (const check of draft.checks) {
53
74
  check.id = replace(check.id);
54
75
  check.dependsOnChecks = check.dependsOnChecks.map(replace);
@@ -58,11 +79,89 @@ export function allocateStableCheckIds(candidate) {
58
79
  for (const observation of draft.baseline)
59
80
  if (observation.checkId)
60
81
  observation.checkId = replace(observation.checkId);
61
- for (const concern of draft.unresolved)
82
+ for (const check of draft.checks) {
83
+ check.expectedBehavior = rewrite(check.expectedBehavior);
84
+ check.expectedEvidence = check.expectedEvidence.map(rewrite);
85
+ if (check.expectedBaseline)
86
+ check.expectedBaseline = rewrite(check.expectedBaseline);
87
+ if (check.applicability.predicate)
88
+ check.applicability.predicate = rewrite(check.applicability.predicate);
89
+ check.applicability.requiredEvidence = check.applicability.requiredEvidence.map(rewrite);
90
+ for (const method of check.verification) {
91
+ method.expectedOutcome = rewrite(method.expectedOutcome);
92
+ if (method.steps)
93
+ method.steps = method.steps.map(rewrite);
94
+ }
95
+ }
96
+ for (const observation of draft.baseline) {
97
+ observation.procedure = rewrite(observation.procedure);
98
+ observation.relevance = rewrite(observation.relevance);
99
+ observation.expectedTransition = rewrite(observation.expectedTransition);
100
+ if (observation.predicateEvidence)
101
+ observation.predicateEvidence = observation.predicateEvidence.map(rewrite);
102
+ }
103
+ for (const evidence of draft.preparationEvidence)
104
+ evidence.analysis = rewrite(evidence.analysis);
105
+ for (const concern of draft.unresolved) {
62
106
  concern.checkIds = concern.checkIds.map(replace);
107
+ concern.reason = rewrite(concern.reason);
108
+ concern.nextAction = rewrite(concern.nextAction);
109
+ }
63
110
  return draft;
64
111
  }
65
112
  export function inventoryDigest(requirements) { return contractDigest("qa-requirement-inventory", requirements); }
113
+ /** Derive a missing baseline binding only from an explicit, unique check prerequisite reference. */
114
+ export function bindUniqueBaselineCheckIds(candidate) {
115
+ const draft = structuredClone(candidate);
116
+ for (const observation of draft.baseline) {
117
+ if (observation.checkId)
118
+ continue;
119
+ const matches = draft.checks.filter(check => check.prerequisiteRefs.includes(observation.id));
120
+ if (matches.length === 1)
121
+ observation.checkId = matches[0].id;
122
+ }
123
+ return draft;
124
+ }
125
+ /**
126
+ * The host owns the before/after path comparison. Providers may still draft a
127
+ * check that asks them to enumerate every changed file, which turns host
128
+ * bookkeeping into a Builder obligation. Replace only that explicit shape;
129
+ * ordinary product-scope checks remain provider-authored and unchanged.
130
+ */
131
+ export function rewriteProviderHostScopeChecks(candidate, baselineId) {
132
+ const draft = structuredClone(candidate);
133
+ const hostScope = (check) => {
134
+ const text = [check.expectedBehavior, ...check.expectedEvidence, ...check.verification.flatMap(method => [method.expectedOutcome, ...(method.steps ?? []), ...(method.argv ?? [])])].join(" ").toLowerCase();
135
+ return /\b(?:complete|full|all|every)\s+(?:changed|modified|touched)[ -](?:path|paths|file|files)\b/.test(text)
136
+ || /\b(?:changed|modified|touched)[ -](?:path|paths|file|files)\s+(?:inventory|list|manifest)\b/.test(text)
137
+ || /\bunchanged[- ]files?\s+(?:comparison|check|verification)\b/.test(text)
138
+ || /\bpath\s+manifest\b.{0,80}\b(?:compare|comparison|unchanged|scope)\b/.test(text);
139
+ };
140
+ const matches = draft.checks.filter(hostScope);
141
+ if (!matches.length)
142
+ return draft;
143
+ const primary = matches[0];
144
+ const mergedRequirements = [...new Set(matches.flatMap(check => check.requirementRefs))];
145
+ const mergedPrerequisites = [...new Set([...primary.prerequisiteRefs, baselineId])];
146
+ primary.requirementRefs = mergedRequirements;
147
+ primary.origin = "invariant";
148
+ primary.expectedBehavior = "Rafi compares the final product paths with the host-captured preimplementation baseline and rejects unauthorized product changes.";
149
+ primary.obligation = "mandatory";
150
+ primary.timing = "postimplementation";
151
+ primary.applicability = { kind: "unconditional", decisionOwner: "host", requiredEvidence: [baselineId] };
152
+ primary.verification = [{ kind: "procedure", steps: ["Rafi performs the host-owned before/after product-path comparison, excluding approved bookkeeping and bounded evidence paths."], cwd: ".", fixtures: [], runtime: "Rafi host", timeoutMs: 30_000, expectedOutcome: "Only approved product changes are present; unauthorized, destructive, protected, or requirement-relevant changes are rejected." }];
153
+ primary.expectedEvidence = ["Rafi host-owned postimplementation scope comparison bound to the retained preimplementation baseline."];
154
+ primary.prerequisiteRefs = mergedPrerequisites;
155
+ const removed = new Set(matches.slice(1).map(check => check.id));
156
+ draft.checks = draft.checks.filter(check => !removed.has(check.id));
157
+ for (const row of draft.coverage)
158
+ row.checkIds = row.checkIds.map(id => removed.has(id) ? primary.id : id).filter((id, index, ids) => ids.indexOf(id) === index);
159
+ for (const check of draft.checks)
160
+ check.dependsOnChecks = check.dependsOnChecks.map(id => removed.has(id) ? primary.id : id).filter((id, index, ids) => ids.indexOf(id) === index);
161
+ for (const concern of draft.unresolved)
162
+ concern.checkIds = concern.checkIds.map(id => removed.has(id) ? primary.id : id).filter((id, index, ids) => ids.indexOf(id) === index);
163
+ return draft;
164
+ }
66
165
  export function authoritativeInventory(ticket, checklist, inputs) {
67
166
  const result = [];
68
167
  for (const [kind, entries] of [["acceptance", ticket.acceptance], ["required_tests", ticket.required_tests], ["checklist", checklist]]) {
@@ -84,16 +183,67 @@ export function parsePreparationArtifact(text, tag, maximum = 2 * 1024 * 1024) {
84
183
  if (Buffer.byteLength(text) > maximum)
85
184
  throw new Error("QA preparation response exceeds size limit");
86
185
  const start = `${tag}_START`, end = `${tag}_END`;
87
- if (text.split(start).length !== 2 || text.split(end).length !== 2)
186
+ // Marker names can appear inline in provider prose or in a quoted repair
187
+ // instruction. Only a standalone marker line is an envelope boundary.
188
+ // This keeps the parser strict about malformed envelopes without treating
189
+ // explanatory text as a second/truncated artifact.
190
+ const markerOffsets = (marker) => {
191
+ const offsets = [];
192
+ const pattern = new RegExp(`^[ \\t]*${marker}[ \\t]*$`, "gm");
193
+ for (let match = pattern.exec(text); match; match = pattern.exec(text))
194
+ offsets.push(match.index);
195
+ return offsets;
196
+ };
197
+ const starts = markerOffsets(start), ends = markerOffsets(end);
198
+ if (!starts.length || starts.length !== ends.length)
88
199
  throw new Error(`Expected exactly one ${tag} artifact`);
89
- const from = text.indexOf(start) + start.length, to = text.indexOf(end);
90
- if (to < from)
91
- throw new Error("Malformed preparation artifact envelope");
92
- return JSON.parse(text.slice(from, to).trim());
200
+ const artifacts = starts.map((at, index) => {
201
+ const from = at + start.length, to = ends[index];
202
+ if (to < from)
203
+ throw new Error("Malformed preparation artifact envelope");
204
+ return text.slice(from, to).trim();
205
+ });
206
+ if (artifacts.length > 1) {
207
+ const normalized = artifacts.map(artifact => canonicalContractJson(JSON.parse(artifact)));
208
+ if (normalized.some(artifact => artifact !== normalized[0]))
209
+ throw new Error(`Expected exactly one unambiguous ${tag} artifact`);
210
+ }
211
+ return JSON.parse(artifacts[0]);
212
+ }
213
+ /** Capture safe absolute snapshot paths before the disposable snapshot is removed. */
214
+ export function mapSnapshotWorkingDirectories(text, snapshotPath) {
215
+ const result = {};
216
+ try {
217
+ const draft = parsePreparationArtifact(text, "RAFI_QA_CANDIDATE");
218
+ const root = realpathSync(snapshotPath);
219
+ for (const check of draft.checks ?? [])
220
+ for (const method of check.verification ?? []) {
221
+ if (typeof method.cwd !== "string" || !isAbsolute(method.cwd))
222
+ continue;
223
+ try {
224
+ const target = realpathSync(method.cwd);
225
+ const path = relative(root, target);
226
+ if (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path) && statSync(target).isDirectory())
227
+ result[method.cwd] = path || ".";
228
+ }
229
+ catch { /* Missing or inaccessible paths remain invalid. */ }
230
+ }
231
+ }
232
+ catch { /* Parsing and validation report malformed provider responses. */ }
233
+ return result;
234
+ }
235
+ /** Providers sometimes repeat an absolute disposable-snapshot path from an earlier
236
+ * conversation. That path is host-owned and must not enter the durable contract;
237
+ * normalize only the bounded QA snapshot naming convention to stable relative
238
+ * locations before parsing the provider draft. */
239
+ export function normalizeSnapshotPaths(text) {
240
+ return text
241
+ .replace(/(?:\/(?:private\/var|var|tmp)(?:\/[^\s"']+)*\/)?rafi-qa-[^\/\s"']+\/scratch(?=[\/"'\s}]|$)/g, "scratch")
242
+ .replace(/(?:\/(?:private\/var|var|tmp)(?:\/[^\s"']+)*\/)?rafi-qa-[^\/\s"']+(?=[\/"'\s}]|$)/g, ".");
93
243
  }
94
244
  export function validateCandidate(candidate, inventory, resolveEquivalent) {
95
245
  if (!candidateSchema(candidatePayload(candidate)))
96
- return (candidateSchema.errors ?? []).map(error => `${error.instancePath} ${error.message}`);
246
+ return schemaIssues(candidateSchema.errors);
97
247
  const issues = validateDepthDecision(candidate.depthDecision);
98
248
  if (candidate.version !== 1 || !candidate.contractId || !candidate.runId || !candidate.workId || !candidate.admissionDigest || !Number.isSafeInteger(candidate.revision) || candidate.revision < 1)
99
249
  issues.push("Invalid contract identity");
@@ -102,6 +252,10 @@ export function validateCandidate(candidate, inventory, resolveEquivalent) {
102
252
  issues.push(`Missing ${field} array`);
103
253
  if (issues.length)
104
254
  return issues;
255
+ if (candidate.documentation && (!Array.isArray(candidate.documentation.targets) || !Array.isArray(candidate.documentation.criteria)))
256
+ issues.push("Invalid documentation policy");
257
+ if (candidate.documentation?.required && (!candidate.documentation.targets.length || !candidate.documentation.criteria.length))
258
+ issues.push("Documentation policy requires explicit targets and criteria");
105
259
  const unique = (items, label) => {
106
260
  const ids = new Set();
107
261
  for (const item of items) {
@@ -128,9 +282,12 @@ export function validateCandidate(candidate, inventory, resolveEquivalent) {
128
282
  const rows = candidate.coverage.filter(row => row.requirementId === req.id);
129
283
  if (rows.length !== 1 || !rows[0].checkIds.length || rows[0].dispositionRef)
130
284
  issues.push(`Requirement lacks concrete coverage ${req.id}`);
131
- for (const id of rows[0]?.checkIds ?? [])
132
- if (!checks.has(id) || !candidate.checks.find(check => check.id === id)?.requirementRefs.includes(req.id))
133
- issues.push(`Invalid coverage ${req.id}/${id}`);
285
+ for (const id of rows[0]?.checkIds ?? []) {
286
+ if (!checks.has(id))
287
+ issues.push(`Invalid coverage ${req.id}/${id}: unknown check ID`);
288
+ else if (!candidate.checks.find(check => check.id === id)?.requirementRefs.includes(req.id))
289
+ issues.push(`Invalid coverage ${req.id}/${id}: that check's requirementRefs must include ${req.id}`);
290
+ }
134
291
  if (req.obligation === "mandatory" && !(rows[0]?.checkIds ?? []).some(id => candidate.checks.find(check => check.id === id)?.obligation === "mandatory"))
135
292
  issues.push(`Mandatory requirement only covered by advisory checks ${req.id}`);
136
293
  }
@@ -186,14 +343,16 @@ export function validateCandidate(candidate, inventory, resolveEquivalent) {
186
343
  for (const obligation of depthObligations(candidate.depthDecision.level))
187
344
  if (!candidate.preparationEvidence.some(evidence => evidence.obligation === obligation && evidence.analysis?.trim() && evidence.evidenceDigest && evidence.operationId && evidence.sessionId && evidence.references.length))
188
345
  issues.push(`Missing depth evidence ${obligation}`);
189
- if (candidate.unresolved.some(concern => concern.material))
190
- issues.push("Unresolved material concern");
346
+ for (const concern of candidate.unresolved.filter(item => item.material).slice(0, 5))
347
+ issues.push(`Unresolved material concern: ${concern.reason.slice(0, 500)}${concern.nextAction ? `; next action: ${concern.nextAction.slice(0, 300)}` : ""}`);
348
+ if (candidate.unresolved.filter(item => item.material).length > 5)
349
+ issues.push("Additional material concerns are retained in the preparation record");
191
350
  return [...new Set(issues)];
192
351
  }
193
352
  export function validateAssessment(candidate, assessment, authorSessionId) {
194
353
  const validator = "approach" in assessment ? challengeSchema : assessmentSchema;
195
354
  if (!validator(assessment))
196
- return (validator.errors ?? []).map(error => `${error.instancePath} ${error.message}`);
355
+ return schemaIssues(validator.errors);
197
356
  const issues = [];
198
357
  if (assessment.version !== 1 || assessment.draftPayloadDigest !== draftDigest(candidate) || assessment.inventoryDigest !== inventoryDigest(candidate.requirements) || assessment.policyVersion !== candidate.depthDecision.policyVersion)
199
358
  issues.push("Assessment does not bind the exact draft/inventory/policy");
@@ -205,8 +364,14 @@ export function validateAssessment(candidate, assessment, authorSessionId) {
205
364
  for (const obligation of depthObligations(candidate.depthDecision.level))
206
365
  if (!assessment.assessedObligations?.includes(obligation))
207
366
  issues.push(`Obligation not assessed ${obligation}`);
208
- if (!Array.isArray(assessment.concerns) || assessment.concerns.some(concern => concern.material))
367
+ if (!Array.isArray(assessment.concerns))
209
368
  issues.push("Semantic concerns unresolved");
369
+ else {
370
+ for (const concern of assessment.concerns.filter(item => item.material).slice(0, 5))
371
+ issues.push(`Semantic concerns unresolved: ${concern.reason.slice(0, 500)}${concern.nextAction ? `; next action: ${concern.nextAction.slice(0, 300)}` : ""}`);
372
+ if (assessment.concerns.filter(item => item.material).length > 5)
373
+ issues.push("Additional semantic concerns are retained in the preparation record");
374
+ }
210
375
  return issues;
211
376
  }
212
377
  export function assembleContract(candidate, inventory, assessment, authorSessionId, challenge, resolveEquivalent) {
@@ -231,6 +396,7 @@ export function verifyContractDigest(contract) {
231
396
  throw new Error("Corrupt verification contract");
232
397
  }
233
398
  export function renderVerificationContract(contract) {
234
- return [`Verification contract ${contract.contractId}, revision ${contract.revision}`, `Work: ${contract.workId}; digest: ${contract.contentDigest}`, `Depth: ${contract.depthDecision.level}: ${contract.depthDecision.rationale}`,
399
+ const documentation = contract.documentation ?? { required: false, targets: [], criteria: [] };
400
+ return [`Verification contract ${contract.contractId}, revision ${contract.revision}`, `Work: ${contract.workId}; digest: ${contract.contentDigest}`, `Depth: ${contract.depthDecision.level}: ${contract.depthDecision.rationale}`, `Documentation required: ${documentation.required ? "yes" : "no"}${documentation.required ? `; targets: ${documentation.targets.join(", ")}; criteria: ${documentation.criteria.join("; ")}` : ""}`,
235
401
  ...contract.checks.map(check => [`${check.id}: ${check.obligation}; ${check.timing}; ${check.applicability.kind}`, check.expectedBehavior, check.applicability.predicate ?? "Always applicable", `Evidence: ${check.expectedEvidence.join("; ")}`, ...check.verification.map(method => JSON.stringify(method))].join("\n"))].join("\n\n");
236
402
  }
@@ -8,6 +8,7 @@ import { parse, stringify } from "yaml";
8
8
  import { fetchAndSnapshotUrl, normalizePublicHttpUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
9
9
  import { fetchJiraIssues, fetchLinearIssues } from "../tickets/importer.js";
10
10
  import { withActivityPhase } from "../activity.js";
11
+ import { structuredSourceRequestSchema, zodIssues } from "rafi-spec";
11
12
  const execFileAsync = promisify(execFile);
12
13
  export const SOURCE_REQUEST_START = "RAFI_SOURCE_REQUEST_START";
13
14
  export const SOURCE_REQUEST_END = "RAFI_SOURCE_REQUEST_END";
@@ -156,14 +157,24 @@ export function extractSourceRequests(output) {
156
157
  throw new Error(`source request is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
157
158
  }
158
159
  const values = Array.isArray(value) ? value : [value];
159
- for (const item of values)
160
- if (item && typeof item === "object" && !Array.isArray(item))
161
- requests.push(item);
160
+ for (const item of values) {
161
+ const parsed = structuredSourceRequestSchema.safeParse(item);
162
+ if (!parsed.success)
163
+ throw new Error(`source request contract validation failed:\n${zodIssues(parsed.error).map((issue) => `- ${issue}`).join("\n")}`);
164
+ requests.push(parsed.data);
165
+ }
162
166
  cursor = end + SOURCE_REQUEST_END.length;
163
167
  }
164
168
  // Compatibility with the original single-line protocol. Preserve the complete remainder.
165
- for (const match of output.matchAll(/^SOURCE_REQUEST:\s*(.+)$/gim))
166
- requests.push({ description: match[1].trim() });
169
+ for (const match of output.matchAll(/^SOURCE_REQUEST:\s*(.+)$/gim)) {
170
+ const legacy = structuredSourceRequestSchema.safeParse({ description: match[1].trim() });
171
+ // Legacy lines are intentionally converted into the same typed contract;
172
+ // they remain description-only pending requests after registration, but
173
+ // they no longer bypass shape validation.
174
+ if (!legacy.success)
175
+ throw new Error(`legacy source request contract validation failed:\n${zodIssues(legacy.error).map((issue) => `- ${issue}`).join("\n")}`);
176
+ requests.push(legacy.data);
177
+ }
167
178
  return requests;
168
179
  }
169
180
  /** Deterministically recognize only an entire obvious locator; all other language stays pending for agent interpretation. */
@@ -41,6 +41,20 @@ export interface StateInspectResult {
41
41
  manifest: StateBundleManifestV1;
42
42
  valid: boolean;
43
43
  }
44
+ export interface BuildProjectIdentityStatus {
45
+ currentRoot: string;
46
+ storedRoot?: string;
47
+ matches: boolean;
48
+ recoveryDatabasePresent: boolean;
49
+ }
50
+ /** Read the immutable root binding without opening a normal workflow writer. */
51
+ export declare function inspectBuildProjectIdentity(projectDir: string): BuildProjectIdentityStatus;
52
+ /**
53
+ * Explicitly adopt copied local build state at its new canonical project root.
54
+ * This is intentionally separate from ordinary workflow writers and records an
55
+ * immutable audit row before changing the binding.
56
+ */
57
+ export declare function rebindBuildProjectIdentity(projectDir: string): BuildProjectIdentityStatus;
44
58
  export declare function exportStateBundle(projectDir: string, outputFile: string, opts?: {
45
59
  now?: Date;
46
60
  packageVersion?: string;
@@ -17,6 +17,57 @@ import { OBSERVABILITY_DB_FILE } from "./observability.js";
17
17
  export const STATE_BUNDLE_FORMAT = "rafi-state-bundle.v1";
18
18
  export const STATE_MANIFEST_PATH = "rafi-state-manifest.v1.json";
19
19
  export const STATE_LINEAGE_PATH = ".rafi/state-transfer.json";
20
+ /** Read the immutable root binding without opening a normal workflow writer. */
21
+ export function inspectBuildProjectIdentity(projectDir) {
22
+ const root = realpathSync(projectDir);
23
+ const path = join(root, WORKFLOW_DB_FILE);
24
+ if (!existsSync(path))
25
+ return { currentRoot: root, matches: true, recoveryDatabasePresent: false };
26
+ const db = new Database(path, { readonly: true, fileMustExist: true });
27
+ try {
28
+ if (!tableExists(db, "build_project_identity"))
29
+ return { currentRoot: root, matches: true, recoveryDatabasePresent: true };
30
+ const row = db.prepare("SELECT canonical_root FROM build_project_identity WHERE singleton=1").get();
31
+ if (!row)
32
+ return { currentRoot: root, matches: true, recoveryDatabasePresent: true };
33
+ return { currentRoot: root, storedRoot: row.canonical_root, matches: row.canonical_root === root, recoveryDatabasePresent: true };
34
+ }
35
+ finally {
36
+ db.close();
37
+ }
38
+ }
39
+ /**
40
+ * Explicitly adopt copied local build state at its new canonical project root.
41
+ * This is intentionally separate from ordinary workflow writers and records an
42
+ * immutable audit row before changing the binding.
43
+ */
44
+ export function rebindBuildProjectIdentity(projectDir) {
45
+ const status = inspectBuildProjectIdentity(projectDir);
46
+ if (!status.recoveryDatabasePresent || !status.storedRoot || status.matches)
47
+ return status;
48
+ assertNoLiveLease(status.currentRoot, "rebind copied local state");
49
+ const path = join(status.currentRoot, WORKFLOW_DB_FILE);
50
+ const db = new Database(path, { fileMustExist: true });
51
+ try {
52
+ db.function("rafi_writer_protocol", () => 4);
53
+ db.transaction(() => {
54
+ db.exec("CREATE TABLE IF NOT EXISTS build_project_rebindings(sequence INTEGER PRIMARY KEY AUTOINCREMENT,project_id TEXT NOT NULL,previous_root TEXT NOT NULL,new_root TEXT NOT NULL,rebound_at TEXT NOT NULL)");
55
+ for (const action of ["INSERT", "UPDATE", "DELETE"])
56
+ db.exec(`CREATE TRIGGER IF NOT EXISTS rebinding_protocol_${action} BEFORE ${action} ON build_project_rebindings BEGIN SELECT CASE WHEN rafi_writer_protocol()<>4 THEN RAISE(ABORT,'incompatible project rebinding writer') END; END`);
57
+ for (const action of ["UPDATE", "DELETE"])
58
+ db.exec(`CREATE TRIGGER IF NOT EXISTS rebinding_immutable_${action} BEFORE ${action} ON build_project_rebindings BEGIN SELECT RAISE(ABORT,'Project rebinding history is immutable'); END`);
59
+ const identity = db.prepare("SELECT project_id,canonical_root FROM build_project_identity WHERE singleton=1").get();
60
+ if (identity.canonical_root !== status.storedRoot)
61
+ throw new Error("project identity changed before rebinding; inspect and retry");
62
+ db.prepare("INSERT INTO build_project_rebindings(project_id,previous_root,new_root,rebound_at) VALUES(?,?,?,?)").run(identity.project_id, identity.canonical_root, status.currentRoot, new Date().toISOString());
63
+ db.prepare("UPDATE build_project_identity SET canonical_root=? WHERE singleton=1").run(status.currentRoot);
64
+ })();
65
+ return inspectBuildProjectIdentity(status.currentRoot);
66
+ }
67
+ finally {
68
+ db.close();
69
+ }
70
+ }
20
71
  const TRANSFER_ROOTS = [
21
72
  ".tickets/tickets.yaml",
22
73
  ".tickets/config.yaml",
@@ -76,6 +76,14 @@ export declare class RoleStatusAdapter implements BuilderAdapter {
76
76
  private readonly onClose;
77
77
  readonly agent: "claude" | "codex";
78
78
  constructor(adapter: BuilderAdapter, onActive: (adapter: BuilderAdapter) => void, onClose: () => void);
79
+ contractCapabilities(): {
80
+ sameSessionAcceptance: boolean;
81
+ nativeCompactionBarrier: boolean;
82
+ };
83
+ enableContractEnforcement(): void;
84
+ contractCompactionSequence(): number;
85
+ acceptContractDelivery(sequence: number): void;
86
+ configureQaContractReminder(reminder: string, onEvent?: Parameters<NonNullable<BuilderAdapter["configureQaContractReminder"]>>[1]): void;
79
87
  sendTurn(text: string, policy?: Parameters<BuilderAdapter["sendTurn"]>[1]): Promise<TurnResult>;
80
88
  sessionId(): string | undefined;
81
89
  sessionRef(): import("rafi-spec").ProviderSessionRefV1 | undefined;
@@ -171,6 +171,11 @@ export class RoleStatusAdapter {
171
171
  this.onClose = onClose;
172
172
  this.agent = adapter.agent;
173
173
  }
174
+ contractCapabilities() { return this.adapter.contractCapabilities?.() ?? { sameSessionAcceptance: false, nativeCompactionBarrier: false }; }
175
+ enableContractEnforcement() { this.adapter.enableContractEnforcement?.(); }
176
+ contractCompactionSequence() { return this.adapter.contractCompactionSequence?.() ?? 0; }
177
+ acceptContractDelivery(sequence) { this.adapter.acceptContractDelivery?.(sequence); }
178
+ configureQaContractReminder(reminder, onEvent) { this.adapter.configureQaContractReminder?.(reminder, onEvent); }
174
179
  async sendTurn(text, policy) { this.onActive(this.adapter); return this.adapter.sendTurn(text, policy); }
175
180
  sessionId() { return this.adapter.sessionId(); }
176
181
  sessionRef() { return this.adapter.sessionRef?.(); }
@@ -322,6 +322,27 @@ export async function superviseStart(projectDir, runId, policy, selection) {
322
322
  const stopTerm = () => { stopRequestSource = "parent-SIGTERM"; supervisor.requestStop(); };
323
323
  process.once("SIGINT", stopInt);
324
324
  process.once("SIGTERM", stopTerm);
325
+ let supervisorCompleted = false;
326
+ const recordUnexpectedSupervisorExit = (code) => {
327
+ if (supervisorCompleted)
328
+ return;
329
+ try {
330
+ const state = db.supervisorState(runId);
331
+ const run = db.getRun(runId);
332
+ if (!state || state.pid !== process.pid || state.status !== "running" || !run || ["completed", "cancelled", "superseded"].includes(run.status))
333
+ return;
334
+ db.putSupervisorState(runId, { ...state, status: "failed", pid: undefined, workerPid: undefined, heartbeatAt: new Date().toISOString() });
335
+ db.transitionSupervisor(runId, state.workerGeneration, {
336
+ status: "paused",
337
+ checkpoint: "supervisor-process-exit",
338
+ event: "supervisor_process_exit",
339
+ payload: { exitCode: code, lastCheckpoint: run.checkpoint, generation: state.generation },
340
+ });
341
+ }
342
+ catch { /* Exit handlers must not mask the original process termination. */ }
343
+ };
344
+ const onProcessExit = (code) => recordUnexpectedSupervisorExit(code);
345
+ process.once("exit", onProcessExit);
325
346
  try {
326
347
  const outcome = await supervisor.run();
327
348
  if (outcome.kind === "failed") {
@@ -332,8 +353,10 @@ export async function superviseStart(projectDir, runId, policy, selection) {
332
353
  exitCode ||= 2;
333
354
  }
334
355
  finally {
356
+ supervisorCompleted = true;
335
357
  process.off("SIGINT", stopInt);
336
358
  process.off("SIGTERM", stopTerm);
359
+ process.off("exit", onProcessExit);
337
360
  db.close();
338
361
  }
339
362
  process.exitCode = exitCode;