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.
@@ -6,7 +6,12 @@ import { loadTicketSetupConfigWithDefaults, loadRafiConfigObject } from "./ticke
6
6
  import { resolveQaPreparationConfig } from "./qaPreparationPolicy.js";
7
7
  import { contractDigest } from "./qaVerificationContract.js";
8
8
  export 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.";
9
- export function finalQaRoleInstructions(rules) { return `${rules}\n\n${FINAL_QA_ROLE_SUFFIX}`; }
9
+ export const QA_COMMAND_DISCIPLINE = [
10
+ "QA command discipline: issue one shell command per Bash call. Prefer the Read tool for source inspection and the configured project test command for verification.",
11
+ "Do not use shell sequencing or scaffolding such as ;, ||, redirects, command substitution, echo exit markers, temporary baseline copies, cp, cmp, or mkdir. Do not reconstruct or compare preimplementation files; Rafi owns baseline, path-scope, unchanged-file, and byte-identity comparisons.",
12
+ "If a command is denied by policy, do not retry the same denied command or wrap it in another compound command. Use a permitted equivalent that tests the same product behavior and record any limitation truthfully.",
13
+ ].join(" ");
14
+ export function finalQaRoleInstructions(rules) { return `${rules}\n\n${QA_COMMAND_DISCIPLINE}\n\n${FINAL_QA_ROLE_SUFFIX}`; }
10
15
  export function loadQaPreparationPolicy(projectDir) {
11
16
  const config = loadRafiConfigObject(projectDir);
12
17
  return resolveQaPreparationConfig(config?.qa_preparation, config?.qa_preparation_shadow === true, config?.qa_preparation_wall_time_ms);
@@ -44,12 +44,26 @@ export function normalizeExpectedFutureAssessment(assessment, candidate) {
44
44
  const copy = structuredClone(assessment);
45
45
  const checks = new Map(candidate.checks.map(check => [check.id, check]));
46
46
  copy.concerns = copy.concerns.map(concern => {
47
+ // A provider can report a stale check identity copied into descriptive
48
+ // prose from an earlier draft. The host has already validated the
49
+ // structured check/coverage/dependency references; when the assessor also
50
+ // says the referenced acceptance item is already covered, this is a
51
+ // non-material labeling defect rather than an unresolved contract gap.
52
+ // Keep the concern in the retained assessment, but do not block Builder.
53
+ const staleCoveredReference = /not in the declared check IDs or its dependsOnChecks/i.test(concern.reason)
54
+ && /reference can be removed|already covered/i.test(`${concern.reason} ${concern.nextAction}`)
55
+ && concern.checkIds.length > 0
56
+ && concern.checkIds.every(id => checks.has(id));
57
+ if (concern.material && staleCoveredReference)
58
+ return { ...concern, material: false };
47
59
  if (!concern.material || !concern.checkIds.length || !concern.checkIds.every(id => checks.get(id)?.timing === "postimplementation"))
48
60
  return concern;
49
61
  const expectedFutureAbsence = /\b(absent|missing|not (?:yet )?(?:implemented|present|available)|could not find|not found|does not exist|fails? with)\b/i.test(concern.reason)
50
62
  && /\b(?:before|pre[- ]?builder|not yet)\b/i.test(concern.reason)
51
63
  && /\b(?:implement|add|create|rerun|re-?run|builder|after implementation)\b/i.test(concern.nextAction);
52
- return expectedFutureAbsence ? { ...concern, material: false } : concern;
64
+ const inventedSiblingAbsence = /\b(?:no|without|absen[ct]|missing)\s+(?:the\s+)?[a-z0-9_-]+\s+export\b|\b(?:sibling|other)\s+(?:feature|slice)\s+(?:absen[ct]|removed|present)\b/i.test(concern.reason)
65
+ && /\b(?:own|imports? only|no [a-z0-9_-]+ symbol|drop|remove)\b/i.test(concern.nextAction);
66
+ return expectedFutureAbsence || inventedSiblingAbsence ? { ...concern, material: false } : concern;
53
67
  });
54
68
  return copy;
55
69
  }
@@ -411,7 +425,7 @@ export async function prepareVerificationContract(initial, inventory, deps) {
411
425
  normalized.assessedRequirementIds = [...fallback.assessedRequirementIds];
412
426
  return normalized;
413
427
  };
414
- let assessmentResponse = retainedAssessment?.draftPayloadDigest === draftDigest(candidate) ? undefined : await run("assess", "assessment", `Independent semantic assessment in a fresh conversation. Assess each original requirement and selected-depth obligation; identify omissions, conflicts, practical verification gaps, accidental scope, and unsupported dispositions. Treat Rafi-owned bookkeeping paths (.tickets, .rafi, generated docs/configuration, and agent instructions) as expected host changes. Scope concerns must be evaluated against approved product paths and ticket requirements; do not require an exact full-worktree status listing. Future Builder actions are not preparation failures: when an approved requirement specifies test-first ordering, a migration, rollback rehearsal, or other implementation-time sequence, represent it as a mandatory postimplementation check requiring retained Builder evidence. Do not mark the absence of that future evidence before Builder starts as a material concern, and do not demand that preparation reproduce historical order. Only report a material concern when the contract cannot state an actionable postimplementation check or when an already-required preimplementation fact is missing. Return RAFI_QA_ASSESSMENT_START/END with QaSemanticAssessmentV1 (schema: ${JSON.stringify(qaPreparationDepthDecisionV1Schema)}). The concerns field is an array of objects, never strings; every concern object must have exactly these six fields and no others: {reason:string, requirementIds:string[], checkIds:string[], material:boolean, owner:string, nextAction:string}. Do not use legacy fields such as severity, evidence, title, message, category, or resolution. Use concerns: [] only when all issues are resolved. assessedRequirementIds MUST contain every ID in this exact list, even when a requirement is host-owned or has no concern: ${JSON.stringify(candidate.requirements.map(requirement => requirement.id))}. Put every individually assessed required obligation label in assessedObligations, using the exact labels below; do not combine labels or claim to have assessed anything you did not assess. Required obligation labels: ${JSON.stringify(depthObligations(candidate.depthDecision.level))}. Bind exactly ${JSON.stringify(binding)}.\nPrior independent concerns requiring concrete resolution evidence: ${JSON.stringify(targetedConcerns)}\nOriginal inventory:\n${JSON.stringify(inventory)}\nDraft:\n${JSON.stringify(candidate)}`);
428
+ let assessmentResponse = retainedAssessment?.draftPayloadDigest === draftDigest(candidate) ? undefined : await run("assess", "assessment", `Independent semantic assessment in a fresh conversation. Assess each original requirement and selected-depth obligation; identify omissions, conflicts, practical verification gaps, accidental scope, and unsupported dispositions. Treat Rafi-owned bookkeeping paths (.tickets, .rafi, generated docs/configuration, and agent instructions) as expected host changes. Scope concerns must be evaluated against approved product paths and ticket requirements; do not require an exact full-worktree status listing. Future Builder actions are not preparation failures: when an approved requirement specifies test-first ordering, a migration, rollback rehearsal, or other implementation-time sequence, represent it as a mandatory postimplementation check requiring retained Builder evidence. Do not mark the absence of that future evidence before Builder starts as a material concern, and do not demand that preparation reproduce historical order. If a concern's only next action is to inspect, compare, run, or otherwise produce evidence for a check that is already declared with postimplementation timing, set that concern material:false; it is an actionable future check, not a preparation blocker. Only report a material concern when the contract cannot state an actionable postimplementation check or when an already-required preimplementation fact is missing. Return RAFI_QA_ASSESSMENT_START/END with QaSemanticAssessmentV1 (schema: ${JSON.stringify(qaPreparationDepthDecisionV1Schema)}). The concerns field is an array of objects, never strings; every concern object must have exactly these six fields and no others: {reason:string, requirementIds:string[], checkIds:string[], material:boolean, owner:string, nextAction:string}. Do not use legacy fields such as severity, evidence, title, message, category, or resolution. Use concerns: [] only when all issues are resolved. assessedRequirementIds MUST contain every ID in this exact list, even when a requirement is host-owned or has no concern: ${JSON.stringify(candidate.requirements.map(requirement => requirement.id))}. Put every individually assessed required obligation label in assessedObligations, using the exact labels below; do not combine labels or claim to have assessed anything you did not assess. Required obligation labels: ${JSON.stringify(depthObligations(candidate.depthDecision.level))}. Bind exactly ${JSON.stringify(binding)}.\nPrior independent concerns requiring concrete resolution evidence: ${JSON.stringify(targetedConcerns)}\nOriginal inventory:\n${JSON.stringify(inventory)}\nDraft:\n${JSON.stringify(candidate)}`);
415
429
  let assessed = assessmentResponse
416
430
  ? await parsedContract(assessmentResponse, "RAFI_QA_ASSESSMENT", validateAssessmentContract, "QA semantic assessment", (value, response) => normalizeAssessment(value, response))
417
431
  : { value: retainedAssessment, response: undefined };
@@ -419,6 +433,11 @@ export async function prepareVerificationContract(initial, inventory, deps) {
419
433
  // session identity, but it cannot prove or safely author the builder's ID.
420
434
  assessed.value.authorSessionId = binding.authorSessionId;
421
435
  for (let repair = 0; assessmentResponse && repair < 3; repair++) {
436
+ // Apply host-owned current-branch applicability normalization before
437
+ // semantic validation. Otherwise an assessor's material sibling-absence
438
+ // concern is rejected before the host can downgrade that non-binding
439
+ // concern to an auditable, non-material observation.
440
+ assessed.value = normalizeExpectedFutureAssessment(assessed.value, candidate);
422
441
  const semanticIssues = validateAssessment(candidate, assessed.value, authorSessionId);
423
442
  if (!semanticIssues.length)
424
443
  break;
@@ -9,7 +9,7 @@ export function migrateQaPreparation(db) {
9
9
  db.exec(`
10
10
  CREATE TABLE IF NOT EXISTS qa_preparation_policy(run_id TEXT PRIMARY KEY REFERENCES workflow_runs(run_id),record_json TEXT NOT NULL);
11
11
  CREATE TABLE IF NOT EXISTS qa_preparation_budgets(id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),work_id TEXT NOT NULL,admission_digest TEXT NOT NULL,record_json TEXT NOT NULL,UNIQUE(run_id,work_id,admission_digest));
12
- CREATE TABLE IF NOT EXISTS qa_depth_decisions(decision_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),work_id TEXT NOT NULL,record_json TEXT NOT NULL);
12
+ CREATE TABLE IF NOT EXISTS qa_depth_decisions(decision_id TEXT NOT NULL,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),work_id TEXT NOT NULL,admission_digest TEXT NOT NULL,record_json TEXT NOT NULL,PRIMARY KEY(decision_id,run_id,work_id,admission_digest));
13
13
  CREATE TABLE IF NOT EXISTS qa_preparation_attempts(operation_id TEXT PRIMARY KEY,budget_id TEXT NOT NULL REFERENCES qa_preparation_budgets(id),state TEXT NOT NULL,intent_json TEXT NOT NULL,result_json TEXT);
14
14
  CREATE TABLE IF NOT EXISTS qa_preparation_progress(budget_id TEXT NOT NULL REFERENCES qa_preparation_budgets(id),stage TEXT NOT NULL,digest TEXT NOT NULL,PRIMARY KEY(budget_id,stage));
15
15
  CREATE TABLE IF NOT EXISTS qa_verification_contracts(digest TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),work_id TEXT NOT NULL,admission_digest TEXT NOT NULL,revision INTEGER NOT NULL,record_json TEXT NOT NULL,UNIQUE(run_id,work_id,admission_digest,revision));
@@ -19,6 +19,7 @@ export function migrateQaPreparation(db) {
19
19
  CREATE TABLE IF NOT EXISTS qa_contract_artifacts(digest TEXT PRIMARY KEY,kind TEXT NOT NULL,record_json TEXT NOT NULL);
20
20
  CREATE TABLE IF NOT EXISTS qa_preparation_events(event_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),work_id TEXT NOT NULL,kind TEXT NOT NULL,record_json TEXT NOT NULL);
21
21
  `);
22
+ migrateQaDepthDecisions(db);
22
23
  for (const table of ["qa_preparation_policy", "qa_depth_decisions", "qa_verification_contracts", "qa_contract_check_history", "qa_contract_receipts", "qa_contract_artifacts", "qa_preparation_events"]) {
23
24
  for (const action of ["UPDATE", "DELETE"])
24
25
  db.exec(`CREATE TRIGGER IF NOT EXISTS preparation_immutable_${table}_${action} BEFORE ${action} ON ${table} BEGIN SELECT RAISE(ABORT,'Preparation authority and evidence are immutable'); END`);
@@ -33,6 +34,35 @@ export function migrateQaPreparation(db) {
33
34
  db.exec(`CREATE TRIGGER IF NOT EXISTS preparation_owner_${table}_${action} BEFORE ${action} ON ${table} BEGIN SELECT CASE WHEN rafi_qa_preparation_owner(${expression})<>1 THEN RAISE(ABORT,'Original preparation owner required') END; END`);
34
35
  }
35
36
  }
37
+ /** Provider decision ids are scoped to the admitted preparation, not global. */
38
+ function migrateQaDepthDecisions(db) {
39
+ const columns = db.prepare("PRAGMA table_info(qa_depth_decisions)").all();
40
+ if (!columns.length || columns.some(column => column.name === "admission_digest"))
41
+ return;
42
+ db.transaction(() => {
43
+ for (const action of ["INSERT", "UPDATE", "DELETE"]) {
44
+ db.exec(`DROP TRIGGER IF EXISTS preparation_immutable_qa_depth_decisions_${action}`);
45
+ db.exec(`DROP TRIGGER IF EXISTS preparation_owner_qa_depth_decisions_${action}`);
46
+ db.exec(`DROP TRIGGER IF EXISTS preparation_lease_qa_depth_decisions_${action}`);
47
+ }
48
+ db.exec("CREATE TABLE qa_depth_decisions_scoped(decision_id TEXT NOT NULL,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),work_id TEXT NOT NULL,admission_digest TEXT NOT NULL,record_json TEXT NOT NULL,PRIMARY KEY(decision_id,run_id,work_id,admission_digest))");
49
+ db.exec("INSERT INTO qa_depth_decisions_scoped(decision_id,run_id,work_id,admission_digest,record_json) SELECT decision_id,run_id,work_id,COALESCE(NULLIF(json_extract(record_json,'$.admissionDigest'),''),'legacy:' || run_id || ':' || work_id),record_json FROM qa_depth_decisions");
50
+ db.exec("DROP TABLE qa_depth_decisions");
51
+ db.exec("ALTER TABLE qa_depth_decisions_scoped RENAME TO qa_depth_decisions");
52
+ for (const action of ["INSERT", "UPDATE", "DELETE"]) {
53
+ const row = action === "DELETE" ? "OLD" : "NEW";
54
+ db.exec(`CREATE TRIGGER preparation_immutable_qa_depth_decisions_${action} BEFORE ${action} ON qa_depth_decisions BEGIN SELECT RAISE(ABORT,'Preparation authority and evidence are immutable'); END`);
55
+ const ownerRow = action === "DELETE" ? "OLD" : "NEW";
56
+ db.exec(`CREATE TRIGGER preparation_owner_qa_depth_decisions_${action} BEFORE ${action} ON qa_depth_decisions BEGIN SELECT CASE WHEN rafi_qa_preparation_owner(${ownerRow}.run_id)<>1 THEN RAISE(ABORT,'Original preparation owner required') END; END`);
57
+ const leaseRun = ownerRow;
58
+ db.exec(`CREATE TRIGGER preparation_lease_qa_depth_decisions_${action} BEFORE ${action} ON qa_depth_decisions
59
+ WHEN (EXISTS(SELECT 1 FROM build_admission) OR rafi_build_writer_run()<>'' OR EXISTS(SELECT 1 FROM build_runtime_runs WHERE run_id=${leaseRun}.run_id))
60
+ AND NOT EXISTS(SELECT 1 FROM build_admission WHERE json_extract(record_json,'$.token')=rafi_build_writer_token() AND (json_extract(record_json,'$.runId')=${leaseRun}.run_id OR EXISTS(SELECT 1 FROM build_child_runs WHERE child=${leaseRun}.run_id AND parent=json_extract(record_json,'$.runId'))))
61
+ AND NOT (NOT EXISTS(SELECT 1 FROM build_admission) AND EXISTS(SELECT 1 FROM project_lease WHERE owner=rafi_build_lease_owner() AND generation=rafi_build_lease_generation() AND run_id=${leaseRun}.run_id))
62
+ BEGIN SELECT RAISE(ABORT,'Preparation requires the original durable lease generation'); END`);
63
+ }
64
+ })();
65
+ }
36
66
  /** Tagged enforcing authority also fences older callers that omit new fields. */
37
67
  export function migrateQaPreparationGuards(db) {
38
68
  const scopes = {
@@ -242,13 +272,11 @@ export class QaPreparationStore {
242
272
  current.level = decision.level;
243
273
  current.deadlineMs = Math.max(current.deadlineMs, current.startMs + current.policy.wallTimeMs[decision.level - 1]);
244
274
  this.saveBudget(current);
245
- const prior = this.read("qa_depth_decisions", "decision_id", decision.decisionId);
246
- const priorScope = this.db.prepare("SELECT run_id,work_id FROM qa_depth_decisions WHERE decision_id=?").get(decision.decisionId);
247
- if (priorScope && (priorScope.run_id !== budget.runId || priorScope.work_id !== budget.workId))
248
- throw new Error("Planner depth identity belongs to another admitted work");
275
+ const priorRow = this.db.prepare("SELECT record_json FROM qa_depth_decisions WHERE decision_id=? AND run_id=? AND work_id=? AND admission_digest=?").get(decision.decisionId, budget.runId, budget.workId, budget.admissionDigest);
276
+ const prior = priorRow ? this.decode(priorRow.record_json) : undefined;
249
277
  if (prior && this.protectedJson(prior) !== this.protectedJson(decision))
250
278
  throw new Error("Conflicting planner depth identity");
251
- this.db.prepare("INSERT INTO qa_depth_decisions VALUES(?,?,?,?) ON CONFLICT(decision_id) DO NOTHING").run(decision.decisionId, budget.runId, budget.workId, this.protectedJson(decision));
279
+ this.db.prepare("INSERT INTO qa_depth_decisions VALUES(?,?,?,?,?) ON CONFLICT(decision_id,run_id,work_id,admission_digest) DO NOTHING").run(decision.decisionId, budget.runId, budget.workId, budget.admissionDigest, this.protectedJson(decision));
252
280
  });
253
281
  }
254
282
  retainProgress(budget, stage, value) {
package/dist/qaReview.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { withGraphDerivedAccess } from "./graph/derived.js";
2
2
  import { dispatchWithGraphAccess } from "./graph/session.js";
3
- import { finalQaRoleInstructions, resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
3
+ import { finalQaRoleInstructions, QA_COMMAND_DISCIPLINE, resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
4
4
  import { COVERAGE_INSTRUCTIONS, bindCoverageHostFields, parseContractCoverage, validateContractCoverage } from "./qaContractCoverage.js";
5
5
  import { contractDigest, renderVerificationContract } from "./qaVerificationContract.js";
6
6
  import { finalReviewContractContext } from "./qaContractDelivery.js";
@@ -26,7 +26,10 @@ 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
+ import { createHostQaComparison, findHostQaCheck } from "./hostQaComparison.js";
30
+ import { createHostGraphApplicability, findGraphQaChecks } from "./hostGraphApplicability.js";
31
+ import { applyHostQaAugmentations } from "./hostQaAugmentation.js";
32
+ import { qaUnresolvedToolDecision, recordQaUnresolvedTools } from "./qaToolRecovery.js";
30
33
  function ensureProtocolPausePacket(opts, head, stage, detail, frozenState) {
31
34
  const db = new WorkflowDb(opts.recovery.projectDir);
32
35
  try {
@@ -946,6 +949,13 @@ async function oneReview(opts, identity) {
946
949
  responseProtocolError = `invalid QA response contract after blocker recovery: ${responseContract.errors.join("; ")}`;
947
950
  }
948
951
  }
952
+ const unresolvedDecision = qaUnresolvedToolDecision(opts.recovery.projectDir, opts.recovery.runId, opts.ticket.id, qaEvents);
953
+ if (unresolvedDecision.action !== "none") {
954
+ recordQaUnresolvedTools(opts.recovery.projectDir, opts.recovery.runId, opts.ticket.id, qaEvents);
955
+ if (unresolvedDecision.action === "retry")
956
+ return retryFreshQaReview(opts, "unresolved-provider-tool", unresolvedDecision.detail, (recoverySnapshot ?? snapshot).frozenState);
957
+ return pauseQaReview(opts, "unresolved-provider-tool", unresolvedDecision.detail, (recoverySnapshot ?? snapshot).frozenState);
958
+ }
949
959
  opts.state.reviews += 1;
950
960
  opts.state.sessionId = qa.sessionId();
951
961
  opts.state.sessionRef = qa.sessionRef?.();
@@ -1155,6 +1165,7 @@ export function buildQaReviewHandoff(ticket, builderSummary, diffDigest, validat
1155
1165
  `Project validation checklist: ${validationChecklist.join("; ")}`,
1156
1166
  `Prior authoritative QA/report and Builder-remediation history: ${history.length ? JSON.stringify(boundedQaHistory(history)) : "(none; perform a complete first review)"}`,
1157
1167
  `Failure report JSON Schema: ${JSON.stringify(qaFailureReportV1Schema)}`,
1168
+ QA_COMMAND_DISCIPLINE,
1158
1169
  `A failing response must use exactly:\n${QA_FAILURE_REPORT_START}\n{...valid QaFailureReportV1 JSON...}\n${QA_FAILURE_REPORT_END}\nSTEP_STATUS: qa_fail | issues="short plain-text synopsis"`,
1159
1170
  "Be very thorough. Don't leave anything out. Make sure all required fields are added. Triple check your work to ensure nothing is left out and all required fields are added and populated correctly.",
1160
1171
  "Do not change source, configuration, documentation, tickets, or control files. Only ignored cache, coverage, and build output is allowed.",
@@ -2010,9 +2021,11 @@ function finishV2Pass(opts, adapter, detail) {
2010
2021
  const hostComparison = findHostQaCheck(contract)
2011
2022
  ? createHostQaComparison({ db, projectDir: binding.projectDir, worktree: opts.builderWorktree, runId: binding.runId, ticket: opts.ticket, contract, reviewAttemptId: binding.reviewAttemptId, reviewBasisDigest: binding.reviewBasisDigest })
2012
2023
  : undefined;
2013
- const coverage = hostComparison
2014
- ? applyHostQaComparison(providerCoverage, contract, hostComparison.result, hostComparison.resultDigest, binding.projectDir)
2015
- : providerCoverage;
2024
+ const hostGraph = findGraphQaChecks(contract).length
2025
+ ? createHostGraphApplicability({ projectDir: binding.projectDir, runId: binding.runId, ticketId: binding.ticketId, contract, reviewAttemptId: binding.reviewAttemptId, reviewBasisDigest: binding.reviewBasisDigest, sourceDigest: binding.sourceStateDigest, store: db.qaPreparationStore() })
2026
+ : undefined;
2027
+ const commandObservations = retainedQaCommandObservations(opts.recovery.projectDir, opts.recovery.runId, opts.ticket.id, false);
2028
+ const coverage = applyHostQaAugmentations({ coverage: providerCoverage, contract, projectDir: binding.projectDir, hostComparison, hostGraph, commandObservations });
2016
2029
  const errors = validateContractCoverage(contract, coverage, coverageBinding);
2017
2030
  if (errors.length)
2018
2031
  throw new Error(`Final QA coverage incomplete: ${errors.join("; ")}`);
@@ -0,0 +1,12 @@
1
+ import type { BuilderEvent } from "./adapters/types.js";
2
+ export type QaUnresolvedToolDecision = {
3
+ action: "none";
4
+ } | {
5
+ action: "retry";
6
+ detail: string;
7
+ } | {
8
+ action: "pause";
9
+ detail: string;
10
+ };
11
+ export declare function recordQaUnresolvedTools(projectDir: string, runId: string, ticketId: string, events: BuilderEvent[]): void;
12
+ export declare function qaUnresolvedToolDecision(projectDir: string, runId: string, ticketId: string, events: BuilderEvent[]): QaUnresolvedToolDecision;
@@ -0,0 +1,44 @@
1
+ import { WorkflowDb } from "./workflowDb.js";
2
+ function unresolvedTools(events) {
3
+ const latest = new Map();
4
+ for (const event of events)
5
+ if (event.kind === "tool" && event.callId && event.lifecycle === "completed")
6
+ latest.set(event.callId, event);
7
+ return [...latest.values()].filter(event => event.completionKnown === false).map(event => ({ callId: event.callId, digest: event.outputDigest, name: event.name }));
8
+ }
9
+ export function recordQaUnresolvedTools(projectDir, runId, ticketId, events) {
10
+ const unresolved = unresolvedTools(events);
11
+ if (!unresolved.length)
12
+ return;
13
+ const db = new WorkflowDb(projectDir);
14
+ try {
15
+ const prior = db.continuityEvents(runId).filter(event => event.kind === "qa-unresolved-tool-result" && event.payload.ticketId === ticketId);
16
+ const known = new Set(prior.map(event => JSON.stringify(event.payload)));
17
+ const head = db.qaTicketHead(runId, ticketId);
18
+ for (const item of unresolved) {
19
+ const payload = { ticketId, callId: item.callId, toolName: item.name, outputDigest: item.digest ?? null };
20
+ if (!known.has(JSON.stringify(payload)))
21
+ db.appendContinuityEvent({ runId, role: "qa", kind: "qa-unresolved-tool-result", payload, authoritativeStateRevision: head.revision });
22
+ }
23
+ }
24
+ finally {
25
+ db.close();
26
+ }
27
+ }
28
+ export function qaUnresolvedToolDecision(projectDir, runId, ticketId, events) {
29
+ const unresolved = unresolvedTools(events);
30
+ if (!unresolved.length)
31
+ return { action: "none" };
32
+ const db = new WorkflowDb(projectDir);
33
+ try {
34
+ const records = db.continuityEvents(runId).filter(event => event.kind === "qa-unresolved-tool-result" && event.payload.ticketId === ticketId);
35
+ const priorKeys = new Set(records.map(event => { const payload = event.payload; return payload.outputDigest ?? payload.toolName; }));
36
+ const repeated = unresolved.find(item => priorKeys.has(item.digest ?? item.name));
37
+ if (repeated)
38
+ return { action: "pause", detail: `QA tool result remains unresolved after one controlled recovery (${repeated.name}, ${repeated.digest ?? repeated.name}); no provider action was replayed` };
39
+ return { action: "retry", detail: `QA observed an unresolved ${unresolved[0].name} result; starting one fresh review without replaying the uncertain tool action` };
40
+ }
41
+ finally {
42
+ db.close();
43
+ }
44
+ }
@@ -10,6 +10,8 @@ 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
+ /** Ensure the host-owned scope invariant survives providers that omit or rename it. */
14
+ export declare function ensureHostScopeCheck(candidate: QaContractCandidateV1, baselineId: string): QaContractCandidateV1;
13
15
  /**
14
16
  * The host owns the before/after path comparison. Providers may still draft a
15
17
  * check that asks them to enumerate every changed file, which turns host
@@ -113,8 +113,12 @@ export function inventoryDigest(requirements) { return contractDigest("qa-requir
113
113
  /** Derive a missing baseline binding only from an explicit, unique check prerequisite reference. */
114
114
  export function bindUniqueBaselineCheckIds(candidate) {
115
115
  const draft = structuredClone(candidate);
116
+ const proposedIds = new Set(draft.checks.map(check => check.id));
116
117
  for (const observation of draft.baseline) {
117
- if (observation.checkId)
118
+ // A correction may copy a stable check ID from an earlier draft. Repair
119
+ // that stale binding from the observation's unique prerequisite instead
120
+ // of sending it through stable-ID allocation as if it were current.
121
+ if (observation.checkId && proposedIds.has(observation.checkId))
118
122
  continue;
119
123
  const matches = draft.checks.filter(check => check.prerequisiteRefs.includes(observation.id));
120
124
  if (matches.length === 1)
@@ -122,6 +126,36 @@ export function bindUniqueBaselineCheckIds(candidate) {
122
126
  }
123
127
  return draft;
124
128
  }
129
+ /** Ensure the host-owned scope invariant survives providers that omit or rename it. */
130
+ export function ensureHostScopeCheck(candidate, baselineId) {
131
+ const copy = structuredClone(candidate);
132
+ const hasHostScope = copy.checks.some(check => check.applicability.decisionOwner === "host"
133
+ && /scope comparison|product paths|preimplementation baseline/i.test(`${check.expectedBehavior} ${check.expectedEvidence.join(" ")} ${check.applicability.requiredEvidence.join(" ")}`));
134
+ if (hasHostScope)
135
+ return copy;
136
+ // Every contract check must trace to at least one authoritative requirement.
137
+ // The host scope invariant is cross-cutting rather than provider-authored, so
138
+ // bind it to the first approved requirement when a provider omitted the
139
+ // invariant entirely. This keeps the invariant structurally valid without
140
+ // inventing a new requirement outside the authoritative inventory.
141
+ const scopeRequirement = copy.requirements[0]?.id;
142
+ if (!scopeRequirement)
143
+ throw new Error("Cannot add host scope check without an authoritative requirement");
144
+ copy.checks.push({
145
+ id: "host-scope",
146
+ requirementRefs: [scopeRequirement],
147
+ origin: "invariant",
148
+ expectedBehavior: "Rafi compares the final product paths with the host-captured preimplementation baseline and rejects unauthorized product changes.",
149
+ obligation: "mandatory",
150
+ timing: "postimplementation",
151
+ applicability: { kind: "unconditional", decisionOwner: "host", requiredEvidence: [baselineId] },
152
+ 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
+ expectedEvidence: ["Rafi host-owned postimplementation scope comparison bound to the retained preimplementation baseline."],
154
+ prerequisiteRefs: [baselineId],
155
+ dependsOnChecks: [],
156
+ });
157
+ return copy;
158
+ }
125
159
  /**
126
160
  * The host owns the before/after path comparison. Providers may still draft a
127
161
  * check that asks them to enumerate every changed file, which turns host
@@ -135,7 +169,8 @@ export function rewriteProviderHostScopeChecks(candidate, baselineId) {
135
169
  return /\b(?:complete|full|all|every)\s+(?:changed|modified|touched)[ -](?:path|paths|file|files)\b/.test(text)
136
170
  || /\b(?:changed|modified|touched)[ -](?:path|paths|file|files)\s+(?:inventory|list|manifest)\b/.test(text)
137
171
  || /\bunchanged[- ]files?\s+(?:comparison|check|verification)\b/.test(text)
138
- || /\bpath\s+manifest\b.{0,80}\b(?:compare|comparison|unchanged|scope)\b/.test(text);
172
+ || /\bpath\s+manifest\b.{0,80}\b(?:compare|comparison|unchanged|scope)\b/.test(text)
173
+ || (check.applicability.decisionOwner === "host" && /scope.{0,80}comparison|comparison.{0,80}scope|product paths?.{0,100}baseline/.test(text));
139
174
  };
140
175
  const matches = draft.checks.filter(hostScope);
141
176
  if (!matches.length)
@@ -1,5 +1,5 @@
1
1
  import { WorkflowDb } from "./workflowDb.js";
2
- import type { ResolvedAutonomyPolicy } from "rafi-spec";
2
+ import { type ResolvedAutonomyPolicy } from "rafi-spec";
3
3
  /** Worker IPC is created by the parent; an environment flag alone cannot bypass supervision. */
4
4
  export declare function supervisedRunId(): string | undefined;
5
5
  /** Both ordinary and branch starts enter through this process boundary. */
@@ -6,6 +6,7 @@ import { join } from "node:path";
6
6
  import { hostname } from "node:os";
7
7
  import { DurableSupervisor } from "./supervisor.js";
8
8
  import { WorkflowDb } from "./workflowDb.js";
9
+ import { stopCause } from "rafi-spec";
9
10
  import { processStartIdentity } from "./processIdentity.js";
10
11
  import { cancelOwnedRuntimeProbes } from "./runtimeReadiness.js";
11
12
  /** Worker IPC is created by the parent; an environment flag alone cannot bypass supervision. */
@@ -51,8 +52,9 @@ export async function superviseStart(projectDir, runId, policy, selection) {
51
52
  const owner = durable.supervisorState(runId);
52
53
  if (owner?.workerGeneration !== Number(process.env.RAFI_BUILD_WORKER_GENERATION) || owner.workerPid !== process.pid)
53
54
  return;
54
- durable.transition(runId, { status: "paused", checkpoint: "orphaned-worker", event: "supervisor_disconnected", payload: { workerPid: process.pid } });
55
- durable.putSupervisorState(runId, { ...owner, status: "stopping" });
55
+ const cause = stopCause({ cause: "worker-disconnect", source: "worker", actor: "worker", runId, supervisorGeneration: owner.generation, workerGeneration: owner.workerGeneration, pid: process.pid, processStart: processStartIdentity(), checkpoint: "orphaned-worker", at: new Date().toISOString(), requested: false, detail: "Worker IPC disconnected before the supervisor acknowledged shutdown" });
56
+ durable.transition(runId, { status: "paused", checkpoint: "orphaned-worker", event: "supervisor_disconnected", payload: { workerPid: process.pid, cause } });
57
+ durable.putSupervisorState(runId, { ...owner, status: "stopping", stopCause: cause });
56
58
  });
57
59
  }
58
60
  catch (error) {
@@ -156,9 +158,10 @@ export async function superviseStart(projectDir, runId, policy, selection) {
156
158
  db.ensureRun(runId, "build");
157
159
  db.freezeAutonomyPolicy(runId, policy);
158
160
  let exitCode = 2;
159
- let stopRequestSource;
161
+ let stopRequestCause;
160
162
  const supervisor = new DurableSupervisor({ projectDir, runId, policy, admission: localBuildAuthority(projectDir),
161
163
  checkpoint: () => db.getRun(runId)?.checkpoint ?? "preparing",
164
+ onStopRequested: cause => { stopRequestCause = cause; },
162
165
  spawnWorker: generation => {
163
166
  let authority = localBuildAuthority(projectDir);
164
167
  if (db.buildAdmission()?.token !== authority.token) {
@@ -212,7 +215,7 @@ export async function superviseStart(projectDir, runId, policy, selection) {
212
215
  };
213
216
  const heartbeatTimeout = policy.runtimeDeadlines?.heartbeat_ms ?? 120_000;
214
217
  const watchdog = setInterval(() => { if (Date.now() - lastHeartbeat > heartbeatTimeout) {
215
- stopRequestSource = "heartbeat-timeout";
218
+ stopRequestCause = stopCause({ cause: "heartbeat-timeout", source: "heartbeat", actor: "supervisor", runId, supervisorGeneration: generation, workerGeneration: generation, checkpoint: db.getRun(runId)?.checkpoint ?? "unknown", at: new Date().toISOString(), requested: true, detail: `worker heartbeat exceeded ${heartbeatTimeout}ms` });
216
219
  stop();
217
220
  } }, Math.min(5_000, Math.max(25, heartbeatTimeout / 4)));
218
221
  watchdog.unref();
@@ -256,8 +259,15 @@ export async function superviseStart(projectDir, runId, policy, selection) {
256
259
  exitCode = code ?? 2;
257
260
  try {
258
261
  const exitState = db.getRun(runId);
262
+ const exitCause = stopRequestCause ?? stopCause({ cause: "worker-exit", source: "worker", actor: "worker", runId, supervisorGeneration: generation, workerGeneration: generation, pid: child.pid, processStart: child.pid ? processStartIdentity(child.pid) : undefined, checkpoint: exitState?.checkpoint ?? "unknown", at: new Date().toISOString(), requested: false, detail: signal ? `worker exited from ${signal}` : `worker exited with code ${code ?? "unknown"}` });
259
263
  if (exitState)
260
- db.transitionSupervisor(runId, generation, { checkpoint: exitState.checkpoint, event: "worker_exit", payload: { code, signal, stopRequested: stopping, source: stopRequestSource ?? "unknown", at: new Date().toISOString(), generation } });
264
+ db.transitionSupervisor(runId, generation, { checkpoint: exitState.checkpoint, event: "worker_exit", payload: {
265
+ code, signal, stopRequested: stopping, cause: exitCause,
266
+ // Preserve the historical diagnostic field while consumers migrate
267
+ // to the closed StopCause contract.
268
+ source: exitCause.cause === "parent-sigterm" ? "parent-SIGTERM" : exitCause.cause === "user-sigint" ? "user-SIGINT" : exitCause.cause === "heartbeat-timeout" ? "heartbeat-timeout" : exitCause.cause,
269
+ at: new Date().toISOString(), generation,
270
+ } });
261
271
  // Durable records are authoritative even when the worker never sent IPC.
262
272
  const deadline = Date.now() + 5000;
263
273
  const owned = db.readinessProcesses(runId).filter(row => {
@@ -286,14 +296,36 @@ export async function superviseStart(projectDir, runId, policy, selection) {
286
296
  const workerQuiescent = await ownedGroupIsQuiescent(child.pid);
287
297
  const preparationReconciled = canRestartPreparation(db, runId);
288
298
  if (!stopping && readinessQuiescent && workerQuiescent && preparationReconciled) {
289
- db.transitionSupervisor(runId, generation, { status: "paused", checkpoint: "preparation-reconciled", event: "worker_preparation_reconciled", payload: { generation, signal, noImplementationAuthority: true } });
299
+ const preparationCause = stopCause({
300
+ cause: "worker-exit", source: "worker", actor: "worker", runId,
301
+ supervisorGeneration: generation, workerGeneration: generation,
302
+ pid: child.pid, processStart: child.pid ? processStartIdentity(child.pid) : undefined,
303
+ checkpoint: "preparation-reconciled", at: new Date().toISOString(), requested: false,
304
+ detail: "Worker exited before acquiring implementation authority; owned process group is quiescent",
305
+ });
306
+ db.transitionSupervisor(runId, generation, { status: "paused", checkpoint: "preparation-reconciled", event: "worker_preparation_reconciled", payload: { generation, signal, noImplementationAuthority: true, cause: preparationCause } });
290
307
  resolve({ kind: "crashed", detail: "Preparation worker exited before acquiring implementation authority; owned process group is quiescent" });
291
308
  return;
292
309
  }
293
310
  // Process death does not establish that remote side effects stopped.
294
311
  // Never replay an unacknowledged provider action automatically.
295
312
  const uncertain = [...db.unresolvedRoleDispatches(runId, "builder"), ...db.unresolvedRoleDispatches(runId, "qa")];
296
- db.transitionSupervisor(runId, generation, { status: "paused", checkpoint: uncertain.length ? "uncertain-worker-dispatch" : "worker-interrupted", event: "worker_reconciliation_required", payload: { generation, signal, uncertainDispatches: uncertain.map(item => item.idempotencyKey), readinessWasActive, readinessQuiescent, workerQuiescent, preparationReconciled, readinessCleanup, readinessOwnershipVerified: [...readinessChildren.values()].every(value => value !== "unavailable") } });
313
+ const reconciliationCause = stopCause({
314
+ ...(stopRequestCause ?? { runId, supervisorGeneration: generation, workerGeneration: generation }),
315
+ cause: "worker-reconciliation-required",
316
+ source: "reconciliation",
317
+ actor: "supervisor",
318
+ runId,
319
+ supervisorGeneration: generation,
320
+ workerGeneration: generation,
321
+ pid: child.pid,
322
+ processStart: child.pid ? processStartIdentity(child.pid) : undefined,
323
+ checkpoint: uncertain.length ? "uncertain-worker-dispatch" : "worker-interrupted",
324
+ at: new Date().toISOString(),
325
+ requested: false,
326
+ detail: "Worker stopped before all owned work had a durable terminal outcome",
327
+ });
328
+ db.transitionSupervisor(runId, generation, { status: "paused", checkpoint: uncertain.length ? "uncertain-worker-dispatch" : "worker-interrupted", event: "worker_reconciliation_required", payload: { generation, signal, cause: reconciliationCause, uncertainDispatches: uncertain.map(item => item.idempotencyKey), readinessWasActive, readinessQuiescent, workerQuiescent, preparationReconciled, readinessCleanup, readinessOwnershipVerified: [...readinessChildren.values()].every(value => value !== "unavailable") } });
297
329
  resolve({ kind: "waiting_for_human" });
298
330
  }
299
331
  else {
@@ -318,8 +350,8 @@ export async function superviseStart(projectDir, runId, policy, selection) {
318
350
  return { pid: child.pid, result, stop };
319
351
  },
320
352
  });
321
- const stopInt = () => { stopRequestSource = "parent-SIGINT"; supervisor.requestStop(); };
322
- const stopTerm = () => { stopRequestSource = "parent-SIGTERM"; supervisor.requestStop(); };
353
+ const stopInt = () => { supervisor.requestStop("user-sigint"); };
354
+ const stopTerm = () => { supervisor.requestStop("parent-sigterm"); };
323
355
  process.once("SIGINT", stopInt);
324
356
  process.once("SIGTERM", stopTerm);
325
357
  let supervisorCompleted = false;
@@ -331,12 +363,13 @@ export async function superviseStart(projectDir, runId, policy, selection) {
331
363
  const run = db.getRun(runId);
332
364
  if (!state || state.pid !== process.pid || state.status !== "running" || !run || ["completed", "cancelled", "superseded"].includes(run.status))
333
365
  return;
334
- db.putSupervisorState(runId, { ...state, status: "failed", pid: undefined, workerPid: undefined, heartbeatAt: new Date().toISOString() });
366
+ const cause = stopCause({ cause: "supervisor-process-exit", source: "supervisor", actor: "supervisor", runId, supervisorGeneration: state.generation, workerGeneration: state.workerGeneration, pid: process.pid, processStart: state.processStart, checkpoint: run.checkpoint, at: new Date().toISOString(), requested: false, detail: `supervisor exited with code ${code}` });
367
+ db.putSupervisorState(runId, { ...state, status: "failed", pid: undefined, workerPid: undefined, heartbeatAt: new Date().toISOString(), stopCause: cause });
335
368
  db.transitionSupervisor(runId, state.workerGeneration, {
336
369
  status: "paused",
337
370
  checkpoint: "supervisor-process-exit",
338
371
  event: "supervisor_process_exit",
339
- payload: { exitCode: code, lastCheckpoint: run.checkpoint, generation: state.generation },
372
+ payload: { exitCode: code, lastCheckpoint: run.checkpoint, generation: state.generation, cause },
340
373
  });
341
374
  }
342
375
  catch { /* Exit handlers must not mask the original process termination. */ }
@@ -1,5 +1,5 @@
1
1
  import type { BuildAdmission } from "./buildAdmission.js";
2
- import type { ResolvedAutonomyPolicy, SupervisorState } from "rafi-spec";
2
+ import { type ResolvedAutonomyPolicy, type StopCause, type StopCauseName, type SupervisorState } from "rafi-spec";
3
3
  export type WorkerOutcome = {
4
4
  kind: "completed";
5
5
  } | {
@@ -18,6 +18,19 @@ export interface SupervisorWorkerHandle {
18
18
  result: Promise<WorkerOutcome>;
19
19
  stop: () => Promise<void> | void;
20
20
  }
21
+ /**
22
+ * Retry only the short synchronous persistence operation. The caller owns
23
+ * the retry budget and remains responsible for deciding whether ownership is
24
+ * safe after the budget is exhausted.
25
+ */
26
+ export declare function persistWithBoundedRetry(persist: () => void, sleep: (milliseconds: number) => Promise<void>, attempts?: number): Promise<{
27
+ ok: true;
28
+ attempts: number;
29
+ } | {
30
+ ok: false;
31
+ attempts: number;
32
+ error: unknown;
33
+ }>;
21
34
  export interface DurableSupervisorOptions {
22
35
  projectDir: string;
23
36
  runId: string;
@@ -27,16 +40,19 @@ export interface DurableSupervisorOptions {
27
40
  checkpoint: () => string;
28
41
  sleep?: (milliseconds: number) => Promise<void>;
29
42
  now?: () => Date;
43
+ onStopRequested?: (cause: StopCause) => void;
44
+ heartbeatIntervalMs?: number;
30
45
  }
31
46
  /** Durable worker restart loop. The supervisor never owns the project's mutation lease. */
32
47
  export declare class DurableSupervisor {
33
48
  private readonly options;
34
49
  private stopRequested;
50
+ private requestedStopCause?;
35
51
  private active?;
36
52
  private readonly sleep;
37
53
  private readonly now;
38
54
  constructor(options: DurableSupervisorOptions);
39
- requestStop(): void;
55
+ requestStop(cause?: StopCauseName): void;
40
56
  run(): Promise<WorkerOutcome>;
41
57
  }
42
58
  export declare function initialSupervisorState(): SupervisorState;