ai-foreman 1.7.26 → 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.
Files changed (98) hide show
  1. package/dist/adapters/codex.d.ts +12 -0
  2. package/dist/adapters/codex.js +50 -1
  3. package/dist/adapters/recovering.d.ts +4 -0
  4. package/dist/adapters/recovering.js +10 -0
  5. package/dist/adapters/sessionFailure.d.ts +6 -0
  6. package/dist/adapters/sessionFailure.js +19 -0
  7. package/dist/adapters/types.d.ts +6 -0
  8. package/dist/branch/currentGuard.d.ts +8 -0
  9. package/dist/branch/currentGuard.js +5 -0
  10. package/dist/branch/runner.js +6 -2
  11. package/dist/buildApproval.js +2 -1
  12. package/dist/buildRuns.d.ts +12 -0
  13. package/dist/buildRuns.js +50 -0
  14. package/dist/buildWorkContext.js +1 -1
  15. package/dist/buildWorkMigration.d.ts +10 -0
  16. package/dist/buildWorkMigration.js +18 -2
  17. package/dist/cli/start.d.ts +10 -1
  18. package/dist/cli/start.js +186 -31
  19. package/dist/cli/state.js +39 -1
  20. package/dist/cli/tickets.d.ts +37 -0
  21. package/dist/cli/tickets.js +367 -12
  22. package/dist/config.js +14 -4
  23. package/dist/continuity.d.ts +1 -0
  24. package/dist/continuity.js +5 -0
  25. package/dist/foreman.js +89 -8
  26. package/dist/graph/preparation.js +6 -3
  27. package/dist/graph/semantic.js +2 -1
  28. package/dist/graph/turn.js +8 -1
  29. package/dist/handoffProtocol.js +2 -1
  30. package/dist/handoffs.js +7 -19
  31. package/dist/hostQaComparison.d.ts +50 -0
  32. package/dist/hostQaComparison.js +195 -0
  33. package/dist/log.d.ts +1 -1
  34. package/dist/managerPacket.js +1 -1
  35. package/dist/observability.d.ts +2 -0
  36. package/dist/observability.js +2 -1
  37. package/dist/permissions/policy.js +32 -0
  38. package/dist/planChanges.js +1 -1
  39. package/dist/projectDiagnostics.js +30 -0
  40. package/dist/providerQuestions.js +4 -24
  41. package/dist/qaBuildGate.d.ts +6 -1
  42. package/dist/qaBuildGate.js +76 -16
  43. package/dist/qaBuilderCoverage.d.ts +2 -1
  44. package/dist/qaBuilderCoverage.js +58 -7
  45. package/dist/qaBuilderEvidence.d.ts +18 -0
  46. package/dist/qaBuilderEvidence.js +60 -0
  47. package/dist/qaContractCoverage.d.ts +12 -1
  48. package/dist/qaContractCoverage.js +34 -2
  49. package/dist/qaContractDelivery.d.ts +2 -2
  50. package/dist/qaContractDelivery.js +88 -10
  51. package/dist/qaEffectiveConfig.js +2 -1
  52. package/dist/qaFailureDelivery.d.ts +23 -0
  53. package/dist/qaFailureDelivery.js +87 -6
  54. package/dist/qaHostFacts.d.ts +2 -0
  55. package/dist/qaHostFacts.js +11 -0
  56. package/dist/qaPreparation.d.ts +38 -1
  57. package/dist/qaPreparation.js +372 -31
  58. package/dist/qaPreparationInputs.js +5 -1
  59. package/dist/qaPreparationPolicy.d.ts +2 -1
  60. package/dist/qaPreparationPolicy.js +39 -9
  61. package/dist/qaPreparationStore.d.ts +4 -0
  62. package/dist/qaPreparationStore.js +8 -0
  63. package/dist/qaRecovery.d.ts +7 -0
  64. package/dist/qaRecovery.js +73 -1
  65. package/dist/qaReview.js +58 -9
  66. package/dist/qaReviewEvidence.d.ts +6 -0
  67. package/dist/qaReviewEvidence.js +37 -0
  68. package/dist/qaRollbackRehearsal.d.ts +2 -0
  69. package/dist/qaRollbackRehearsal.js +78 -0
  70. package/dist/qaSnapshot.d.ts +3 -1
  71. package/dist/qaSnapshot.js +3 -2
  72. package/dist/qaVerificationContract.d.ts +16 -0
  73. package/dist/qaVerificationContract.js +183 -17
  74. package/dist/sources/sourceRegistry.js +16 -5
  75. package/dist/stateTransfer.d.ts +14 -0
  76. package/dist/stateTransfer.js +51 -0
  77. package/dist/statusReporter.d.ts +8 -0
  78. package/dist/statusReporter.js +5 -0
  79. package/dist/supervisedStart.js +23 -0
  80. package/dist/ticketPlanning.d.ts +373 -0
  81. package/dist/ticketPlanning.js +34 -3
  82. package/dist/ticketPopulation.d.ts +28 -3
  83. package/dist/ticketPopulation.js +127 -15
  84. package/dist/tickets/details.d.ts +2 -1
  85. package/dist/tickets/details.js +7 -3
  86. package/dist/tickets/eligibility.d.ts +6 -0
  87. package/dist/tickets/eligibility.js +12 -1
  88. package/dist/tickets/riskWaivers.d.ts +3 -0
  89. package/dist/tickets/riskWaivers.js +12 -0
  90. package/dist/tickets/stateDb.d.ts +10 -0
  91. package/dist/tickets/stateDb.js +23 -0
  92. package/dist/tickets/ticketLoader.d.ts +9 -0
  93. package/dist/tickets/ticketLoader.js +15 -2
  94. package/dist/tickets/ticketSchema.d.ts +14 -1
  95. package/dist/tickets/ticketSchema.js +2 -1
  96. package/dist/tickets/validate.js +11 -1
  97. package/dist/workflowDb.js +24 -3
  98. package/package.json +9 -4
