ai-foreman 1.7.26 → 1.7.27

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 (91) 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 +8 -0
  18. package/dist/cli/start.js +164 -27
  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 +1 -0
  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/log.d.ts +1 -1
  32. package/dist/managerPacket.js +1 -1
  33. package/dist/permissions/policy.js +21 -0
  34. package/dist/planChanges.js +1 -1
  35. package/dist/projectDiagnostics.js +30 -0
  36. package/dist/providerQuestions.js +4 -24
  37. package/dist/qaBuildGate.d.ts +6 -1
  38. package/dist/qaBuildGate.js +76 -16
  39. package/dist/qaBuilderCoverage.d.ts +2 -1
  40. package/dist/qaBuilderCoverage.js +47 -5
  41. package/dist/qaBuilderEvidence.d.ts +18 -0
  42. package/dist/qaBuilderEvidence.js +60 -0
  43. package/dist/qaContractCoverage.d.ts +12 -1
  44. package/dist/qaContractCoverage.js +34 -2
  45. package/dist/qaContractDelivery.d.ts +2 -2
  46. package/dist/qaContractDelivery.js +88 -10
  47. package/dist/qaEffectiveConfig.js +2 -1
  48. package/dist/qaFailureDelivery.d.ts +23 -0
  49. package/dist/qaFailureDelivery.js +87 -6
  50. package/dist/qaHostFacts.d.ts +2 -0
  51. package/dist/qaHostFacts.js +11 -0
  52. package/dist/qaPreparation.d.ts +38 -1
  53. package/dist/qaPreparation.js +372 -31
  54. package/dist/qaPreparationInputs.js +5 -1
  55. package/dist/qaPreparationPolicy.d.ts +2 -1
  56. package/dist/qaPreparationPolicy.js +39 -9
  57. package/dist/qaRecovery.d.ts +7 -0
  58. package/dist/qaRecovery.js +73 -1
  59. package/dist/qaReview.js +35 -8
  60. package/dist/qaReviewEvidence.d.ts +6 -0
  61. package/dist/qaReviewEvidence.js +37 -0
  62. package/dist/qaRollbackRehearsal.d.ts +2 -0
  63. package/dist/qaRollbackRehearsal.js +78 -0
  64. package/dist/qaSnapshot.d.ts +3 -1
  65. package/dist/qaSnapshot.js +3 -2
  66. package/dist/qaVerificationContract.d.ts +9 -0
  67. package/dist/qaVerificationContract.js +143 -17
  68. package/dist/sources/sourceRegistry.js +16 -5
  69. package/dist/stateTransfer.d.ts +14 -0
  70. package/dist/stateTransfer.js +51 -0
  71. package/dist/statusReporter.d.ts +8 -0
  72. package/dist/statusReporter.js +5 -0
  73. package/dist/ticketPlanning.d.ts +373 -0
  74. package/dist/ticketPlanning.js +34 -3
  75. package/dist/ticketPopulation.d.ts +28 -3
  76. package/dist/ticketPopulation.js +125 -15
  77. package/dist/tickets/details.d.ts +2 -1
  78. package/dist/tickets/details.js +7 -3
  79. package/dist/tickets/eligibility.d.ts +6 -0
  80. package/dist/tickets/eligibility.js +12 -1
  81. package/dist/tickets/riskWaivers.d.ts +3 -0
  82. package/dist/tickets/riskWaivers.js +12 -0
  83. package/dist/tickets/stateDb.d.ts +10 -0
  84. package/dist/tickets/stateDb.js +23 -0
  85. package/dist/tickets/ticketLoader.d.ts +9 -0
  86. package/dist/tickets/ticketLoader.js +15 -2
  87. package/dist/tickets/ticketSchema.d.ts +14 -1
  88. package/dist/tickets/ticketSchema.js +2 -1
  89. package/dist/tickets/validate.js +11 -1
  90. package/dist/workflowDb.js +11 -2
  91. 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");
@@ -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";
@@ -229,7 +231,10 @@ async function runIsolatedQaOwned(opts) {
229
231
  const maxBuilderFixes = Math.min(opts.maxCycles, durableLimit);
230
232
  let automaticFixes = persistedQaFixCount(opts);
231
233
  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);
