ai-foreman 1.7.28 → 1.7.29

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.
@@ -749,7 +749,7 @@ export class ClaudeAdapter {
749
749
  const outputDigest = createHash("sha256").update(text).digest("hex");
750
750
  // Persist only correlation metadata. The result text can contain source
751
751
  // paths, command output, or credentials and must not enter lifecycle logs.
752
- this.opts.onLifecycleTrace?.({ phase: "provider-tool-result", at: new Date().toISOString(), sessionId: this._sessionId, providerTurnId: this.activeProviderTurnId, toolCallId: callId, toolName: call?.name ?? "unidentified tool", classification: status, resultReason, isError: failed, outputDigest, rawOutputBytes: Buffer.byteLength(text) });
752
+ this.opts.onLifecycleTrace?.({ phase: "provider-tool-result", at: new Date().toISOString(), sessionId: this._sessionId, providerTurnId: this.activeProviderTurnId, toolCallId: callId, toolName: call?.name ?? "unidentified tool", classification: status, resultReason, isError: failed, outputDigest, rawOutputBytes: Buffer.byteLength(text), completionKnown: status !== "unknown" });
753
753
  if (status === "unknown") {
754
754
  if (this.unknownToolResults.get(callId) === outputDigest)
755
755
  continue;
@@ -287,6 +287,7 @@ export interface BuilderAdapterOptions {
287
287
  isError?: boolean;
288
288
  outputDigest?: string;
289
289
  rawOutputBytes?: number;
290
+ completionKnown?: boolean;
290
291
  }) => void;
291
292
  }
292
293
  export interface BuilderAdapter {
@@ -7,5 +7,20 @@ export declare function approvedBuildScope(projectDir: string, ticketIds?: reado
7
7
  export declare function requiresBuildApproval(projectDir: string, ticketIds: readonly string[] | undefined, explicitYes: boolean, consequences?: Record<string, unknown>): boolean;
8
8
  /** Bind decisions to current files so an old answer cannot approve a changed scope. */
9
9
  export declare function buildScopeRevision(projectDir: string, consequences?: Record<string, unknown>): string;
10
+ export interface PlanApprovalBinding {
11
+ planDigest: string;
12
+ ticketSetDigest: string;
13
+ ticketDefinitionsDigest: string;
14
+ scopeRevision: string;
15
+ workflowConsequenceDigest: string;
16
+ }
17
+ /** Compute the exact host-owned inputs covered by a recovery plan approval. */
18
+ export declare function planApprovalBinding(projectDir: string, ticketIds: readonly string[], consequences?: Record<string, unknown>): PlanApprovalBinding;
19
+ export declare function validatePlanApprovalBinding(projectDir: string, ticketIds: readonly string[], approval: PlanApprovalBinding, consequences?: Record<string, unknown>): {
20
+ valid: true;
21
+ } | {
22
+ valid: false;
23
+ reason: string;
24
+ };
10
25
  /** Reuse a decision only while its source and consequences remain unchanged. */
11
26
  export declare function createBuildApprovalGate(projectDir: string, ticketIds: readonly string[] | undefined, explicitYes: boolean, consequences: Record<string, unknown> | undefined, requestApproval: () => Promise<void>): () => Promise<void>;
@@ -125,6 +125,49 @@ export function buildScopeRevision(projectDir, consequences) {
125
125
  });
126
126
  return createHash("sha256").update(JSON.stringify([contents, consequences])).digest("hex");
127
127
  }
128
+ /** Compute the exact host-owned inputs covered by a recovery plan approval. */
129
+ export function planApprovalBinding(projectDir, ticketIds, consequences) {
130
+ const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
131
+ const all = loadTickets(paths.tickets);
132
+ const selected = ticketIds.map(id => all.find(ticket => ticket.id === id));
133
+ if (selected.some(ticket => !ticket))
134
+ throw new Error("plan approval cannot bind a missing ticket definition");
135
+ const config = parse(readFileSync(join(projectDir, "rafi-config.yaml"), "utf8"));
136
+ const docsRoot = config.docs?.root ?? "docs";
137
+ const plan = JSON.parse(readFileSync(join(projectDir, docsRoot, "rafi-plan.json"), "utf8"));
138
+ const planDigest = createHash("sha256").update(canonical({ ...plan, content_digest: "" })).digest("hex");
139
+ const db = new WorkflowDb(projectDir);
140
+ try {
141
+ const approved = db.completedPlanApprovals().some(receipt => {
142
+ const decision = receipt.decisionReceipt;
143
+ return receipt.planId === plan.plan_id && receipt.revision === plan.revision
144
+ && receipt.digest === planDigest && decision?.planDigest === planDigest;
145
+ });
146
+ if (!approved)
147
+ throw new Error("current plan has no matching completed host approval");
148
+ }
149
+ finally {
150
+ db.close();
151
+ }
152
+ const ticketSetDigest = createHash("sha256").update(canonical([...ticketIds].sort())).digest("hex");
153
+ const ticketDefinitionsDigest = createHash("sha256").update(canonical(selected.map(ticket => material(ticket)).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))))).digest("hex");
154
+ const workflow = { build: config.tickets?.build ?? {}, consequences: consequences ?? {} };
155
+ const workflowConsequenceDigest = createHash("sha256").update(canonical(workflow)).digest("hex");
156
+ return { planDigest, ticketSetDigest, ticketDefinitionsDigest, scopeRevision: buildScopeRevision(projectDir, consequences), workflowConsequenceDigest };
157
+ }
158
+ export function validatePlanApprovalBinding(projectDir, ticketIds, approval, consequences) {
159
+ try {
160
+ const current = planApprovalBinding(projectDir, ticketIds, consequences);
161
+ for (const key of ["planDigest", "ticketSetDigest", "ticketDefinitionsDigest", "scopeRevision", "workflowConsequenceDigest"]) {
162
+ if (current[key] !== approval[key])
163
+ return { valid: false, reason: `${key} changed` };
164
+ }
165
+ return { valid: true };
166
+ }
167
+ catch (error) {
168
+ return { valid: false, reason: `approval inputs unavailable: ${String(error)}` };
169
+ }
170
+ }
128
171
  /** Reuse a decision only while its source and consequences remain unchanged. */