package/dist/cli/start.js CHANGED
@@ -31,8 +31,9 @@ import { loadRoleBundle } from "../roles.js";
31
31
  import { ensureBuildRuntimeReadyForCommand } from "./runtimeAuthPrompt.js";
32
32
  import { resolveAgentForProject } from "./runtimeSelection.js";
33
33
  import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "../tickets/config.js";
34
- import { loadTickets } from "../tickets/ticketLoader.js";
34
+ import { hasRollbackOrMitigationPlan, loadTickets } from "../tickets/ticketLoader.js";
35
35
  import { StateDb } from "../tickets/stateDb.js";
36
+ import { ticketDefinitionDigest } from "../tickets/riskWaivers.js";
36
37
  import { applySharedDeliveryBranch, applySizeBranchPolicy, buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
37
38
  import { currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
38
39
  import { captureDirtyBaseline } from "../branch/dirtyBaseline.js";
@@ -40,7 +41,8 @@ import { formatGitHubFailure, preflightGh } from "../branch/github.js";
40
41
  import { preflightGlab } from "../branch/gitlab.js";
41
42
  import { readDeliveryUnitSession, runBranchPlan } from "../branch/runner.js";
42
43
  import { branchPlanLogMetadata, presentBranchPlan } from "../branch/presentation.js";
43
- import { buildRunSessionBinding, checkpointBuildRun, completeBuildRun, createBuildRun, currentBranchRecoveryTicket, currentBranchTicketProgress, recoveryExecutionTickets, finishRecoveredTicketScope, heartbeatBuildRun, persistBuildSession, persistCurrentBuildSession, projectBuildRecovery, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
44
+ import { branchTicketScopeProgress, buildRunSessionBinding, checkpointBuildRun, completeBuildRun, createBuildRun, currentBranchRecoveryTicket, currentBranchTicketProgress, recoveryExecutionTickets, finishBranchTicketScope, finishRecoveredTicketScope, heartbeatBuildRun, persistBuildSession, persistCurrentBuildSession, projectBuildRecovery, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
45
+ import { parseProviderContract, qaRemediationProposalV1Schema } from "rafi-spec";
44
46
  import { parse as parseYaml } from "yaml";
45
47
  import { findResumableBranchSessions, formatBranchContinueCommand, formatBranchSummaryFollowupCommands, } from "../branch/resume.js";
46
48
  import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig.js";
@@ -51,6 +53,8 @@ import { fireTerminalBell } from "../notify.js";
51
53
  import { WorkflowDb, readCurrentWorkflowLease } from "../workflowDb.js";
52
54
  import { makeLogPath as makeRoleLogPath, readOnlyPermissionConfig, runRoleInstruction } from "../agentRun.js";
53
55
  import { eligibleTickets, evaluateTicketEligibility } from "../tickets/eligibility.js";
56
+ import { globallyEligibleTickets } from "../tickets/eligibility.js";
57
+ import { cmdValidate } from "../tickets/commands.js";
54
58
  import { BUILTIN_BRANCH_PREFIX, validateBranchPrefix } from "../branch/prefix.js";
55
59
  import { captureCurrentWorkflowSessionIdentity, CurrentWorkflowChangedError, CurrentWorkflowGuardAdapter } from "../branch/currentGuard.js";
56
60
  import { ContinuityAdapter, ContinuityRecoveryRequiredError, SessionUnavailableContinuityError } from "../continuity.js";
@@ -61,7 +65,7 @@ import { SessionUnavailableError } from "../adapters/sessionFailure.js";
61
65
  import { resolveProviderSessionAvailability } from "../sessionAvailability.js";
62
66
  import { ObservabilityStore, RunObserver } from "../observability.js";
63
67
  import { loadProjectAutonomyConfig, resolveAutonomyPolicy, resolveQaEnablement } from "../recoveryPolicy.js";
64
- import { loadQaRecoveryPacket, recoverPendingQaRecoveryPublications } from "../qaRecovery.js";
68
+ import { canonicalizeQaRecoveryRequirements, loadQaRecoveryPacket, recoverPendingQaRecoveryPublications } from "../qaRecovery.js";
65
69
  import { qaDigest } from "../qaProtocolV2.js";
66
70
  import { acceptQaRuntimeHandoff, describeQaRuntimeHandle, frozenQaRuntimeSettings } from "../qaRuntime.js";
67
71
  const FOREMAN_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
@@ -79,7 +83,7 @@ function pendingQaRecoveryPacket(projectDir, runId, ticketId) {
79
83
  const selected = ticketId ? pending.find((item) => item.ticketId === ticketId) : pending[0];
80
84
  if (!selected || selected.pendingAction === "resolved")
81
85
  return undefined;
82
- const packet = loadQaRecoveryPacket(selected.packetPath);
86
+ const packet = canonicalizeQaRecoveryRequirements(loadQaRecoveryPacket(selected.packetPath));
83
87
  const state = db.qaRecoveryHead(runId, packet.manifest.ticketId);
84
88
  if (!state || packet.manifest.packetDigest !== state.packetDigest || packet.manifest.runId !== runId
85
89
  || packet.manifest.ticketId !== state.ticketId || packet.manifest.packetId !== state.packetId
@@ -422,6 +426,71 @@ export function validateStartOptions(opts, requireCount) {
422
426
  throw new Error("Preparation launch requires both run and token");
423
427
  if (opts.launchToken && !opts.preparationRun && !opts.recoverRun)
424
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);
443
+ }
444
+ /** A current waiver is an already-recorded decision: surface it, never demand a second start-time approval. */
445
+ function warnRiskPlanWaivers(projectDir, ticketIds) {
446
+ if (!ticketIds.length || !isTicketsInitialized(projectDir))
447
+ return;
448
+ const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
449
+ const definitions = new Map(loadTickets(paths.tickets).map((ticket) => [ticket.id, ticket]));
450
+ const db = new StateDb(paths.stateDb, { readOnly: true });
451
+ try {
452
+ const waived = ticketIds.filter((id) => {
453
+ const ticket = definitions.get(id);
454
+ return ticket && ticket.risk !== "Low" && !hasRollbackOrMitigationPlan(ticket.rollback)
455
+ && Boolean(db.getTicketRiskWaiver(id, ticketDefinitionDigest(ticket)));
456
+ });
457
+ if (waived.length)
458
+ console.warn(`foreman: warning: ${waived.join(", ")} have explicitly waived missing rollback or mitigation plans.`);
459
+ }
460
+ finally {
461
+ db.close();
462
+ }
463
+ }
464
+ /** Preserve the distinction between a staged handoff and an unattempted acceptance. */
465
+ export function recordHandoffAcceptanceNotAttempted(projectDir, runId, role, generation, stage, detail) {
466
+ const db = new WorkflowDb(projectDir);
467
+ try {
468
+ db.appendContinuityEvent({
469
+ runId,
470
+ role: "host",
471
+ kind: "handoff_acceptance_not_attempted",
472
+ payload: { generation, role, stage, detail },
473
+ authoritativeStateRevision: db.continuityHead(runId, role)?.authoritativeStateRevision ?? 0,
474
+ });
475
+ }
476
+ finally {
477
+ db.close();
478
+ }
479
+ }
480
+ /** Default start selection is deliberately the same eligibility model users see in the ticket queue. */
481
+ export function selectDefaultExecutionTickets(tickets, states, implementationLimit, steps, delivery) {
482
+ return globallyEligibleTickets({ tickets, states, implementationLimit, delivery }).slice(0, steps).map((ticket) => ticket.id);
483
+ }
484
+ /** Fail before runtime/provider admission when canonical ticket data is unsafe. */
485
+ export function assertStartTicketPreflight(projectDir) {
486
+ const validation = cmdValidate(projectDir);
487
+ const errors = validation.issues.filter((issue) => issue.severity === "error");
488
+ const warnings = validation.issues.filter((issue) => issue.severity === "warn");
489
+ for (const issue of warnings)
490
+ console.warn(`foreman: warning: ${issue.message}`);
491
+ if (errors.length) {
492
+ throw new Error(`ticket validation must be resolved before starting:\n${errors.map((issue) => `- ${issue.message}`).join("\n")}\nRun \`rafi tickets validate\` after repair to confirm readiness.`);
493
+ }
425
494
  }
426
495
  /** Validate generated established recovery before reserving execution authority. */
427
496
  export function validateRecoveryArguments(args, projectDir) {
@@ -490,6 +559,7 @@ export function buildStartCommand(options = {}) {
490
559
  .option("--base <ref>", "base ref for root ticket branches (default: current branch or HEAD)")
491
560
  .option("--branch-prefix <prefix>", "branch name prefix for ticket branches")
492
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")
493
563
  .option("--hide-session-cost", "hide session cost/token usage for Builder and QA")
494
564
  .option("--auto-compact-threshold <percent>", "initial Builder context compaction threshold (1-99)")
495
565
  .option("--max-branch-depth <n>", "maximum selected branch stack depth", "5")
@@ -566,6 +636,17 @@ export function buildStartCommand(options = {}) {
566
636
  let dirtyWork = recoveryRecord?.runDecisions?.dirtyWork;
567
637
  if (opts.recoverRun && !recoveryRecord)
568
638
  fail(`recoverable build run not found: ${opts.recoverRun}`);
639
+ // Both current-branch and branch delivery runs share this gate. A
640
+ // recovery resumes a previously admitted scope and is deliberately not
641
+ // reclassified by later tracker edits.
642
+ if (!recoveryRecord && isTicketsInitialized(cwd)) {
643
+ try {
644
+ assertStartTicketPreflight(cwd);
645
+ }
646
+ catch (err) {
647
+ fail(err instanceof Error ? err.message : String(err));
648
+ }
649
+ }
569
650
  const autonomyProfile = opts.autonomy;
570
651
  if (autonomyProfile && !["supervised", "balanced", "unattended"].includes(autonomyProfile))
571
652
  fail("--autonomy must be supervised, balanced, or unattended");
@@ -610,6 +691,7 @@ export function buildStartCommand(options = {}) {
610
691
  const readinessOwner = { project: cwd, runId: supervisedInvocationId, authority: localBuildAuthority(cwd) };
611
692
  const autoApprovePlanUpdates = recoveryRecord
612
693
  ? recoveryRecord.recoveryDecision?.planUpdateApproval === "auto"
694
+ && (recoveryRecord.recoveryDecision.planUpdateApprovalSource === "explicit-yes" || recoveryRecord.recoveryDecision.planUpdateApprovalSource === "explicit-choice")
613
695
  : Boolean(opts.yes);
614
696
  if (opts.recoveryMode) {
615
697
  const allowed = ["exact-session", "fresh-with-handoff", "fresh-recovery-only", "guided-recovery"];
@@ -756,7 +838,7 @@ export function buildStartCommand(options = {}) {
756
838
  ? requestedTicketIds.length > 0
757
839
  ? requestedTicketIds
758
840
  : branchDefaults.branchMode && recoveryRecord
759
- ? [...recoveryRecord.tickets]
841
+ ? branchTicketScopeProgress(cwd, recoveryRecord)?.remaining ?? [...recoveryRecord.tickets]
760
842
  : recoveryRecord ? [currentBranchRecoveryTicket(cwd, recoveryRecord)].filter((ticket) => ticket !== undefined) : []
761
843
  : [];
762
844
  if (recoveryRecord && continueTickets.some(ticket => !recoveryRecord.tickets.includes(ticket)))
@@ -868,6 +950,8 @@ export function buildStartCommand(options = {}) {
868
950
  ? recoveryExecutionTickets(recoveryRecord, requestedTicketIds, Boolean(opts.recoveryMode))
869
951
  : selectedNewTicket ? [selectedNewTicket] : undefined;
870
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" };
871
955
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
872
956
  const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
873
957
  const log = new Log(logPath);
@@ -882,9 +966,9 @@ export function buildStartCommand(options = {}) {
882
966
  console.error(`rafi: warning: ${qaResolution.deprecationWarning}`);
883
967
  const invocationRunId = supervisedInvocationId;
884
968
  let observabilityStore;
885
- if (config.observability.enabled) {
969
+ if (observabilityConfig.enabled) {
886
970
  try {
887
- observabilityStore = new ObservabilityStore(cwd, { config: config.observability });
971
+ observabilityStore = new ObservabilityStore(cwd, { config: observabilityConfig });
888
972
  }
889
973
  catch (error) {
890
974
  console.error(`rafi: observability unavailable; build will continue: ${String(error).replace(/\s+/g, " ").slice(0, 300)}`);
@@ -1249,7 +1333,12 @@ export function buildStartCommand(options = {}) {
1249
1333
  const recoveryMode = String(opts.recoveryMode ?? "");
1250
1334
  const effectiveSessionId = recoveryMode && recoveryMode !== "exact-session" ? undefined : sessionId;
1251
1335
  let adapter = await createRecoveringBuilder(builderCwd, effectiveSessionId, effectiveSessionId ? sessionRef : undefined);
1252
- if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "builder" && activeContinuityRunId === recoveryRecord?.runId) {
1336
+ // The persisted recovery record is the authority for the staged
1337
+ // handoff's run. During QA-only recovery the active continuity ID is
1338
+ // assigned later than adapter construction, so gating acceptance on
1339
+ // that transient variable can silently skip the acceptance branch and
1340
+ // leave the staged generation pending until the post-run guard fails.
1341
+ if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "builder" && recoveryRecord?.runId) {
1253
1342
  const service = new HandoffService(cwd);
1254
1343
  const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
1255
1344
  const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(resolvedContinuitySettings("builder"), runtime)), handoffRecoveryOptions("builder"));
@@ -1566,10 +1655,26 @@ export function buildStartCommand(options = {}) {
1566
1655
  // compatibility caller still supplies the previous raw ID.
1567
1656
  const effectiveSessionId = undefined;
1568
1657
  void sessionId;
1569
- let adapter = await createRecoveringQa(qaCwd, effectiveSessionId);
1658
+ let adapter;
1659
+ try {
1660
+ adapter = await createRecoveringQa(qaCwd, effectiveSessionId);
1661
+ }
1662
+ catch (error) {
1663
+ // A staged QA handoff is intentionally preserved when the successor
1664
+ // cannot reach a validated session (for example, host runtime
1665
+ // preparation fails). Record the exact pre-acceptance boundary so a
1666
+ // later recovery can distinguish "not attempted" from a rejected
1667
+ // acknowledgement without weakening the acceptance gate.
1668
+ if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && recoveryRecord?.runId) {
1669
+ const detail = error instanceof Error ? error.message : String(error);
1670
+ recordHandoffAcceptanceNotAttempted(cwd, recoveryRecord.runId, "qa", pendingHandoffGeneration, "qa-runtime-preparation", detail);
1671
+ log.write("handoff-acceptance-not-attempted", { role: "qa", generation: pendingHandoffGeneration, stage: "qa-runtime-preparation", detail });
1672
+ }
1673
+ throw error;
1674
+ }
1570
1675
  let explicitRecoveryAccepted = false;
1571
1676
  let handoffReceipt = { kind: "initial" };
1572
- if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && activeContinuityRunId === recoveryRecord?.runId) {
1677
+ if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && recoveryRecord?.runId) {
1573
1678
  const service = new HandoffService(cwd);
1574
1679
  const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
1575
1680
  const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
@@ -1716,7 +1821,7 @@ export function buildStartCommand(options = {}) {
1716
1821
  cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1717
1822
  } } : {}),
1718
1823
  compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1719
- resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true, purpose: "Frozen QA action across the session boundary" }, ...(recovery ? recovery.resources.map((resource) => ({
1824
+ resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true, purpose: "Frozen QA action across the session boundary" }, ...(recovery ? recovery.resources.filter((resource) => resource.requiredForRecovery).map((resource) => ({
1720
1825
  label: resource.label, digest: resource.digest, authoritative: true, requiredForRecovery: resource.requiredForRecovery,
1721
1826
  mediaType: resource.mediaType, path: resource.path, purpose: resource.purpose, bytes: resource.bytes,
1722
1827
  })) : [])],
@@ -1822,14 +1927,31 @@ export function buildStartCommand(options = {}) {
1822
1927
  if (continueTickets.length > 0) {
1823
1928
  const sessions = findResumableBranchSessions(join(cwd, ".foreman"));
1824
1929
  const sessionByTicket = new Map(sessions.map((session) => [session.ticket, session]));
1930
+ const historicalReader = new WorkflowReader(cwd);
1931
+ let historicalSessionTickets;
1932
+ try {
1933
+ historicalSessionTickets = new Set(historicalReader.branchResumeSessions(false).map((session) => session.ticket));
1934
+ }
1935
+ finally {
1936
+ historicalReader.close();
1937
+ }
1825
1938
  const nodes = [];
1939
+ const freshTicketIds = [];
1826
1940
  for (const ticketId of continueTickets) {
1827
1941
  const session = sessionByTicket.get(ticketId);
1828
1942
  if (!session) {
1829
- fail([
1830
- `no resumable branch session found for ticket ${ticketId}`,
1831
- branchContinueTicketHelp(cwd),
1832
- ].join("\n"));
1943
+ const ticketState = states.get(ticketId)?.status ?? "planned";
1944
+ if (opts.resume || !recoveryRecord || recoveryRecord.branchMode !== "per-ticket" || historicalSessionTickets.has(ticketId) || !["planned", "next"].includes(ticketState)) {
1945
+ fail([
1946
+ `no resumable branch session found for ticket ${ticketId}`,
1947
+ branchContinueTicketHelp(cwd),
1948
+ ].join("\n"));
1949
+ }
1950
+ // This ticket has no prior branch/session evidence and remains in a
1951
+ // not-started tracker state. It is new work within the already
1952
+ // admitted original scope, not an interrupted session to replay.
1953
+ freshTicketIds.push(ticketId);
1954
+ continue;
1833
1955
  }
1834
1956
  const ticket = ticketById.get(ticketId);
1835
1957
  if (!ticket)
@@ -1857,6 +1979,20 @@ export function buildStartCommand(options = {}) {
1857
1979
  deliveryUnitFinal: session.deliveryUnitFinal,
1858
1980
  });
1859
1981
  }
1982
+ if (freshTicketIds.length) {
1983
+ const fresh = buildBranchPlan(tickets, states, {
1984
+ steps: freshTicketIds.length,
1985
+ baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
1986
+ branchPrefix,
1987
+ maxBranchDepth,
1988
+ rootBaseBranches: branchDefaults.rootBaseBranches,
1989
+ ticketIds: freshTicketIds,
1990
+ });
1991
+ if (fresh.issues.some(issue => issue.blocking) || fresh.nodes.length !== freshTicketIds.length || fresh.nodes.some(node => !freshTicketIds.includes(node.ticket.id))) {
1992
+ fail(`remaining branch tickets are not runnable as fresh approved work: ${fresh.issues.filter(issue => issue.blocking).map(issue => issue.message).join("; ") || "planner did not return the exact remaining scope"}`);
1993
+ }
1994
+ nodes.push(...fresh.nodes);
1995
+ }
1860
1996
  plan = {
1861
1997
  baseRef: nodes[0]?.baseRef ?? (opts.base ?? currentGitRef(cwd)),
1862
1998
  nodes,
@@ -1939,6 +2075,7 @@ export function buildStartCommand(options = {}) {
1939
2075
  stacked: selectedStacks.length > 0,
1940
2076
  resumed: continueTickets.length > 0 || Boolean(recoveryRecord),
1941
2077
  });
2078
+ warnRiskPlanWaivers(cwd, plan.nodes.map((node) => node.ticket.id));
1942
2079
  log.write("branch-plan", {
1943
2080
  ...branchPlanLogMetadata(plan, branchPresentation),
1944
2081
  ...(auditDependencyCount === undefined ? {} : { auditDependencyCount }),
@@ -2234,8 +2371,9 @@ export function buildStartCommand(options = {}) {
2234
2371
  : `foreman: inspect recovery with: ${formatRecoveryCommand(cwd)}`);
2235
2372
  process.exit(2);
2236
2373
  }
2237
- if (pendingHandoffGeneration !== undefined)
2238
- throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
2374
+ if (pendingHandoffGeneration !== undefined) {
2375
+ throw new HandoffRecoveryPausedError(recoveryRecord?.runId ?? invocationRunId, pendingHandoffGeneration, `Recovery handoff generation ${pendingHandoffGeneration} was not consumed; the staged handoff remains available for a subsequent validated recovery`);
2376
+ }
2239
2377
  clearInterval(branchHeartbeat);
2240
2378
  if (selectedStacks.length) {
2241
2379
  let allComplete = true;
@@ -2256,15 +2394,15 @@ export function buildStartCommand(options = {}) {
2256
2394
  console.log(allComplete ? "foreman: stack publication complete; delivery is awaiting_review (no merges were attempted)" : "foreman: partial stack prefix published; resume point is preserved");
2257
2395
  }
2258
2396
  masterRun = withRunLocalAcceptedRuntimes(masterRun);
2259
- masterRun = summaries.every((row) => row.buildStatus === "done")
2260
- ? completeBuildRun(cwd, masterRun)
2261
- : releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "branch-run-interrupted", { status: "recoverable" }), "recoverable");
2397
+ // A targeted QA recovery returns one completed summary. Reconcile the
2398
+ // original admitted scope before declaring the master run complete.
2399
+ masterRun = finishBranchTicketScope(cwd, masterRun, summaries.every((row) => row.buildStatus === "done") ? "branch-partial-scope-complete" : "branch-run-interrupted");
2262
2400
  activeObserver?.finish(masterRun.status, observationSummaryMetadata(cwd, masterRun, qaEnabled, capturedBranchBuilder.make, summaries.find(row => row.pr?.url)));
2263
2401
  observabilityStore?.closeLogFile(logPath, masterRun.status);
2264
2402
  observabilityStore?.enforceLimits();
2265
2403
  observabilityStore?.maintainLogs(cwd);
2266
2404
  observabilityStore?.close();
2267
- pruneRecoveryTelemetryAtCompletion(cwd, config.observability.detail_retention_days);
2405
+ pruneRecoveryTelemetryAtCompletion(cwd, observabilityConfig.detail_retention_days);
2268
2406
  activeObserver = undefined;
2269
2407
  process.off("exit", finishObservationOnExit);
2270
2408
  console.log("foreman: branch run summary");
@@ -2295,14 +2433,20 @@ export function buildStartCommand(options = {}) {
2295
2433
  process.exit(failed ? 2 : 0);
2296
2434
  }
2297
2435
  if (!recoveryRecord && isTicketsInitialized(cwd)) {
2298
- const paths = resolveTicketPaths(loadTicketsConfig(cwd), cwd);
2436
+ const ticketsConfig = loadTicketsConfig(cwd);
2437
+ const paths = resolveTicketPaths(ticketsConfig, cwd);
2299
2438
  const state = new StateDb(paths.stateDb);
2300
2439
  try {
2301
- executionTickets = executionTickets ?? loadTickets(paths.tickets).filter(ticket => !["done", "canceled", "cancelled", "obsolete"].includes(state.getState(ticket.id)?.status ?? "planned")).sort((a, b) => a.order - b.order).slice(0, steps).map(ticket => ticket.id);
2440
+ if (!executionTickets) {
2441
+ executionTickets = selectDefaultExecutionTickets(loadTickets(paths.tickets), state.getAllStates(), ticketsConfig.implementationLimit, steps, deliveryConfig);
2442
+ if (!executionTickets.length)
2443
+ fail("no eligible tickets are available to start; run `rafi tickets queue` to inspect blockers");
2444
+ }
2302
2445
  }
2303
2446
  finally {
2304
2447
  state.close();
2305
2448
  }
2449
+ warnRiskPlanWaivers(cwd, executionTickets);
2306
2450
  }
2307
2451
  if (grantedNetwork || opts.builderApprovals)
2308
2452
  console.log(`foreman: requested Builder shell capabilities: network ${grantedNetwork ? "approved for this build" : "off"}; approvals ${opts.builderApprovals ? "per operation" : "never"}. Effective permissions will be verified with the provider.`);
@@ -2578,8 +2722,13 @@ export function buildStartCommand(options = {}) {
2578
2722
  const qa = await (activeObserver
2579
2723
  ? activeObserver.span("qa_recovery", "Exact QA-only recovery", () => foreman.completePendingQaRecovery(ticketId))
2580
2724
  : foreman.completePendingQaRecovery(ticketId));
2581
- if (pendingHandoffGeneration !== undefined)
2582
- throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted`);
2725
+ if (pendingHandoffGeneration !== undefined) {
2726
+ // QA can stop before allocating a provider session (for example
2727
+ // when host prerequisites are unavailable). Keep the staged
2728
+ // handoff recoverable and report that boundary explicitly; do not
2729
+ // turn an unattempted acceptance into a terminal build failure.
2730
+ throw new HandoffRecoveryPausedError(buildRun.runId, pendingHandoffGeneration, `QA handoff generation ${pendingHandoffGeneration} was not consumed because QA stopped before session acceptance; the handoff remains staged for recovery`);
2731
+ }
2583
2732
  if (foreman.qaSessionId())
2584
2733
  buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
2585
2734
  buildRun = qa.outcome === "passed" || qa.outcome === "waived"
@@ -2679,8 +2828,9 @@ export function buildStartCommand(options = {}) {
2679
2828
  };
2680
2829
  const batch = () => foreman.runBatch(steps, trackerRelPath, onTicketStart, preferredTicket, executionTickets);
2681
2830
  const result = await (activeObserver ? activeObserver.span("builder_work", "Builder and QA batch", batch) : batch());
2682
- if (pendingHandoffGeneration !== undefined)
2683
- throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
2831
+ if (pendingHandoffGeneration !== undefined) {
2832
+ throw new HandoffRecoveryPausedError(buildRun.runId, pendingHandoffGeneration, `Recovery handoff generation ${pendingHandoffGeneration} was not consumed; the staged handoff remains available for a subsequent validated recovery`);
2833
+ }
2684
2834
  if (foreman.builderSessionId())
2685
2835
  buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
2686
2836
  if (foreman.qaSessionId())
@@ -2699,7 +2849,7 @@ export function buildStartCommand(options = {}) {
2699
2849
  observabilityStore?.enforceLimits();
2700
2850
  observabilityStore?.maintainLogs(cwd);
2701
2851
  observabilityStore?.close();
2702
- pruneRecoveryTelemetryAtCompletion(cwd, config.observability.detail_retention_days);
2852
+ pruneRecoveryTelemetryAtCompletion(cwd, observabilityConfig.detail_retention_days);
2703
2853
  activeObserver = undefined;
2704
2854
  process.off("exit", finishObservationOnExit);
2705
2855
  console.log(`\n${accent("foreman:")} ${result.completed}/${result.requested} step(s) completed`);
@@ -3003,9 +3153,14 @@ export function createQaNonconvergenceHandler(projectDir, noninteractive, planne
3003
3153
  const match = /RAFI_QA_REMEDIATION_START\s*([\s\S]*?)\s*RAFI_QA_REMEDIATION_END/.exec(run.turn.result.text);
3004
3154
  if (!match)
3005
3155
  throw new Error("Planner remediation did not return the required structured proposal");
3006
- const proposal = JSON.parse(match[1].replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""));
3007
- if (!proposal.summary || !Array.isArray(proposal.fix_instructions) || !proposal.fix_instructions.every((item) => typeof item === "string"))
3008
- throw new Error("Planner remediation proposal is malformed");
3156
+ let parsed;
3157
+ try {
3158
+ parsed = JSON.parse(match[1].replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""));
3159
+ }
3160
+ catch (error) {
3161
+ throw new Error(`Planner remediation proposal is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
3162
+ }
3163
+ const proposal = parseProviderContract(qaRemediationProposalV1Schema, parsed, "RAFI_QA_REMEDIATION");
3009
3164
  console.log(`\nPlanner remediation: ${proposal.summary}\n${proposal.fix_instructions.map((item) => `- ${item}`).join("\n")}`);
3010
3165
  const decision = await ask(`QA remediation approval ${revision}: ${JSON.stringify(proposal)}`, [{ id: "approve", label: "Approve" }, { id: "revise", label: "Revise" }, { id: "cancel", label: "Return to choices" }], () => select({ message: "Use this remediation for a new Builder fix session?", options: [
3011
3166
  { value: "approve", label: "Approve (Recommended)" }, { value: "revise", label: "Discuss/revise" }, { value: "cancel", label: "Cancel and return to choices" },
package/dist/cli/state.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { resolve } from "node:path";
3
- import { exportStateBundle, hasTransferableState, importStateBundle, inspectStateBundle } from "../stateTransfer.js";
3
+ import { exportStateBundle, hasTransferableState, importStateBundle, inspectBuildProjectIdentity, inspectStateBundle, rebindBuildProjectIdentity } from "../stateTransfer.js";
4
4
  export function buildStateCommand(opts = {}) {
5
5
  const command = new Command("state")
6
6
  .description("Export, inspect, and import portable Rafi local state bundles.");
@@ -65,5 +65,43 @@ export function buildStateCommand(opts = {}) {
65
65
  console.log(` previous state backup: ${result.backupDir}`);
66
66
  console.log(" provider sessions are historical only; resume with `rafi build:resume --fresh-with-handoff` if exact session recovery fails");
67
67
  });
68
+ command
69
+ .command("rebind")
70
+ .description("Inspect or explicitly rebind copied local build state to this checkout.")
71
+ .argument("[project]", "project directory", ".")
72
+ .option("-y, --yes", "confirm rebinding copied local state")
73
+ .action(async (project, options) => {
74
+ const root = opts.resolveProject ? opts.resolveProject(project) : resolve(project);
75
+ const status = inspectBuildProjectIdentity(root);
76
+ if (!status.recoveryDatabasePresent) {
77
+ console.log(`rafi state rebind: no local recovery database at ${status.currentRoot}; nothing to rebind`);
78
+ return;
79
+ }
80
+ if (!status.storedRoot) {
81
+ console.log(`rafi state rebind: local recovery database at ${status.currentRoot} predates project-root binding; nothing to rebind`);
82
+ return;
83
+ }
84
+ console.log("rafi state rebind:");
85
+ console.log(` stored path: ${status.storedRoot}`);
86
+ console.log(` current path: ${status.currentRoot}`);
87
+ if (status.matches) {
88
+ console.log(" status: already bound to this checkout");
89
+ return;
90
+ }
91
+ console.log(" status: copied or moved local build state");
92
+ console.log(" rebinding preserves local history and records the path change; it does not merge state from the original checkout.");
93
+ if (!options.yes) {
94
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
95
+ throw new Error("rebinding changes local build-state identity; rerun with --yes after reviewing the stored and current paths");
96
+ const { confirm, isCancel } = await import("@clack/prompts");
97
+ const approved = await confirm({ message: "Rebind this copied local build state to the current checkout?", initialValue: false });
98
+ if (isCancel(approved) || !approved) {
99
+ console.log("rafi state rebind: cancelled; no state changed");
100
+ return;
101
+ }
102
+ }
103
+ rebindBuildProjectIdentity(root);
104
+ console.log(`rafi state rebind: rebound copied local state to ${status.currentRoot}`);
105
+ });
68
106
  return command;
69
107
  }
@@ -5,7 +5,29 @@ import type { EffortLevel } from "../adapters/types.js";
5
5
  import { type RoleBuilderOptions } from "../agentRun.js";
6
6
  import type { TicketsConfig } from "../tickets/config.js";
7
7
  import { type TicketPopulateRuntimeMode, type TicketSourceConfig, type TicketsSetupConfig } from "../tickets/setupConfig.js";
8
+ /** Commander preserves comma-delimited values as one argument; accept both documented forms. */
9
+ export declare function normalizeRiskWaiverTicketIds(values: readonly string[] | undefined): string[];
10
+ /** Record an explicit, definition-bound exception for existing risk tickets. */
11
+ export declare function waiveTicketRiskNotes(projectDir: string, ticketIds: string[], reason: string, actor?: string): void;
12
+ export interface RiskNoteRepairProposal {
13
+ version: 1;
14
+ tickets: Array<{
15
+ id: string;
16
+ rollback: string;
17
+ }>;
18
+ }
19
+ export declare function extractRiskNoteRepairProposal(output: string): RiskNoteRepairProposal;
20
+ /** Validate a deliberately narrow repair: only exactly the missing rollback fields may change. */
21
+ export declare function applyRiskNoteRepairProposal(tickets: import("../tickets/ticketSchema.js").TicketDef[], proposal: RiskNoteRepairProposal): import("../tickets/ticketSchema.js").TicketDef[];
8
22
  export declare function buildPopulateInstruction(approvedPlanPath: string, contextSources?: string[], progressDoc?: string): string;
23
+ /** A bounded correction loop ended without a publishable proposal. */
24
+ export declare class TicketPopulationRiskCorrectionExhaustedError extends Error {
25
+ readonly ticketIds: string[];
26
+ readonly projectDir: string;
27
+ readonly interviewId: string;
28
+ readonly proposalFile: string;
29
+ constructor(ticketIds: string[], projectDir: string, interviewId: string, proposalFile: string);
30
+ }
9
31
  export declare function resolvePopulateSources(projectDir: string, explicitSources: string[] | undefined, ticketsConfig: TicketsConfig): string[] | undefined;
10
32
  export declare function buildPopulateAgentRunOptions(opts: {
11
33
  projectDir: string;
@@ -27,10 +49,25 @@ interface PopulateCommandOptions {
27
49
  fast?: boolean;
28
50
  yes?: boolean;
29
51
  authorizeRetire?: string[];
52
+ waiveRiskNotes?: string[];
53
+ waiverReason?: string;
54
+ waiverActor?: string;
30
55
  resumeSession?: string;
56
+ proposalFile?: string;
31
57
  }
32
58
  export declare function combineRequirementSelections(selected: string[], additions: string): string[];
33
59
  export declare function cmdPopulateCli(opts: PopulateCommandOptions): Promise<void>;
60
+ interface RepairRiskNotesOptions {
61
+ project?: string;
62
+ agent?: string;
63
+ model?: string;
64
+ effort?: string;
65
+ fast?: boolean;
66
+ apply?: boolean;
67
+ yes?: boolean;
68
+ }
69
+ /** Ask Ticket Maker for a reviewable, rollback-only repair of published tickets. */
70
+ export declare function cmdRepairRiskNotesCli(opts: RepairRiskNotesOptions): Promise<void>;
34
71
  /** Resolve only the strategy. Context documents never select an import mode. */
35
72
  export declare function resolvePopulationMode(setup: TicketsSetupConfig | undefined): TicketPopulateRuntimeMode;
36
73
  export interface PopulationContextSelection {