234
+ // Remediation generations are scoped to a ticket head. The surrounding
235
+ // build state is shared across tickets, so using its counter here can leak
236
+ // T-001's fixes into T-002 and make a valid review fail binding validation.
237
+ let remediationGeneration = Math.max(durableRemediationGeneration, ...durableReviews.map((item) => item.remediationGeneration), ...durableRemediations.map((item) => item.generation), 0);
233
238
  const resumedHeadDb = new WorkflowDb(opts.recovery.projectDir);
234
239
  resumedHeadDb.reconcileInstructionDeliveries(opts.recovery.runId, opts.ticket.id);
235
240
  opts.qaRemediationAuthorization ??= resumedHeadDb.consumeInstructionControls(opts.recovery.runId, opts.ticket.id, "attempts");
@@ -549,6 +554,10 @@ function deliverQaFailureOrLegacyFix(opts, request, causingAttemptId) {
549
554
  const reportDigest = request.reportDigest;
550
555
  if (!attempt)
551
556
  throw new Error(`QA failure delivery is missing review attempt ${causingAttemptId}`);
557
+ const admission = headDb.assertAdmittedWork(opts.recovery.runId, opts.ticket.id);
558
+ const preparation = headDb.qaPreparationStore();
559
+ const contractHead = preparation.head(opts.recovery.runId, opts.ticket.id, admission.requirementsDigest);
560
+ const contract = contractHead.state === "ready" && contractHead.digest ? preparation.contract(contractHead.digest) : undefined;
552
561
  const input = {
553
562
  projectDir: opts.recovery.projectDir,
554
563
  runId: opts.recovery.runId,
@@ -560,6 +569,7 @@ function deliverQaFailureOrLegacyFix(opts, request, causingAttemptId) {
560
569
  reviewNumber: attempt.reviewNumber,
561
570
  reviewedSourceStateDigest: attempt.sourceDigest,
562
571
  reviewBasisDigest: headDb.qaTicketHead(opts.recovery.runId, opts.ticket.id).reviewBasisDigest ?? "",
572
+ ...(contract ? { contractDigest: contract.contentDigest } : {}),
563
573
  remediationGeneration: attempt.remediationGeneration,
564
574
  latestBuilderResult: request.latestBuilderResult,
565
575
  history: request.history,
@@ -980,7 +990,14 @@ async function oneReview(opts, identity) {
980
990
  return pauseQaReview(opts, "invalid-pass-contract", `invalid QA pass response: ${contract.errors.join("; ")}`, reviewedSnapshot.frozenState);
981
991
  if (missingPrerequisites.length)
982
992
  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");
993
+ let certificate;
994
+ try {
995
+ certificate = finishV2Pass(opts, qa, status.summary ?? "qa_pass");
996
+ }
997
+ catch (error) {
998
+ const detail = error instanceof Error ? error.message : String(error);
999
+ return retryFreshQaReview(opts, "invalid-qa-coverage", `QA pass evidence was not parseable or complete: ${detail}`, reviewedSnapshot.frozenState);
1000
+ }
984
1001
  markQaRecoveryResolved(opts);
985
1002
  durableReviewFinished = true;
986
1003
  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 +1051,14 @@ async function oneReview(opts, identity) {
1034
1051
  if (contract.valid && contract.status === "qa_pass") {
1035
1052
  if (missingPrerequisites.length)
1036
1053
  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");
1054
+ let certificate;
1055
+ try {
1056
+ certificate = finishV2Pass(opts, qa, contract.fields.summary ?? "qa_pass");
1057
+ }
1058
+ catch (error) {
1059
+ const detail = error instanceof Error ? error.message : String(error);
1060
+ return retryFreshQaReview(opts, "invalid-qa-coverage", `Recovered QA pass evidence was not parseable or complete: ${detail}`, reviewedSnapshot.frozenState);
1061
+ }
1038
1062
  markQaRecoveryResolved(opts);
1039
1063
  durableReviewFinished = true;
1040
1064
  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 +1601,7 @@ function validateBoundaryReceipt(receipt, packet, successorHandle) {
1577
1601
  receiptDb.close();
1578
1602
  }
1579
1603
  const byLabel = new Map(receipt.resources.map((resource) => [resource.label, resource]));
1580
- for (const expected of packet.manifest.resources) {
1604
+ for (const expected of packet.manifest.resources.filter((resource) => resource.requiredForRecovery)) {
1581
1605
  const actual = byLabel.get(expected.path);
1582
1606
  if (!actual || actual.authoritative !== true || actual.digest !== expected.digest || actual.path !== expected.path || actual.purpose !== expected.purpose
1583
1607
  || actual.bytes !== expected.bytes || actual.mediaType !== expected.mediaType || actual.requiredForRecovery !== expected.requiredForRecovery) {
@@ -1635,7 +1659,9 @@ function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle,
1635
1659
  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
1660
  ? [{ operationId: operation.idempotencyKey, digest: result.coverageDigest, coverage: preparationStore.artifact(result.coverageDigest, "builder-coverage") }] : [];
1637
1661
  });
1638
- instruction += finalReviewContractContext(contract, preparationStore, reviewedSnapshotPath, builderClaims);
1662
+ const commandObservations = retainedQaCommandObservations(opts.recovery.projectDir, opts.recovery.runId, opts.ticket.id, qaCommandOutputExcerptsAllowed(opts.ticket.id));
1663
+ instruction += `\n${qaHostGraphFact(opts.recovery.projectDir)}\n`;
1664
+ instruction += finalReviewContractContext(contract, preparationStore, reviewedSnapshotPath, builderClaims, commandObservations);
1639
1665
  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
1666
  }
1641
1667
  const originalInstruction = instruction;
@@ -1963,8 +1989,9 @@ function finishV2Pass(opts, adapter, detail) {
1963
1989
  if (!contractHead.digest || contractHead.state !== "ready")
1964
1990
  throw new Error("Final coverage requires the current ready contract");
1965
1991
  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 });
1992
+ const coverageBinding = { phase: "qa", sourceDigest: binding.sourceStateDigest, inputBasisDigest: binding.reviewBasisDigest, attemptId: binding.reviewAttemptId, sessionId: binding.sessionRef.sessionId };
1993
+ const coverage = bindCoverageHostFields(parseContractCoverage(binding.lastResponse ?? ""), contract, coverageBinding);
1994
+ const errors = validateContractCoverage(contract, coverage, coverageBinding);
1968
1995
  if (errors.length)
1969
1996
  throw new Error(`Final QA coverage incomplete: ${errors.join("; ")}`);
1970
1997
  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,17 @@ 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;
11
13
  export declare function authoritativeInventory(ticket: TicketDef, checklist: string[], inputs: ContractInputRef[]): RequirementRef[];
12
14
  export declare function parsePreparationArtifact<T>(text: string, tag: string, maximum?: number): T;
15
+ /** Capture safe absolute snapshot paths before the disposable snapshot is removed. */
16
+ export declare function mapSnapshotWorkingDirectories(text: string, snapshotPath: string): Record<string, string>;
17
+ /** Providers sometimes repeat an absolute disposable-snapshot path from an earlier
18
+ * conversation. That path is host-owned and must not enter the durable contract;
19
+ * normalize only the bounded QA snapshot naming convention to stable relative
20
+ * locations before parsing the provider draft. */
21
+ export declare function normalizeSnapshotPaths(text: string): string;
13
22
  export declare function validateCandidate(candidate: QaContractCandidateV1, inventory: RequirementRef[], resolveEquivalent?: EquivalentAuthorityResolver): string[];
14
23
  export declare function validateAssessment(candidate: QaContractCandidateV1, assessment: QaSemanticAssessmentV1, authorSessionId: string): string[];
15
24
  export declare function assembleContract(candidate: QaContractCandidateV1, inventory: RequirementRef[], assessment: QaSemanticAssessmentV1, authorSessionId: string, challenge?: QaChallengeReceiptV1, resolveEquivalent?: EquivalentAuthorityResolver): QaVerificationContractV1;