ai-foreman 1.7.27 → 1.7.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/start.d.ts +2 -1
- package/dist/cli/start.js +22 -4
- package/dist/config.js +13 -4
- package/dist/hostQaComparison.d.ts +50 -0
- package/dist/hostQaComparison.js +195 -0
- package/dist/observability.d.ts +2 -0
- package/dist/observability.js +2 -1
- package/dist/permissions/policy.js +11 -0
- package/dist/qaBuildGate.js +2 -2
- package/dist/qaBuilderCoverage.js +11 -2
- package/dist/qaContractCoverage.js +1 -1
- package/dist/qaPreparationStore.d.ts +4 -0
- package/dist/qaPreparationStore.js +8 -0
- package/dist/qaReview.js +24 -2
- package/dist/qaVerificationContract.d.ts +7 -0
- package/dist/qaVerificationContract.js +40 -0
- package/dist/supervisedStart.js +23 -0
- package/dist/ticketPopulation.js +2 -0
- package/dist/workflowDb.js +14 -2
- package/package.json +3 -3
package/dist/cli/start.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { AgentRoleDefaultsV1 } from "rafi-spec";
|
|
|
5
5
|
import type { BranchPlan } from "../branch/types.js";
|
|
6
6
|
import { type DeliveryConfig, type DeliveryStack } from "../tickets/delivery.js";
|
|
7
7
|
import type { QaNonconvergenceContext, QaNonconvergenceDecision, QaReportRecoveryHandler } from "../qaReview.js";
|
|
8
|
-
import { RunObserver } from "../observability.js";
|
|
8
|
+
import { RunObserver, type ObservabilityMode } from "../observability.js";
|
|
9
9
|
export declare function formatStartResumeCommand(executable: "ai-foreman", projectDir: string, steps: number, sessionId: string): string;
|
|
10
10
|
export declare function formatResumeGuidance(executable: "rafi" | "ai-foreman", projectDir: string, steps: number, sessionId?: string, run?: {
|
|
11
11
|
runId: string;
|
|
@@ -22,6 +22,7 @@ export declare function formatResumeGuidance(executable: "rafi" | "ai-foreman",
|
|
|
22
22
|
export declare function savedStartArguments(command: Command, projectDir: string, opts: Record<string, unknown>): string[];
|
|
23
23
|
/** Pure validation shared by interactive start and persisted preparation. */
|
|
24
24
|
export declare function validateStartOptions(opts: Record<string, any>, requireCount: boolean): void;
|
|
25
|
+
export declare function resolveAuditLoggingMode(cli: unknown, configured: unknown, environment?: string | undefined): ObservabilityMode;
|
|
25
26
|
/** Preserve the distinction between a staged handoff and an unattempted acceptance. */
|
|
26
27
|
export declare function recordHandoffAcceptanceNotAttempted(projectDir: string, runId: string, role: "builder" | "qa", generation: number, stage: string, detail: string): void;
|
|
27
28
|
/** Default start selection is deliberately the same eligibility model users see in the ticket queue. */
|
package/dist/cli/start.js
CHANGED
|
@@ -426,6 +426,20 @@ export function validateStartOptions(opts, requireCount) {
|
|
|
426
426
|
throw new Error("Preparation launch requires both run and token");
|
|
427
427
|
if (opts.launchToken && !opts.preparationRun && !opts.recoverRun)
|
|
428
428
|
throw new Error("Launch token requires a preparation or recovery run");
|
|
429
|
+
if (opts.auditLogging !== undefined && !["off", "summary", "trace"].includes(String(opts.auditLogging)))
|
|
430
|
+
throw new Error("--audit-logging must be off, summary, or trace");
|
|
431
|
+
}
|
|
432
|
+
export function resolveAuditLoggingMode(cli, configured, environment = process.env.RAFI_AUDIT_LOGGING) {
|
|
433
|
+
const value = cli ?? configured ?? environment ?? "summary";
|
|
434
|
+
if (!["off", "summary", "trace"].includes(String(value)))
|
|
435
|
+
throw new Error("audit logging must be off, summary, or trace");
|
|
436
|
+
return String(value);
|
|
437
|
+
}
|
|
438
|
+
function configuredAuditLoggingMode(path) {
|
|
439
|
+
if (!existsSync(path))
|
|
440
|
+
return undefined;
|
|
441
|
+
const raw = parseYaml(readFileSync(path, "utf8"));
|
|
442
|
+
return raw?.observability?.mode === undefined ? undefined : resolveAuditLoggingMode(undefined, raw.observability.mode, undefined);
|
|
429
443
|
}
|
|
430
444
|
/** A current waiver is an already-recorded decision: surface it, never demand a second start-time approval. */
|
|
431
445
|
function warnRiskPlanWaivers(projectDir, ticketIds) {
|
|
@@ -545,6 +559,7 @@ export function buildStartCommand(options = {}) {
|
|
|
545
559
|
.option("--base <ref>", "base ref for root ticket branches (default: current branch or HEAD)")
|
|
546
560
|
.option("--branch-prefix <prefix>", "branch name prefix for ticket branches")
|
|
547
561
|
.option("--show-session-cost", "show authoritative cost or trustworthy cumulative session tokens for Builder and QA")
|
|
562
|
+
.option("--audit-logging <mode>", "host-owned audit logging (off | summary | trace)", "summary")
|
|
548
563
|
.option("--hide-session-cost", "hide session cost/token usage for Builder and QA")
|
|
549
564
|
.option("--auto-compact-threshold <percent>", "initial Builder context compaction threshold (1-99)")
|
|
550
565
|
.option("--max-branch-depth <n>", "maximum selected branch stack depth", "5")
|
|
@@ -676,6 +691,7 @@ export function buildStartCommand(options = {}) {
|
|
|
676
691
|
const readinessOwner = { project: cwd, runId: supervisedInvocationId, authority: localBuildAuthority(cwd) };
|
|
677
692
|
const autoApprovePlanUpdates = recoveryRecord
|
|
678
693
|
? recoveryRecord.recoveryDecision?.planUpdateApproval === "auto"
|
|
694
|
+
&& (recoveryRecord.recoveryDecision.planUpdateApprovalSource === "explicit-yes" || recoveryRecord.recoveryDecision.planUpdateApprovalSource === "explicit-choice")
|
|
679
695
|
: Boolean(opts.yes);
|
|
680
696
|
if (opts.recoveryMode) {
|
|
681
697
|
const allowed = ["exact-session", "fresh-with-handoff", "fresh-recovery-only", "guided-recovery"];
|
|
@@ -934,6 +950,8 @@ export function buildStartCommand(options = {}) {
|
|
|
934
950
|
? recoveryExecutionTickets(recoveryRecord, requestedTicketIds, Boolean(opts.recoveryMode))
|
|
935
951
|
: selectedNewTicket ? [selectedNewTicket] : undefined;
|
|
936
952
|
const config = loadConfig(join(cwd, "foreman.yaml"));
|
|
953
|
+
const auditLogging = resolveAuditLoggingMode(command.getOptionValueSource("auditLogging") === "cli" ? opts.auditLogging : undefined, configuredAuditLoggingMode(join(cwd, "foreman.yaml")));
|
|
954
|
+
const observabilityConfig = { ...config.observability, mode: auditLogging, enabled: auditLogging !== "off" };
|
|
937
955
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
938
956
|
const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
|
|
939
957
|
const log = new Log(logPath);
|
|
@@ -948,9 +966,9 @@ export function buildStartCommand(options = {}) {
|
|
|
948
966
|
console.error(`rafi: warning: ${qaResolution.deprecationWarning}`);
|
|
949
967
|
const invocationRunId = supervisedInvocationId;
|
|
950
968
|
let observabilityStore;
|
|
951
|
-
if (
|
|
969
|
+
if (observabilityConfig.enabled) {
|
|
952
970
|
try {
|
|
953
|
-
observabilityStore = new ObservabilityStore(cwd, { config:
|
|
971
|
+
observabilityStore = new ObservabilityStore(cwd, { config: observabilityConfig });
|
|
954
972
|
}
|
|
955
973
|
catch (error) {
|
|
956
974
|
console.error(`rafi: observability unavailable; build will continue: ${String(error).replace(/\s+/g, " ").slice(0, 300)}`);
|
|
@@ -2384,7 +2402,7 @@ export function buildStartCommand(options = {}) {
|
|
|
2384
2402
|
observabilityStore?.enforceLimits();
|
|
2385
2403
|
observabilityStore?.maintainLogs(cwd);
|
|
2386
2404
|
observabilityStore?.close();
|
|
2387
|
-
pruneRecoveryTelemetryAtCompletion(cwd,
|
|
2405
|
+
pruneRecoveryTelemetryAtCompletion(cwd, observabilityConfig.detail_retention_days);
|
|
2388
2406
|
activeObserver = undefined;
|
|
2389
2407
|
process.off("exit", finishObservationOnExit);
|
|
2390
2408
|
console.log("foreman: branch run summary");
|
|
@@ -2831,7 +2849,7 @@ export function buildStartCommand(options = {}) {
|
|
|
2831
2849
|
observabilityStore?.enforceLimits();
|
|
2832
2850
|
observabilityStore?.maintainLogs(cwd);
|
|
2833
2851
|
observabilityStore?.close();
|
|
2834
|
-
pruneRecoveryTelemetryAtCompletion(cwd,
|
|
2852
|
+
pruneRecoveryTelemetryAtCompletion(cwd, observabilityConfig.detail_retention_days);
|
|
2835
2853
|
activeObserver = undefined;
|
|
2836
2854
|
process.off("exit", finishObservationOnExit);
|
|
2837
2855
|
console.log(`\n${accent("foreman:")} ${result.completed}/${result.requested} step(s) completed`);
|
package/dist/config.js
CHANGED
|
@@ -84,9 +84,16 @@ export function loadConfig(path = "foreman.yaml") {
|
|
|
84
84
|
...(raw.notifications ?? {}),
|
|
85
85
|
},
|
|
86
86
|
qa: { ...DEFAULT_CONFIG.qa, ...(raw.qa ?? {}) },
|
|
87
|
-
observability: { ...DEFAULT_CONFIG.observability, ...(raw.observability ?? {}) },
|
|
87
|
+
observability: normalizeObservabilityConfig({ ...DEFAULT_CONFIG.observability, ...(raw.observability ?? {}) }),
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
|
+
function normalizeObservabilityConfig(config) {
|
|
91
|
+
if (config.enabled === false)
|
|
92
|
+
return { ...config, mode: "off" };
|
|
93
|
+
if (config.mode === "off")
|
|
94
|
+
return { ...config, enabled: false };
|
|
95
|
+
return config;
|
|
96
|
+
}
|
|
90
97
|
function validateConfig(raw, path) {
|
|
91
98
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
92
99
|
throw new Error(`${path}: expected a YAML object`);
|
|
@@ -123,17 +130,19 @@ function validateObservability(value, path) {
|
|
|
123
130
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
124
131
|
throw new Error(`${path}: observability must be an object`);
|
|
125
132
|
const input = value;
|
|
126
|
-
const keys = ["enabled", "sample_interval_seconds", "detail_retention_days", "log_retention_days", "detail_soft_limit_mb", "detail_hard_limit_mb", "log_limit_mb"];
|
|
133
|
+
const keys = ["enabled", "mode", "sample_interval_seconds", "detail_retention_days", "log_retention_days", "detail_soft_limit_mb", "detail_hard_limit_mb", "log_limit_mb"];
|
|
127
134
|
for (const key of Object.keys(input))
|
|
128
135
|
if (!keys.includes(key))
|
|
129
136
|
throw new Error(`${path}: unknown observability setting ${key}`);
|
|
130
137
|
if (input.enabled !== undefined && typeof input.enabled !== "boolean")
|
|
131
138
|
throw new Error(`${path}: observability.enabled must be a boolean`);
|
|
132
|
-
|
|
139
|
+
if (input.mode !== undefined && !["off", "summary", "trace"].includes(String(input.mode)))
|
|
140
|
+
throw new Error(`${path}: observability.mode must be off, summary, or trace`);
|
|
141
|
+
for (const key of keys.filter((key) => key !== "enabled" && key !== "mode")) {
|
|
133
142
|
if (input[key] !== undefined && (!Number.isInteger(input[key]) || Number(input[key]) <= 0))
|
|
134
143
|
throw new Error(`${path}: observability.${key} must be a positive integer`);
|
|
135
144
|
}
|
|
136
|
-
const merged = { ...DEFAULT_OBSERVABILITY_CONFIG, ...input };
|
|
145
|
+
const merged = { ...DEFAULT_OBSERVABILITY_CONFIG, ...input, ...(input.enabled === false && input.mode === undefined ? { mode: "off" } : {}), ...(input.mode === "off" && input.enabled === undefined ? { enabled: false } : {}) };
|
|
137
146
|
if (merged.detail_soft_limit_mb >= merged.detail_hard_limit_mb)
|
|
138
147
|
throw new Error(`${path}: observability.detail_soft_limit_mb must be less than detail_hard_limit_mb`);
|
|
139
148
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { QaContractCoverageV1, QaVerificationContractV1 } from "rafi-spec";
|
|
3
|
+
import type { TicketDef } from "./tickets/ticketSchema.js";
|
|
4
|
+
import type { WorkflowDb } from "./workflowDb.js";
|
|
5
|
+
export declare const hostQaComparisonV1Schema: z.ZodObject<{
|
|
6
|
+
version: z.ZodLiteral<1>;
|
|
7
|
+
runId: z.ZodString;
|
|
8
|
+
ticketId: z.ZodString;
|
|
9
|
+
assignmentId: z.ZodString;
|
|
10
|
+
contractDigest: z.ZodString;
|
|
11
|
+
contractRevision: z.ZodNumber;
|
|
12
|
+
reviewAttemptId: z.ZodString;
|
|
13
|
+
reviewBasisDigest: z.ZodString;
|
|
14
|
+
baselineSourceDigest: z.ZodString;
|
|
15
|
+
finalSourceDigest: z.ZodString;
|
|
16
|
+
scopeBasisDigest: z.ZodString;
|
|
17
|
+
status: z.ZodEnum<{
|
|
18
|
+
failed: "failed";
|
|
19
|
+
passed: "passed";
|
|
20
|
+
blocked: "blocked";
|
|
21
|
+
}>;
|
|
22
|
+
changedPaths: z.ZodArray<z.ZodString>;
|
|
23
|
+
unauthorizedPaths: z.ZodArray<z.ZodString>;
|
|
24
|
+
deletedPaths: z.ZodArray<z.ZodString>;
|
|
25
|
+
protectedPaths: z.ZodArray<z.ZodString>;
|
|
26
|
+
reason: z.ZodString;
|
|
27
|
+
comparedAt: z.ZodString;
|
|
28
|
+
}, z.core.$strip>;
|
|
29
|
+
export type HostQaComparisonV1 = z.infer<typeof hostQaComparisonV1Schema>;
|
|
30
|
+
export declare function findHostQaCheck(contract: QaVerificationContractV1): QaVerificationContractV1["checks"][number] | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Performs the private host comparison and returns the immutable artifact plus
|
|
33
|
+
* the coverage row replacement. Provider output is never used to decide scope.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createHostQaComparison(input: {
|
|
36
|
+
db: Pick<WorkflowDb, "operations" | "qaPreparationStore">;
|
|
37
|
+
projectDir: string;
|
|
38
|
+
worktree: string;
|
|
39
|
+
runId: string;
|
|
40
|
+
ticket: TicketDef;
|
|
41
|
+
contract: QaVerificationContractV1;
|
|
42
|
+
reviewAttemptId: string;
|
|
43
|
+
reviewBasisDigest: string;
|
|
44
|
+
}): {
|
|
45
|
+
result: HostQaComparisonV1;
|
|
46
|
+
resultDigest: string;
|
|
47
|
+
checkId?: string;
|
|
48
|
+
};
|
|
49
|
+
export declare function applyHostQaComparison(coverage: QaContractCoverageV1, contract: QaVerificationContractV1, result: HostQaComparisonV1, resultDigest: string, projectDir: string): QaContractCoverageV1;
|
|
50
|
+
export declare function hostComparisonDigestFromCoverage(coverage: QaContractCoverageV1, checkId: string): string | undefined;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { captureBuildSource } from "./buildAssignment.js";
|
|
3
|
+
import { contractDigest } from "./qaVerificationContract.js";
|
|
4
|
+
const digest = z.string().regex(/^[a-f0-9]{64}$/);
|
|
5
|
+
const path = z.string().min(1).max(1024);
|
|
6
|
+
export const hostQaComparisonV1Schema = z.object({
|
|
7
|
+
version: z.literal(1),
|
|
8
|
+
runId: z.string().min(1),
|
|
9
|
+
ticketId: z.string().min(1),
|
|
10
|
+
assignmentId: z.string().min(1),
|
|
11
|
+
contractDigest: digest,
|
|
12
|
+
contractRevision: z.number().int().positive(),
|
|
13
|
+
reviewAttemptId: z.string().min(1),
|
|
14
|
+
reviewBasisDigest: digest,
|
|
15
|
+
baselineSourceDigest: digest,
|
|
16
|
+
finalSourceDigest: digest,
|
|
17
|
+
scopeBasisDigest: digest,
|
|
18
|
+
status: z.enum(["passed", "failed", "blocked"]),
|
|
19
|
+
changedPaths: z.array(path).max(256),
|
|
20
|
+
unauthorizedPaths: z.array(path).max(256),
|
|
21
|
+
deletedPaths: z.array(path).max(256),
|
|
22
|
+
protectedPaths: z.array(path).max(256),
|
|
23
|
+
reason: z.string().max(2048),
|
|
24
|
+
comparedAt: z.string().datetime(),
|
|
25
|
+
});
|
|
26
|
+
function identity(item) {
|
|
27
|
+
if (!item)
|
|
28
|
+
return "missing";
|
|
29
|
+
return JSON.stringify({
|
|
30
|
+
headObject: item.headObject,
|
|
31
|
+
indexObject: item.indexObject,
|
|
32
|
+
worktreeObject: item.worktreeObject,
|
|
33
|
+
untracked: item.untracked,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function matchesScope(candidate, patterns) {
|
|
37
|
+
return patterns.some(pattern => {
|
|
38
|
+
const normalized = pattern.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
39
|
+
if (normalized.endsWith("/**"))
|
|
40
|
+
return candidate === normalized.slice(0, -3) || candidate.startsWith(normalized.slice(0, -2));
|
|
41
|
+
if (normalized.includes("*")) {
|
|
42
|
+
const expression = new RegExp(`^${normalized.split("*").map(part => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
|
|
43
|
+
return expression.test(candidate);
|
|
44
|
+
}
|
|
45
|
+
return candidate === normalized;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function isHostQaCheck(check) {
|
|
49
|
+
const text = `${check.expectedBehavior} ${check.expectedEvidence.join(" ")} ${check.applicability.requiredEvidence.join(" ")}`;
|
|
50
|
+
const hostScopeEvidence = check.applicability.requiredEvidence.some(evidence => /host[- ]owned.*scope comparison/i.test(evidence));
|
|
51
|
+
const scopeSemantics = /scope/i.test(text) && /comparison/i.test(text) && /baseline|protected|approved scope|product changes/i.test(text);
|
|
52
|
+
return !/documentation/i.test(text)
|
|
53
|
+
&& (hostScopeEvidence || (check.applicability.decisionOwner === "host" && (/Rafi compares the final product paths/i.test(check.expectedBehavior) || scopeSemantics)));
|
|
54
|
+
}
|
|
55
|
+
export function findHostQaCheck(contract) {
|
|
56
|
+
return contract.checks.find(isHostQaCheck);
|
|
57
|
+
}
|
|
58
|
+
function findHostQaChecks(contract) {
|
|
59
|
+
return contract.checks.filter(isHostQaCheck);
|
|
60
|
+
}
|
|
61
|
+
function findHostDocumentationCheck(contract) {
|
|
62
|
+
return contract.checks.find(check => check.applicability.decisionOwner === "host"
|
|
63
|
+
&& /documentation/i.test(check.expectedBehavior)
|
|
64
|
+
&& /documentation/i.test(check.expectedEvidence.join(" ")));
|
|
65
|
+
}
|
|
66
|
+
function findAssignment(db, runId, ticketId, baselineDigest) {
|
|
67
|
+
const operations = db.operations(runId);
|
|
68
|
+
return operations
|
|
69
|
+
.filter(operation => operation.intent?.ticketId === ticketId && operation.intent?.before?.digest === baselineDigest
|
|
70
|
+
&& Array.isArray(operation.intent.before.pathInventory) && Array.isArray(operation.result?.after?.pathInventory))
|
|
71
|
+
.at(-1);
|
|
72
|
+
}
|
|
73
|
+
function baselineDigest(contract) {
|
|
74
|
+
return contract.inputs.find(input => input.id === "preimplementation-source")?.digest;
|
|
75
|
+
}
|
|
76
|
+
function resultEvidence(resultDigest, projectDir, sourceDigest, status, reason) {
|
|
77
|
+
return {
|
|
78
|
+
procedure: "Rafi host-owned comparison of the retained preimplementation inventory and final Builder source",
|
|
79
|
+
cwd: projectDir,
|
|
80
|
+
runtime: "Rafi host",
|
|
81
|
+
sourceDigest,
|
|
82
|
+
reference: `host-qa-comparison:${resultDigest}`,
|
|
83
|
+
...(status === "passed" ? {} : { limitation: reason }),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Performs the private host comparison and returns the immutable artifact plus
|
|
88
|
+
* the coverage row replacement. Provider output is never used to decide scope.
|
|
89
|
+
*/
|
|
90
|
+
export function createHostQaComparison(input) {
|
|
91
|
+
const check = findHostQaCheck(input.contract);
|
|
92
|
+
if (!check)
|
|
93
|
+
throw new Error("Host QA comparison contract check is missing");
|
|
94
|
+
const expectedBaseline = baselineDigest(input.contract);
|
|
95
|
+
if (!expectedBaseline)
|
|
96
|
+
throw new Error("Host QA comparison lacks the admitted preimplementation baseline");
|
|
97
|
+
const assignment = findAssignment(input.db, input.runId, input.ticket.id, expectedBaseline);
|
|
98
|
+
const finalFirst = captureBuildSource(input.worktree);
|
|
99
|
+
const finalSecond = captureBuildSource(input.worktree);
|
|
100
|
+
if (finalFirst.digest !== finalSecond.digest)
|
|
101
|
+
throw new Error("Host QA comparison source changed during final capture");
|
|
102
|
+
const final = finalSecond;
|
|
103
|
+
const before = assignment?.intent.before;
|
|
104
|
+
const assignmentId = assignment?.idempotencyKey ?? "missing-assignment";
|
|
105
|
+
const beforePaths = new Map((before?.pathInventory ?? []).map(item => [item.path, item]));
|
|
106
|
+
const afterPaths = new Map(final.pathInventory.map(item => [item.path, item]));
|
|
107
|
+
const allPaths = [...new Set([...beforePaths.keys(), ...afterPaths.keys()])].sort();
|
|
108
|
+
const changedPaths = allPaths.filter(item => identity(beforePaths.get(item)) !== identity(afterPaths.get(item)));
|
|
109
|
+
const deletedPaths = [...beforePaths.keys()].filter(item => !afterPaths.has(item)).sort();
|
|
110
|
+
const protectedPaths = changedPaths.filter(item => item === ".git" || item.startsWith(".git/") || item === ".rafi" || item.startsWith(".rafi/") || item === ".foreman" || item.startsWith(".foreman/")).sort();
|
|
111
|
+
const scopePatterns = input.ticket.likely_files.map(item => item.replaceAll("\\", "/"));
|
|
112
|
+
const unauthorizedPaths = scopePatterns.length ? changedPaths.filter(item => !matchesScope(item, scopePatterns)) : changedPaths;
|
|
113
|
+
const scopeBasisDigest = contractDigest("host-qa-scope-basis", { ticketId: input.ticket.id, ticket: input.ticket, contractDigest: input.contract.contentDigest, assignmentId });
|
|
114
|
+
const missingBaseline = !assignment || !before || before.digest !== expectedBaseline;
|
|
115
|
+
const status = missingBaseline || protectedPaths.length || deletedPaths.length || unauthorizedPaths.length ? (missingBaseline ? "blocked" : "failed") : "passed";
|
|
116
|
+
const reason = missingBaseline
|
|
117
|
+
? "The admitted Builder assignment baseline was not retained or does not match the contract baseline."
|
|
118
|
+
: protectedPaths.length
|
|
119
|
+
? `Protected paths changed: ${protectedPaths.slice(0, 8).join(", ")}`
|
|
120
|
+
: deletedPaths.length
|
|
121
|
+
? `Product paths were deleted: ${deletedPaths.slice(0, 8).join(", ")}`
|
|
122
|
+
: unauthorizedPaths.length
|
|
123
|
+
? `Changed paths are outside the host-resolved ticket scope: ${unauthorizedPaths.slice(0, 8).join(", ")}`
|
|
124
|
+
: "Host-observed product paths match the retained baseline and host-resolved ticket scope.";
|
|
125
|
+
const result = hostQaComparisonV1Schema.parse({ version: 1, runId: input.runId, ticketId: input.ticket.id, assignmentId, contractDigest: input.contract.contentDigest, contractRevision: input.contract.revision, reviewAttemptId: input.reviewAttemptId, reviewBasisDigest: input.reviewBasisDigest, baselineSourceDigest: expectedBaseline, finalSourceDigest: final.digest, scopeBasisDigest, status, changedPaths, unauthorizedPaths, deletedPaths, protectedPaths, reason, comparedAt: new Date().toISOString() });
|
|
126
|
+
const store = input.db.qaPreparationStore();
|
|
127
|
+
const existing = store.artifacts("host-qa-comparison").find(item => {
|
|
128
|
+
const candidate = hostQaComparisonV1Schema.safeParse(item.value);
|
|
129
|
+
if (!candidate.success)
|
|
130
|
+
return false;
|
|
131
|
+
const prior = candidate.data;
|
|
132
|
+
return prior.runId === result.runId && prior.ticketId === result.ticketId && prior.assignmentId === result.assignmentId
|
|
133
|
+
&& prior.contractDigest === result.contractDigest && prior.contractRevision === result.contractRevision
|
|
134
|
+
&& prior.reviewAttemptId === result.reviewAttemptId && prior.reviewBasisDigest === result.reviewBasisDigest
|
|
135
|
+
&& prior.baselineSourceDigest === result.baselineSourceDigest && prior.finalSourceDigest === result.finalSourceDigest
|
|
136
|
+
&& prior.scopeBasisDigest === result.scopeBasisDigest && prior.status === result.status
|
|
137
|
+
&& JSON.stringify(prior.changedPaths) === JSON.stringify(result.changedPaths)
|
|
138
|
+
&& JSON.stringify(prior.unauthorizedPaths) === JSON.stringify(result.unauthorizedPaths)
|
|
139
|
+
&& JSON.stringify(prior.deletedPaths) === JSON.stringify(result.deletedPaths)
|
|
140
|
+
&& JSON.stringify(prior.protectedPaths) === JSON.stringify(result.protectedPaths)
|
|
141
|
+
&& prior.reason === result.reason;
|
|
142
|
+
});
|
|
143
|
+
const resultDigest = existing?.digest ?? store.putArtifact("host-qa-comparison", result);
|
|
144
|
+
return { result: existing ? hostQaComparisonV1Schema.parse(existing.value) : result, resultDigest, checkId: check.id };
|
|
145
|
+
}
|
|
146
|
+
export function applyHostQaComparison(coverage, contract, result, resultDigest, projectDir) {
|
|
147
|
+
const parsed = hostQaComparisonV1Schema.parse(result);
|
|
148
|
+
if (parsed.contractDigest !== contract.contentDigest || parsed.contractRevision !== contract.revision || parsed.runId !== contract.runId || parsed.ticketId !== contract.workId || parsed.finalSourceDigest !== coverage.sourceDigest || parsed.reviewAttemptId !== coverage.attemptId || parsed.reviewBasisDigest !== coverage.inputBasisDigest)
|
|
149
|
+
throw new Error("Host QA comparison is not bound to the current contract, review, or source");
|
|
150
|
+
const checks = findHostQaChecks(contract);
|
|
151
|
+
if (!checks.length)
|
|
152
|
+
throw new Error("Host QA comparison contract check is missing");
|
|
153
|
+
const copy = structuredClone(coverage);
|
|
154
|
+
for (const check of checks) {
|
|
155
|
+
const row = {
|
|
156
|
+
checkId: check.id,
|
|
157
|
+
applicability: "applicable",
|
|
158
|
+
predicateEvidence: ["Rafi host-owned scope comparison executed against the retained baseline."],
|
|
159
|
+
outcome: parsed.status === "passed" ? "passed" : parsed.status === "failed" ? "failed" : "blocked",
|
|
160
|
+
evidence: [resultEvidence(resultDigest, projectDir, parsed.finalSourceDigest, parsed.status, parsed.reason)],
|
|
161
|
+
};
|
|
162
|
+
const index = copy.checks.findIndex(item => item.checkId === check.id);
|
|
163
|
+
if (index < 0)
|
|
164
|
+
copy.checks.push(row);
|
|
165
|
+
else
|
|
166
|
+
copy.checks[index] = row;
|
|
167
|
+
}
|
|
168
|
+
const documentationCheck = findHostDocumentationCheck(contract);
|
|
169
|
+
if (documentationCheck && contract.documentation?.required === false) {
|
|
170
|
+
const documentationRow = {
|
|
171
|
+
checkId: documentationCheck.id,
|
|
172
|
+
applicability: "applicable",
|
|
173
|
+
predicateEvidence: ["The admitted contract explicitly sets documentation.required to false."],
|
|
174
|
+
outcome: "passed",
|
|
175
|
+
evidence: [{
|
|
176
|
+
procedure: "Rafi host-owned evaluation of the admitted documentation policy",
|
|
177
|
+
cwd: projectDir,
|
|
178
|
+
runtime: "Rafi host",
|
|
179
|
+
sourceDigest: parsed.finalSourceDigest,
|
|
180
|
+
reference: `host-qa-documentation:${contract.contentDigest}`,
|
|
181
|
+
}],
|
|
182
|
+
};
|
|
183
|
+
const documentationIndex = copy.checks.findIndex(item => item.checkId === documentationCheck.id);
|
|
184
|
+
if (documentationIndex < 0)
|
|
185
|
+
copy.checks.push(documentationRow);
|
|
186
|
+
else
|
|
187
|
+
copy.checks[documentationIndex] = documentationRow;
|
|
188
|
+
}
|
|
189
|
+
return copy;
|
|
190
|
+
}
|
|
191
|
+
export function hostComparisonDigestFromCoverage(coverage, checkId) {
|
|
192
|
+
const reference = coverage.checks.find(row => row.checkId === checkId)?.evidence.find(item => item.reference.startsWith("host-qa-comparison:"))?.reference;
|
|
193
|
+
const value = reference?.slice("host-qa-comparison:".length);
|
|
194
|
+
return value && /^[a-f0-9]{64}$/.test(value) ? value : undefined;
|
|
195
|
+
}
|
package/dist/observability.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { ConfigurableAgentRole, DiagnosticSourceResultV1, ManagerRunSummaryV1, RunCurrentStateV1, RunRollupV1, RunSpanV1 } from "rafi-spec";
|
|
2
2
|
export declare const OBSERVABILITY_DB_FILE = ".rafi/observability.sqlite3";
|
|
3
3
|
export declare const OBSERVABILITY_SCHEMA_VERSION: 2;
|
|
4
|
+
export type ObservabilityMode = "off" | "summary" | "trace";
|
|
4
5
|
export interface ObservabilityConfig {
|
|
5
6
|
enabled: boolean;
|
|
7
|
+
mode: ObservabilityMode;
|
|
6
8
|
sample_interval_seconds: number;
|
|
7
9
|
detail_retention_days: number;
|
|
8
10
|
log_retention_days: number;
|
package/dist/observability.js
CHANGED
|
@@ -10,6 +10,7 @@ export const OBSERVABILITY_DB_FILE = ".rafi/observability.sqlite3";
|
|
|
10
10
|
export const OBSERVABILITY_SCHEMA_VERSION = 2;
|
|
11
11
|
export const DEFAULT_OBSERVABILITY_CONFIG = {
|
|
12
12
|
enabled: true,
|
|
13
|
+
mode: "summary",
|
|
13
14
|
sample_interval_seconds: 60,
|
|
14
15
|
detail_retention_days: 30,
|
|
15
16
|
log_retention_days: 30,
|
|
@@ -49,7 +50,7 @@ export class ObservabilityStore {
|
|
|
49
50
|
this.db.pragma("foreign_keys = ON");
|
|
50
51
|
this.migrate();
|
|
51
52
|
this.backfillBuildSnapshots();
|
|
52
|
-
this.summaryOnly = this.meta("summary_only") === "1";
|
|
53
|
+
this.summaryOnly = this.config.mode === "summary" || this.meta("summary_only") === "1";
|
|
53
54
|
}
|
|
54
55
|
close() { if (this.db.open)
|
|
55
56
|
this.db.close(); }
|
|
@@ -65,6 +65,17 @@ export class PermissionPolicy {
|
|
|
65
65
|
if (hitEscalate) {
|
|
66
66
|
return { decision: "escalate", reason: `command matches "${hitEscalate}"` };
|
|
67
67
|
}
|
|
68
|
+
// Permit only the bounded exit-status wrapper needed to retain explicit
|
|
69
|
+
// evidence for an already allow-listed project test. The base command is
|
|
70
|
+
// still parsed by the restricted grammar; this does not grant arbitrary
|
|
71
|
+
// shell sequencing, expansion, or output redirection.
|
|
72
|
+
const exitCapture = /^\s*(.+?)\s+2>&1\s*;\s*echo\s+(?:"EXIT_CODE=\$\?"|'EXIT_CODE=\$\?'|EXIT_CODE=\$\?)\s*$/.exec(command);
|
|
73
|
+
if (exitCapture) {
|
|
74
|
+
const reason = classifySupportedShellForm(exitCapture[1], this.cwd, executionCwd, this.config.allowBash);
|
|
75
|
+
return reason
|
|
76
|
+
? { decision: "escalate", reason: `exit-code capture base command rejected: ${reason}` }
|
|
77
|
+
: { decision: "allow", reason: "bounded exit-code capture for an allowed project test" };
|
|
78
|
+
}
|
|
68
79
|
const entry = process.env.RAFI_REHEARSAL_CLI_ENTRY;
|
|
69
80
|
const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
70
81
|
const launchingRehearsal = entry && isAbsolute(entry) && !/[\r\n\0]/.test(entry)
|
package/dist/qaBuildGate.js
CHANGED
|
@@ -14,7 +14,7 @@ import { WorkflowDb } from "./workflowDb.js";
|
|
|
14
14
|
import { createRoleBuilder, readOnlyPermissionConfig } from "./agentRun.js";
|
|
15
15
|
import { captureBuildSource, assertBuildAssignmentReconciled } from "./buildAssignment.js";
|
|
16
16
|
import { createDisposableQaSnapshotAsync } from "./qaSnapshot.js";
|
|
17
|
-
import { allocateStableCheckIds, authoritativeInventory, bindUniqueBaselineCheckIds, contractDigest, mapSnapshotWorkingDirectories, normalizeSnapshotPaths, renderVerificationContract } from "./qaVerificationContract.js";
|
|
17
|
+
import { allocateStableCheckIds, authoritativeInventory, bindUniqueBaselineCheckIds, contractDigest, mapSnapshotWorkingDirectories, normalizeSnapshotPaths, renderVerificationContract, rewriteProviderHostScopeChecks } from "./qaVerificationContract.js";
|
|
18
18
|
import { resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
|
|
19
19
|
import { prepareVerificationContract } from "./qaPreparation.js";
|
|
20
20
|
import { qaHostGraphFact } from "./qaHostFacts.js";
|
|
@@ -129,7 +129,7 @@ async function ensureBuilderContractWithProvenance(projectDir, runId, workId, wo
|
|
|
129
129
|
provider: builder.agent,
|
|
130
130
|
store, now: Date.now, hostFacts: `${qaHostGraphFact(projectDir)}\n${deliveryFact}\nHost-owned baseline fact: Rafi captured the complete preimplementation per-file path manifest and will perform the exact unchanged-file and scope comparison. Its digest is ${preimplementationPathManifestDigest}. Do not require the provider to receive or reproduce the raw manifest; cite the retained host-preimplementation-source observation and assess the host comparison result instead. Do not author a provider-executed check for Rafi's host postimplementation path manifest, complete changed-path inventory, or unchanged-file comparison; Rafi performs that comparison outside the provider snapshot. Ordinary approved product paths, exports, and tests are not a material QA concern merely because the provider cannot prove whether they were present before implementation; Rafi owns that comparison. Do not reconstruct or disclose the full baseline. For current-branch additive tickets, a provider-authored or inferred request to test the branch with an already-completed sibling feature removed is not an applicable requirement when the approved scope says not to remove or reconstruct that sibling; verify the ticket's own behavior and tests against the shared baseline instead. Treat sibling-absence testing as binding only when the user explicitly approved that absence requirement. Escalate only an actual host-observed out-of-scope, destructive, protected-file, or requirement-relevant change.`,
|
|
131
131
|
normalizeDraft: (draft, response) => {
|
|
132
|
-
const normalized = bindUniqueBaselineCheckIds(allocateStableCheckIds(draft));
|
|
132
|
+
const normalized = rewriteProviderHostScopeChecks(bindUniqueBaselineCheckIds(allocateStableCheckIds(draft)), hostBaseline.id);
|
|
133
133
|
if (!normalized.baseline.some(observation => observation.id === hostBaseline.id))
|
|
134
134
|
normalized.baseline.unshift(structuredClone(hostBaseline));
|
|
135
135
|
for (const check of normalized.checks)
|
|
@@ -21,7 +21,7 @@ async function collectBuilderContractCoverageOwned(projectDir, runId, workId, wo
|
|
|
21
21
|
const session = actualContractSession(adapter, workspace, projectDir);
|
|
22
22
|
const contract = assertContractReceipt(store, runId, workId, admission.requirementsDigest, session, adapter);
|
|
23
23
|
const source = captureBuildSource(workspace);
|
|
24
|
-
|
|
24
|
+
let binding = { phase: "builder", sourceDigest: source.digest, inputBasisDigest: contractDigest("builder-input-basis", { operationId, instruction, contractDigest: contract.contentDigest }), attemptId: operationId, sessionId: session.sessionId };
|
|
25
25
|
const eventId = `builder-coverage:${operationId}`;
|
|
26
26
|
const existing = db.operation(eventId);
|
|
27
27
|
if (existing) {
|
|
@@ -44,8 +44,17 @@ async function collectBuilderContractCoverageOwned(projectDir, runId, workId, wo
|
|
|
44
44
|
db.updateOperation(eventId, "uncertain", { result: { responseDigest } });
|
|
45
45
|
if (response.isError || response.failure)
|
|
46
46
|
throw new Error("Builder coverage provider failed; retained response requires reconciliation");
|
|
47
|
-
|
|
47
|
+
const afterSession = actualContractSession(adapter, workspace, projectDir);
|
|
48
|
+
const sameScopedSession = afterSession.provider === session.provider
|
|
49
|
+
&& afterSession.workspace === session.workspace
|
|
50
|
+
&& afterSession.configRoot === session.configRoot
|
|
51
|
+
&& (afterSession.sessionId === session.sessionId || afterSession.compactionSequence > session.compactionSequence);
|
|
52
|
+
if (!sameScopedSession || captureBuildSource(workspace).digest !== source.digest)
|
|
48
53
|
throw new Error("Builder coverage changed session or product source");
|
|
54
|
+
// A provider may compact while producing this response-only evidence.
|
|
55
|
+
// Bind the retained coverage to the host-observed post-compaction session
|
|
56
|
+
// while continuing to reject unscoped session changes and source edits.
|
|
57
|
+
binding = { ...binding, sessionId: afterSession.sessionId };
|
|
49
58
|
const coverage = bindCoverageHostFields(parseContractCoverage(response.text), contract, binding);
|
|
50
59
|
bindHostOwnedBuilderFacts(db, coverage, contract, hostObservations, initialSourceDigest, source.digest, workId, projectDir);
|
|
51
60
|
const errors = validateContractCoverage(contract, coverage, binding, false);
|
|
@@ -2,7 +2,7 @@ import { Ajv } from "ajv";
|
|
|
2
2
|
import { qaContractCoverageV1Schema } from "rafi-spec";
|
|
3
3
|
const coverageSchema = new Ajv({ allErrors: true }).compile(qaContractCoverageV1Schema);
|
|
4
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. The exact schema is ${JSON.stringify(qaContractCoverageV1Schema)}. Put every binding field at the TOP LEVEL, not inside a binding object, and copy the host-supplied binding values exactly. For each check use checkId (not id), applicability as applicable/not-applicable/unresolved, predicateEvidence as an array of strings, outcome as passed/failed/blocked/not-run, and evidence as an array of objects with procedure, cwd, runtime, sourceDigest and reference. For postimplementation evidence sourceDigest is the exact host-supplied sourceDigest, not an individual file hash. Unconditional checks cannot be marked not-applicable or unresolved; for an absence or non-impact check, mark it applicable and provide bounded inspection evidence showing that the absent change or impact was assessed. For independent QA coverage of an applicable passed preimplementation-only check, inspect its matching passed contract.baseline observation and set at least one evidence reference to that observation's exact evidenceDigest; the historical observation is bound to its original source, not the current source. Assess its relevance rather than inventing or claiming a new baseline. Before emitting qa_pass, run every applicable mandatory check that is not backed by a passed retained baseline; do not leave such a check as not-run merely because an earlier turn omitted it. If an exact required command is blocked by the provider shell policy, use a semantically equivalent permitted read-only procedure, record the substitution and limitation truthfully, and assess the same predicate; do not mark the check blocked merely because the literal command syntax was denied. If a required command is permitted and read-only, run it now and retain its bounded result. Do not add unsupported fields. Include every check with truthful applicability and concrete 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.`;
|
|
5
|
+
export const COVERAGE_INSTRUCTIONS = `Return RAFI_QA_COVERAGE_START, one QaContractCoverageV1 JSON object, RAFI_QA_COVERAGE_END before the final status marker. The exact schema is ${JSON.stringify(qaContractCoverageV1Schema)}. Put every binding field at the TOP LEVEL, not inside a binding object, and copy the host-supplied binding values exactly. For each check use checkId (not id), applicability as applicable/not-applicable/unresolved, predicateEvidence as an array of strings, outcome as passed/failed/blocked/not-run, and evidence as an array of objects with procedure, cwd, runtime, sourceDigest and reference. For postimplementation evidence sourceDigest is the exact host-supplied sourceDigest, not an individual file hash. Unconditional checks cannot be marked not-applicable or unresolved; for an absence or non-impact check, mark it applicable and provide bounded inspection evidence showing that the absent change or impact was assessed. For independent QA coverage of an applicable passed preimplementation-only check, inspect its matching passed contract.baseline observation and set at least one evidence reference to that observation's exact evidenceDigest; the historical observation is bound to its original source, not the current source. Assess its relevance rather than inventing or claiming a new baseline. Host-owned checks whose applicability decisionOwner is host are the exception to provider execution: include the check truthfully as applicable/not-run with evidence that the host-owned comparison is pending, and do not block or fail QA over it; Rafi will execute the comparison and replace that row before accepting the pass. Before emitting qa_pass, run every other applicable mandatory check that is not backed by a passed retained baseline; do not leave such a provider-owned check as not-run merely because an earlier turn omitted it. If an exact required command is blocked by the provider shell policy, use a semantically equivalent permitted read-only procedure, record the substitution and limitation truthfully, and assess the same predicate; do not mark the check blocked merely because the literal command syntax was denied. If a required command is permitted and read-only, run it now and retain its bounded result. Do not add unsupported fields. Include every check with truthful applicability and concrete evidence. Missing, blocked, not-run, unresolved applicability, or failed provider-owned mandatory coverage cannot authorize normal completion. Builder coverage is a claim; final QA independently verifies it.`;
|
|
6
6
|
// The source and attempt binding do not exist until the implementation turn has finished.
|
|
7
7
|
export const BUILDER_WORK_COVERAGE_INSTRUCTIONS = `Carry out the applicable contract checks and report your work and evidence honestly in your normal final response. When a check requires historical ordering, retain bounded output for each required step and an explicit ordering record while performing it; a later passing test cannot prove an earlier failing step. Do not emit RAFI_QA_COVERAGE JSON during this implementation turn or stop for coverage binding values. After the implementation turn, Rafi will capture the completed source and request a separate response-only coverage record with the exact host-owned bindings.`;
|
|
8
8
|
export function parseContractCoverage(text) { return parsePreparationArtifact(text, "RAFI_QA_COVERAGE"); }
|
|
@@ -140,6 +140,10 @@ export declare class QaPreparationStore {
|
|
|
140
140
|
extendBudget(budget: LogicalPreparationBudget, decisionId: string): void;
|
|
141
141
|
putArtifact(kind: string, value: unknown): string;
|
|
142
142
|
artifact<T>(digest: string, kind: string): T;
|
|
143
|
+
artifacts(kind: string): Array<{
|
|
144
|
+
digest: string;
|
|
145
|
+
value: unknown;
|
|
146
|
+
}>;
|
|
143
147
|
head(runId: string, workId: string, admissionDigest: string): {
|
|
144
148
|
digest?: string;
|
|
145
149
|
generation: number;
|
|
@@ -324,6 +324,14 @@ export class QaPreparationStore {
|
|
|
324
324
|
throw new Error("Corrupt preparation artifact");
|
|
325
325
|
return value;
|
|
326
326
|
}
|
|
327
|
+
artifacts(kind) {
|
|
328
|
+
return this.db.prepare("SELECT digest,record_json FROM qa_contract_artifacts WHERE kind=? ORDER BY digest").all(kind).map(row => {
|
|
329
|
+
const value = this.decode(row.record_json);
|
|
330
|
+
if (contractDigest(`artifact-${kind}`, value) !== row.digest)
|
|
331
|
+
throw new Error("Corrupt preparation artifact");
|
|
332
|
+
return { digest: row.digest, value };
|
|
333
|
+
});
|
|
334
|
+
}
|
|
327
335
|
head(runId, workId, admissionDigest) {
|
|
328
336
|
const row = this.db.prepare("SELECT digest,generation,state,detail FROM qa_contract_heads WHERE run_id=? AND work_id=? AND admission_digest=?").get(runId, workId, admissionDigest);
|
|
329
337
|
return row ? { ...row, digest: row.digest ?? undefined, detail: row.detail && this.projectDir && !graphDerivedAllowed(this.db, this.projectDir, bytesDigest(row.detail)) ? "Graph-derived preparation detail withheld; source-based recovery required" : row.detail ?? undefined } : { generation: 0, state: "preparation-required" };
|
package/dist/qaReview.js
CHANGED
|
@@ -26,6 +26,7 @@ import { canonicalSessionPath } from "./sessionIdentity.js";
|
|
|
26
26
|
import { loadProjectAutonomyConfig, resolveAutonomyPolicy } from "./recoveryPolicy.js";
|
|
27
27
|
import { appendQaRecoveryAttempt, appendQaRecoveryResource, appendQaRecoveryReviewedState, compareQaRecoveryReviewedState, createQaRecoveryPacket, ensureQaRecoveryExcluded, loadQaRecoveryPacket, materializeQaRecoveryContext, qaRecoveryInventory, qaReportDigest, renderQaRecoveryAcknowledgementInstruction, updateQaRecoveryPosition, updateQaRecoveryReviewIdentity, validateManualQaReport, validateQaRecoveryAcknowledgement, } from "./qaRecovery.js";
|
|
28
28
|
import { canonicalJson, createQaFindingRefs, qaDigest } from "./qaProtocolV2.js";
|
|
29
|
+
import { applyHostQaComparison, createHostQaComparison, findHostQaCheck } from "./hostQaComparison.js";
|
|
29
30
|
function ensureProtocolPausePacket(opts, head, stage, detail, frozenState) {
|
|
30
31
|
const db = new WorkflowDb(opts.recovery.projectDir);
|
|
31
32
|
try {
|
|
@@ -1653,11 +1654,26 @@ function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle,
|
|
|
1653
1654
|
const expected = contract.inputs.find(input => input.kind === "checklist");
|
|
1654
1655
|
if (expected?.digest !== contractDigest("input-checklist", checklist))
|
|
1655
1656
|
throw new Error("Final QA checklist differs from delivered contract");
|
|
1657
|
+
const needsHistoricalEvidence = contract.checks.some(check => /red|test.?first|ordering|historical/i.test(JSON.stringify(check)));
|
|
1656
1658
|
const builderClaims = db.operations(opts.recovery.runId).flatMap(operation => {
|
|
1657
1659
|
const intent = operation.intent;
|
|
1658
1660
|
const result = operation.result;
|
|
1659
1661
|
return operation.kind === "builder-coverage" && operation.status === "confirmed" && intent.workId === opts.ticket.id && intent.contractDigest === contract.contentDigest && result?.binding?.sourceDigest === source.digest && result.coverageDigest
|
|
1660
|
-
? [{
|
|
1662
|
+
? [{
|
|
1663
|
+
operationId: operation.idempotencyKey,
|
|
1664
|
+
digest: result.coverageDigest,
|
|
1665
|
+
coverage: preparationStore.artifact(result.coverageDigest, "builder-coverage"),
|
|
1666
|
+
...(needsHistoricalEvidence && result.implementationDigest ? {
|
|
1667
|
+
// The response is already host-retained by finishBuildAssignment.
|
|
1668
|
+
// Expose only this bounded, contract-relevant excerpt to the
|
|
1669
|
+
// independent reviewer; do not retain or disclose all Builder
|
|
1670
|
+
// output globally.
|
|
1671
|
+
implementationResponse: {
|
|
1672
|
+
digest: result.implementationDigest,
|
|
1673
|
+
response: db.getEvidence(result.implementationDigest)?.toString() ?? "unavailable",
|
|
1674
|
+
},
|
|
1675
|
+
} : {}),
|
|
1676
|
+
}] : [];
|
|
1661
1677
|
});
|
|
1662
1678
|
const commandObservations = retainedQaCommandObservations(opts.recovery.projectDir, opts.recovery.runId, opts.ticket.id, qaCommandOutputExcerptsAllowed(opts.ticket.id));
|
|
1663
1679
|
instruction += `\n${qaHostGraphFact(opts.recovery.projectDir)}\n`;
|
|
@@ -1990,7 +2006,13 @@ function finishV2Pass(opts, adapter, detail) {
|
|
|
1990
2006
|
throw new Error("Final coverage requires the current ready contract");
|
|
1991
2007
|
const contract = db.qaPreparationStore().contract(contractHead.digest);
|
|
1992
2008
|
const coverageBinding = { phase: "qa", sourceDigest: binding.sourceStateDigest, inputBasisDigest: binding.reviewBasisDigest, attemptId: binding.reviewAttemptId, sessionId: binding.sessionRef.sessionId };
|
|
1993
|
-
const
|
|
2009
|
+
const providerCoverage = bindCoverageHostFields(parseContractCoverage(binding.lastResponse ?? ""), contract, coverageBinding);
|
|
2010
|
+
const hostComparison = findHostQaCheck(contract)
|
|
2011
|
+
? createHostQaComparison({ db, projectDir: binding.projectDir, worktree: opts.builderWorktree, runId: binding.runId, ticket: opts.ticket, contract, reviewAttemptId: binding.reviewAttemptId, reviewBasisDigest: binding.reviewBasisDigest })
|
|
2012
|
+
: undefined;
|
|
2013
|
+
const coverage = hostComparison
|
|
2014
|
+
? applyHostQaComparison(providerCoverage, contract, hostComparison.result, hostComparison.resultDigest, binding.projectDir)
|
|
2015
|
+
: providerCoverage;
|
|
1994
2016
|
const errors = validateContractCoverage(contract, coverage, coverageBinding);
|
|
1995
2017
|
if (errors.length)
|
|
1996
2018
|
throw new Error(`Final QA coverage incomplete: ${errors.join("; ")}`);
|
|
@@ -10,6 +10,13 @@ export declare function allocateStableCheckIds(candidate: QaContractCandidateV1)
|
|
|
10
10
|
export declare function inventoryDigest(requirements: RequirementRef[]): string;
|
|
11
11
|
/** Derive a missing baseline binding only from an explicit, unique check prerequisite reference. */
|
|
12
12
|
export declare function bindUniqueBaselineCheckIds(candidate: QaContractCandidateV1): QaContractCandidateV1;
|
|
13
|
+
/**
|
|
14
|
+
* The host owns the before/after path comparison. Providers may still draft a
|
|
15
|
+
* check that asks them to enumerate every changed file, which turns host
|
|
16
|
+
* bookkeeping into a Builder obligation. Replace only that explicit shape;
|
|
17
|
+
* ordinary product-scope checks remain provider-authored and unchanged.
|
|
18
|
+
*/
|
|
19
|
+
export declare function rewriteProviderHostScopeChecks(candidate: QaContractCandidateV1, baselineId: string): QaContractCandidateV1;
|
|
13
20
|
export declare function authoritativeInventory(ticket: TicketDef, checklist: string[], inputs: ContractInputRef[]): RequirementRef[];
|
|
14
21
|
export declare function parsePreparationArtifact<T>(text: string, tag: string, maximum?: number): T;
|
|
15
22
|
/** Capture safe absolute snapshot paths before the disposable snapshot is removed. */
|
|
@@ -122,6 +122,46 @@ export function bindUniqueBaselineCheckIds(candidate) {
|
|
|
122
122
|
}
|
|
123
123
|
return draft;
|
|
124
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* The host owns the before/after path comparison. Providers may still draft a
|
|
127
|
+
* check that asks them to enumerate every changed file, which turns host
|
|
128
|
+
* bookkeeping into a Builder obligation. Replace only that explicit shape;
|
|
129
|
+
* ordinary product-scope checks remain provider-authored and unchanged.
|
|
130
|
+
*/
|
|
131
|
+
export function rewriteProviderHostScopeChecks(candidate, baselineId) {
|
|
132
|
+
const draft = structuredClone(candidate);
|
|
133
|
+
const hostScope = (check) => {
|
|
134
|
+
const text = [check.expectedBehavior, ...check.expectedEvidence, ...check.verification.flatMap(method => [method.expectedOutcome, ...(method.steps ?? []), ...(method.argv ?? [])])].join(" ").toLowerCase();
|
|
135
|
+
return /\b(?:complete|full|all|every)\s+(?:changed|modified|touched)[ -](?:path|paths|file|files)\b/.test(text)
|
|
136
|
+
|| /\b(?:changed|modified|touched)[ -](?:path|paths|file|files)\s+(?:inventory|list|manifest)\b/.test(text)
|
|
137
|
+
|| /\bunchanged[- ]files?\s+(?:comparison|check|verification)\b/.test(text)
|
|
138
|
+
|| /\bpath\s+manifest\b.{0,80}\b(?:compare|comparison|unchanged|scope)\b/.test(text);
|
|
139
|
+
};
|
|
140
|
+
const matches = draft.checks.filter(hostScope);
|
|
141
|
+
if (!matches.length)
|
|
142
|
+
return draft;
|
|
143
|
+
const primary = matches[0];
|
|
144
|
+
const mergedRequirements = [...new Set(matches.flatMap(check => check.requirementRefs))];
|
|
145
|
+
const mergedPrerequisites = [...new Set([...primary.prerequisiteRefs, baselineId])];
|
|
146
|
+
primary.requirementRefs = mergedRequirements;
|
|
147
|
+
primary.origin = "invariant";
|
|
148
|
+
primary.expectedBehavior = "Rafi compares the final product paths with the host-captured preimplementation baseline and rejects unauthorized product changes.";
|
|
149
|
+
primary.obligation = "mandatory";
|
|
150
|
+
primary.timing = "postimplementation";
|
|
151
|
+
primary.applicability = { kind: "unconditional", decisionOwner: "host", requiredEvidence: [baselineId] };
|
|
152
|
+
primary.verification = [{ kind: "procedure", steps: ["Rafi performs the host-owned before/after product-path comparison, excluding approved bookkeeping and bounded evidence paths."], cwd: ".", fixtures: [], runtime: "Rafi host", timeoutMs: 30_000, expectedOutcome: "Only approved product changes are present; unauthorized, destructive, protected, or requirement-relevant changes are rejected." }];
|
|
153
|
+
primary.expectedEvidence = ["Rafi host-owned postimplementation scope comparison bound to the retained preimplementation baseline."];
|
|
154
|
+
primary.prerequisiteRefs = mergedPrerequisites;
|
|
155
|
+
const removed = new Set(matches.slice(1).map(check => check.id));
|
|
156
|
+
draft.checks = draft.checks.filter(check => !removed.has(check.id));
|
|
157
|
+
for (const row of draft.coverage)
|
|
158
|
+
row.checkIds = row.checkIds.map(id => removed.has(id) ? primary.id : id).filter((id, index, ids) => ids.indexOf(id) === index);
|
|
159
|
+
for (const check of draft.checks)
|
|
160
|
+
check.dependsOnChecks = check.dependsOnChecks.map(id => removed.has(id) ? primary.id : id).filter((id, index, ids) => ids.indexOf(id) === index);
|
|
161
|
+
for (const concern of draft.unresolved)
|
|
162
|
+
concern.checkIds = concern.checkIds.map(id => removed.has(id) ? primary.id : id).filter((id, index, ids) => ids.indexOf(id) === index);
|
|
163
|
+
return draft;
|
|
164
|
+
}
|
|
125
165
|
export function authoritativeInventory(ticket, checklist, inputs) {
|
|
126
166
|
const result = [];
|
|
127
167
|
for (const [kind, entries] of [["acceptance", ticket.acceptance], ["required_tests", ticket.required_tests], ["checklist", checklist]]) {
|
package/dist/supervisedStart.js
CHANGED
|
@@ -322,6 +322,27 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
322
322
|
const stopTerm = () => { stopRequestSource = "parent-SIGTERM"; supervisor.requestStop(); };
|
|
323
323
|
process.once("SIGINT", stopInt);
|
|
324
324
|
process.once("SIGTERM", stopTerm);
|
|
325
|
+
let supervisorCompleted = false;
|
|
326
|
+
const recordUnexpectedSupervisorExit = (code) => {
|
|
327
|
+
if (supervisorCompleted)
|
|
328
|
+
return;
|
|
329
|
+
try {
|
|
330
|
+
const state = db.supervisorState(runId);
|
|
331
|
+
const run = db.getRun(runId);
|
|
332
|
+
if (!state || state.pid !== process.pid || state.status !== "running" || !run || ["completed", "cancelled", "superseded"].includes(run.status))
|
|
333
|
+
return;
|
|
334
|
+
db.putSupervisorState(runId, { ...state, status: "failed", pid: undefined, workerPid: undefined, heartbeatAt: new Date().toISOString() });
|
|
335
|
+
db.transitionSupervisor(runId, state.workerGeneration, {
|
|
336
|
+
status: "paused",
|
|
337
|
+
checkpoint: "supervisor-process-exit",
|
|
338
|
+
event: "supervisor_process_exit",
|
|
339
|
+
payload: { exitCode: code, lastCheckpoint: run.checkpoint, generation: state.generation },
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
catch { /* Exit handlers must not mask the original process termination. */ }
|
|
343
|
+
};
|
|
344
|
+
const onProcessExit = (code) => recordUnexpectedSupervisorExit(code);
|
|
345
|
+
process.once("exit", onProcessExit);
|
|
325
346
|
try {
|
|
326
347
|
const outcome = await supervisor.run();
|
|
327
348
|
if (outcome.kind === "failed") {
|
|
@@ -332,8 +353,10 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
332
353
|
exitCode ||= 2;
|
|
333
354
|
}
|
|
334
355
|
finally {
|
|
356
|
+
supervisorCompleted = true;
|
|
335
357
|
process.off("SIGINT", stopInt);
|
|
336
358
|
process.off("SIGTERM", stopTerm);
|
|
359
|
+
process.off("exit", onProcessExit);
|
|
337
360
|
db.close();
|
|
338
361
|
}
|
|
339
362
|
process.exitCode = exitCode;
|
package/dist/ticketPopulation.js
CHANGED
|
@@ -17,6 +17,8 @@ function additiveAbsenceRequirement(text) {
|
|
|
17
17
|
return [
|
|
18
18
|
/\bslice alone\b/i,
|
|
19
19
|
/\bwithout\b.*\b(?:present|implemented|enabled)\b/i,
|
|
20
|
+
/\bwithout\b.*\btest\/\S+/i,
|
|
21
|
+
/\bwithout\b.*\bthe\s+\S+\s+export\b/i,
|
|
20
22
|
/\bonly\b.*\bown\b.*\btest files?\b/i,
|
|
21
23
|
/\bonly\b.*\btest files?\b.*\bpresent\b/i,
|
|
22
24
|
].some((pattern) => pattern.test(text));
|
package/dist/workflowDb.js
CHANGED
|
@@ -3,6 +3,7 @@ import { canonicalJson } from "./qaProtocolV2.js";
|
|
|
3
3
|
import { resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
|
|
4
4
|
import { registerGraphDerived, graphDerivedAllowed, currentGraphDerivedAccess } from "./graph/derived.js";
|
|
5
5
|
import { bindCoverageHostFields, parseContractCoverage, validateContractCoverage } from "./qaContractCoverage.js";
|
|
6
|
+
import { applyHostQaComparison, findHostQaCheck, hostComparisonDigestFromCoverage, hostQaComparisonV1Schema } from "./hostQaComparison.js";
|
|
6
7
|
import { QaPreparationStore, migrateQaPreparation, migrateQaPreparationGuards } from "./qaPreparationStore.js";
|
|
7
8
|
import { GraphStore, migrateGraphStore } from "./graph/storage.js";
|
|
8
9
|
import { reconcileWork } from "./buildWorkReconciliation.js";
|
|
@@ -2099,7 +2100,7 @@ export class WorkflowDb {
|
|
|
2099
2100
|
const receipts = this.db.prepare("SELECT receipt_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND source_state_digest=? AND review_basis_digest=? AND status='completed'").all(input.runId, input.ticketId, input.sourceStateDigest, input.reviewBasisDigest);
|
|
2100
2101
|
const receipt = receipts.map(row => parseJson(row.receipt_json)).find(row => qaDigest("turn-receipt", row) === input.turnReceiptDigest && row.terminalEventObserved);
|
|
2101
2102
|
const response = receipt?.cleanedResponseDigest ? this.getEvidence(receipt.cleanedResponseDigest) : undefined;
|
|
2102
|
-
const
|
|
2103
|
+
const retainedProviderCoverage = receipt && response
|
|
2103
2104
|
? bindCoverageHostFields(parseContractCoverage(response.toString()), contract, {
|
|
2104
2105
|
phase: "qa",
|
|
2105
2106
|
sourceDigest: input.sourceStateDigest,
|
|
@@ -2108,8 +2109,19 @@ export class WorkflowDb {
|
|
|
2108
2109
|
sessionId: binding.sessionId,
|
|
2109
2110
|
})
|
|
2110
2111
|
: undefined;
|
|
2112
|
+
let retainedCoverage = retainedProviderCoverage;
|
|
2113
|
+
const hostCheck = findHostQaCheck(contract);
|
|
2114
|
+
if (hostCheck) {
|
|
2115
|
+
const hostDigest = hostComparisonDigestFromCoverage(coverage, hostCheck.id);
|
|
2116
|
+
if (!hostDigest)
|
|
2117
|
+
throw new Error("Coverage is missing the host-owned comparison artifact reference");
|
|
2118
|
+
const hostResult = hostQaComparisonV1Schema.parse(store.artifact(hostDigest, "host-qa-comparison"));
|
|
2119
|
+
retainedCoverage = retainedProviderCoverage
|
|
2120
|
+
? applyHostQaComparison(retainedProviderCoverage, contract, hostResult, hostDigest, this.projectDir)
|
|
2121
|
+
: undefined;
|
|
2122
|
+
}
|
|
2111
2123
|
if (!receipt || !retainedCoverage || canonicalJson(retainedCoverage) !== canonicalJson(coverage))
|
|
2112
|
-
throw new Error("Coverage is not bound to the retained terminal review receipt");
|
|
2124
|
+
throw new Error("Coverage is not bound to the retained terminal review receipt and authorized host augmentation");
|
|
2113
2125
|
}
|
|
2114
2126
|
issueQaPassCertificate(input, now = new Date()) {
|
|
2115
2127
|
this.assertContractCertificate(input);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-foreman",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.28",
|
|
4
4
|
"description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -311,8 +311,8 @@
|
|
|
311
311
|
"luxon": "^3.7.2",
|
|
312
312
|
"parse5": "7.2.1",
|
|
313
313
|
"pdfjs-dist": "4.10.38",
|
|
314
|
-
"rafi-spec": "0.8.
|
|
315
|
-
"special-agents": "0.8.
|
|
314
|
+
"rafi-spec": "0.8.17",
|
|
315
|
+
"special-agents": "0.8.17",
|
|
316
316
|
"yaml": "^2.5.1",
|
|
317
317
|
"zod": "4.4.3"
|
|
318
318
|
},
|