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
@@ -9,10 +9,34 @@ export const DEPTH_OBLIGATIONS = [
9
9
  ["approach-proposal", "independent-challenge", "coordinated-verification"],
10
10
  ];
11
11
  export function depthObligations(level) { return DEPTH_OBLIGATIONS.slice(0, level).flat(); }
12
- export function resolveQaPreparationConfig(value) {
13
- const defaults = { mode: "legacy", policyVersion: QA_PREPARATION_POLICY_VERSION, wallTimeMs: [300_000, 600_000, 1_200_000, 1_800_000, 2_700_000] };
14
- if (value === undefined)
15
- return defaults;
12
+ /** Resolve public boolean configuration while temporarily accepting old mode objects. */
13
+ export function resolveQaPreparationConfig(value, shadow = false, wallTimeMs) {
14
+ // Preparation may require several fresh provider conversations (draft,
15
+ // independent assessment, challenge, and bounded repairs). Keep this
16
+ // ceiling separate from provider turn deadlines and give the logical
17
+ // workflow enough time to reconcile retained work without replaying an
18
+ // uncertain dispatch.
19
+ const defaults = { mode: "legacy", policyVersion: QA_PREPARATION_POLICY_VERSION, wallTimeMs: [600_000, 1_200_000, 2_400_000, 3_600_000, 5_400_000] };
20
+ const resolveWallTime = (candidate) => {
21
+ if (candidate === undefined)
22
+ return undefined;
23
+ if (!Array.isArray(candidate) || candidate.length !== 5 || candidate.some((v, i) => !Number.isSafeInteger(v) || v < defaults.wallTimeMs[i]))
24
+ throw new Error("QA preparation ceilings must specify five integer limits at least as large as policy defaults");
25
+ return candidate;
26
+ };
27
+ const publicWallTime = resolveWallTime(wallTimeMs);
28
+ if (value === undefined) {
29
+ if (shadow)
30
+ throw new Error("qa_preparation_shadow requires qa_preparation: true");
31
+ return { ...defaults, ...(publicWallTime ? { wallTimeMs: publicWallTime } : {}) };
32
+ }
33
+ if (typeof value === "boolean") {
34
+ if (shadow && !value)
35
+ throw new Error("qa_preparation_shadow requires qa_preparation: true");
36
+ return { ...defaults, ...(publicWallTime ? { wallTimeMs: publicWallTime } : {}), mode: !value ? "legacy" : shadow ? "shadow" : "enforce" };
37
+ }
38
+ if (shadow)
39
+ throw new Error("qa_preparation_shadow cannot be combined with legacy qa_preparation object configuration");
16
40
  if (!value || typeof value !== "object" || Array.isArray(value))
17
41
  throw new Error("qa_preparation must be an object");
18
42
  const raw = value;
@@ -22,9 +46,10 @@ export function resolveQaPreparationConfig(value) {
22
46
  throw new Error("qa_preparation.mode must be legacy, shadow, or enforce");
23
47
  if (raw.policyVersion !== undefined && raw.policyVersion !== defaults.policyVersion)
24
48
  throw new Error("Unsupported QA preparation policy version");
25
- if (raw.wallTimeMs !== undefined && (!Array.isArray(raw.wallTimeMs) || raw.wallTimeMs.length !== 5 || raw.wallTimeMs.some((v, i) => !Number.isSafeInteger(v) || v < defaults.wallTimeMs[i])))
26
- throw new Error("QA preparation ceilings must specify five integer limits at least as large as policy defaults");
27
- return { ...defaults, mode: raw.mode, ...(raw.wallTimeMs ? { wallTimeMs: raw.wallTimeMs } : {}) };
49
+ if (wallTimeMs !== undefined)
50
+ throw new Error("qa_preparation_wall_time_ms cannot be combined with legacy qa_preparation object configuration");
51
+ const legacyWallTime = resolveWallTime(raw.wallTimeMs);
52
+ return { ...defaults, mode: raw.mode, ...(legacyWallTime ? { wallTimeMs: legacyWallTime } : {}) };
28
53
  }
29
54
  export function validateDepthDecision(value, predecessor) {
30
55
  if (!validateDecision(value))
@@ -36,8 +61,13 @@ export function validateDepthDecision(value, predecessor) {
36
61
  issues.push("Invalid planner timestamp");
37
62
  if (decision.level === 1 && (!decision.lowRisk || decision.riskFactors.length))
38
63
  issues.push("Focused requires explicit low-risk assessment and no consequential risks");
39
- const effectiveMinimum = decision.level === 1 && decision.lowRisk && !decision.riskFactors.length ? 1 : minimum;
40
- if (decision.minimumLevel !== effectiveMinimum || decision.level < effectiveMinimum)
64
+ // The minimum describes the risk floor, not the level the planner selected.
65
+ // A planner may choose extra preparation while recognizing that Focused is sufficient.
66
+ const effectiveMinimum = decision.lowRisk && !decision.riskFactors.length ? 1 : minimum;
67
+ // Older plans described Standard as the minimum when selecting it, even with
68
+ // low-risk evidence. Continue accepting those conservative recorded plans.
69
+ const legacyStandardMinimum = effectiveMinimum === 1 && decision.level >= 2 && decision.minimumLevel === 2;
70
+ if ((!legacyStandardMinimum && decision.minimumLevel !== effectiveMinimum) || decision.level < effectiveMinimum)
41
71
  issues.push(`Planner decision is below or misstates host minimum ${effectiveMinimum}`);
42
72
  if (decision.riskFactors.some(risk => !decision.minimumReasons.includes(risk.category)))
43
73
  issues.push("Minimum reasons must enumerate evidenced risk categories");
@@ -140,6 +140,10 @@ export declare class QaPreparationStore {
140
140
  extendBudget(budget: LogicalPreparationBudget, decisionId: string): void;
141
141
  putArtifact(kind: string, value: unknown): string;
142
142
  artifact<T>(digest: string, kind: string): T;
143
+ artifacts(kind: string): Array<{
144
+ digest: string;
145
+ value: unknown;
146
+ }>;
143
147
  head(runId: string, workId: string, admissionDigest: string): {
144
148
  digest?: string;
145
149
  generation: number;
@@ -324,6 +324,14 @@ export class QaPreparationStore {
324
324
  throw new Error("Corrupt preparation artifact");
325
325
  return value;
326
326
  }
327
+ artifacts(kind) {
328
+ return this.db.prepare("SELECT digest,record_json FROM qa_contract_artifacts WHERE kind=? ORDER BY digest").all(kind).map(row => {
329
+ const value = this.decode(row.record_json);
330
+ if (contractDigest(`artifact-${kind}`, value) !== row.digest)
331
+ throw new Error("Corrupt preparation artifact");
332
+ return { digest: row.digest, value };
333
+ });
334
+ }
327
335
  head(runId, workId, admissionDigest) {
328
336
  const row = this.db.prepare("SELECT digest,generation,state,detail FROM qa_contract_heads WHERE run_id=? AND work_id=? AND admission_digest=?").get(runId, workId, admissionDigest);
329
337
  return row ? { ...row, digest: row.digest ?? undefined, detail: row.detail && this.projectDir && !graphDerivedAllowed(this.db, this.projectDir, bytesDigest(row.detail)) ? "Graph-derived preparation detail withheld; source-based recovery required" : row.detail ?? undefined } : { generation: 0, state: "preparation-required" };
@@ -86,6 +86,13 @@ export interface QaRecoveryPacketInput {
86
86
  }>;
87
87
  reportJson?: string;
88
88
  }
89
+ /**
90
+ * Keeps an unresolved packet auditable without making a successor reread every
91
+ * failed attempt. Sealed history remains in the packet and in its digest
92
+ * lineage; only the current review/source closure and this digest-indexed
93
+ * history manifest are required for recovery.
94
+ */
95
+ export declare function canonicalizeQaRecoveryRequirements(packet: QaRecoveryPacket): QaRecoveryPacket;
89
96
  export declare function loadQaRecoveryPacket(directory: string): QaRecoveryPacket;
90
97
  export declare function inspectLegacyQaRecoveryPacket(directory: string): {
91
98
  directory: string;
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { chmodSync, closeSync, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readdirSync, realpathSync, renameSync, writeFileSync, } from "node:fs";
4
4
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
- import { parseQaFailureReport } from "rafi-spec";
5
+ import { parseProviderContract, parseQaFailureReport, qaRecoveryAcknowledgementV1Schema } from "rafi-spec";
6
6
  import { calculateFrozenQaStateDigest, captureFrozenQaSource } from "./qaSnapshot.js";
7
7
  import { WorkflowDb } from "./workflowDb.js";
8
8
  export const QA_RECOVERY_ROOT = ".foreman/qa-report-recovery";
@@ -17,6 +17,69 @@ export class LegacyQaRecoveryPacketError extends Error {
17
17
  this.name = "LegacyQaRecoveryPacketError";
18
18
  }
19
19
  }
20
+ /**
21
+ * Keeps an unresolved packet auditable without making a successor reread every
22
+ * failed attempt. Sealed history remains in the packet and in its digest
23
+ * lineage; only the current review/source closure and this digest-indexed
24
+ * history manifest are required for recovery.
25
+ */
26
+ export function canonicalizeQaRecoveryRequirements(packet) {
27
+ const loaded = loadQaRecoveryPacket(packet.directory);
28
+ if (loaded.manifest.packetDigest !== packet.manifest.packetDigest)
29
+ throw new Error("cannot canonicalize a stale QA recovery packet revision");
30
+ // Ordinary first-attempt packets stay byte-for-byte on their established
31
+ // publication path. The bundle is introduced only once retry/turn history
32
+ // would otherwise make the next successor inspect an accumulating archive.
33
+ const hasBundle = loaded.manifest.resources.some((resource) => resource.path.startsWith("canonical-recovery-bundle/"));
34
+ const hasRetryHistory = loaded.manifest.resources.some((resource) => /^(?:reviews\/|turns\/|prompts\/|qa-responses\/|validation\/|recovery-history\/)/.test(resource.path));
35
+ if (!hasBundle && !hasRetryHistory)
36
+ return loaded;
37
+ const currentSourcePrefix = currentReviewedStatePrefix(loaded);
38
+ const currentReviewPrefix = `reviews/r${loaded.manifest.reviewAttempt}/context/`;
39
+ const currentTransition = `review-transitions/${loaded.manifest.reviewAttemptId}.json`;
40
+ const baseContext = new Set([
41
+ "ticket", "qa-review-prompt", "report-schema", "builder-result", "builder-response-history",
42
+ "original-host-prompt", "original-provider-prompt", "original-raw-response", "original-cleaned-response",
43
+ "original-validation", "tool-events", "continuity", "settings", "context-usage",
44
+ "uncertain-turn-instruction", "uncertain-turn-detail", "review-binding",
45
+ ]);
46
+ const isCurrent = (resource) => {
47
+ if (resource.path === "report.json")
48
+ return true;
49
+ if (resource.path.startsWith(`${currentSourcePrefix}/`) || resource.path.startsWith(currentReviewPrefix))
50
+ return true;
51
+ if (resource.path === currentTransition)
52
+ return true;
53
+ const context = /^context\/([^./]+)\.(?:json|txt)$/.exec(resource.path)?.[1];
54
+ return Boolean(context && baseContext.has(context));
55
+ };
56
+ const history = loaded.manifest.resources
57
+ .filter((resource) => !resource.path.startsWith("canonical-recovery-bundle/") && !isCurrent(resource))
58
+ .map(({ path, digest, bytes, mediaType, purpose, integrityPolicy }) => ({ path, digest, bytes, mediaType, purpose, integrityPolicy }));
59
+ const current = loaded.manifest.resources
60
+ .filter((resource) => !resource.path.startsWith("canonical-recovery-bundle/") && isCurrent(resource))
61
+ .map(({ path, digest, bytes, mediaType, purpose }) => ({ path, digest, bytes, mediaType, purpose }));
62
+ const bundleBytes = encodeValue({
63
+ version: 1,
64
+ purpose: "Bounded canonical QA recovery closure; all omitted records remain sealed packet history and are verified by the packet digest and revision lineage",
65
+ runId: loaded.manifest.runId, ticketId: loaded.manifest.ticketId,
66
+ reviewAttempt: loaded.manifest.reviewAttempt, reviewAttemptId: loaded.manifest.reviewAttemptId,
67
+ reviewedStateDigest: loaded.manifest.reviewedStateDigest,
68
+ currentRequiredResources: current,
69
+ retainedHistory: history,
70
+ }, false);
71
+ const priorBundle = loaded.manifest.resources.filter((resource) => resource.path.startsWith("canonical-recovery-bundle/")).at(-1);
72
+ const unchanged = priorBundle && priorBundle.digest === hash(bundleBytes)
73
+ && loaded.manifest.resources.every((resource) => resource.requiredForRecovery === (isCurrent(resource) || resource.path === priorBundle.path));
74
+ if (unchanged)
75
+ return loaded;
76
+ const bundle = storeSealed(loaded.directory, `canonical-recovery-bundle/r${loaded.manifest.revision + 1}.json`, bundleBytes, "Canonical current recovery closure and digest-indexed sealed history", "application/json", true);
77
+ const resources = loaded.manifest.resources
78
+ .map((resource) => ({ ...resource, requiredForRecovery: isCurrent(resource) }))
79
+ .concat(bundle)
80
+ .sort(resourceOrder);
81
+ return revise(loaded, { resources });
82
+ }
20
83
  export function loadQaRecoveryPacket(directory) {
21
84
  const root = resolve(directory);
22
85
  const manifestPath = join(root, "manifest.json");
@@ -446,6 +509,15 @@ export function validateQaRecoveryAcknowledgement(text, packet, options = {}) {
446
509
  const errors = [];
447
510
  if (lines[0] !== expected)
448
511
  errors.push("missing or malformed exact recovery-packet acknowledgement");
512
+ const acknowledgement = /^RAFI_QA_RECOVERY_ACK packet="([a-f0-9]{64})" reviewed_state="([a-f0-9]{64})" required_resources_read="(all)"$/.exec(lines[0] ?? "");
513
+ if (acknowledgement) {
514
+ try {
515
+ parseProviderContract(qaRecoveryAcknowledgementV1Schema, { packet: acknowledgement[1], reviewed_state: acknowledgement[2], required_resources_read: acknowledgement[3] }, "RAFI_QA_RECOVERY_ACK");
516
+ }
517
+ catch (error) {
518
+ errors.push(error instanceof Error ? error.message : String(error));
519
+ }
520
+ }
449
521
  if (options.continuityAlreadyValidated) {
450
522
  if (lines.length !== 1)
451
523
  errors.push("recovery acknowledgement must not contain additional text");
package/dist/qaReview.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { withGraphDerivedAccess } from "./graph/derived.js";
2
2
  import { dispatchWithGraphAccess } from "./graph/session.js";
3
3
  import { finalQaRoleInstructions, resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
4
- import { COVERAGE_INSTRUCTIONS, parseContractCoverage, validateContractCoverage } from "./qaContractCoverage.js";
4
+ import { COVERAGE_INSTRUCTIONS, bindCoverageHostFields, parseContractCoverage, validateContractCoverage } from "./qaContractCoverage.js";
5
5
  import { contractDigest, renderVerificationContract } from "./qaVerificationContract.js";
6
6
  import { finalReviewContractContext } from "./qaContractDelivery.js";
7
+ import { qaCommandOutputExcerptsAllowed, retainedQaCommandObservations } from "./qaReviewEvidence.js";
8
+ import { qaHostGraphFact } from "./qaHostFacts.js";
7
9
  import { graphContext, parseGraphRequest, prepareGraphInstruction, sendGraphTurn } from "./graph/turn.js";
8
10
  import { BuildControlBoundary } from "./buildInterventions.js";
9
11
  import { formatRecoveryCommand } from "./recoveryGuidance.js";
@@ -24,6 +26,7 @@ import { canonicalSessionPath } from "./sessionIdentity.js";
24
26
  import { loadProjectAutonomyConfig, resolveAutonomyPolicy } from "./recoveryPolicy.js";
25
27
  import { appendQaRecoveryAttempt, appendQaRecoveryResource, appendQaRecoveryReviewedState, compareQaRecoveryReviewedState, createQaRecoveryPacket, ensureQaRecoveryExcluded, loadQaRecoveryPacket, materializeQaRecoveryContext, qaRecoveryInventory, qaReportDigest, renderQaRecoveryAcknowledgementInstruction, updateQaRecoveryPosition, updateQaRecoveryReviewIdentity, validateManualQaReport, validateQaRecoveryAcknowledgement, } from "./qaRecovery.js";
26
28
  import { canonicalJson, createQaFindingRefs, qaDigest } from "./qaProtocolV2.js";
29
+ import { applyHostQaComparison, createHostQaComparison, findHostQaCheck } from "./hostQaComparison.js";
27
30
  function ensureProtocolPausePacket(opts, head, stage, detail, frozenState) {
28
31
  const db = new WorkflowDb(opts.recovery.projectDir);
29
32
  try {
@@ -229,7 +232,10 @@ async function runIsolatedQaOwned(opts) {
229
232
  const maxBuilderFixes = Math.min(opts.maxCycles, durableLimit);
230
233
  let automaticFixes = persistedQaFixCount(opts);
231
234
  let reviewAttempt = Math.max(opts.state.reviews, ...durableReviews.map((item) => item.reviewNumber), 0);
232
- let remediationGeneration = Math.max(opts.state.remediationGeneration ?? 0, durableRemediationGeneration, ...durableReviews.map((item) => item.remediationGeneration), ...durableRemediations.map((item) => item.generation), 0);
235
+ // Remediation generations are scoped to a ticket head. The surrounding
236
+ // build state is shared across tickets, so using its counter here can leak
237
+ // T-001's fixes into T-002 and make a valid review fail binding validation.
238
+ let remediationGeneration = Math.max(durableRemediationGeneration, ...durableReviews.map((item) => item.remediationGeneration), ...durableRemediations.map((item) => item.generation), 0);
233
239
  const resumedHeadDb = new WorkflowDb(opts.recovery.projectDir);
234
240
  resumedHeadDb.reconcileInstructionDeliveries(opts.recovery.runId, opts.ticket.id);
235
241
  opts.qaRemediationAuthorization ??= resumedHeadDb.consumeInstructionControls(opts.recovery.runId, opts.ticket.id, "attempts");
@@ -549,6 +555,10 @@ function deliverQaFailureOrLegacyFix(opts, request, causingAttemptId) {
549
555
  const reportDigest = request.reportDigest;
550
556
  if (!attempt)
551
557
  throw new Error(`QA failure delivery is missing review attempt ${causingAttemptId}`);
558
+ const admission = headDb.assertAdmittedWork(opts.recovery.runId, opts.ticket.id);
559
+ const preparation = headDb.qaPreparationStore();
560
+ const contractHead = preparation.head(opts.recovery.runId, opts.ticket.id, admission.requirementsDigest);
561
+ const contract = contractHead.state === "ready" && contractHead.digest ? preparation.contract(contractHead.digest) : undefined;
552
562
  const input = {
553
563
  projectDir: opts.recovery.projectDir,
554
564
  runId: opts.recovery.runId,
@@ -560,6 +570,7 @@ function deliverQaFailureOrLegacyFix(opts, request, causingAttemptId) {
560
570
  reviewNumber: attempt.reviewNumber,
561
571
  reviewedSourceStateDigest: attempt.sourceDigest,
562
572
  reviewBasisDigest: headDb.qaTicketHead(opts.recovery.runId, opts.ticket.id).reviewBasisDigest ?? "",
573
+ ...(contract ? { contractDigest: contract.contentDigest } : {}),
563
574
  remediationGeneration: attempt.remediationGeneration,
564
575
  latestBuilderResult: request.latestBuilderResult,
565
576
  history: request.history,
@@ -980,7 +991,14 @@ async function oneReview(opts, identity) {
980
991
  return pauseQaReview(opts, "invalid-pass-contract", `invalid QA pass response: ${contract.errors.join("; ")}`, reviewedSnapshot.frozenState);
981
992
  if (missingPrerequisites.length)
982
993
  return pauseQaReview(opts, "verification-not-run", "Required verification remains not_run; prerequisite recovery and fresh QA required", reviewedSnapshot.frozenState);
983
- const certificate = finishV2Pass(opts, qa, status.summary ?? "qa_pass");
994
+ let certificate;
995
+ try {
996
+ certificate = finishV2Pass(opts, qa, status.summary ?? "qa_pass");
997
+ }
998
+ catch (error) {
999
+ const detail = error instanceof Error ? error.message : String(error);
1000
+ return retryFreshQaReview(opts, "invalid-qa-coverage", `QA pass evidence was not parseable or complete: ${detail}`, reviewedSnapshot.frozenState);
1001
+ }
984
1002
  markQaRecoveryResolved(opts);
985
1003
  durableReviewFinished = true;
986
1004
  opts.evidence?.({ cycle, reviewAttempt: activeBinding.reviewNumber, remediationGeneration, attemptId: activeBinding.reviewAttemptId, outcome: "passed", detail: status.summary ?? "qa_pass", durationMs: Math.max(0, performance.now() - started) });
@@ -1034,7 +1052,14 @@ async function oneReview(opts, identity) {
1034
1052
  if (contract.valid && contract.status === "qa_pass") {
1035
1053
  if (missingPrerequisites.length)
1036
1054
  return pauseQaReview(opts, "verification-not-run", "Required verification remains not_run; prerequisite recovery and fresh QA required", reviewedSnapshot.frozenState);
1037
- const certificate = finishV2Pass(opts, qa, contract.fields.summary ?? "qa_pass");
1055
+ let certificate;
1056
+ try {
1057
+ certificate = finishV2Pass(opts, qa, contract.fields.summary ?? "qa_pass");
1058
+ }
1059
+ catch (error) {
1060
+ const detail = error instanceof Error ? error.message : String(error);
1061
+ return retryFreshQaReview(opts, "invalid-qa-coverage", `Recovered QA pass evidence was not parseable or complete: ${detail}`, reviewedSnapshot.frozenState);
1062
+ }
1038
1063
  markQaRecoveryResolved(opts);
1039
1064
  durableReviewFinished = true;
1040
1065
  opts.evidence?.({ cycle, reviewAttempt: activeBinding.reviewNumber, remediationGeneration, attemptId: activeBinding.reviewAttemptId, outcome: "passed", detail: contract.fields.summary ?? "qa_pass", durationMs: Math.max(0, performance.now() - started) });
@@ -1577,7 +1602,7 @@ function validateBoundaryReceipt(receipt, packet, successorHandle) {
1577
1602
  receiptDb.close();
1578
1603
  }
1579
1604
  const byLabel = new Map(receipt.resources.map((resource) => [resource.label, resource]));
1580
- for (const expected of packet.manifest.resources) {
1605
+ for (const expected of packet.manifest.resources.filter((resource) => resource.requiredForRecovery)) {
1581
1606
  const actual = byLabel.get(expected.path);
1582
1607
  if (!actual || actual.authoritative !== true || actual.digest !== expected.digest || actual.path !== expected.path || actual.purpose !== expected.purpose
1583
1608
  || actual.bytes !== expected.bytes || actual.mediaType !== expected.mediaType || actual.requiredForRecovery !== expected.requiredForRecovery) {
@@ -1629,13 +1654,30 @@ function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle,
1629
1654
  const expected = contract.inputs.find(input => input.kind === "checklist");
1630
1655
  if (expected?.digest !== contractDigest("input-checklist", checklist))
1631
1656
  throw new Error("Final QA checklist differs from delivered contract");
1657
+ const needsHistoricalEvidence = contract.checks.some(check => /red|test.?first|ordering|historical/i.test(JSON.stringify(check)));
1632
1658
  const builderClaims = db.operations(opts.recovery.runId).flatMap(operation => {
1633
1659
  const intent = operation.intent;
1634
1660
  const result = operation.result;
1635
1661
  return operation.kind === "builder-coverage" && operation.status === "confirmed" && intent.workId === opts.ticket.id && intent.contractDigest === contract.contentDigest && result?.binding?.sourceDigest === source.digest && result.coverageDigest
1636
- ? [{ operationId: operation.idempotencyKey, digest: result.coverageDigest, coverage: preparationStore.artifact(result.coverageDigest, "builder-coverage") }] : [];
1662
+ ? [{
1663
+ operationId: operation.idempotencyKey,
1664
+ digest: result.coverageDigest,
1665
+ coverage: preparationStore.artifact(result.coverageDigest, "builder-coverage"),
1666
+ ...(needsHistoricalEvidence && result.implementationDigest ? {
1667
+ // The response is already host-retained by finishBuildAssignment.
1668
+ // Expose only this bounded, contract-relevant excerpt to the
1669
+ // independent reviewer; do not retain or disclose all Builder
1670
+ // output globally.
1671
+ implementationResponse: {
1672
+ digest: result.implementationDigest,
1673
+ response: db.getEvidence(result.implementationDigest)?.toString() ?? "unavailable",
1674
+ },
1675
+ } : {}),
1676
+ }] : [];
1637
1677
  });
1638
- instruction += finalReviewContractContext(contract, preparationStore, reviewedSnapshotPath, builderClaims);
1678
+ const commandObservations = retainedQaCommandObservations(opts.recovery.projectDir, opts.recovery.runId, opts.ticket.id, qaCommandOutputExcerptsAllowed(opts.ticket.id));
1679
+ instruction += `\n${qaHostGraphFact(opts.recovery.projectDir)}\n`;
1680
+ instruction += finalReviewContractContext(contract, preparationStore, reviewedSnapshotPath, builderClaims, commandObservations);
1639
1681
  instruction += `\n${renderVerificationContract(contract)}\n${COVERAGE_INSTRUCTIONS}\nCoverage binding: ${JSON.stringify({ phase: "qa", runId: opts.recovery.runId, workId: opts.ticket.id, contractDigest: contract.contentDigest, revision: contract.revision, sourceDigest: source.digest, attemptId: attempt.attemptId, sessionId: handle.sessionIdentity().sessionId })}`;
1640
1682
  }
1641
1683
  const originalInstruction = instruction;
@@ -1963,8 +2005,15 @@ function finishV2Pass(opts, adapter, detail) {
1963
2005
  if (!contractHead.digest || contractHead.state !== "ready")
1964
2006
  throw new Error("Final coverage requires the current ready contract");
1965
2007
  const contract = db.qaPreparationStore().contract(contractHead.digest);
1966
- const coverage = parseContractCoverage(binding.lastResponse ?? "");
1967
- const errors = validateContractCoverage(contract, coverage, { phase: "qa", sourceDigest: binding.sourceStateDigest, inputBasisDigest: binding.reviewBasisDigest, attemptId: binding.reviewAttemptId, sessionId: binding.sessionRef.sessionId });
2008
+ const coverageBinding = { phase: "qa", sourceDigest: binding.sourceStateDigest, inputBasisDigest: binding.reviewBasisDigest, attemptId: binding.reviewAttemptId, sessionId: binding.sessionRef.sessionId };
2009
+ const providerCoverage = bindCoverageHostFields(parseContractCoverage(binding.lastResponse ?? ""), contract, coverageBinding);
2010
+ const hostComparison = findHostQaCheck(contract)
2011
+ ? createHostQaComparison({ db, projectDir: binding.projectDir, worktree: opts.builderWorktree, runId: binding.runId, ticket: opts.ticket, contract, reviewAttemptId: binding.reviewAttemptId, reviewBasisDigest: binding.reviewBasisDigest })
2012
+ : undefined;
2013
+ const coverage = hostComparison
2014
+ ? applyHostQaComparison(providerCoverage, contract, hostComparison.result, hostComparison.resultDigest, binding.projectDir)
2015
+ : providerCoverage;
2016
+ const errors = validateContractCoverage(contract, coverage, coverageBinding);
1968
2017
  if (errors.length)
1969
2018
  throw new Error(`Final QA coverage incomplete: ${errors.join("; ")}`);
1970
2019
  const coverageDigest = db.qaPreparationStore().putArtifact("final-coverage", coverage);
@@ -0,0 +1,6 @@
1
+ import type { RunSpanV1 } from "rafi-spec";
2
+ /** Explicit authorization is scoped to one nonempty ticket ID. */
3
+ export declare function qaCommandOutputExcerptsAllowed(workId: string, configuredTicket?: string | undefined): boolean;
4
+ /** Excerpts are opt-in; command text is never forwarded. */
5
+ export declare function selectQaCommandObservations(spans: RunSpanV1[], runId: string, workId: string, includeOutputExcerpts?: boolean): unknown[];
6
+ export declare function retainedQaCommandObservations(projectDir: string, runId: string, workId: string, includeOutputExcerpts?: boolean): unknown[];
@@ -0,0 +1,37 @@
1
+ import { ObservabilityReader } from "./observability.js";
2
+ /** Explicit authorization is scoped to one nonempty ticket ID. */
3
+ export function qaCommandOutputExcerptsAllowed(workId, configuredTicket = process.env.RAFI_QA_COMMAND_OUTPUT_EXCERPTS) {
4
+ return workId.length > 0 && configuredTicket === workId;
5
+ }
6
+ /** Excerpts are opt-in; command text is never forwarded. */
7
+ export function selectQaCommandObservations(spans, runId, workId, includeOutputExcerpts = false) {
8
+ const turns = new Set(spans.filter(span => span.runId === runId && span.kind === "provider_turn" && span.role === "builder" && span.ticketId === workId).map(span => span.spanId));
9
+ return spans.filter(span => span.runId === runId && span.kind === "tool" && span.role === "builder" && span.parentSpanId && turns.has(span.parentSpanId) && span.completionKnown && span.endedAt)
10
+ .flatMap(span => {
11
+ const attributes = span.attributes ?? {};
12
+ const input = attributes.input;
13
+ const command = typeof input?.command === "string" ? input.command : "";
14
+ const kind = /\bqa-rehearse-rollback\b/.test(command) ? "rollback-rehearsal" : /\bnpm\s+test\b|\bnode\s+--test\b/.test(command) ? "test" : undefined;
15
+ if (!kind)
16
+ return [];
17
+ const outcome = ["completed", "failed", "cancelled", "timed_out"].includes(span.outcome ?? "") ? span.outcome : "unknown";
18
+ const exitCode = typeof attributes.exitCode === "number" && Number.isSafeInteger(attributes.exitCode) ? attributes.exitCode : undefined;
19
+ const outputDigest = typeof attributes.outputDigest === "string" && /^[a-f0-9]{64}$/.test(attributes.outputDigest) ? attributes.outputDigest : undefined;
20
+ const outputExcerpt = includeOutputExcerpts && typeof attributes.outputSummary === "string" ? attributes.outputSummary.slice(0, 1000) : undefined;
21
+ return [{ spanId: span.spanId, providerTurnId: span.providerTurnId, startedAt: span.startedAt, endedAt: span.endedAt, kind, outcome, exitCode, outputDigest, ...(outputExcerpt === undefined ? {} : { outputExcerpt, excerptMayBeTruncated: true }) }];
22
+ }).slice(-40);
23
+ }
24
+ export function retainedQaCommandObservations(projectDir, runId, workId, includeOutputExcerpts = false) {
25
+ try {
26
+ const reader = new ObservabilityReader(projectDir);
27
+ try {
28
+ return selectQaCommandObservations(reader.spans(runId), runId, workId, includeOutputExcerpts);
29
+ }
30
+ finally {
31
+ reader.close();
32
+ }
33
+ }
34
+ catch {
35
+ return [];
36
+ } // Diagnostics are optional; never prevent independent review.
37
+ }
@@ -0,0 +1,2 @@
1
+ /** Rehearse the current uncommitted product changes against HEAD, never the source Git index. */
2
+ export declare function rehearseRollback(projectDir: string): string;
@@ -0,0 +1,78 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { tmpdir } from "node:os";
3
+ import { captureFrozenQaSource, createDisposableQaSnapshot } from "./qaSnapshot.js";
4
+ import { canonicalToolPath, isWithinProject } from "./permissions/shellForms.js";
5
+ /** Rehearse the current uncommitted product changes against HEAD, never the source Git index. */
6
+ export function rehearseRollback(projectDir) {
7
+ const unsafeGitEnvironment = Object.keys(process.env).filter(key => /^GIT_(?:DIR|WORK_TREE|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|COMMON_DIR|EXEC_PATH|TEMPLATE_DIR|SHALLOW_FILE|QUARANTINE_PATH|NAMESPACE)$/.test(key));
8
+ if (unsafeGitEnvironment.length)
9
+ throw new Error(`Rollback rehearsal cannot use Git repository overrides: ${unsafeGitEnvironment.join(", ")}`);
10
+ // Provider sandboxes may inject Git configuration. Never apply those
11
+ // settings to the independent clone, and restore the caller's environment.
12
+ const configKeys = Object.keys(process.env).filter(key => /^GIT_CONFIG(?:$|_)/.test(key));
13
+ const savedConfig = new Map(configKeys.map(key => [key, process.env[key]]));
14
+ for (const key of configKeys)
15
+ delete process.env[key];
16
+ process.env.GIT_CONFIG_NOSYSTEM = "1";
17
+ process.env.GIT_CONFIG_GLOBAL = "/dev/null";
18
+ try {
19
+ return rehearseWithCleanGitConfig(projectDir);
20
+ }
21
+ finally {
22
+ for (const key of Object.keys(process.env).filter(key => /^GIT_CONFIG(?:$|_)/.test(key)))
23
+ delete process.env[key];
24
+ for (const [key, value] of savedConfig)
25
+ if (value !== undefined)
26
+ process.env[key] = value;
27
+ }
28
+ }
29
+ function rehearseWithCleanGitConfig(projectDir) {
30
+ const topLevel = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: projectDir, encoding: "utf8", timeout: 30_000 });
31
+ if (topLevel.error || topLevel.status !== 0)
32
+ throw new Error("Rollback rehearsal requires a Git project");
33
+ if (canonicalToolPath(projectDir) !== canonicalToolPath(topLevel.stdout.trim()))
34
+ throw new Error("Run rollback rehearsal from the Git project root");
35
+ if (isWithinProject(projectDir, tmpdir()))
36
+ throw new Error("Rollback rehearsal requires a temporary directory outside the source project");
37
+ const snapshot = createDisposableQaSnapshot(projectDir, { projectDependencies: false });
38
+ const output = [];
39
+ const childEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", npm_config_offline: "true", npm_config_update_notifier: "false", npm_config_audit: "false", npm_config_fund: "false" };
40
+ // Inherited node:test context otherwise makes a nested test runner silently
41
+ // skip all test files while returning success.
42
+ delete childEnv.NODE_TEST_CONTEXT;
43
+ const run = (program, args) => {
44
+ const result = spawnSync(program, args, {
45
+ cwd: snapshot.path, encoding: "utf8", timeout: 300_000, maxBuffer: 8 * 1024 * 1024,
46
+ env: childEnv,
47
+ });
48
+ if (!args.includes("--stage"))
49
+ output.push(`$ ${[program, ...args].join(" ")}\n${result.stdout ?? ""}${result.stderr ?? ""}`);
50
+ if (result.error || result.status !== 0)
51
+ throw new Error(`Rollback rehearsal failed: ${program} ${args.join(" ")} (${result.error?.message ?? `exit ${result.status}`})\n${output.join("\n")}`);
52
+ return result.stdout;
53
+ };
54
+ try {
55
+ const source = snapshot.frozenState;
56
+ if (source.untracked.some(item => item.kind === "symlink"))
57
+ throw new Error("Rollback rehearsal cannot run with untracked symbolic links");
58
+ run("git", ["-c", "core.hooksPath=/dev/null", "add", "-A", "--", ".", ":(exclude).foreman/**", ":(exclude).rafi/**"]);
59
+ const entries = run("git", ["ls-files", "--stage", "-z"]);
60
+ if (entries.split("\0").some(entry => entry.startsWith("120000 ")))
61
+ throw new Error("Rollback rehearsal cannot run with tracked symbolic links");
62
+ const changed = run("git", ["diff", "--cached", "--name-only"]);
63
+ if (!changed.trim())
64
+ throw new Error("Rollback rehearsal requires uncommitted product changes relative to HEAD");
65
+ run("git", ["-c", "core.hooksPath=/dev/null", "-c", "user.name=Rafi rehearsal", "-c", "user.email=rafi-rehearsal@example.invalid", "-c", "commit.gpgsign=false", "commit", "-m", "Temporary rollback rehearsal"]);
66
+ run("npm", ["test"]);
67
+ run("git", ["-c", "core.hooksPath=/dev/null", "-c", "user.name=Rafi rehearsal", "-c", "user.email=rafi-rehearsal@example.invalid", "-c", "commit.gpgsign=false", "revert", "--no-edit", "HEAD"]);
68
+ run("npm", ["test"]);
69
+ run("git", ["diff", "--exit-code", source.head]);
70
+ const after = captureFrozenQaSource(projectDir);
71
+ if (after.digest !== source.digest)
72
+ throw new Error("Source project changed during rollback rehearsal; inspect it before continuing");
73
+ return `Rollback rehearsal passed in an independent temporary repository. Verification: npm test before revert exited 0; git revert exited 0; npm test after revert exited 0; git diff --exit-code against captured HEAD exited 0. Its tracked files match the captured HEAD baseline: ${source.head}. The synthetic commit contains all uncommitted product changes, not necessarily only one ticket. Source digest: ${source.digest}.\n${output.join("\n")}`;
74
+ }
75
+ finally {
76
+ snapshot.remove();
77
+ }
78
+ }
@@ -80,7 +80,9 @@ export declare function captureFrozenQaSource(worktree: string): FrozenQaSourceS
80
80
  export declare function captureFrozenQaSourceAsync(worktree: string, progress?: QaSnapshotProgress): Promise<FrozenQaSourceState>;
81
81
  export declare function captureStableFrozenQaSource(read: (attempt: number, pass: 1 | 2) => FrozenQaSourceState, now?: Date): FrozenQaSourceState;
82
82
  export declare function captureStableFrozenQaSourceAsync(read: (attempt: number, pass: 1 | 2) => Promise<FrozenQaSourceState>, now?: Date): Promise<FrozenQaSourceState>;
83
- export declare function createDisposableQaSnapshot(builderWorktree: string): DisposableQaSnapshot;
83
+ export declare function createDisposableQaSnapshot(builderWorktree: string, options?: {
84
+ projectDependencies?: boolean;
85
+ }): DisposableQaSnapshot;
84
86
  export declare function createDisposableQaSnapshotAsync(builderWorktree: string, progress?: QaSnapshotProgress): Promise<AsyncDisposableQaSnapshot>;
85
87
  export declare function frozenQaStateManifest(state: FrozenQaSourceState): QaChangeManifest;
86
88
  export declare function changeManifest(worktree: string): QaChangeManifest;
@@ -45,7 +45,7 @@ export async function captureStableFrozenQaSourceAsync(read, now = new Date()) {
45
45
  }
46
46
  throw new QaSourceInstabilityError(MAX_CAPTURE_ATTEMPTS);
47
47
  }
48
- export function createDisposableQaSnapshot(builderWorktree) {
48
+ export function createDisposableQaSnapshot(builderWorktree, options = {}) {
49
49
  const source = resolve(builderWorktree);
50
50
  const root = gitText(source, ["rev-parse", "--show-toplevel"]);
51
51
  const frozenState = captureFrozenQaSource(source);
@@ -54,7 +54,8 @@ export function createDisposableQaSnapshot(builderWorktree) {
54
54
  cloneRepositorySync(root, review, frozenState.head);
55
55
  try {
56
56
  applyFrozenSync(review, frozenState);
57
- projectDependencyTrees(root, review);
57
+ if (options.projectDependencies !== false)
58
+ projectDependencyTrees(root, review);
58
59
  mkdirSync(join(tempRoot, "scratch"), { mode: 0o700 });
59
60
  const manifest = manifestFromFrozen(frozenState);
60
61
  const snapshot = {
@@ -8,8 +8,24 @@ export declare function draftDigest(candidate: QaContractCandidateV1): string;
8
8
  /** Allocate identities from meaning, replacing proposed agent IDs throughout the draft. */
9
9
  export declare function allocateStableCheckIds(candidate: QaContractCandidateV1): QaContractCandidateV1;
10
10
  export declare function inventoryDigest(requirements: RequirementRef[]): string;
11
+ /** Derive a missing baseline binding only from an explicit, unique check prerequisite reference. */
12
+ export declare function bindUniqueBaselineCheckIds(candidate: QaContractCandidateV1): QaContractCandidateV1;
13
+ /**
14
+ * The host owns the before/after path comparison. Providers may still draft a
15
+ * check that asks them to enumerate every changed file, which turns host
16
+ * bookkeeping into a Builder obligation. Replace only that explicit shape;
17
+ * ordinary product-scope checks remain provider-authored and unchanged.
18
+ */
19
+ export declare function rewriteProviderHostScopeChecks(candidate: QaContractCandidateV1, baselineId: string): QaContractCandidateV1;
11
20
  export declare function authoritativeInventory(ticket: TicketDef, checklist: string[], inputs: ContractInputRef[]): RequirementRef[];
12
21
  export declare function parsePreparationArtifact<T>(text: string, tag: string, maximum?: number): T;
22
+ /** Capture safe absolute snapshot paths before the disposable snapshot is removed. */
23
+ export declare function mapSnapshotWorkingDirectories(text: string, snapshotPath: string): Record<string, string>;
24
+ /** Providers sometimes repeat an absolute disposable-snapshot path from an earlier
25
+ * conversation. That path is host-owned and must not enter the durable contract;
26
+ * normalize only the bounded QA snapshot naming convention to stable relative
27
+ * locations before parsing the provider draft. */
28
+ export declare function normalizeSnapshotPaths(text: string): string;
13
29
  export declare function validateCandidate(candidate: QaContractCandidateV1, inventory: RequirementRef[], resolveEquivalent?: EquivalentAuthorityResolver): string[];
14
30
  export declare function validateAssessment(candidate: QaContractCandidateV1, assessment: QaSemanticAssessmentV1, authorSessionId: string): string[];
15
31
  export declare function assembleContract(candidate: QaContractCandidateV1, inventory: RequirementRef[], assessment: QaSemanticAssessmentV1, authorSessionId: string, challenge?: QaChallengeReceiptV1, resolveEquivalent?: EquivalentAuthorityResolver): QaVerificationContractV1;