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.
- package/dist/adapters/codex.d.ts +12 -0
- package/dist/adapters/codex.js +50 -1
- package/dist/adapters/recovering.d.ts +4 -0
- package/dist/adapters/recovering.js +10 -0
- package/dist/adapters/sessionFailure.d.ts +6 -0
- package/dist/adapters/sessionFailure.js +19 -0
- package/dist/adapters/types.d.ts +6 -0
- package/dist/branch/currentGuard.d.ts +8 -0
- package/dist/branch/currentGuard.js +5 -0
- package/dist/branch/runner.js +6 -2
- package/dist/buildApproval.js +2 -1
- package/dist/buildRuns.d.ts +12 -0
- package/dist/buildRuns.js +50 -0
- package/dist/buildWorkContext.js +1 -1
- package/dist/buildWorkMigration.d.ts +10 -0
- package/dist/buildWorkMigration.js +18 -2
- package/dist/cli/start.d.ts +8 -0
- package/dist/cli/start.js +164 -27
- package/dist/cli/state.js +39 -1
- package/dist/cli/tickets.d.ts +37 -0
- package/dist/cli/tickets.js +367 -12
- package/dist/config.js +1 -0
- package/dist/continuity.d.ts +1 -0
- package/dist/continuity.js +5 -0
- package/dist/foreman.js +89 -8
- package/dist/graph/preparation.js +6 -3
- package/dist/graph/semantic.js +2 -1
- package/dist/graph/turn.js +8 -1
- package/dist/handoffProtocol.js +2 -1
- package/dist/handoffs.js +7 -19
- package/dist/log.d.ts +1 -1
- package/dist/managerPacket.js +1 -1
- package/dist/permissions/policy.js +21 -0
- package/dist/planChanges.js +1 -1
- package/dist/projectDiagnostics.js +30 -0
- package/dist/providerQuestions.js +4 -24
- package/dist/qaBuildGate.d.ts +6 -1
- package/dist/qaBuildGate.js +76 -16
- package/dist/qaBuilderCoverage.d.ts +2 -1
- package/dist/qaBuilderCoverage.js +47 -5
- package/dist/qaBuilderEvidence.d.ts +18 -0
- package/dist/qaBuilderEvidence.js +60 -0
- package/dist/qaContractCoverage.d.ts +12 -1
- package/dist/qaContractCoverage.js +34 -2
- package/dist/qaContractDelivery.d.ts +2 -2
- package/dist/qaContractDelivery.js +88 -10
- package/dist/qaEffectiveConfig.js +2 -1
- package/dist/qaFailureDelivery.d.ts +23 -0
- package/dist/qaFailureDelivery.js +87 -6
- package/dist/qaHostFacts.d.ts +2 -0
- package/dist/qaHostFacts.js +11 -0
- package/dist/qaPreparation.d.ts +38 -1
- package/dist/qaPreparation.js +372 -31
- package/dist/qaPreparationInputs.js +5 -1
- package/dist/qaPreparationPolicy.d.ts +2 -1
- package/dist/qaPreparationPolicy.js +39 -9
- package/dist/qaRecovery.d.ts +7 -0
- package/dist/qaRecovery.js +73 -1
- package/dist/qaReview.js +35 -8
- package/dist/qaReviewEvidence.d.ts +6 -0
- package/dist/qaReviewEvidence.js +37 -0
- package/dist/qaRollbackRehearsal.d.ts +2 -0
- package/dist/qaRollbackRehearsal.js +78 -0
- package/dist/qaSnapshot.d.ts +3 -1
- package/dist/qaSnapshot.js +3 -2
- package/dist/qaVerificationContract.d.ts +9 -0
- package/dist/qaVerificationContract.js +143 -17
- package/dist/sources/sourceRegistry.js +16 -5
- package/dist/stateTransfer.d.ts +14 -0
- package/dist/stateTransfer.js +51 -0
- package/dist/statusReporter.d.ts +8 -0
- package/dist/statusReporter.js +5 -0
- package/dist/ticketPlanning.d.ts +373 -0
- package/dist/ticketPlanning.js +34 -3
- package/dist/ticketPopulation.d.ts +28 -3
- package/dist/ticketPopulation.js +125 -15
- package/dist/tickets/details.d.ts +2 -1
- package/dist/tickets/details.js +7 -3
- package/dist/tickets/eligibility.d.ts +6 -0
- package/dist/tickets/eligibility.js +12 -1
- package/dist/tickets/riskWaivers.d.ts +3 -0
- package/dist/tickets/riskWaivers.js +12 -0
- package/dist/tickets/stateDb.d.ts +10 -0
- package/dist/tickets/stateDb.js +23 -0
- package/dist/tickets/ticketLoader.d.ts +9 -0
- package/dist/tickets/ticketLoader.js +15 -2
- package/dist/tickets/ticketSchema.d.ts +14 -1
- package/dist/tickets/ticketSchema.js +2 -1
- package/dist/tickets/validate.js +11 -1
- package/dist/workflowDb.js +11 -2
- 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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 (
|
|
26
|
-
throw new Error("
|
|
27
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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");
|
package/dist/qaRecovery.d.ts
CHANGED
|
@@ -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;
|
package/dist/qaRecovery.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1967
|
-
const
|
|
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,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
|
+
}
|
package/dist/qaSnapshot.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/qaSnapshot.js
CHANGED
|
@@ -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
|
-
|
|
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;
|