129
172
  export function createBuildApprovalGate(projectDir, ticketIds, explicitYes, consequences, requestApproval) {
130
173
  let approvedRevision;
package/dist/cli/start.js CHANGED
@@ -11,7 +11,7 @@ import { captureFrozenQaSource } from "../qaSnapshot.js";
11
11
  import { finishStartAdmission, superviseStart, supervisedRunId } from "../supervisedStart.js";
12
12
  import { assertBuildAssignmentReconciled, isInterruptedBuilderRecovery, assertInterruptedSourceSnapshot } from "../buildAssignment.js";
13
13
  import { durableHumanDecision, HumanDecisionRequired, HumanDecisionCancelled, servicePendingHumanDecisions } from "../humanDecision.js";
14
- import { requiresBuildApproval, buildScopeRevision, createBuildApprovalGate } from "../buildApproval.js";
14
+ import { requiresBuildApproval, buildScopeRevision, createBuildApprovalGate, validatePlanApprovalBinding } from "../buildApproval.js";
15
15
  import { Command, Option } from "commander";
16
16
  import { accent, error, sanitizeTerminalText, success, warning } from "../terminalStyle.js";
17
17
  import { resolve, join, relative } from "node:path";
@@ -693,6 +693,18 @@ export function buildStartCommand(options = {}) {
693
693
  ? recoveryRecord.recoveryDecision?.planUpdateApproval === "auto"
694
694
  && (recoveryRecord.recoveryDecision.planUpdateApprovalSource === "explicit-yes" || recoveryRecord.recoveryDecision.planUpdateApprovalSource === "explicit-choice")
695
695
  : Boolean(opts.yes);
696
+ const recoveryTickets = recoveryRecord?.recoveryDecision?.executionTickets ?? recoveryRecord?.tickets ?? [];
697
+ // Empty-scope recovery is used by established/supervisor lifecycle
698
+ // recovery fixtures and has no ticket plan to bind. Ticket-bearing
699
+ // recovery remains fail-closed on an exact current-plan approval.
700
+ if (recoveryRecord && autoApprovePlanUpdates && recoveryTickets.length > 0) {
701
+ const decision = recoveryRecord.recoveryDecision;
702
+ if (!decision?.planApproval)
703
+ fail("saved --yes approval has no exact plan binding; rerun rafi build:resume --yes to approve the current plan");
704
+ const approvalCheck = validatePlanApprovalBinding(cwd, recoveryTickets, decision.planApproval);
705
+ if (!approvalCheck.valid)
706
+ fail(`saved --yes approval is stale (${approvalCheck.reason}); rerun rafi build:resume --yes before provider work`);
707
+ }
696
708
  if (opts.recoveryMode) {
697
709
  const allowed = ["exact-session", "fresh-with-handoff", "fresh-recovery-only", "guided-recovery"];
698
710
  if (!allowed.includes(String(opts.recoveryMode)))
@@ -1,10 +1,17 @@
1
1
  import { Command } from "commander";
2
2
  import type { StructuredPlanV1 } from "rafi-spec";
3
3
  import { Log } from "../log.js";
4
+ import { type StepStatus } from "../foreman.js";
4
5
  import type { EffortLevel } from "../adapters/types.js";
5
6
  import { type RoleBuilderOptions } from "../agentRun.js";
6
7
  import type { TicketsConfig } from "../tickets/config.js";
7
8
  import { type TicketPopulateRuntimeMode, type TicketSourceConfig, type TicketsSetupConfig } from "../tickets/setupConfig.js";
9
+ /**
10
+ * Ticket Maker may append an explanatory note after its machine envelope.
11
+ * Preserve a schema-valid proposal before Foreman's strict generic marker
12
+ * repair path replaces the response with a status-only correction.
13
+ */
14
+ export declare function acceptTicketPopulationCompletion(text: string): StepStatus | undefined;
8
15
  /** Commander preserves comma-delimited values as one argument; accept both documented forms. */
9
16
  export declare function normalizeRiskWaiverTicketIds(values: readonly string[] | undefined): string[];
10
17
  /** Record an explicit, definition-bound exception for existing risk tickets. */
@@ -7,7 +7,7 @@ import { riskNoteRepairProposalV1Schema, zodIssues } from "rafi-spec";
7
7
  import { select, text, confirm, isCancel, multiselect } from "@clack/prompts";
8
8
  import { loadConfig } from "../config.js";
9
9
  import { Log } from "../log.js";
10
- import { Foreman } from "../foreman.js";
10
+ import { Foreman, parseStepStatus } from "../foreman.js";
11
11
  import { printEvents } from "./events.js";
12
12
  import { cmdInit, cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdCancel, cmdDiscover, cmdAcceptFutureWork, cmdReorder, cmdRender, cmdValidate, cmdQueue, cmdStackQueue, cmdArchive, cmdReview, } from "../tickets/commands.js";
13
13
  import { loadDeliveryConfig } from "../tickets/delivery.js";
@@ -35,6 +35,21 @@ let nestedTicketCommandDepth = 0;
35
35
  class TicketInterviewCancelled extends Error {
36
36
  }
37
37
  function cancelTicketInterview() { throw new TicketInterviewCancelled("ticket interview cancelled"); }
38
+ /**
39
+ * Ticket Maker may append an explanatory note after its machine envelope.
40
+ * Preserve a schema-valid proposal before Foreman's strict generic marker
41
+ * repair path replaces the response with a status-only correction.
42
+ */
43
+ export function acceptTicketPopulationCompletion(text) {
44
+ try {
45
+ extractTicketPopulationProposal(text);
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ const status = parseStepStatus(text, { requireFinalLine: false });
51
+ return status.kind === "done" || status.kind === "plan_complete" ? status : undefined;
52
+ }
38
53
  function fail(msg) {
39
54
  if (nestedTicketCommandDepth > 0)
40
55
  throw new Error(msg);
@@ -208,7 +223,7 @@ Output one JSON object between ${TICKET_POPULATION_PROPOSAL_START} and ${TICKET_
208
223
  {"version":1,"plan_id":"approved plan ID","revision":1,"tickets":[{"slice_ref":"slc_low","title":"...","area":"...","priority":"P1","size":"S","risk":"Low","summary":"...","acceptance":["..."],"required_tests":["..."],"likely_files":["..."],"documentation":{"required":false,"targets":[],"criteria":[]},"depends_on":[],"rollback":null,"notes":null},{"slice_ref":"slc_medium","title":"...","area":"...","priority":"P1","size":"S","risk":"Medium","summary":"...","acceptance":["..."],"required_tests":["..."],"likely_files":["..."],"documentation":{"required":false,"targets":[],"criteria":[]},"depends_on":["slc_low"],"rollback":"Disable the feature flag, restore the previous handler, and verify the affected error rate returns to baseline.","notes":null}],"retirements":["exact existing ticket ID"]}
209
224
  Triple-check that every approved slice appears exactly once.
210
225
 
211
- End with exactly one marker line as the final non-empty line:
226
+ End with exactly one marker line as the final non-empty line; do not append notes or commentary after it:
212
227
  STEP_STATUS: done | summary="populated Foreman tickets from existing project ticket sources"
213
228
  or
214
229
  STEP_STATUS: blocked | reason="why ticket population cannot proceed"`;
@@ -1465,7 +1480,7 @@ export async function cmdPopulateCli(opts) {
1465
1480
  if (contextSources.length) {
1466
1481
  console.log(`foreman tickets: context sources ${contextSources.join(" ")}`);
1467
1482
  }
1468
- let turn = await foreman.runInstruction(buildPopulateInstruction(approvedPlan.path, contextSources, ticketsConfig.paths.progressDoc));
1483
+ let turn = await foreman.runInstruction(buildPopulateInstruction(approvedPlan.path, contextSources, ticketsConfig.paths.progressDoc), { completion: acceptTicketPopulationCompletion });
1469
1484
  planningRecord = checkpointInterview(dir, planningRecord, { runtime: { runtime: roleBuilder.runtime, model: roleBuilder.model, sessionId: builder.sessionId() }, decisions: { ...planningRecord.decisions, proposalResponse: turn.result.text } });
1470
1485
  log.write("ticket-populate", {
1471
1486
  statusKind: turn.status.kind,
package/dist/foreman.d.ts CHANGED
@@ -20,11 +20,18 @@ export interface StepStatus {
20
20
  branchDependency?: string;
21
21
  error?: string;
22
22
  }
23
+ /**
24
+ * Workflow-specific completion is opt-in. Callers may preserve a valid
25
+ * structured response before the generic strict STEP_STATUS repair path runs.
26
+ */
27
+ export type InstructionCompletion = (text: string) => StepStatus | undefined;
23
28
  /**
24
29
  * Pull the STEP_STATUS marker out of a turn's final text.
25
30
  * Format: `STEP_STATUS: <kind> | key="value" key="value"`.
26
31
  */
27
- export declare function parseStepStatus(text: string): StepStatus;
32
+ export declare function parseStepStatus(text: string, options?: {
33
+ requireFinalLine?: boolean;
34
+ }): StepStatus;
28
35
  /** Heuristic: did a marker-less turn end by asking the human something? */
29
36
  export declare function looksLikeQuestion(text: string): boolean;
30
37
  export { MARKER_SPEC } from "./markers.js";
@@ -126,7 +133,9 @@ export declare class Foreman {
126
133
  /** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
127
134
  sendPreflightFeedback(feedback: string): Promise<void>;
128
135
  /** Send one custom instruction through the same needs_input loop as a batch turn. */
129
- runInstruction(instruction: string): Promise<{
136
+ runInstruction(instruction: string, options?: {
137
+ completion?: InstructionCompletion;
138
+ }): Promise<{
130
139
  result: TurnResult;
131
140
  status: StepStatus;
132
141
  }>;
package/dist/foreman.js CHANGED
@@ -60,7 +60,7 @@ const QUESTION_HINTS = [
60
60
  * Pull the STEP_STATUS marker out of a turn's final text.
61
61
  * Format: `STEP_STATUS: <kind> | key="value" key="value"`.
62
62
  */
63
- export function parseStepStatus(text) {
63
+ export function parseStepStatus(text, options = {}) {
64
64
  const markerLines = text.split(/\r?\n/).filter((line) => /^\s*STEP_STATUS:/.test(line));
65
65
  const markerCount = markerLines.length;
66
66
  if (markerCount > 1) {
@@ -69,12 +69,14 @@ export function parseStepStatus(text) {
69
69
  const lines = text.trimEnd().split(/\r?\n/).filter((line) => line.trim().length > 0);
70
70
  const last = lines[lines.length - 1] ?? "";
71
71
  if (!last.includes("STEP_STATUS:")) {
72
- if (markerCount === 1) {
72
+ if (markerCount === 1 && options.requireFinalLine !== false) {
73
73
  return { kind: "unknown", error: "STEP_STATUS marker was not the final non-empty line" };
74
74
  }
75
- return { kind: "unknown" };
75
+ if (markerCount === 0)
76
+ return { kind: "unknown" };
76
77
  }
77
- const match = last.match(/^STEP_STATUS:\s*(done|blocked|plan_complete|needs_input|qa_pass|qa_fail)\b\s*(?:\|\s*(.*))?$/i);
78
+ const marker = options.requireFinalLine === false ? markerLines[0]?.trim() ?? "" : last;
79
+ const match = marker.match(/^STEP_STATUS:\s*(done|blocked|plan_complete|needs_input|qa_pass|qa_fail)\b\s*(?:\|\s*(.*))?$/i);
78
80
  if (!match) {
79
81
  return { kind: "unknown", error: "malformed STEP_STATUS marker" };
80
82
  }
@@ -438,12 +440,12 @@ export class Foreman {
438
440
  this.builder = await this.builderFactory(this.projectDir);
439
441
  this.log.write("branch-session", { role: "builder", transition: "missing-session-fresh", continuityLost: true });
440
442
  }
441
- async doTurnWith(adapter, instruction, mode = "builder") {
443
+ async doTurnWith(adapter, instruction, mode = "builder", completion) {
442
444
  return this.projectDir
443
- ? withGraphSessionAccess(this.projectDir, adapter, () => this.doTurnWithOwned(adapter, instruction, mode))
444
- : this.doTurnWithOwned(adapter, instruction, mode);
445
+ ? withGraphSessionAccess(this.projectDir, adapter, () => this.doTurnWithOwned(adapter, instruction, mode, completion))
446
+ : this.doTurnWithOwned(adapter, instruction, mode, completion);
445
447
  }
446
- async doTurnWithOwned(adapter, instruction, mode = "builder") {
448
+ async doTurnWithOwned(adapter, instruction, mode = "builder", completion) {
447
449
  const ticket = mode === "builder" ? this.currentTicketId : undefined;
448
450
  const hostToolObservations = [];
449
451
  const scopedInstruction = (text) => !ticket || text.includes(`Ticket scope: ${ticket}.`) ? text
@@ -498,6 +500,11 @@ export class Foreman {
498
500
  let result = await send(instruction, turnPolicy);
499
501
  await this.observeBuilderNative(adapter);
500
502
  let status = parseStepStatus(result.text);
503
+ if (!result.isError && !result.failure) {
504
+ const completed = completion?.(result.text);
505
+ if (completed)
506
+ return { result, status: completed };
507
+ }
501
508
  if (ticket && validateBuildAssignment(ticket, status))
502
509
  return { result, status };
503
510
  if (result.isError || result.failure)
@@ -626,6 +633,11 @@ export class Foreman {
626
633
  if (result.isError || result.failure)
627
634
  return { result: { ...result, isError: true }, status: { kind: "blocked", reason: result.text } };
628
635
  status = parseStepStatus(result.text);
636
+ if (completion) {
637
+ const completed = completion(result.text);
638
+ if (completed)
639
+ return { result, status: completed };
640
+ }
629
641
  if (ticket && validateBuildAssignment(ticket, status))
630
642
  return { result, status };
631
643
  }
@@ -710,8 +722,8 @@ export class Foreman {
710
722
  this.preflightPlan = result.text;
711
723
  }
712
724
  /** Send one custom instruction through the same needs_input loop as a batch turn. */
713
- async runInstruction(instruction) {
714
- return this.doTurn(instruction);
725
+ async runInstruction(instruction, options = {}) {
726
+ return this.doTurnWith(this.builder, instruction, "builder", options.completion);
715
727
  }
716
728
  /** Ask an already-active role session to propose choices for its reported blocker. */
717
729
  async resolveBlocker(adapter, reason, mode = "builder") {
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ import type { QaContractCoverageV1, QaVerificationContractV1, QaContractCandidateV1 } from "rafi-spec";
3
+ export declare const hostGraphApplicabilityV1Schema: z.ZodObject<{
4
+ version: z.ZodLiteral<1>;
5
+ runId: z.ZodString;
6
+ ticketId: z.ZodString;
7
+ contractDigest: z.ZodString;
8
+ contractRevision: z.ZodNumber;
9
+ reviewAttemptId: z.ZodString;
10
+ reviewBasisDigest: z.ZodString;
11
+ sourceDigest: z.ZodString;
12
+ status: z.ZodEnum<{
13
+ unavailable: "unavailable";
14
+ disabled: "disabled";
15
+ unadopted: "unadopted";
16
+ "adopted-enabled": "adopted-enabled";
17
+ }>;
18
+ reason: z.ZodString;
19
+ observedAt: z.ZodString;
20
+ }, z.core.$strip>;
21
+ export type HostGraphApplicabilityV1 = z.infer<typeof hostGraphApplicabilityV1Schema>;
22
+ export declare function isGraphQaCheck(check: QaVerificationContractV1["checks"][number]): boolean;
23
+ export declare function findGraphQaChecks(contract: QaVerificationContractV1): QaVerificationContractV1["checks"];
24
+ export declare function normalizeGraphQaChecks(candidate: QaContractCandidateV1): QaContractCandidateV1;
25
+ export declare function createHostGraphApplicability(input: {
26
+ projectDir: string;
27
+ runId: string;
28
+ ticketId: string;
29
+ contract: QaVerificationContractV1;
30
+ reviewAttemptId: string;
31
+ reviewBasisDigest: string;
32
+ sourceDigest: string;
33
+ store: {
34
+ artifacts(kind: string): Array<{
35
+ digest: string;
36
+ value: unknown;
37
+ }>;
38
+ putArtifact(kind: string, value: unknown): string;
39
+ };
40
+ }): {
41
+ result: HostGraphApplicabilityV1;
42
+ resultDigest: string;
43
+ };
44
+ export declare function applyHostGraphApplicability(coverage: QaContractCoverageV1, contract: QaVerificationContractV1, result: HostGraphApplicabilityV1, resultDigest: string, projectDir: string): QaContractCoverageV1;
45
+ export declare function hostGraphApplicabilityDigestFromCoverage(coverage: QaContractCoverageV1, checkId: string): string | undefined;
@@ -0,0 +1,99 @@
1
+ import { z } from "zod";
2
+ import { loadGraphConfig } from "./graph/config.js";
3
+ const digest = z.string().regex(/^[a-f0-9]{64}$/);
4
+ export const hostGraphApplicabilityV1Schema = z.object({
5
+ version: z.literal(1),
6
+ runId: z.string().min(1),
7
+ ticketId: z.string().min(1),
8
+ contractDigest: digest,
9
+ contractRevision: z.number().int().positive(),
10
+ reviewAttemptId: z.string().min(1),
11
+ reviewBasisDigest: digest,
12
+ sourceDigest: digest,
13
+ status: z.enum(["adopted-enabled", "disabled", "unadopted", "unavailable"]),
14
+ reason: z.string().max(2048),
15
+ observedAt: z.string().datetime(),
16
+ });
17
+ function graphText(check) {
18
+ return [check.expectedBehavior, ...check.expectedEvidence, ...check.applicability.requiredEvidence,
19
+ ...check.verification.flatMap(method => [method.expectedOutcome, ...(method.steps ?? []), ...(method.argv ?? [])])].join(" ");
20
+ }
21
+ export function isGraphQaCheck(check) {
22
+ return /\bgraphify(?:y)?\b|\brafi[- ]managed graph(?:ify)?\b|\bgraphify adoption\b|\bgraphify[- ](?:receipt|evidence|command)\b/i.test(graphText(check));
23
+ }
24
+ export function findGraphQaChecks(contract) {
25
+ return contract.checks.filter(isGraphQaCheck);
26
+ }
27
+ export function normalizeGraphQaChecks(candidate) {
28
+ const copy = structuredClone(candidate);
29
+ for (const check of copy.checks) {
30
+ if (!isGraphQaCheck(check))
31
+ continue;
32
+ check.origin = "invariant";
33
+ check.obligation = "mandatory";
34
+ check.timing = "postimplementation";
35
+ check.applicability = {
36
+ kind: "conditional",
37
+ predicate: "Rafi-managed Graphify is adopted and enabled for this project",
38
+ decisionOwner: "host",
39
+ requiredEvidence: ["Host-owned Graphify applicability observation"],
40
+ };
41
+ }
42
+ return copy;
43
+ }
44
+ export function createHostGraphApplicability(input) {
45
+ let status = "unavailable";
46
+ let reason = "Rafi-managed Graphify applicability could not be observed; Graphify is optional and direct source inspection remains authoritative.";
47
+ try {
48
+ const graph = loadGraphConfig(input.projectDir);
49
+ status = graph.enabled ? "adopted-enabled" : graph.reason === "disabled" ? "disabled" : "unadopted";
50
+ reason = graph.enabled
51
+ ? "Rafi-managed Graphify is explicitly adopted and enabled."
52
+ : `Rafi-managed Graphify is not required: ${graph.reason}.`;
53
+ }
54
+ catch (error) {
55
+ reason = `Rafi-managed Graphify applicability is unavailable (${error instanceof Error ? error.message.slice(0, 400) : String(error).slice(0, 400)}); direct source inspection remains authoritative.`;
56
+ }
57
+ const result = hostGraphApplicabilityV1Schema.parse({ version: 1, runId: input.runId, ticketId: input.ticketId, contractDigest: input.contract.contentDigest, contractRevision: input.contract.revision, reviewAttemptId: input.reviewAttemptId, reviewBasisDigest: input.reviewBasisDigest, sourceDigest: input.sourceDigest, status, reason, observedAt: new Date().toISOString() });
58
+ const existing = input.store.artifacts("host-graph-applicability").find(item => {
59
+ const parsed = hostGraphApplicabilityV1Schema.safeParse(item.value);
60
+ return parsed.success && JSON.stringify(parsed.data) === JSON.stringify(result);
61
+ });
62
+ return { result, resultDigest: existing?.digest ?? input.store.putArtifact("host-graph-applicability", result) };
63
+ }
64
+ export function applyHostGraphApplicability(coverage, contract, result, resultDigest, projectDir) {
65
+ const parsed = hostGraphApplicabilityV1Schema.parse(result);
66
+ if (parsed.contractDigest !== contract.contentDigest || parsed.contractRevision !== contract.revision || parsed.runId !== contract.runId || parsed.ticketId !== contract.workId || parsed.sourceDigest !== coverage.sourceDigest || parsed.reviewAttemptId !== coverage.attemptId || parsed.reviewBasisDigest !== coverage.inputBasisDigest)
67
+ throw new Error("Host Graphify applicability is not bound to the current contract, review, or source");
68
+ const checks = findGraphQaChecks(contract);
69
+ if (!checks.length)
70
+ return coverage;
71
+ const copy = structuredClone(coverage);
72
+ for (const check of checks) {
73
+ const index = copy.checks.findIndex(row => row.checkId === check.id);
74
+ if (parsed.status !== "adopted-enabled") {
75
+ const row = {
76
+ checkId: check.id,
77
+ applicability: "not-applicable",
78
+ predicateEvidence: [`Host-owned Graphify applicability: ${parsed.status}. ${parsed.reason}`],
79
+ outcome: "passed",
80
+ evidence: [{ procedure: "Rafi host-owned Graphify applicability observation", cwd: projectDir, runtime: "Rafi host", sourceDigest: parsed.sourceDigest, reference: `host-graph-applicability:${resultDigest}` }],
81
+ };
82
+ if (index < 0)
83
+ copy.checks.push(row);
84
+ else
85
+ copy.checks[index] = row;
86
+ }
87
+ else if (index >= 0) {
88
+ copy.checks[index].applicability = "applicable";
89
+ copy.checks[index].predicateEvidence = [...new Set([...(copy.checks[index].predicateEvidence ?? []), "Host-owned Graphify applicability: adopted-enabled."])];
90
+ copy.checks[index].evidence.push({ procedure: "Rafi host-owned Graphify applicability observation", cwd: projectDir, runtime: "Rafi host", sourceDigest: parsed.sourceDigest, reference: `host-graph-applicability:${resultDigest}` });
91
+ }
92
+ }
93
+ return copy;
94
+ }
95
+ export function hostGraphApplicabilityDigestFromCoverage(coverage, checkId) {
96
+ const reference = coverage.checks.find(row => row.checkId === checkId)?.evidence.find(item => item.reference.startsWith("host-graph-applicability:"))?.reference;
97
+ const value = reference?.slice("host-graph-applicability:".length);
98
+ return value && /^[a-f0-9]{64}$/.test(value) ? value : undefined;
99
+ }
@@ -0,0 +1,18 @@
1
+ import type { QaContractCoverageV1, QaVerificationContractV1 } from "rafi-spec";
2
+ import { type HostQaComparisonV1 } from "./hostQaComparison.js";
3
+ import { type HostGraphApplicabilityV1 } from "./hostGraphApplicability.js";
4
+ /** Applies every host-owned coverage decision in one canonical order. */
5
+ export declare function applyHostQaAugmentations(input: {
6
+ coverage: QaContractCoverageV1;
7
+ contract: QaVerificationContractV1;
8
+ projectDir: string;
9
+ hostComparison?: {
10
+ result: HostQaComparisonV1;
11
+ resultDigest: string;
12
+ };
13
+ hostGraph?: {
14
+ result: HostGraphApplicabilityV1;
15
+ resultDigest: string;
16
+ };
17
+ commandObservations?: unknown[];
18
+ }): QaContractCoverageV1;
@@ -0,0 +1,64 @@
1
+ import { applyHostQaComparison } from "./hostQaComparison.js";
2
+ import { applyHostGraphApplicability } from "./hostGraphApplicability.js";
3
+ function commandObservationFor(check, observations) {
4
+ const command = check.verification.find(method => method.kind === "command");
5
+ const argv = command?.argv ?? [];
6
+ const kind = argv[0] === "npm" && argv[1] === "test" ? "npm-test" : argv[0] === "node" && argv[1] === "--test" ? "node-test" : undefined;
7
+ if (!kind)
8
+ return undefined;
9
+ return observations.find(observation => observation.kind === kind && observation.outcome === "completed" && observation.exitCode === 0 && typeof observation.spanId === "string");
10
+ }
11
+ function applyHostCommandObservations(coverage, contract, observations) {
12
+ const parsed = observations.filter((observation) => Boolean(observation && typeof observation === "object"));
13
+ const copy = structuredClone(coverage);
14
+ for (const check of contract.checks) {
15
+ const observation = commandObservationFor(check, parsed);
16
+ if (!observation)
17
+ continue;
18
+ const row = copy.checks.find(item => item.checkId === check.id);
19
+ if (!row || row.outcome === "failed")
20
+ continue;
21
+ row.outcome = "passed";
22
+ row.evidence = [...(row.evidence ?? []), {
23
+ procedure: "Rafi host-retained command observation",
24
+ cwd: ".",
25
+ runtime: "Rafi host",
26
+ sourceDigest: copy.sourceDigest,
27
+ reference: `host-command-span:${observation.spanId}`,
28
+ }];
29
+ }
30
+ return copy;
31
+ }
32
+ function applyBaselineAuthorities(coverage, contract) {
33
+ const copy = structuredClone(coverage);
34
+ for (const check of contract.checks) {
35
+ if (check.timing !== "preimplementation")
36
+ continue;
37
+ const baseline = contract.baseline.find(observation => observation.checkId === check.id && observation.status === "passed");
38
+ const row = copy.checks.find(item => item.checkId === check.id);
39
+ if (!baseline || !row || row.applicability !== "applicable" || row.outcome !== "passed")
40
+ continue;
41
+ if (row.evidence?.some(evidence => evidence.reference === baseline.evidenceDigest))
42
+ continue;
43
+ row.evidence = [...(row.evidence ?? []), {
44
+ procedure: "Rafi host-retained preimplementation baseline authority",
45
+ cwd: ".",
46
+ runtime: "Rafi host",
47
+ sourceDigest: copy.sourceDigest,
48
+ reference: baseline.evidenceDigest,
49
+ }];
50
+ }
51
+ return copy;
52
+ }
53
+ /** Applies every host-owned coverage decision in one canonical order. */
54
+ export function applyHostQaAugmentations(input) {
55
+ let coverage = input.coverage;
56
+ coverage = applyBaselineAuthorities(coverage, input.contract);
57
+ if (input.commandObservations?.length)
58
+ coverage = applyHostCommandObservations(coverage, input.contract, input.commandObservations);
59
+ if (input.hostComparison)
60
+ coverage = applyHostQaComparison(coverage, input.contract, input.hostComparison.result, input.hostComparison.resultDigest, input.projectDir);
61
+ if (input.hostGraph)
62
+ coverage = applyHostGraphApplicability(coverage, input.contract, input.hostGraph.result, input.hostGraph.resultDigest, input.projectDir);
63
+ return coverage;
64
+ }
@@ -49,8 +49,9 @@ function isHostQaCheck(check) {
49
49
  const text = `${check.expectedBehavior} ${check.expectedEvidence.join(" ")} ${check.applicability.requiredEvidence.join(" ")}`;
50
50
  const hostScopeEvidence = check.applicability.requiredEvidence.some(evidence => /host[- ]owned.*scope comparison/i.test(evidence));
51
51
  const scopeSemantics = /scope/i.test(text) && /comparison/i.test(text) && /baseline|protected|approved scope|product changes/i.test(text);
52
+ const changedProductPaths = /changed product paths/i.test(text) && /host[- ]retained|baseline|bookkeeping|evidence exclusions/i.test(text);
52
53
  return !/documentation/i.test(text)
53
- && (hostScopeEvidence || (check.applicability.decisionOwner === "host" && (/Rafi compares the final product paths/i.test(check.expectedBehavior) || scopeSemantics)));
54
+ && (hostScopeEvidence || changedProductPaths || (check.applicability.decisionOwner === "host" && (/Rafi compares the final product paths/i.test(check.expectedBehavior) || scopeSemantics)));
54
55
  }
55
56
  export function findHostQaCheck(contract) {
56
57
  return contract.checks.find(isHostQaCheck);
@@ -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, rewriteProviderHostScopeChecks } from "./qaVerificationContract.js";
17
+ import { allocateStableCheckIds, authoritativeInventory, bindUniqueBaselineCheckIds, contractDigest, ensureHostScopeCheck, 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";
@@ -23,6 +23,7 @@ import { BUILDER_WORK_COVERAGE_INSTRUCTIONS } from "./qaContractCoverage.js";
23
23
  import { renderBuildWorkContext } from "./buildWorkContext.js";
24
24
  import { compareContractInputs } from "./qaContractFreshness.js";
25
25
  import { readBuildRuns } from "./buildRuns.js";
26
+ import { normalizeGraphQaChecks } from "./hostGraphApplicability.js";
26
27
  /** A received response missing only continuity metadata is a bounded format defect. */
27
28
  export function preparationTurnPayload(turn) {
28
29
  const formatErrors = turn.continuityErrors ?? [];
@@ -129,7 +130,7 @@ async function ensureBuilderContractWithProvenance(projectDir, runId, workId, wo
129
130
  provider: builder.agent,
130
131
  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
132
  normalizeDraft: (draft, response) => {
132
- const normalized = rewriteProviderHostScopeChecks(bindUniqueBaselineCheckIds(allocateStableCheckIds(draft)), hostBaseline.id);
133
+ const normalized = allocateStableCheckIds(bindUniqueBaselineCheckIds(rewriteProviderHostScopeChecks(normalizeGraphQaChecks(ensureHostScopeCheck(draft, hostBaseline.id)), hostBaseline.id)));
133
134
  if (!normalized.baseline.some(observation => observation.id === hostBaseline.id))
134
135
  normalized.baseline.unshift(structuredClone(hostBaseline));
135
136
  for (const check of normalized.checks)
@@ -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. 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.`;
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. Graphify is optional evidence: do not install, refresh, scan, or claim Graphify execution unless the host says Rafi-managed Graphify is adopted and enabled. Host-owned Graphify applicability is authoritative; disabled, unadopted, or unavailable Graphify is not a QA blocker, while adopted-enabled Graphify is applicable and cannot be waived by provider output. Other 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. For command exit status, use the host-retained tool event's exitCode and bounded output when available; that is authoritative evidence, and you must not create an echo wrapper, compound command, or other shell scaffold solely to print a numeric status. 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"); }
@@ -20,10 +20,15 @@ export function bindCoverageHostFields(coverage, contract, binding) {
20
20
  // Optional schema fields may be emitted as JSON null by providers. Null is
21
21
  // not a meaningful limitation, so omit only that representation variant;
22
22
  // non-string values elsewhere remain validation errors.
23
- for (const check of copy.checks ?? [])
23
+ for (const check of copy.checks ?? []) {
24
+ // Some providers mirror the applicability token into outcome. Canonicalize
25
+ // that harmless representation mistake at the host boundary.
26
+ if (check.outcome === "not-applicable")
27
+ check.outcome = "not-run";
24
28
  for (const evidence of check.evidence ?? [])
25
29
  if (evidence.limitation === null)
26
30
  delete evidence.limitation;
31
+ }
27
32
  copy.version = 1;
28
33
  copy.runId = contract.runId;
29
34
  copy.workId = contract.workId;
@@ -61,7 +66,11 @@ export function validateContractCoverage(contract, coverage, binding, requirePas
61
66
  issues.push(`Unconditional applicability cannot be waived ${row.checkId}`);
62
67
  if (row.applicability === "not-applicable" && !row.predicateEvidence?.length)
63
68
  issues.push(`Not-applicable requires predicate evidence ${row.checkId}`);
64
- if (check.applicability.kind === "conditional" && row.applicability === "applicable" && !row.predicateEvidence?.length)
69
+ // Host-owned conditional predicates are resolved by the host augmentation
70
+ // after provider coverage is collected (for example Graphify adoption).
71
+ // Requiring provider predicate evidence here would make the host-owned,
72
+ // fail-open path block before the authoritative host observation exists.
73
+ if (check.applicability.kind === "conditional" && check.applicability.decisionOwner !== "host" && row.applicability === "applicable" && !row.predicateEvidence?.length)
65
74
  issues.push(`Conditional applicability requires predicate evidence ${row.checkId}`);
66
75
  if (requirePass && check.obligation === "mandatory" && (row.applicability === "unresolved" || row.applicability === "applicable" && row.outcome !== "passed"))
67
76
  issues.push(`Mandatory check incomplete ${row.checkId}: run its permitted verification now and resubmit passed evidence, or report a truthful blocked result for host recovery`);
@@ -1,5 +1,6 @@
1
1
  import type { ResolvedAgentSettings } from "rafi-spec";
2
2
  export declare const FINAL_QA_ROLE_SUFFIX = "You are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.";
3
+ export declare const QA_COMMAND_DISCIPLINE: string;
3
4
  export declare function finalQaRoleInstructions(rules: string): string;
4
5
  export declare function loadQaPreparationPolicy(projectDir: string): import("special-agents").QaPreparationConfigV1;
5
6
  /** Resolve only explicit rule/skill content; never project secrets or mutable .rafi state. */