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.
@@ -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 (config.observability.enabled) {
969
+ if (observabilityConfig.enabled) {
952
970
  try {
953
- observabilityStore = new ObservabilityStore(cwd, { config: config.observability });
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, config.observability.detail_retention_days);
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, config.observability.detail_retention_days);
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
- for (const key of keys.filter((key) => key !== "enabled")) {
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
+ }
@@ -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;
@@ -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)
@@ -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
- const binding = { phase: "builder", sourceDigest: source.digest, inputBasisDigest: contractDigest("builder-input-basis", { operationId, instruction, contractDigest: contract.contentDigest }), attemptId: operationId, sessionId: session.sessionId };
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
- if (JSON.stringify(actualContractSession(adapter, workspace, projectDir)) !== JSON.stringify(session) || captureBuildSource(workspace).digest !== source.digest)
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
- ? [{ operationId: operation.idempotencyKey, digest: result.coverageDigest, coverage: preparationStore.artifact(result.coverageDigest, "builder-coverage") }] : [];
1662
+ ? [{
1663
+ operationId: operation.idempotencyKey,
1664
+ digest: result.coverageDigest,
1665
+ coverage: preparationStore.artifact(result.coverageDigest, "builder-coverage"),
1666
+ ...(needsHistoricalEvidence && result.implementationDigest ? {
1667
+ // The response is already host-retained by finishBuildAssignment.
1668
+ // Expose only this bounded, contract-relevant excerpt to the
1669
+ // independent reviewer; do not retain or disclose all Builder
1670
+ // output globally.
1671
+ implementationResponse: {
1672
+ digest: result.implementationDigest,
1673
+ response: db.getEvidence(result.implementationDigest)?.toString() ?? "unavailable",
1674
+ },
1675
+ } : {}),
1676
+ }] : [];
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 coverage = bindCoverageHostFields(parseContractCoverage(binding.lastResponse ?? ""), contract, coverageBinding);
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]]) {
@@ -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;
@@ -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));
@@ -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 retainedCoverage = receipt && response
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.27",
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.16",
315
- "special-agents": "0.8.16",
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
  },