ai-foreman 1.7.19 → 1.7.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -5
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +18 -6
- package/dist/adapters/claude.d.ts +16 -0
- package/dist/adapters/claude.js +28 -3
- package/dist/adapters/codex.d.ts +20 -0
- package/dist/adapters/codex.js +99 -5
- package/dist/adapters/recovering.d.ts +9 -0
- package/dist/adapters/recovering.js +10 -0
- package/dist/adapters/types.d.ts +15 -0
- package/dist/agentRun.d.ts +7 -0
- package/dist/agentRun.js +24 -13
- package/dist/branch/currentGuard.d.ts +1 -0
- package/dist/branch/currentGuard.js +1 -0
- package/dist/branch/finalization.js +6 -1
- package/dist/branch/git.d.ts +1 -0
- package/dist/branch/git.js +15 -1
- package/dist/branch/runner.d.ts +2 -0
- package/dist/branch/runner.js +92 -5
- package/dist/buildAdmission.d.ts +61 -0
- package/dist/buildAdmission.js +286 -0
- package/dist/buildApproval.d.ts +2 -0
- package/dist/buildApproval.js +17 -0
- package/dist/buildAssignment.d.ts +33 -0
- package/dist/buildAssignment.js +215 -0
- package/dist/buildInterventions.d.ts +90 -0
- package/dist/buildInterventions.js +334 -0
- package/dist/buildLaunchGate.d.ts +1 -0
- package/dist/buildLaunchGate.js +36 -0
- package/dist/buildOwnershipReconciliation.d.ts +19 -0
- package/dist/buildOwnershipReconciliation.js +41 -0
- package/dist/buildOwnershipRepair.d.ts +33 -0
- package/dist/buildOwnershipRepair.js +111 -0
- package/dist/buildRuns.d.ts +24 -1
- package/dist/buildRuns.js +216 -70
- package/dist/buildSyntheticWork.d.ts +2 -0
- package/dist/buildSyntheticWork.js +30 -0
- package/dist/buildWorkAdmission.d.ts +17 -0
- package/dist/buildWorkAdmission.js +57 -0
- package/dist/buildWorkAuthorization.d.ts +2 -0
- package/dist/buildWorkAuthorization.js +23 -0
- package/dist/buildWorkContext.d.ts +3 -0
- package/dist/buildWorkContext.js +6 -0
- package/dist/buildWorkMigration.d.ts +9 -0
- package/dist/buildWorkMigration.js +206 -0
- package/dist/buildWorkReconciliation.d.ts +12 -0
- package/dist/buildWorkReconciliation.js +70 -0
- package/dist/builderGuidanceFollowup.d.ts +15 -0
- package/dist/builderGuidanceFollowup.js +34 -0
- package/dist/cli/doctor.js +3 -2
- package/dist/cli/graph.d.ts +2 -0
- package/dist/cli/graph.js +75 -0
- package/dist/cli/manager.d.ts +8 -0
- package/dist/cli/manager.js +234 -12
- package/dist/cli/recovery.js +3 -2
- package/dist/cli/runtimeAuthPrompt.d.ts +5 -0
- package/dist/cli/runtimeAuthPrompt.js +41 -7
- package/dist/cli/start.d.ts +8 -0
- package/dist/cli/start.js +2091 -1849
- package/dist/cli/status.js +20 -5
- package/dist/cli/tickets.js +9 -7
- package/dist/continuity.d.ts +16 -1
- package/dist/continuity.js +68 -12
- package/dist/foreman.d.ts +12 -4
- package/dist/foreman.js +397 -60
- package/dist/graph/boundary.d.ts +15 -0
- package/dist/graph/boundary.js +50 -0
- package/dist/graph/bridge.d.ts +9 -0
- package/dist/graph/bridge.js +146 -0
- package/dist/graph/bridge.py +205 -0
- package/dist/graph/config.d.ts +11 -0
- package/dist/graph/config.js +40 -0
- package/dist/graph/corpus.d.ts +16 -0
- package/dist/graph/corpus.js +188 -0
- package/dist/graph/derived.d.ts +17 -0
- package/dist/graph/derived.js +73 -0
- package/dist/graph/host.d.ts +4 -0
- package/dist/graph/host.js +160 -0
- package/dist/graph/install.d.ts +3 -0
- package/dist/graph/install.js +50 -0
- package/dist/graph/lifecycle.d.ts +11 -0
- package/dist/graph/lifecycle.js +124 -0
- package/dist/graph/maintenance.d.ts +43 -0
- package/dist/graph/maintenance.js +301 -0
- package/dist/graph/manager.d.ts +9 -0
- package/dist/graph/manager.js +92 -0
- package/dist/graph/preparation.d.ts +41 -0
- package/dist/graph/preparation.js +43 -0
- package/dist/graph/read.d.ts +27 -0
- package/dist/graph/read.js +152 -0
- package/dist/graph/semantic.d.ts +17 -0
- package/dist/graph/semantic.js +27 -0
- package/dist/graph/session.d.ts +8 -0
- package/dist/graph/session.js +92 -0
- package/dist/graph/sources.d.ts +4 -0
- package/dist/graph/sources.js +11 -0
- package/dist/graph/storage.d.ts +38 -0
- package/dist/graph/storage.js +159 -0
- package/dist/graph/turn.d.ts +17 -0
- package/dist/graph/turn.js +360 -0
- package/dist/graph/util.d.ts +5 -0
- package/dist/graph/util.js +51 -0
- package/dist/handoffs.d.ts +1 -0
- package/dist/handoffs.js +5 -1
- package/dist/humanDecision.d.ts +21 -2
- package/dist/humanDecision.js +169 -10
- package/dist/index.js +4 -1
- package/dist/installOwnership.d.ts +46 -0
- package/dist/installOwnership.js +440 -0
- package/dist/linuxProbeInventory.d.ts +12 -0
- package/dist/linuxProbeInventory.js +48 -0
- package/dist/log.d.ts +1 -1
- package/dist/log.js +7 -1
- package/dist/managerEvidence.d.ts +25 -0
- package/dist/managerEvidence.js +326 -0
- package/dist/managerEvidenceArtifacts.d.ts +38 -0
- package/dist/managerEvidenceArtifacts.js +118 -0
- package/dist/managerPacket.d.ts +2 -0
- package/dist/managerPacket.js +23 -0
- package/dist/markers.d.ts +1 -1
- package/dist/markers.js +2 -2
- package/dist/observability.js +1 -1
- package/dist/processIdentity.d.ts +21 -1
- package/dist/processIdentity.js +87 -5
- package/dist/providerPhase.d.ts +13 -0
- package/dist/providerPhase.js +26 -0
- package/dist/qaAuthorizedDisposition.d.ts +24 -0
- package/dist/qaAuthorizedDisposition.js +26 -0
- package/dist/qaBuildGate.d.ts +4 -0
- package/dist/qaBuildGate.js +216 -0
- package/dist/qaBuilderCoverage.d.ts +5 -0
- package/dist/qaBuilderCoverage.js +78 -0
- package/dist/qaContractCoverage.d.ts +10 -0
- package/dist/qaContractCoverage.js +55 -0
- package/dist/qaContractDelivery.d.ts +23 -0
- package/dist/qaContractDelivery.js +145 -0
- package/dist/qaContractFreshness.d.ts +15 -0
- package/dist/qaContractFreshness.js +36 -0
- package/dist/qaDeliveryJournal.d.ts +10 -0
- package/dist/qaDeliveryJournal.js +16 -1
- package/dist/qaEffectiveConfig.d.ts +20 -0
- package/dist/qaEffectiveConfig.js +40 -0
- package/dist/qaEvidenceReader.d.ts +14 -0
- package/dist/qaEvidenceReader.js +176 -0
- package/dist/qaFailureDelivery.d.ts +2 -0
- package/dist/qaFailureDelivery.js +107 -10
- package/dist/qaHandbackMigration.d.ts +1 -1
- package/dist/qaHandbackMigration.js +3 -3
- package/dist/qaPreparation.d.ts +26 -0
- package/dist/qaPreparation.js +150 -0
- package/dist/qaPreparationInputs.d.ts +8 -0
- package/dist/qaPreparationInputs.js +47 -0
- package/dist/qaPreparationMetrics.d.ts +145 -0
- package/dist/qaPreparationMetrics.js +120 -0
- package/dist/qaPreparationPolicy.d.ts +5 -0
- package/dist/qaPreparationPolicy.js +51 -0
- package/dist/qaPreparationStore.d.ts +157 -0
- package/dist/qaPreparationStore.js +412 -0
- package/dist/qaProtocolV2.d.ts +28 -0
- package/dist/qaProtocolV2.js +12 -0
- package/dist/qaReview.d.ts +6 -0
- package/dist/qaReview.js +293 -41
- package/dist/qaTimeline.d.ts +18 -0
- package/dist/qaTimeline.js +123 -0
- package/dist/qaVerificationContract.d.ts +17 -0
- package/dist/qaVerificationContract.js +236 -0
- package/dist/readinessCleanup.d.ts +19 -0
- package/dist/readinessCleanup.js +122 -0
- package/dist/readinessGate.d.ts +1 -0
- package/dist/readinessGate.js +82 -0
- package/dist/recoveryGuidance.d.ts +17 -0
- package/dist/recoveryGuidance.js +38 -0
- package/dist/runtimeAuth.d.ts +1 -0
- package/dist/runtimeAuth.js +5 -1
- package/dist/runtimeReadiness.d.ts +13 -0
- package/dist/runtimeReadiness.js +206 -10
- package/dist/sessionIdentity.d.ts +2 -0
- package/dist/sessionIdentity.js +14 -0
- package/dist/sessionLifecycle.js +3 -3
- package/dist/sources/sourceRegistry.js +7 -0
- package/dist/stateTransfer.js +85 -8
- package/dist/statusReporter.d.ts +1 -0
- package/dist/statusReporter.js +1 -0
- package/dist/supervisedStart.d.ts +6 -0
- package/dist/supervisedStart.js +178 -62
- package/dist/supervisor.d.ts +2 -0
- package/dist/supervisor.js +26 -2
- package/dist/terminalStyle.d.ts +21 -0
- package/dist/terminalStyle.js +50 -0
- package/dist/ticketPopulation.d.ts +1 -0
- package/dist/ticketPopulation.js +5 -1
- package/dist/tickets/commands.js +2 -2
- package/dist/tickets/queue.js +1 -1
- package/dist/tickets/ticketSchema.d.ts +198 -0
- package/dist/tickets/ticketSchema.js +2 -0
- package/dist/windowsProbeJob.d.ts +12 -0
- package/dist/windowsProbeJob.js +144 -0
- package/dist/workflowDb.d.ts +121 -6
- package/dist/workflowDb.js +874 -30
- package/dist/workflowReader.d.ts +116 -2
- package/dist/workflowReader.js +131 -4
- package/package.json +89 -8
- package/scripts/copy-graph-assets.mjs +3 -0
- package/scripts/graph-benchmark.mjs +20 -0
- package/scripts/graph-evaluation.mjs +85 -0
package/dist/processIdentity.js
CHANGED
|
@@ -1,13 +1,56 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { linuxProbeInventory } from "./linuxProbeInventory.js";
|
|
5
|
+
/** Field 2 is parenthesized and may itself contain whitespace/parentheses. */
|
|
6
|
+
export function linuxProcessStart(stat) {
|
|
7
|
+
const end = stat.lastIndexOf(")");
|
|
8
|
+
if (end < 0)
|
|
9
|
+
return undefined;
|
|
10
|
+
const value = stat.slice(end + 1).trim().split(/\s+/)[19];
|
|
11
|
+
return value && /^\d+$/.test(value) ? value : undefined;
|
|
12
|
+
}
|
|
13
|
+
export function classifyProcess(pid, expectedStart, host = hostname(), probe = {
|
|
14
|
+
kill: pid => { process.kill(pid, 0); }, identity: processStartIdentity,
|
|
15
|
+
}) {
|
|
16
|
+
if (host !== hostname())
|
|
17
|
+
return { state: "unknown", reason: "owner is on another host" };
|
|
18
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
19
|
+
return { state: "unknown", reason: "owner PID is missing or invalid" };
|
|
20
|
+
try {
|
|
21
|
+
probe.kill(pid);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return error.code === "ESRCH"
|
|
25
|
+
? { state: "dead", reason: "recorded process no longer exists" }
|
|
26
|
+
: { state: "unknown", reason: "process visibility is unavailable" };
|
|
27
|
+
}
|
|
28
|
+
const actual = probe.identity(pid);
|
|
29
|
+
if (!expectedStart || expectedStart === "unavailable" || actual === "unavailable")
|
|
30
|
+
return { state: "unknown", reason: "process incarnation cannot be verified" };
|
|
31
|
+
return actual === expectedStart ? { state: "live", reason: "owner process verified" } : { state: "dead", reason: "PID belongs to a different process incarnation" };
|
|
32
|
+
}
|
|
3
33
|
/**
|
|
4
34
|
* A stable, host-local process incarnation marker. Linux exposes it through
|
|
5
35
|
* procfs; macOS does not, so use `ps lstart` instead. The value is persisted
|
|
6
36
|
* only to detect PID reuse and is never treated as a portable identifier.
|
|
7
37
|
*/
|
|
8
|
-
export function processStartIdentity(pid = process.pid) {
|
|
38
|
+
export function processStartIdentity(pid = process.pid, timeoutMs = 5000) {
|
|
39
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
40
|
+
return "unavailable";
|
|
41
|
+
if (process.platform === "win32") {
|
|
42
|
+
try {
|
|
43
|
+
const powershell = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
|
|
44
|
+
const script = `[System.Diagnostics.Process]::GetProcessById(${pid}).StartTime.ToUniversalTime().ToFileTimeUtc()`;
|
|
45
|
+
const value = execFileSync(powershell, ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: Math.max(1, timeoutMs) }).trim();
|
|
46
|
+
if (/^\d+$/.test(value))
|
|
47
|
+
return `win:${value}`;
|
|
48
|
+
}
|
|
49
|
+
catch { /* Missing process or unavailable identity remains unverified. */ }
|
|
50
|
+
return "unavailable";
|
|
51
|
+
}
|
|
9
52
|
try {
|
|
10
|
-
const value = readFileSync(`/proc/${pid}/stat`, "utf8")
|
|
53
|
+
const value = linuxProcessStart(readFileSync(`/proc/${pid}/stat`, "utf8"));
|
|
11
54
|
// Preserve the historical Linux representation so an upgrade does not
|
|
12
55
|
// invalidate a lease that was created by the prior release.
|
|
13
56
|
if (value)
|
|
@@ -18,7 +61,7 @@ export function processStartIdentity(pid = process.pid) {
|
|
|
18
61
|
}
|
|
19
62
|
try {
|
|
20
63
|
const value = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
21
|
-
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout:
|
|
64
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: Math.max(1, Math.min(1000, timeoutMs)),
|
|
22
65
|
}).trim();
|
|
23
66
|
if (value)
|
|
24
67
|
return `ps:${value}`;
|
|
@@ -30,11 +73,50 @@ export function processStartIdentity(pid = process.pid) {
|
|
|
30
73
|
}
|
|
31
74
|
/** True only when the PID exists and represents the recorded incarnation. */
|
|
32
75
|
export function isLiveProcessIdentity(pid, expectedStart) {
|
|
76
|
+
return classifyProcess(pid, expectedStart).state === "live";
|
|
77
|
+
}
|
|
78
|
+
/** Complete local process inventory, including orphan descendants of a known group. */
|
|
79
|
+
export function processGroupQuiescent(group, timeoutMs = 1000) {
|
|
80
|
+
if (process.platform === "win32" || !Number.isSafeInteger(group) || group <= 1)
|
|
81
|
+
return false;
|
|
82
|
+
if (process.platform === "linux") {
|
|
83
|
+
const rows = linuxProbeInventory(undefined, timeoutMs);
|
|
84
|
+
return rows !== undefined && rows.filter(row => row.group === group).every(row => row.state === "Z" || row.state === "X");
|
|
85
|
+
}
|
|
33
86
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
87
|
+
const rows = execFileSync("ps", ["-axo", "pid=,pgid=,stat="], { encoding: "utf8", timeout: Math.max(1, timeoutMs), maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] })
|
|
88
|
+
.split("\n").map(line => /^\s*(\d+)\s+(\d+)\s+(\S+)/.exec(line)).filter(row => row !== null);
|
|
89
|
+
return rows.some(row => Number(row[1]) === process.pid) && rows.filter(row => Number(row[2]) === group).every(row => row[3].startsWith("Z"));
|
|
36
90
|
}
|
|
37
91
|
catch {
|
|
38
92
|
return false;
|
|
39
93
|
}
|
|
40
94
|
}
|
|
95
|
+
/** Inventory inherited probe tags without exposing process environments in diagnostics.
|
|
96
|
+
* This covers reparented/setsid children that retain their launch environment.
|
|
97
|
+
* It is not an OS sandbox: deliberately scrubbed environments cannot be traced.
|
|
98
|
+
*/
|
|
99
|
+
export function taggedProcesses(tag, timeoutMs = 1000) {
|
|
100
|
+
if (!/^[a-f0-9-]{36}$/.test(tag) || process.platform === "win32")
|
|
101
|
+
return undefined;
|
|
102
|
+
if (process.platform === "linux")
|
|
103
|
+
return linuxProbeInventory(tag, timeoutMs)?.filter(row => row.tagged).map(({ pid, start }) => ({ pid, start }));
|
|
104
|
+
try {
|
|
105
|
+
const deadline = Date.now() + timeoutMs;
|
|
106
|
+
const rows = execFileSync("ps", ["eww", "-axo", "pid=,stat=,command="], { encoding: "utf8", timeout: Math.max(1, timeoutMs), maxBuffer: 16 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).split("\n");
|
|
107
|
+
if (!rows.some(row => Number(row.trim().split(/\s+/)[0]) === process.pid))
|
|
108
|
+
return undefined;
|
|
109
|
+
return rows.flatMap(row => {
|
|
110
|
+
const match = /^\s*(\d+)\s+(\S+)\s+/.exec(row);
|
|
111
|
+
if (!match || match[2].startsWith("Z") || !row.includes(`RAFI_PROBE_OWNER=${tag}`))
|
|
112
|
+
return [];
|
|
113
|
+
const pid = Number(match[1]);
|
|
114
|
+
if (Date.now() >= deadline)
|
|
115
|
+
throw new Error("Tag inventory deadline exceeded");
|
|
116
|
+
return [{ pid, start: processStartIdentity(pid, deadline - Date.now()) }];
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Host-selected provider boundary. Never inferred from agent prose. */
|
|
2
|
+
export type ProviderTurnPurpose = "implementation" | "planning" | "contract-acceptance" | "preparation" | "assessment" | "initialization" | "response-repair";
|
|
3
|
+
export declare class ProviderPhaseBarrier {
|
|
4
|
+
purpose: ProviderTurnPurpose;
|
|
5
|
+
private needsRenewal;
|
|
6
|
+
private enforcing;
|
|
7
|
+
compactionSequence: number;
|
|
8
|
+
begin(purpose: ProviderTurnPurpose): void;
|
|
9
|
+
enableEnforcement(): void;
|
|
10
|
+
compact(): void;
|
|
11
|
+
accept(sequence: number): void;
|
|
12
|
+
denial(tool: string): string | undefined;
|
|
13
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export class ProviderPhaseBarrier {
|
|
2
|
+
purpose = "implementation";
|
|
3
|
+
needsRenewal = false;
|
|
4
|
+
enforcing = false;
|
|
5
|
+
compactionSequence = 0;
|
|
6
|
+
begin(purpose) { this.purpose = purpose; }
|
|
7
|
+
enableEnforcement() { if (!this.enforcing)
|
|
8
|
+
this.needsRenewal = true; this.enforcing = true; }
|
|
9
|
+
compact() { this.compactionSequence++; if (this.enforcing)
|
|
10
|
+
this.needsRenewal = true; }
|
|
11
|
+
accept(sequence) { if (sequence !== this.compactionSequence)
|
|
12
|
+
throw new Error("Compaction occurred during contract delivery"); this.needsRenewal = false; }
|
|
13
|
+
denial(tool) {
|
|
14
|
+
if (this.enforcing && ["Agent", "Task", "TaskOutput"].includes(tool))
|
|
15
|
+
return "Enforcing Builder cannot delegate mutation outside the contract tool barrier";
|
|
16
|
+
if (this.needsRenewal)
|
|
17
|
+
return "Contract delivery renewal required after native compaction; implementation tools paused";
|
|
18
|
+
if (this.purpose === "implementation")
|
|
19
|
+
return undefined;
|
|
20
|
+
if (["contract-acceptance", "initialization", "response-repair"].includes(this.purpose))
|
|
21
|
+
return "Host response-only phase forbids all tools";
|
|
22
|
+
if (!["Read", "Glob", "Grep", "ListMcpResourcesTool", "ReadMcpResourceTool"].includes(tool))
|
|
23
|
+
return "Host planning phase forbids mutation, shell commands, installs, and child agents";
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import type { QaContractCandidateV1, VerificationCheckV1, VerificationMethod } from "rafi-spec";
|
|
3
|
+
export interface AuthorizedEquivalentV1 {
|
|
4
|
+
version: 1;
|
|
5
|
+
kind: "qa-equivalent-verification";
|
|
6
|
+
runId: string;
|
|
7
|
+
workId: string;
|
|
8
|
+
admissionDigest: string;
|
|
9
|
+
contractId: string;
|
|
10
|
+
revision: number;
|
|
11
|
+
checkId: string;
|
|
12
|
+
expectationDigest: string;
|
|
13
|
+
method: Omit<VerificationMethod, "equivalentAuthorityId">;
|
|
14
|
+
reason: string;
|
|
15
|
+
validity: {
|
|
16
|
+
runtime: string;
|
|
17
|
+
expiresAt: string;
|
|
18
|
+
sourceDigest?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export type EquivalentAuthorityResolver = (reference: string, candidate: QaContractCandidateV1, check: VerificationCheckV1, method: VerificationMethod, sourceDigest?: string, nowMs?: number) => boolean;
|
|
22
|
+
export declare function expectationDigest(check: VerificationCheckV1): string;
|
|
23
|
+
/** Only a human answer to an exactly scoped host decision supplies approval. Agent strings never do. */
|
|
24
|
+
export declare function persistedEquivalentResolver(db: Database.Database): EquivalentAuthorityResolver;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { canonicalJson } from "./qaProtocolV2.js";
|
|
2
|
+
import { contractDigest } from "./qaVerificationContract.js";
|
|
3
|
+
export function expectationDigest(check) {
|
|
4
|
+
const { verification: _methods, dispositionRef: _disposition, ...expectation } = check;
|
|
5
|
+
return contractDigest("equivalent-expectation", expectation);
|
|
6
|
+
}
|
|
7
|
+
/** Only a human answer to an exactly scoped host decision supplies approval. Agent strings never do. */
|
|
8
|
+
export function persistedEquivalentResolver(db) {
|
|
9
|
+
return (reference, candidate, check, method, sourceDigest, nowMs = Date.now()) => {
|
|
10
|
+
const row = db.prepare("SELECT decision_json FROM human_decisions WHERE decision_id=? AND run_id=?").get(reference, candidate.runId);
|
|
11
|
+
if (!row)
|
|
12
|
+
return false;
|
|
13
|
+
const decision = JSON.parse(row.decision_json);
|
|
14
|
+
if (decision.status !== "answered" || decision.selectedChoiceId !== "custom" || !decision.answeredAt || decision.interruptionId !== `qa-equivalent:${candidate.contractId}:${candidate.revision}:${check.id}` || !decision.answer)
|
|
15
|
+
return false;
|
|
16
|
+
let authority;
|
|
17
|
+
try {
|
|
18
|
+
authority = JSON.parse(decision.answer);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const { equivalentAuthorityId: _reference, ...concreteMethod } = method;
|
|
24
|
+
return authority.version === 1 && authority.kind === "qa-equivalent-verification" && authority.runId === candidate.runId && authority.workId === candidate.workId && authority.admissionDigest === candidate.admissionDigest && authority.contractId === candidate.contractId && authority.revision === candidate.revision && authority.checkId === check.id && authority.expectationDigest === expectationDigest(check) && Boolean(authority.reason?.trim()) && canonicalJson(authority.method) === canonicalJson(concreteMethod) && authority.validity?.runtime === method.runtime && Date.parse(authority.validity.expiresAt) > nowMs && (!authority.validity.sourceDigest || authority.validity.sourceDigest === sourceDigest);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { BuilderAdapter } from "./adapters/types.js";
|
|
2
|
+
import type { QaSessionHandle } from "./qaReview.js";
|
|
3
|
+
/** Shared by ordinary, branch, synthetic, remediation and Manager followups. */
|
|
4
|
+
export declare function ensureBuilderContract(projectDir: string, runId: string, workId: string, workspace: string, builder: BuilderAdapter, qaFactory?: (cwd: string) => Promise<QaSessionHandle>): Promise<string>;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { resolveQaPreparationConfig } from "./qaPreparationPolicy.js";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { join, resolve, sep } from "node:path";
|
|
5
|
+
import { isTicketsInitialized, loadTicketsConfig } from "./tickets/config.js";
|
|
6
|
+
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
7
|
+
import { qaDigest } from "./qaProtocolV2.js";
|
|
8
|
+
import { admittedPlanInput } from "./qaPreparationInputs.js";
|
|
9
|
+
import { checkQaPrerequisites } from "./qaPrerequisites.js";
|
|
10
|
+
import { OperationDeadline } from "./util/deadline.js";
|
|
11
|
+
import { sendPreparationGraphTurn } from "./graph/preparation.js";
|
|
12
|
+
import { withGraphDerivedAccess } from "./graph/derived.js";
|
|
13
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
14
|
+
import { createRoleBuilder, readOnlyPermissionConfig } from "./agentRun.js";
|
|
15
|
+
import { captureBuildSource, assertBuildAssignmentReconciled } from "./buildAssignment.js";
|
|
16
|
+
import { createDisposableQaSnapshotAsync } from "./qaSnapshot.js";
|
|
17
|
+
import { allocateStableCheckIds, authoritativeInventory, contractDigest, renderVerificationContract } from "./qaVerificationContract.js";
|
|
18
|
+
import { resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
|
|
19
|
+
import { prepareVerificationContract } from "./qaPreparation.js";
|
|
20
|
+
import { actualContractSession, assertContractReceipt, deliverVerificationContract } from "./qaContractDelivery.js";
|
|
21
|
+
import { COVERAGE_INSTRUCTIONS } from "./qaContractCoverage.js";
|
|
22
|
+
import { renderBuildWorkContext } from "./buildWorkContext.js";
|
|
23
|
+
import { compareContractInputs } from "./qaContractFreshness.js";
|
|
24
|
+
/** Shared by ordinary, branch, synthetic, remediation and Manager followups. */
|
|
25
|
+
export async function ensureBuilderContract(projectDir, runId, workId, workspace, builder, qaFactory) {
|
|
26
|
+
return withGraphDerivedAccess([], () => ensureBuilderContractWithProvenance(projectDir, runId, workId, workspace, builder, qaFactory));
|
|
27
|
+
}
|
|
28
|
+
async function ensureBuilderContractWithProvenance(projectDir, runId, workId, workspace, builder, qaFactory) {
|
|
29
|
+
const db = new WorkflowDb(projectDir);
|
|
30
|
+
try {
|
|
31
|
+
db.ensureRun(runId);
|
|
32
|
+
const store = db.qaPreparationStore();
|
|
33
|
+
const policy = store.policy(runId) ?? store.freezePolicy(runId, resolveQaPreparationConfig(undefined));
|
|
34
|
+
if (policy.mode === "legacy")
|
|
35
|
+
return "";
|
|
36
|
+
if (policy.mode === "enforce" && !builder.contractCapabilities?.().nativeCompactionBarrier)
|
|
37
|
+
throw new Error(`unsupported-capability: ${builder.agent} enforcement requires proven native-compaction delivery continuity`);
|
|
38
|
+
assertBuildAssignmentReconciled(projectDir, runId, workspace);
|
|
39
|
+
const admission = db.assertAdmittedWork(runId, workId);
|
|
40
|
+
const ticket = db.workDefinitions(runId).find(work => work.workId === workId)?.definition;
|
|
41
|
+
if (!ticket)
|
|
42
|
+
throw new Error("Preparation requires the frozen admitted work definition");
|
|
43
|
+
if (admission.kind === "ticket" && isTicketsInitialized(projectDir)) {
|
|
44
|
+
const current = loadTickets(join(projectDir, loadTicketsConfig(projectDir).paths.tickets)).find(ticket => ticket.id === workId);
|
|
45
|
+
if (!current || qaDigest("admitted-requirements", current) !== admission.requirementsDigest)
|
|
46
|
+
throw new Error("Ticket changed from frozen admitted scope; renewed approval is required before preparation or implementation");
|
|
47
|
+
}
|
|
48
|
+
const state = db.getRun(runId)?.state;
|
|
49
|
+
const effective = resolveEffectiveQaConfiguration(projectDir, { make: state?.qa?.settings?.make ?? builder.agent });
|
|
50
|
+
const configurationEvent = `preparation-configuration:${runId}:${workId}:${effective.digest}`;
|
|
51
|
+
if (!store.eventRecord(configurationEvent))
|
|
52
|
+
store.event(runId, workId, configurationEvent, "effective-preparation-configuration", { policy, effectiveDigest: effective.digest, configRoot: effective.configRoot, roleSource: effective.roleSource, provider: builder.agent, capabilities: builder.contractCapabilities?.() ?? null, finalReviewEnabled: db.getRun(runId)?.state?.qaEnabled ?? null });
|
|
53
|
+
const inputs = [
|
|
54
|
+
{ id: "admitted-ticket", kind: "ticket", reference: admission.assignmentId, digest: contractDigest("input-ticket", ticket), authority: "approved-scope", revision: admission.scopeRevision, availability: "available" },
|
|
55
|
+
{ id: "project-checklist", kind: "checklist", reference: "tickets.build.validation_checklist", digest: contractDigest("input-checklist", effective.checklist), authority: "project-rule", revision: "current", availability: "available" },
|
|
56
|
+
{ id: "effective-rules", kind: "rules", reference: effective.configRoot, digest: effective.digest, authority: "project-rule", revision: "current", availability: "available" },
|
|
57
|
+
{ id: "preparation-policy", kind: "policy", reference: policy.policyVersion, digest: contractDigest("input-policy", policy), authority: "planner-policy", revision: policy.policyVersion, availability: "available" },
|
|
58
|
+
];
|
|
59
|
+
const planInput = admittedPlanInput(projectDir, ticket);
|
|
60
|
+
if (planInput)
|
|
61
|
+
inputs.push(planInput.input);
|
|
62
|
+
const inventory = [...authoritativeInventory(ticket, effective.checklist, inputs), ...(planInput?.requirements ?? [])];
|
|
63
|
+
for (const [locator, statement] of [["qa-rules", effective.qaRules], ["builder-rules", effective.builderRules], ...effective.skills.map(skill => [`skill:${skill.name}`, skill.content])]) {
|
|
64
|
+
if (!statement.trim())
|
|
65
|
+
continue;
|
|
66
|
+
inventory.push({ id: `req-rules-${contractDigest("rule-identity", { locator, statement }).slice(0, 24)}`, inputRef: "effective-rules", locator, statement, digest: contractDigest("statement", statement), obligation: "mandatory", origin: "explicit", authority: "approved" });
|
|
67
|
+
}
|
|
68
|
+
const source = captureBuildSource(workspace);
|
|
69
|
+
const head = store.head(runId, workId, admission.requirementsDigest);
|
|
70
|
+
const priorBudget = store.budget(runId, workId, admission.requirementsDigest);
|
|
71
|
+
if (priorBudget)
|
|
72
|
+
store.applyAnsweredExtensions(priorBudget);
|
|
73
|
+
let amendmentPredecessor;
|
|
74
|
+
if (head.digest) {
|
|
75
|
+
const old = store.contract(head.digest);
|
|
76
|
+
const comparison = compareContractInputs(old, inputs);
|
|
77
|
+
const inputDigest = contractDigest("amended-inputs", inputs);
|
|
78
|
+
const retainedIntent = priorBudget ? store.progress(priorBudget, "amendment-inputs") : undefined;
|
|
79
|
+
const changedDuringAmendment = head.state !== "ready" && retainedIntent?.predecessorDigest === old.contentDigest && retainedIntent.inputDigest !== inputDigest;
|
|
80
|
+
if (comparison.changed.length || comparison.unavailable.length || changedDuringAmendment) {
|
|
81
|
+
const qaHead = db.qaTicketHead(runId, workId);
|
|
82
|
+
if (["completed", "waived", "finalizing", "turn-intended", "turn-uncertain", "remediation-intended", "remediation-uncertain"].includes(qaHead.state))
|
|
83
|
+
throw new Error("Active or finalized QA authority requires explicit recovery/reopen before contract amendment");
|
|
84
|
+
const budget = priorBudget;
|
|
85
|
+
if (retainedIntent?.inputDigest !== inputDigest || retainedIntent.predecessorDigest !== old.contentDigest)
|
|
86
|
+
store.beginAmendment(budget, inventory, { reason: `Canonical authorized inputs changed: ${[...comparison.changed, ...comparison.unavailable].join(", ") || "inputs reverted during amendment"}`, inputDigest, predecessorDigest: old.contentDigest });
|
|
87
|
+
amendmentPredecessor = old.contentDigest;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const initial = { version: 1, contractId: head.digest ? store.contract(head.digest).contractId : `contract:${contractDigest("contract-family", { runId, workId, admission: admission.requirementsDigest })}`, revision: head.digest ? store.contract(head.digest).revision + (amendmentPredecessor ? 1 : 0) : 1,
|
|
91
|
+
...(amendmentPredecessor ? { predecessorDigest: amendmentPredecessor } : {}),
|
|
92
|
+
runId, workId, admissionDigest: admission.requirementsDigest, depthDecision: ticket.qa_preparation,
|
|
93
|
+
inputs, baseline: [], requirements: inventory, checks: [], coverage: [], preparationEvidence: [], unresolved: [], createdAt: new Date(priorBudget?.startMs ?? Date.now()).toISOString() };
|
|
94
|
+
const result = await prepareVerificationContract(initial, inventory, {
|
|
95
|
+
store, now: Date.now,
|
|
96
|
+
normalizeDraft: (draft, response) => {
|
|
97
|
+
const normalized = allocateStableCheckIds(draft);
|
|
98
|
+
for (const observation of normalized.baseline) {
|
|
99
|
+
const original = structuredClone(observation);
|
|
100
|
+
observation.sourceDigest = source.digest;
|
|
101
|
+
observation.observedAt = new Date().toISOString();
|
|
102
|
+
observation.phase = "preimplementation-investigation";
|
|
103
|
+
observation.evidenceDigest = store.putArtifact("baseline-observation", { observation: original, sourceDigest: source.digest, operationId: response.operationId, sessionId: response.sessionId, providerReceipt: store.putArtifact("investigation-evidence", response) });
|
|
104
|
+
}
|
|
105
|
+
for (const evidence of normalized.preparationEvidence) {
|
|
106
|
+
evidence.operationId = response.operationId;
|
|
107
|
+
evidence.sessionId = response.sessionId;
|
|
108
|
+
delete evidence.graphReceiptRefs;
|
|
109
|
+
if (response.graphReceiptRefs?.length)
|
|
110
|
+
evidence.graphReceiptRefs = [...response.graphReceiptRefs];
|
|
111
|
+
evidence.evidenceDigest = store.putArtifact("investigation-evidence", response);
|
|
112
|
+
for (const reference of evidence.references) {
|
|
113
|
+
const path = realpathSync(resolve(workspace, reference.path)), root = realpathSync(workspace);
|
|
114
|
+
if (!path.startsWith(root + sep))
|
|
115
|
+
throw new Error("Inspected source reference escapes actual workspace");
|
|
116
|
+
reference.digest = createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return normalized;
|
|
120
|
+
},
|
|
121
|
+
inputsCurrent: candidate => {
|
|
122
|
+
if (db.assertAdmittedWork(runId, workId).requirementsDigest !== admission.requirementsDigest)
|
|
123
|
+
return false;
|
|
124
|
+
const current = resolveEffectiveQaConfiguration(projectDir, { make: state?.qa?.settings?.make ?? builder.agent });
|
|
125
|
+
const compared = compareContractInputs(candidate, inputs.map(input => input.id === "effective-rules" ? { ...input, digest: current.digest } : input.id === "project-checklist" ? { ...input, digest: contractDigest("input-checklist", current.checklist) } : input));
|
|
126
|
+
return !compared.changed.length && !compared.unavailable.length;
|
|
127
|
+
},
|
|
128
|
+
dispatch: async (phase, instruction, operationId, remainingMs) => new OperationDeadline("QA preparation operation", remainingMs).run(async () => {
|
|
129
|
+
const started = performance.now();
|
|
130
|
+
const remaining = () => {
|
|
131
|
+
const value = remainingMs - (performance.now() - started);
|
|
132
|
+
if (value <= 0)
|
|
133
|
+
throw new Error("QA preparation deadline expired during session setup; completion is unknown");
|
|
134
|
+
return value;
|
|
135
|
+
};
|
|
136
|
+
const before = captureBuildSource(workspace);
|
|
137
|
+
if (phase === "planner") {
|
|
138
|
+
const role = await createRoleBuilder({ projectDir: workspace, configRoot: projectDir, role: "planner", label: "QA depth assessment", sandboxMode: "read-only", permissionConfig: readOnlyPermissionConfig(), yes: true, allowSwitch: false });
|
|
139
|
+
try {
|
|
140
|
+
const turn = await sendPreparationGraphTurn(role.builder, `${instruction}\nFrozen admitted work definition: ${JSON.stringify(ticket)}\nOriginal approved plan context: ${JSON.stringify(planInput?.plan ?? null)}\nFrozen effective configuration: ${JSON.stringify(effective)}`, { phase, projectDir, workspace, sourceRef: before.digest, runId, workId, operationId, remainingMs: remaining() });
|
|
141
|
+
store.event(runId, workId, `${operationId}:raw`, "preparation-provider-return", turn);
|
|
142
|
+
if (captureBuildSource(workspace).contentDigest !== before.contentDigest)
|
|
143
|
+
throw new Error("Product source changed during confined planning; reconcile retained dispatch");
|
|
144
|
+
if (turn.isError || turn.failure || !role.builder.sessionId())
|
|
145
|
+
throw new Error(`planner-unavailable: ${turn.text}`);
|
|
146
|
+
return { text: turn.text, graphReceiptRefs: turn.graphReceiptRefs, sessionId: role.builder.sessionId(), usage: { inputTokens: turn.usage?.scope === "turn-delta" ? turn.usage.inputTokens : turn.inputTokens, outputTokens: turn.usage?.scope === "turn-delta" ? turn.usage.outputTokens : turn.outputTokens, costUsd: turn.costAuthoritative ? turn.costUsd : undefined } };
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
await role.builder.close();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const snapshot = await createDisposableQaSnapshotAsync(workspace);
|
|
153
|
+
let adapter;
|
|
154
|
+
try {
|
|
155
|
+
remaining();
|
|
156
|
+
const prerequisites = await checkQaPrerequisites({ snapshotPath: snapshot.path, sourceDigest: snapshot.frozenState.digest, ticket });
|
|
157
|
+
const prerequisiteDigest = db.putEvidence("qa", JSON.stringify(prerequisites));
|
|
158
|
+
remaining();
|
|
159
|
+
if (qaFactory)
|
|
160
|
+
adapter = (await qaFactory(snapshot.path)).adapter;
|
|
161
|
+
else
|
|
162
|
+
adapter = (await createRoleBuilder({ projectDir: snapshot.path, configRoot: projectDir, role: "qa", agent: state?.qa?.settings?.make ?? builder.agent, preloadedSkillContent: effective.skills.map(skill => ({ name: skill.name, content: skill.content })), label: "QA preparation", sandboxMode: "read-only", permissionConfig: readOnlyPermissionConfig(), yes: true, allowSwitch: false })).builder;
|
|
163
|
+
const turn = await sendPreparationGraphTurn(adapter, `${instruction}\nOriginal approved plan context: ${JSON.stringify(planInput?.plan ?? null)}\nFrozen admitted work definition: ${JSON.stringify(ticket)}\nHost early prerequisite observations (host availability is not provider access; classify readiness versus final-only dependencies): ${JSON.stringify({ ...prerequisites, evidenceDigest: prerequisiteDigest })}\nFrozen effective configuration: ${JSON.stringify(effective)}\nOperation: ${operationId}; remaining wall time: ${remainingMs}ms; source: ${source.digest}`, { phase, projectDir, workspace: snapshot.path, accessWorkspace: workspace, sourceRef: snapshot.frozenState.digest, runId, workId, operationId, remainingMs: remaining() });
|
|
164
|
+
store.event(runId, workId, `${operationId}:raw`, "preparation-provider-return", turn);
|
|
165
|
+
if (turn.isError || turn.failure || !adapter.sessionId())
|
|
166
|
+
throw new Error(`Preparation provider unavailable: ${turn.text}`);
|
|
167
|
+
if (captureBuildSource(workspace).contentDigest !== before.contentDigest)
|
|
168
|
+
throw new Error("Source changed during preparation; retained dispatch requires reconciliation");
|
|
169
|
+
return { text: turn.text, graphReceiptRefs: turn.graphReceiptRefs, sessionId: adapter.sessionId(), usage: { inputTokens: turn.usage?.scope === "turn-delta" ? turn.usage.inputTokens : turn.inputTokens, outputTokens: turn.usage?.scope === "turn-delta" ? turn.usage.outputTokens : turn.outputTokens, costUsd: turn.costAuthoritative ? turn.costUsd : undefined } };
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
try {
|
|
173
|
+
await adapter?.close();
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
await snapshot.remove();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}),
|
|
180
|
+
});
|
|
181
|
+
if (result.status !== "ready") {
|
|
182
|
+
if (policy.mode === "shadow")
|
|
183
|
+
return "";
|
|
184
|
+
const budget = store.budget(runId, workId, admission.requirementsDigest);
|
|
185
|
+
if (budget && /budget exhausted|allowance exhausted|deadline expired/i.test(result.detail)) {
|
|
186
|
+
const decision = db.ensureHumanDecision({ runId, decisionKey: `qa-preparation-extension:${budget.id}:${budget.deadlineMs}:${budget.extensions.length}`, interruptionId: `qa-preparation:${budget.id}`, prompt: `Preparation allowance exhausted. Additional time/rounds require an operational extension, preserving depth, scope, original start and all consumed/uncertain dispatches. Supply JSON {"budgetId":"${budget.id}","reason":"operator reason","deadlineMs":<absolute epoch milliseconds>,"caps":{"investigation":<total round ceiling>,"assessment":<total round ceiling>}}. Current budget: ${JSON.stringify(budget)}`, choices: [{ id: "custom", label: "Provide a scoped operational extension using --answer" }] });
|
|
187
|
+
throw new Error(`QA preparation ${result.status}: ${result.detail}. Answer ${decision.decisionId} with rafi build:decide --run ${runId} --decision ${decision.decisionId} --choice custom --answer '<scoped JSON>', then resume. Uncertain dispatches still require reconciliation.`);
|
|
188
|
+
}
|
|
189
|
+
throw new Error(`QA preparation ${result.status}: ${result.detail}. ${result.nextAction}`);
|
|
190
|
+
}
|
|
191
|
+
amendmentPredecessor = result.contract.predecessorDigest;
|
|
192
|
+
if (amendmentPredecessor && !store.eventRecord(`amendment:${result.contract.contentDigest}`)) {
|
|
193
|
+
db.atomic(() => {
|
|
194
|
+
db.invalidateUnconsumedQaPassCertificates(runId, workId, "contract-amended");
|
|
195
|
+
db.markQaReportsRecheckRequired(runId, workId, "contract-amended");
|
|
196
|
+
const qaHead = db.qaTicketHead(runId, workId);
|
|
197
|
+
db.transitionQa(runId, workId, qaHead.revision, { type: "contract-amended", predecessorDigest: amendmentPredecessor, contractDigest: result.contract.contentDigest, reason: "Canonical authorized input reconciliation" });
|
|
198
|
+
store.metric(runId, workId, admission.requirementsDigest, `metric:amendment:${result.contract.contentDigest}`, "amendment");
|
|
199
|
+
store.event(runId, workId, `amendment:${result.contract.contentDigest}`, "contract-amendment", { predecessorDigest: amendmentPredecessor, contractDigest: result.contract.contentDigest, evidenceCarryForward: [], disposition: "All final checks require independent current-source review; no evidence silently carried" });
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (policy.mode === "shadow")
|
|
203
|
+
return "";
|
|
204
|
+
await builder.prepareSession?.();
|
|
205
|
+
try {
|
|
206
|
+
assertContractReceipt(store, runId, workId, admission.requirementsDigest, actualContractSession(builder, workspace, projectDir), builder);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
await deliverVerificationContract(result.contract, store, builder, workspace, projectDir, renderBuildWorkContext(ticket, effective.checklist));
|
|
210
|
+
}
|
|
211
|
+
return `${renderVerificationContract(result.contract)}\n${COVERAGE_INSTRUCTIONS}\nCoverage binding: ${JSON.stringify({ phase: "builder", runId, workId, revision: result.contract.revision, contractDigest: result.contract.contentDigest })}`;
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
db.close();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { BuilderAdapter, TurnResult } from "./adapters/types.js";
|
|
2
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
3
|
+
/** Claims are collected after source capture so the provider can name the actual completed source. */
|
|
4
|
+
export declare function collectBuilderContractCoverage(projectDir: string, runId: string, workId: string, workspace: string, adapter: BuilderAdapter, operationId: string, instruction: string, implementation: TurnResult): Promise<void>;
|
|
5
|
+
export declare function assertBuilderContractCoverage(db: WorkflowDb, runId: string, workId: string, operationId: string, sourceDigest: string): void;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { dispatchWithGraphAccess, withGraphSessionAccess } from "./graph/session.js";
|
|
2
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
3
|
+
import { actualContractSession, assertContractReceipt } from "./qaContractDelivery.js";
|
|
4
|
+
import { captureBuildSource } from "./buildAssignment.js";
|
|
5
|
+
import { COVERAGE_INSTRUCTIONS, parseContractCoverage, validateContractCoverage } from "./qaContractCoverage.js";
|
|
6
|
+
import { contractDigest } from "./qaVerificationContract.js";
|
|
7
|
+
import { OperationDeadline } from "./util/deadline.js";
|
|
8
|
+
/** Claims are collected after source capture so the provider can name the actual completed source. */
|
|
9
|
+
export async function collectBuilderContractCoverage(projectDir, runId, workId, workspace, adapter, operationId, instruction, implementation) {
|
|
10
|
+
return withGraphSessionAccess(projectDir, adapter, () => collectBuilderContractCoverageOwned(projectDir, runId, workId, workspace, adapter, operationId, instruction, implementation));
|
|
11
|
+
}
|
|
12
|
+
async function collectBuilderContractCoverageOwned(projectDir, runId, workId, workspace, adapter, operationId, instruction, implementation) {
|
|
13
|
+
const db = new WorkflowDb(projectDir);
|
|
14
|
+
try {
|
|
15
|
+
const store = db.qaPreparationStore();
|
|
16
|
+
if (store.policy(runId)?.mode !== "enforce")
|
|
17
|
+
return;
|
|
18
|
+
const admission = db.assertAdmittedWork(runId, workId);
|
|
19
|
+
const session = actualContractSession(adapter, workspace, projectDir);
|
|
20
|
+
const contract = assertContractReceipt(store, runId, workId, admission.requirementsDigest, session, adapter);
|
|
21
|
+
const source = captureBuildSource(workspace);
|
|
22
|
+
const binding = { phase: "builder", sourceDigest: source.digest, inputBasisDigest: contractDigest("builder-input-basis", { operationId, instruction, contractDigest: contract.contentDigest }), attemptId: operationId, sessionId: session.sessionId };
|
|
23
|
+
const eventId = `builder-coverage:${operationId}`;
|
|
24
|
+
const existing = db.operation(eventId);
|
|
25
|
+
if (existing) {
|
|
26
|
+
if (existing.status !== "confirmed")
|
|
27
|
+
throw new Error("Uncertain Builder coverage dispatch must be reconciled without redispatch");
|
|
28
|
+
const retained = existing.result;
|
|
29
|
+
const errors = validateContractCoverage(contract, store.artifact(retained.coverageDigest, "builder-coverage"), binding, false);
|
|
30
|
+
if (errors.length)
|
|
31
|
+
throw new Error(errors.join("; "));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Preserve the implementation response before a failed acknowledgment or a crash.
|
|
35
|
+
const implementationDigest = db.putEvidence("qa", implementation.rawResponse ?? implementation.text);
|
|
36
|
+
db.planOperation({ runId, idempotencyKey: eventId, kind: "builder-coverage", intent: { workId, contractDigest: contract.contentDigest, binding, implementationDigest, session } });
|
|
37
|
+
db.updateOperation(eventId, "in_progress");
|
|
38
|
+
try {
|
|
39
|
+
const prompt = `Response-only evidence collection for completed implementation ${operationId}. Do not run tools, modify files, repeat work, or invent verification. Record failed, blocked or not-run truthfully. The implementation response is retained as ${implementationDigest}.\n${COVERAGE_INSTRUCTIONS}\nExact coverage binding: ${JSON.stringify({ version: 1, runId, workId, revision: contract.revision, contractDigest: contract.contentDigest, ...binding })}\nAll checks: ${JSON.stringify(contract.checks)}`;
|
|
40
|
+
const response = await new OperationDeadline("Builder coverage acknowledgment", 120_000).run(() => dispatchWithGraphAccess(projectDir, adapter, prompt, { purpose: "response-repair", responseOnly: true }, (text, policy) => adapter.sendTurn(text, policy)), () => { void adapter.close(); });
|
|
41
|
+
const responseDigest = db.putEvidence("qa", response.rawResponse ?? response.text);
|
|
42
|
+
db.updateOperation(eventId, "uncertain", { result: { responseDigest } });
|
|
43
|
+
if (response.isError || response.failure)
|
|
44
|
+
throw new Error("Builder coverage provider failed; retained response requires reconciliation");
|
|
45
|
+
if (JSON.stringify(actualContractSession(adapter, workspace, projectDir)) !== JSON.stringify(session) || captureBuildSource(workspace).digest !== source.digest)
|
|
46
|
+
throw new Error("Builder coverage changed session or product source");
|
|
47
|
+
const coverage = parseContractCoverage(response.text);
|
|
48
|
+
const errors = validateContractCoverage(contract, coverage, binding, false);
|
|
49
|
+
if (errors.length)
|
|
50
|
+
throw new Error(`Builder coverage invalid: ${errors.join("; ")}`);
|
|
51
|
+
const coverageDigest = store.putArtifact("builder-coverage", coverage);
|
|
52
|
+
db.updateOperation(eventId, "confirmed", { result: { responseDigest, coverageDigest, binding, implementationDigest } });
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
throw new Error(`Builder evidence collection blocked: ${String(error)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
db.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function assertBuilderContractCoverage(db, runId, workId, operationId, sourceDigest) {
|
|
63
|
+
const store = db.qaPreparationStore();
|
|
64
|
+
if (store.policy(runId)?.mode !== "enforce")
|
|
65
|
+
return;
|
|
66
|
+
const admission = db.assertAdmittedWork(runId, workId), head = store.head(runId, workId, admission.requirementsDigest);
|
|
67
|
+
if (head.state !== "ready" || !head.digest)
|
|
68
|
+
throw new Error("Completion requires the ready contract");
|
|
69
|
+
const operation = db.operation(`builder-coverage:${operationId}`);
|
|
70
|
+
if (operation?.status !== "confirmed")
|
|
71
|
+
throw new Error("Completion requires retained, source-bound Builder check coverage");
|
|
72
|
+
const result = operation.result;
|
|
73
|
+
if (result.binding.sourceDigest !== sourceDigest || result.binding.attemptId !== operationId)
|
|
74
|
+
throw new Error("Builder completion coverage is stale");
|
|
75
|
+
const errors = validateContractCoverage(store.contract(head.digest), store.artifact(result.coverageDigest, "builder-coverage"), result.binding, false);
|
|
76
|
+
if (errors.length)
|
|
77
|
+
throw new Error(errors.join("; "));
|
|
78
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { QaContractCoverageV1, QaVerificationContractV1 } from "rafi-spec";
|
|
2
|
+
export declare const COVERAGE_INSTRUCTIONS = "Return RAFI_QA_COVERAGE_START, one QaContractCoverageV1 JSON object, RAFI_QA_COVERAGE_END before the final status marker. Include every check ID with independently evaluated applicability, predicate evidence, outcome (passed/failed/blocked/not-run), and concrete procedure/cwd/runtime/sourceDigest/reference evidence. Missing, blocked, not-run, unresolved applicability, or failed mandatory coverage cannot authorize normal completion. Builder coverage is a claim; final QA independently verifies it.";
|
|
3
|
+
export declare function parseContractCoverage(text: string): QaContractCoverageV1;
|
|
4
|
+
export declare function validateContractCoverage(contract: QaVerificationContractV1, coverage: QaContractCoverageV1, binding: {
|
|
5
|
+
phase: "builder" | "qa";
|
|
6
|
+
sourceDigest: string;
|
|
7
|
+
inputBasisDigest: string;
|
|
8
|
+
attemptId: string;
|
|
9
|
+
sessionId: string;
|
|
10
|
+
}, requirePass?: boolean): string[];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Ajv } from "ajv";
|
|
2
|
+
import { qaContractCoverageV1Schema } from "rafi-spec";
|
|
3
|
+
const coverageSchema = new Ajv({ allErrors: true }).compile(qaContractCoverageV1Schema);
|
|
4
|
+
import { parsePreparationArtifact } from "./qaVerificationContract.js";
|
|
5
|
+
export const COVERAGE_INSTRUCTIONS = `Return RAFI_QA_COVERAGE_START, one QaContractCoverageV1 JSON object, RAFI_QA_COVERAGE_END before the final status marker. Include every check ID with independently evaluated applicability, predicate evidence, outcome (passed/failed/blocked/not-run), and concrete procedure/cwd/runtime/sourceDigest/reference evidence. Missing, blocked, not-run, unresolved applicability, or failed mandatory coverage cannot authorize normal completion. Builder coverage is a claim; final QA independently verifies it.`;
|
|
6
|
+
export function parseContractCoverage(text) { return parsePreparationArtifact(text, "RAFI_QA_COVERAGE"); }
|
|
7
|
+
export function validateContractCoverage(contract, coverage, binding, requirePass = true) {
|
|
8
|
+
if (!coverageSchema(coverage))
|
|
9
|
+
return (coverageSchema.errors ?? []).map(error => `${error.instancePath} ${error.message}`);
|
|
10
|
+
const issues = [];
|
|
11
|
+
if (coverage.version !== 1 || coverage.runId !== contract.runId || coverage.workId !== contract.workId || coverage.revision !== contract.revision || coverage.contractDigest !== contract.contentDigest)
|
|
12
|
+
issues.push("Coverage contract/work binding mismatch");
|
|
13
|
+
for (const key of ["phase", "sourceDigest", "inputBasisDigest", "attemptId", "sessionId"])
|
|
14
|
+
if (coverage[key] !== binding[key])
|
|
15
|
+
issues.push(`Coverage ${key} mismatch`);
|
|
16
|
+
if (!Array.isArray(coverage.checks))
|
|
17
|
+
return [...issues, "Missing check coverage"];
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
for (const row of coverage.checks) {
|
|
20
|
+
const check = contract.checks.find(check => check.id === row.checkId);
|
|
21
|
+
if (!check || seen.has(row.checkId)) {
|
|
22
|
+
issues.push(`Unknown/duplicate coverage ${row.checkId}`);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
seen.add(row.checkId);
|
|
26
|
+
if (!["applicable", "not-applicable", "unresolved"].includes(row.applicability) || !["passed", "failed", "blocked", "not-run"].includes(row.outcome))
|
|
27
|
+
issues.push(`Illegal coverage state ${row.checkId}`);
|
|
28
|
+
if (check.applicability.kind === "unconditional" && row.applicability !== "applicable")
|
|
29
|
+
issues.push(`Unconditional applicability cannot be waived ${row.checkId}`);
|
|
30
|
+
if (row.applicability === "not-applicable" && !row.predicateEvidence?.length)
|
|
31
|
+
issues.push(`Not-applicable requires predicate evidence ${row.checkId}`);
|
|
32
|
+
if (check.applicability.kind === "conditional" && row.applicability === "applicable" && !row.predicateEvidence?.length)
|
|
33
|
+
issues.push(`Conditional applicability requires predicate evidence ${row.checkId}`);
|
|
34
|
+
if (requirePass && check.obligation === "mandatory" && (row.applicability === "unresolved" || row.applicability === "applicable" && row.outcome !== "passed"))
|
|
35
|
+
issues.push(`Mandatory check incomplete ${row.checkId}`);
|
|
36
|
+
if (!Array.isArray(row.evidence))
|
|
37
|
+
issues.push(`Missing evidence array ${row.checkId}`);
|
|
38
|
+
if (row.outcome === "passed" && row.applicability === "applicable") {
|
|
39
|
+
if (binding.phase === "qa" && check.timing === "preimplementation") {
|
|
40
|
+
const baseline = contract.baseline.find(observation => observation.checkId === check.id && observation.status === "passed");
|
|
41
|
+
if (!baseline || !row.evidence?.some(evidence => evidence.reference === baseline.evidenceDigest))
|
|
42
|
+
issues.push(`Preimplementation coverage lacks retained baseline authority ${row.checkId}`);
|
|
43
|
+
}
|
|
44
|
+
if (!row.evidence?.length)
|
|
45
|
+
issues.push(`Passing check requires independent evidence ${row.checkId}`);
|
|
46
|
+
for (const evidence of row.evidence ?? [])
|
|
47
|
+
if (!evidence.reference?.trim() || !evidence.procedure?.trim() || !evidence.cwd?.trim() || !evidence.runtime?.trim() || check.timing !== "preimplementation" && evidence.sourceDigest !== binding.sourceDigest)
|
|
48
|
+
issues.push(`Missing/stale source evidence ${row.checkId}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const check of contract.checks)
|
|
52
|
+
if (!seen.has(check.id))
|
|
53
|
+
issues.push(`Missing check ${check.id}`);
|
|
54
|
+
return issues;
|
|
55
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { QaVerificationContractV1, QaContractDeliveryReceiptV1 } from "rafi-spec";
|
|
2
|
+
import type { BuilderAdapter } from "./adapters/types.js";
|
|
3
|
+
import type { QaPreparationStore } from "./qaPreparationStore.js";
|
|
4
|
+
export interface ContractSessionBinding {
|
|
5
|
+
provider: string;
|
|
6
|
+
sessionId: string;
|
|
7
|
+
generation: number;
|
|
8
|
+
workspace: string;
|
|
9
|
+
configRoot: string;
|
|
10
|
+
compactionSequence: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function actualContractSession(adapter: BuilderAdapter, workspace: string, configRoot: string): ContractSessionBinding;
|
|
13
|
+
export declare function materializeContract(contract: QaVerificationContractV1, workspace: string): {
|
|
14
|
+
json: string;
|
|
15
|
+
markdown: string;
|
|
16
|
+
resourceDigest: string;
|
|
17
|
+
};
|
|
18
|
+
/** Complete retained review inputs, accessible inside the confined QA snapshot. */
|
|
19
|
+
export declare function finalReviewContractContext(contract: QaVerificationContractV1, store: QaPreparationStore, workspace: string, builderClaims?: unknown[]): string;
|
|
20
|
+
export declare function deliverVerificationContract(contract: QaVerificationContractV1, store: QaPreparationStore, adapter: BuilderAdapter, workspace: string, configRoot: string, context: string): Promise<QaContractDeliveryReceiptV1>;
|
|
21
|
+
/** Byte bounded, lossless Unicode transport. Concatenation reconstructs the exact original input. */
|
|
22
|
+
export declare function segmentContractTransport(content: string, maximumBytes?: number): string[];
|
|
23
|
+
export declare function assertContractReceipt(store: QaPreparationStore, runId: string, workId: string, admissionDigest: string, session?: ContractSessionBinding, adapter?: BuilderAdapter): QaVerificationContractV1;
|