ai-foreman 2.0.0 → 2.0.1

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 (50) hide show
  1. package/dist/adapters/claude.js +4 -3
  2. package/dist/adapters/codex.d.ts +2 -0
  3. package/dist/adapters/codex.js +7 -2
  4. package/dist/branch/git.js +3 -8
  5. package/dist/buildApproval.js +3 -9
  6. package/dist/buildRuns.d.ts +1 -0
  7. package/dist/buildRuns.js +6 -1
  8. package/dist/cli/doctor.js +3 -1
  9. package/dist/cli/start.js +63 -13
  10. package/dist/cli/tickets.d.ts +0 -1
  11. package/dist/cli/tickets.js +11 -22
  12. package/dist/continuity.d.ts +0 -2
  13. package/dist/continuity.js +30 -34
  14. package/dist/dispatchNotSent.d.ts +4 -0
  15. package/dist/dispatchNotSent.js +10 -0
  16. package/dist/foreman.d.ts +5 -12
  17. package/dist/foreman.js +19 -85
  18. package/dist/gitExclude.d.ts +16 -0
  19. package/dist/gitExclude.js +46 -0
  20. package/dist/graph/turn.d.ts +1 -1
  21. package/dist/graph/turn.js +1 -1
  22. package/dist/handoffs.d.ts +3 -3
  23. package/dist/handoffs.js +5 -32
  24. package/dist/humanDecision.d.ts +7 -0
  25. package/dist/humanDecision.js +11 -0
  26. package/dist/observability.js +3 -8
  27. package/dist/qaBuildGate.js +13 -1
  28. package/dist/qaPreparation.js +21 -7
  29. package/dist/qaPreparationStore.d.ts +11 -0
  30. package/dist/qaPreparationStore.js +44 -4
  31. package/dist/qaRecovery.d.ts +0 -3
  32. package/dist/qaRecovery.js +1 -24
  33. package/dist/qaReview.js +32 -22
  34. package/dist/qaSnapshot.js +19 -14
  35. package/dist/qaStructuredResponse.d.ts +8 -0
  36. package/dist/qaStructuredResponse.js +24 -0
  37. package/dist/qaTimeline.js +3 -5
  38. package/dist/recoveryOwners.d.ts +32 -0
  39. package/dist/recoveryOwners.js +87 -0
  40. package/dist/runtimePreflight.d.ts +11 -0
  41. package/dist/runtimePreflight.js +77 -0
  42. package/dist/sources/sourceRegistry.d.ts +0 -3
  43. package/dist/sources/sourceRegistry.js +0 -42
  44. package/dist/structuredOperation.js +26 -4
  45. package/dist/ticketPlanning.d.ts +0 -3
  46. package/dist/ticketPlanning.js +0 -23
  47. package/dist/ticketPopulationPaging.js +23 -1
  48. package/dist/workflowDb.d.ts +16 -1
  49. package/dist/workflowDb.js +89 -22
  50. package/package.json +11 -3
@@ -63,14 +63,15 @@ export function buildClaudeQueryOptions(opts, scratchDirectory = join(dirname(op
63
63
  failIfUnavailable: true,
64
64
  autoAllowBashIfSandboxed: true,
65
65
  allowUnsandboxedCommands: false,
66
- network: { allowedDomains: [], deniedDomains: ["*"] },
66
+ // QA stays offline unless the operator granted --qa-network for this build.
67
+ network: opts.networkAccess === true ? { allowedDomains: ["*"] } : { allowedDomains: [], deniedDomains: ["*"] },
67
68
  filesystem: { allowWrite: [scratchDirectory], denyWrite: [opts.cwd, ...(opts.configRoot ? [opts.configRoot] : [])] },
68
69
  } } : {}),
69
70
  };
70
71
  const exactSkills = opts.preloadedSkillContent?.length
71
72
  ? ["# Preloaded Skills", "Use the following skills for this run.", ...opts.preloadedSkillContent.map((skill) => skill.content)].join("\n\n")
72
73
  : undefined;
73
- const systemAppend = [handoffSystemInstructions(opts.sessionRole, opts.systemPromptAppend), opts.sessionRole === "builder" ? `Builder runtime contract: shell network ${opts.networkAccess === true ? "explicitly approved for this build" : "disabled"}; sandbox fallback disabled. If acquisition requires unavailable access, request input first or use a provenance-verified local bundle. A ticket answer does not grant runtime permissions.` : undefined, exactSkills].filter((part) => Boolean(part)).join("\n\n");
74
+ const systemAppend = [handoffSystemInstructions(opts.sessionRole, opts.systemPromptAppend), opts.sessionRole === "builder" ? `Builder runtime contract: shell network ${opts.networkAccess === true ? "explicitly approved for this build" : "disabled"}; sandbox fallback disabled. If acquisition requires unavailable access, request input first or use a provenance-verified local bundle. A ticket answer does not grant runtime permissions.` : undefined, opts.sessionRole === "qa" && opts.networkAccess === true ? "QA runtime contract: shell network explicitly approved for this review (--qa-network); you may run live checks against operator-provided local or remote services. Workspace stays read-only." : undefined, exactSkills].filter((part) => Boolean(part)).join("\n\n");
74
75
  if (systemAppend) {
75
76
  base.systemPrompt = { type: "preset", preset: "claude_code", append: systemAppend };
76
77
  }
@@ -1341,7 +1342,7 @@ export class ClaudeAdapter {
1341
1342
  pending.idleTimer.unref();
1342
1343
  }
1343
1344
  providerIdleTimeoutMs() {
1344
- const configured = this.opts.providerIdleTimeoutMs;
1345
+ const configured = this.opts.providerIdleTimeoutMs ?? Number(process.env.RAFI_PROVIDER_IDLE_TIMEOUT_MS);
1345
1346
  return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_PROVIDER_IDLE_TIMEOUT_MS;
1346
1347
  }
1347
1348
  streamFailureResult(message, sessionUnavailable) {
@@ -132,6 +132,8 @@ export declare class CodexAdapter implements BuilderAdapter {
132
132
  private traceLifecycle;
133
133
  private touchWaiters;
134
134
  private providerIdleTimeoutMs;
135
+ /** Read-only QA may reach the network only with the operator's --qa-network grant. */
136
+ private qaNetworkAccess;
135
137
  private effectiveNetworkAccess;
136
138
  private effectiveApprovalPolicy;
137
139
  /** Fail closed; approval is for this operation only, never a session cache or policy amendment. */
@@ -151,6 +151,7 @@ export class CodexAdapter {
151
151
  buildInstruction(instruction) {
152
152
  return [this.opts.systemPromptAppend,
153
153
  this.opts.sessionRole === "builder" ? `Builder runtime contract: shell network ${this.effectiveNetworkAccess() ? "explicitly approved for this build" : "disabled"}; sandbox escalation ${this.effectiveApprovalPolicy() === "on-request" ? "requires separate approval for each exact operation" : "disabled"}. Web tools do not establish shell network access. If acquisition needs unavailable capabilities, request input before running acquisition, or use a local source/dependency bundle with verified provenance. A ticket answer does not change runtime permissions.` : undefined,
154
+ this.qaNetworkAccess() ? "QA runtime contract: shell network explicitly approved for this review (--qa-network); you may run live checks against operator-provided local or remote services. Workspace stays read-only." : undefined,
154
155
  this.buildSkillsAppendix(), instruction]
155
156
  .filter((part) => Boolean(part)).join("\n\n");
156
157
  }
@@ -293,7 +294,7 @@ export class CodexAdapter {
293
294
  ...(this.opts.fast ? { serviceTier: this.fastTier ?? "default" } : {}),
294
295
  approvalPolicy: policy?.responseOnly || policy?.purpose && policy.purpose !== "implementation" ? "never" : this.effectiveApprovalPolicy(),
295
296
  sandboxPolicy: readOnlyTurn
296
- ? { type: "readOnly", networkAccess: false }
297
+ ? { type: "readOnly", networkAccess: this.qaNetworkAccess() }
297
298
  : { type: "workspaceWrite", writableRoots: [this.opts.cwd], networkAccess: this.effectiveNetworkAccess() },
298
299
  };
299
300
  let started;
@@ -1090,9 +1091,13 @@ export class CodexAdapter {
1090
1091
  waiter.touch?.();
1091
1092
  }
1092
1093
  providerIdleTimeoutMs() {
1093
- const configured = this.opts.providerIdleTimeoutMs;
1094
+ const configured = this.opts.providerIdleTimeoutMs ?? Number(process.env.RAFI_PROVIDER_IDLE_TIMEOUT_MS);
1094
1095
  return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_PROVIDER_IDLE_TIMEOUT_MS;
1095
1096
  }
1097
+ /** Read-only QA may reach the network only with the operator's --qa-network grant. */
1098
+ qaNetworkAccess() {
1099
+ return this.opts.sessionRole === "qa" && this.opts.networkAccess === true;
1100
+ }
1096
1101
  effectiveNetworkAccess() {
1097
1102
  return this.opts.sandboxMode !== "read-only" && this.opts.sessionRole !== "qa" && this.opts.networkAccess === true;
1098
1103
  }
@@ -1,6 +1,7 @@
1
+ import { ensureLocalGitExcludes } from "../gitExclude.js";
1
2
  import { invalidateGraphWorkspace } from "../graph/lifecycle.js";
2
3
  import { execFileSync } from "node:child_process";
3
- import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
4
+ import { mkdirSync, rmSync } from "node:fs";
4
5
  import { dirname, join } from "node:path";
5
6
  export function runGit(cwd, args) {
6
7
  const stdout = execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
@@ -41,13 +42,7 @@ export function ensureCleanBaseWorktree(cwd, opts = {}) {
41
42
  }
42
43
  }
43
44
  export function ensureForemanExcluded(cwd) {
44
- const excludePath = join(cwd, ".git", "info", "exclude");
45
- if (!existsSync(excludePath))
46
- return;
47
- const line = ".foreman/";
48
- if (readFileSync(excludePath, "utf8").split(/\r?\n/).includes(line))
49
- return;
50
- appendFileSync(excludePath, `\n${line}\n`, "utf8");
45
+ ensureLocalGitExcludes(cwd, [".foreman/"], { leadingBlankLine: true });
51
46
  }
52
47
  export function createTicketWorktree(projectDir, runId, branch, base) {
53
48
  const worktreePath = join(projectDir, ".foreman", "worktrees", runId, branch.replace(/\//g, "__"));
@@ -97,15 +97,9 @@ export function requiresBuildApproval(projectDir, ticketIds, explicitYes, conseq
97
97
  }
98
98
  if (scope.approved && !changedConsequences)
99
99
  return false;
100
- if (!explicitYes)
101
- return true;
102
- const db = new WorkflowDb(projectDir);
103
- try {
104
- return db.completedPlanApprovals().length > 0;
105
- }
106
- finally {
107
- db.close();
108
- }
100
+ // --yes is the operator's standing approval for this invocation: it never
101
+ // stops to ask, even when the scope differs from the recorded plan approval.
102
+ return !explicitYes;
109
103
  }
110
104
  /** Bind decisions to current files so an old answer cannot approve a changed scope. */
111
105
  export function buildScopeRevision(projectDir, consequences) {
@@ -12,6 +12,7 @@ export interface CreateBuildRunInput {
12
12
  tickets: string[];
13
13
  authorizedBatch?: BuildRunRecordV2["authorizedBatch"];
14
14
  builderCapabilities?: BuildRunRecordV2["builderCapabilities"];
15
+ qaCapabilities?: BuildRunRecordV2["qaCapabilities"];
15
16
  deliveryUnit?: string;
16
17
  branchMode?: BuildRunRecordV2["branchMode"];
17
18
  repositoryRoot: string;
package/dist/buildRuns.js CHANGED
@@ -6,7 +6,7 @@ import { hostname } from "node:os";
6
6
  import { join, resolve } from "node:path";
7
7
  import { execFileSync } from "node:child_process";
8
8
  import Database from "better-sqlite3";
9
- import { WorkflowDb, heartbeatCurrentWorkflowLease, readCurrentWorkflowLease } from "./workflowDb.js";
9
+ import { WorkflowDb, heartbeatCurrentWorkflowLease, isNonBuildRunId, readCurrentWorkflowLease } from "./workflowDb.js";
10
10
  import { WorkflowReader } from "./workflowReader.js";
11
11
  import { canonicalSessionPath, captureWorkspaceIdentity, createProviderSessionRef, latestSessionBinding, upsertSessionBinding } from "./sessionIdentity.js";
12
12
  import { resolveProviderSessionAvailability } from "./sessionAvailability.js";
@@ -53,6 +53,7 @@ export function createBuildRun(input) {
53
53
  tickets: [...input.tickets],
54
54
  authorizedBatch: input.authorizedBatch ?? (input.tickets.length && (input.branchMode ?? "current") === "current" ? { tickets: [...input.tickets], requestedSteps: input.tickets.length, scopeRevision: "explicit-create-selection", startedTickets: [] } : undefined),
55
55
  builderCapabilities: input.builderCapabilities,
56
+ qaCapabilities: input.qaCapabilities,
56
57
  deliveryUnit: input.deliveryUnit,
57
58
  branchMode: input.branchMode ?? "current",
58
59
  checkpoint: "created",
@@ -435,6 +436,10 @@ export function readBuildRuns(projectDir) {
435
436
  catch { /* authoritative DB state can repair an absent or partial projection */ }
436
437
  }
437
438
  for (const stored of workflow.buildRuns()) {
439
+ // Journal runs for agent calls outside a build were once stored with the
440
+ // build kind; they are never builds.
441
+ if (isNonBuildRunId(stored.runId))
442
+ continue;
438
443
  const value = stored.state;
439
444
  if (value?.runId)
440
445
  candidates.set(value.runId, value);
@@ -13,6 +13,7 @@ import { WorkflowReader } from "../workflowReader.js";
13
13
  import { WorkflowDb } from "../workflowDb.js";
14
14
  import { error, success, warning } from "../terminalStyle.js";
15
15
  import { readInterviewRecords } from "../interviews.js";
16
+ import { checkRuntimeEnvironment } from "../runtimePreflight.js";
16
17
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))?.version;
17
18
  function commandVersion(command) {
18
19
  const result = spawnSync(command, ["--version"], {
@@ -50,7 +51,8 @@ export function buildDoctorCommand() {
50
51
  const warn = (label, detail) => {
51
52
  console.log(`${warning("--")} ${label}${detail ? ` — ${detail}` : ""}`);
52
53
  };
53
- report(Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10) >= 20, "node >=20", process.version);
54
+ for (const check of checkRuntimeEnvironment())
55
+ report(check.ok, check.label, check.fix ? `${check.detail}; fix: ${check.fix}` : check.detail);
54
56
  report(Boolean(PACKAGE_VERSION), "foreman package version", PACKAGE_VERSION);
55
57
  report(existsSync(cwd), "project directory exists", cwd);
56
58
  if (!existsSync(cwd))
package/dist/cli/start.js CHANGED
@@ -11,7 +11,7 @@ import { durableReadOnlyProposal } from "../readOnlyProposal.js";
11
11
  import { captureFrozenQaSource } from "../qaSnapshot.js";
12
12
  import { finishStartAdmission, superviseStart, supervisedRunId } from "../supervisedStart.js";
13
13
  import { assertBuildAssignmentReconciled, isInterruptedBuilderRecovery, assertInterruptedSourceSnapshot } from "../buildAssignment.js";
14
- import { durableHumanDecision, HumanDecisionRequired, HumanDecisionCancelled, servicePendingHumanDecisions } from "../humanDecision.js";
14
+ import { durableHumanDecision, HumanDecisionRequired, HumanDecisionCancelled, reusableCapabilityDecision, servicePendingHumanDecisions } from "../humanDecision.js";
15
15
  import { requiresBuildApproval, buildScopeRevision, createBuildApprovalGate, validatePlanApprovalBinding, validateFrozenWorkApprovalBinding } from "../buildApproval.js";
16
16
  import { Command, Option } from "commander";
17
17
  import { accent, error, sanitizeTerminalText, success, warning } from "../terminalStyle.js";
@@ -540,7 +540,7 @@ export function buildStartCommand(options = {}) {
540
540
  .option("--accept-handoff-role <role>", "role owning the pre-staged handoff (builder | qa)", "builder")
541
541
  .option("--continue", "resume the most recent logged session for this project")
542
542
  .option("-t, --tickets <path>", "path to ticket file (.md, .txt, .yaml, …) — passed to the builder as context")
543
- .option("-y, --yes", "skip pre-flight confirmation prompt")
543
+ .option("-y, --yes", "never stop to ask: approve the pre-flight plan, plan updates, and capabilities requested by flag (--builder-network, --qa-network)")
544
544
  .option("--effort <level>", "reasoning effort level (low|medium|high|xhigh)")
545
545
  .option("--fast", "use native fast mode when available; keep the model and reasoning settings")
546
546
  .option("--no-qa", "disable per-ticket QA review (enabled by default)")
@@ -565,13 +565,15 @@ export function buildStartCommand(options = {}) {
565
565
  .option("--auto-compact-threshold <percent>", "initial Builder context compaction threshold (1-99)")
566
566
  .option("--max-branch-depth <n>", "maximum selected branch stack depth", "5")
567
567
  .option("--pr-ready", "create ready-for-review PRs instead of draft PRs")
568
- .option("--builder-network", "request build-scoped builder internet access with separate explicit approval; rechecked on resume")
568
+ .option("--builder-network", "request build-scoped builder internet access; asks for approval unless --yes; rechecked on resume")
569
+ .option("--qa-network", "allow independent QA network access for this build (e.g. live local services); asks for approval unless --yes; rechecked on resume")
569
570
  .option("--builder-approvals", "allow the builder to request explicit per-operation sandbox approvals; QA stays restricted")
570
571
  .option("--keep-worktrees", "keep successful ticket worktrees for inspection")
571
572
  .option("--dirty-work <mode>", "preserve | include: continue in isolated branches without merging into the dirty checkout")
572
573
  .option("--ticket <id>", "select one new ticket, or identify recovery tickets with --resume/--continue/--recover-run", collectTicket, [])
573
574
  .option("--skip-delivery-unit <id>", "skip one unfinished delivery unit for this run", collectTicket, [])
574
575
  .action(async (project, opts, command) => withBuildInvocation(async () => {
576
+ let waitingForDecision = false;
575
577
  try {
576
578
  validateStartOptions(opts, false);
577
579
  }
@@ -667,18 +669,26 @@ export function buildStartCommand(options = {}) {
667
669
  return;
668
670
  }
669
671
  const requestedNetwork = Boolean(opts.builderNetwork || recoveryRecord?.builderCapabilities?.requestedNetwork);
672
+ // --yes never stops to ask: it approves capabilities the operator
673
+ // requested by flag. A resumed run counts only an explicit --yes.
674
+ const explicitYes = recoveryRecord ? recoveryRecord.recoveryDecision?.planUpdateApprovalSource === "explicit-yes" : Boolean(opts.yes);
670
675
  let grantedNetwork = false;
671
- if (requestedNetwork) {
676
+ let networkDecisionId;
677
+ if (requestedNetwork && explicitYes) {
678
+ grantedNetwork = true;
679
+ console.error("foreman: builder internet for this build approved by --yes (requested with --builder-network)");
680
+ }
681
+ else if (requestedNetwork) {
672
682
  const permissionDb = new WorkflowDb(cwd);
673
683
  let networkKey;
674
684
  try {
675
- networkKey = permissionDb.pendingHumanDecisions(supervisedInvocationId).find(decision => decision.interruptionId.startsWith("builder-network:"))?.interruptionId ?? `builder-network:${process.pid}:${randomUUID()}`;
685
+ networkKey = reusableCapabilityDecision(permissionDb, supervisedInvocationId, "builder-network:", recoveryRecord?.builderCapabilities?.decisionId) ?? `builder-network:${process.pid}:${randomUUID()}`;
676
686
  }
677
687
  finally {
678
688
  permissionDb.close();
679
689
  }
680
- const networkDecision = await durableHumanDecision({ projectDir: cwd, runId: supervisedInvocationId, key: networkKey,
681
- prompt: "Allow the builder shell to access the internet for this build? This permits network use by any builder command, not only downloads. Workspace write confinement remains enforced. QA stays offline. Recovery requires approval again; --yes does not approve this permission.",
690
+ const networkDecision = await durableHumanDecision({ projectDir: cwd, runId: supervisedInvocationId, key: networkKey, onAnswered: decision => { networkDecisionId = decision.decisionId; },
691
+ prompt: "Allow the builder shell to access the internet for this build? This permits network use by any builder command, not only downloads. Workspace write confinement remains enforced. QA stays offline unless --qa-network is separately approved. Recovery requires approval again unless resumed with --yes.",
682
692
  choices: [{ id: "allow-network", label: "Allow builder internet for this build" }, { id: "stay-offline", label: "Stay offline and use a verified local bundle" }],
683
693
  operation: async () => {
684
694
  const action = await select({ message: "Allow builder internet for this build?", options: [{ value: "stay-offline", label: "Stay offline" }, { value: "allow-network", label: "Allow builder internet for this build" }] });
@@ -688,7 +698,37 @@ export function buildStartCommand(options = {}) {
688
698
  return;
689
699
  grantedNetwork = networkDecision === "allow-network";
690
700
  }
691
- const builderCapabilities = { requestedNetwork, grantedNetwork, ...(grantedNetwork ? { approvedAt: new Date().toISOString(), grantWorkerPid: process.pid } : {}) };
701
+ const builderCapabilities = { requestedNetwork, grantedNetwork, ...(grantedNetwork ? { approvedAt: new Date().toISOString(), grantWorkerPid: process.pid } : {}), ...(networkDecisionId ? { decisionId: networkDecisionId } : {}) };
702
+ // Independent QA is offline unless the operator grants --qa-network, e.g.
703
+ // so QA can verify a live local service the ticket requires.
704
+ const requestedQaNetwork = Boolean(opts.qaNetwork || recoveryRecord?.qaCapabilities?.requestedNetwork);
705
+ let grantedQaNetwork = false;
706
+ let qaNetworkDecisionId;
707
+ if (requestedQaNetwork && explicitYes) {
708
+ grantedQaNetwork = true;
709
+ console.error("foreman: QA network for this build approved by --yes (requested with --qa-network)");
710
+ }
711
+ else if (requestedQaNetwork) {
712
+ const permissionDb = new WorkflowDb(cwd);
713
+ let qaNetworkKey;
714
+ try {
715
+ qaNetworkKey = reusableCapabilityDecision(permissionDb, supervisedInvocationId, "qa-network:", recoveryRecord?.qaCapabilities?.decisionId) ?? `qa-network:${process.pid}:${randomUUID()}`;
716
+ }
717
+ finally {
718
+ permissionDb.close();
719
+ }
720
+ const qaNetworkDecision = await durableHumanDecision({ projectDir: cwd, runId: supervisedInvocationId, key: qaNetworkKey, onAnswered: decision => { qaNetworkDecisionId = decision.decisionId; },
721
+ prompt: "Allow independent QA shell commands to access the network for this build? This lets QA run live checks (for example against a local service). QA keeps a read-only workspace. Recovery requires approval again unless resumed with --yes.",
722
+ choices: [{ id: "allow-qa-network", label: "Allow QA network for this build" }, { id: "qa-offline", label: "Keep QA offline" }],
723
+ operation: async () => {
724
+ const action = await select({ message: "Allow QA network for this build?", options: [{ value: "qa-offline", label: "Keep QA offline" }, { value: "allow-qa-network", label: "Allow QA network for this build" }] });
725
+ return isCancel(action) ? undefined : action;
726
+ } });
727
+ if (qaNetworkDecision === undefined || isCancel(qaNetworkDecision))
728
+ return;
729
+ grantedQaNetwork = qaNetworkDecision === "allow-qa-network";
730
+ }
731
+ const qaCapabilities = { requestedNetwork: requestedQaNetwork, grantedNetwork: grantedQaNetwork, ...(grantedQaNetwork ? { approvedAt: new Date().toISOString() } : {}), ...(qaNetworkDecisionId ? { decisionId: qaNetworkDecisionId } : {}) };
692
732
  const readinessOwner = { project: cwd, runId: supervisedInvocationId, authority: localBuildAuthority(cwd) };
693
733
  const autoApprovePlanUpdates = recoveryRecord
694
734
  ? recoveryRecord.recoveryDecision?.planUpdateApproval === "auto"
@@ -1101,6 +1141,9 @@ export function buildStartCommand(options = {}) {
1101
1141
  : base;
1102
1142
  const handoffRecoveryOptions = (role) => ({
1103
1143
  allowProviderSwitch: true,
1144
+ // --yes never stops to ask: retry with a fresh successor. The handoff
1145
+ // still pauses after three rejected successors.
1146
+ ...(explicitYes ? { choose: async () => "retry" } : {}),
1104
1147
  desktopNotifications: config.notifications.enabled,
1105
1148
  terminalBell: config.notifications.terminal_bell,
1106
1149
  onAccepted: (accepted) => {
@@ -1185,6 +1228,7 @@ export function buildStartCommand(options = {}) {
1185
1228
  configRoot: cwd,
1186
1229
  sessionRole: "qa",
1187
1230
  sessionStream: "qa",
1231
+ networkAccess: grantedQaNetwork,
1188
1232
  runtimeExecutable: ready.executable,
1189
1233
  runtimePhase: "qa",
1190
1234
  preparationTimeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
@@ -1456,6 +1500,7 @@ export function buildStartCommand(options = {}) {
1456
1500
  configRoot: cwd,
1457
1501
  sessionRole: "qa",
1458
1502
  sessionStream: "qa",
1503
+ networkAccess: grantedQaNetwork,
1459
1504
  runtimeExecutable: ready.executable,
1460
1505
  runtimePhase: "qa",
1461
1506
  preparationTimeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
@@ -2155,7 +2200,7 @@ export function buildStartCommand(options = {}) {
2155
2200
  && (opts.qaRevision !== undefined || pendingQaRecoveryPacket(cwd, recoveryRecord.runId, requestedTicketIds[0])));
2156
2201
  let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { ...(branchQaOnlyRecovery ? {} : { builder: capturedBranchBuilder, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null }), qa: capturedBranchQa, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
2157
2202
  runId: invocationRunId,
2158
- builderCapabilities, tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
2203
+ builderCapabilities, qaCapabilities, tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
2159
2204
  repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
2160
2205
  frozenPolicy, autonomyProfile, qaEnabled,
2161
2206
  runDecisions: { dirtyWork, workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 65, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
@@ -2189,7 +2234,7 @@ export function buildStartCommand(options = {}) {
2189
2234
  }
2190
2235
  for (const node of plan.nodes)
2191
2236
  admitApprovedTicket(cwd, masterRun.runId, node.ticket.id, buildScopeRevision(cwd), `build invocation ${invocationRunId}: approved branch plan`);
2192
- masterRun = checkpointBuildRun(cwd, masterRun, "capabilities-resolved", { builderCapabilities });
2237
+ masterRun = checkpointBuildRun(cwd, masterRun, "capabilities-resolved", { builderCapabilities, qaCapabilities });
2193
2238
  const qaResumedRecovery = recoveryRecord && !isInterruptedBuilderRecovery(recoveryRecord) ? pendingQaRecoveryPacket(cwd, masterRun.runId, requestedTicketIds[0]) : undefined;
2194
2239
  let qaProtocolResumeTicket;
2195
2240
  if (recoveryRecord && !qaResumedRecovery && !isInterruptedBuilderRecovery(recoveryRecord)) {
@@ -2513,11 +2558,11 @@ export function buildStartCommand(options = {}) {
2513
2558
  && (opts.qaRevision !== undefined || pendingQaRecoveryPacket(cwd, recoveryRecord.runId, requestedTicketIds[0])));
2514
2559
  let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { ...(qaOnlyRecovery ? {} : { builder: capturedBuilder, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null }), qa: capturedQa, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
2515
2560
  runId: invocationRunId,
2516
- tickets: [...(executionTickets ?? [])], builderCapabilities, authorizedBatch: { tickets: [...(executionTickets ?? [])], requestedSteps: steps, scopeRevision: buildScopeRevision(cwd, approvalConsequences), startedTickets: [] }, repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
2561
+ tickets: [...(executionTickets ?? [])], builderCapabilities, qaCapabilities, authorizedBatch: { tickets: [...(executionTickets ?? [])], requestedSteps: steps, scopeRevision: buildScopeRevision(cwd, approvalConsequences), startedTickets: [] }, repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
2517
2562
  frozenPolicy, autonomyProfile, qaEnabled,
2518
2563
  runDecisions: { workMode: "current", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBuilder.auto_compact_threshold_percent ?? 65, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
2519
2564
  });
2520
- buildRun = checkpointBuildRun(cwd, buildRun, "capabilities-resolved", { builderCapabilities });
2565
+ buildRun = checkpointBuildRun(cwd, buildRun, "capabilities-resolved", { builderCapabilities, qaCapabilities });
2521
2566
  activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
2522
2567
  activeContinuityRunId = buildRun.runId;
2523
2568
  publishActivatedBuilderSession = ref => { buildRun = persistCurrentBuildSession(cwd, buildRun, "builder", ref); };
@@ -2988,11 +3033,16 @@ export function buildStartCommand(options = {}) {
2988
3033
  if (!(error instanceof HumanDecisionRequired))
2989
3034
  throw error;
2990
3035
  console.error(error.message);
2991
- process.exitCode = 2;
3036
+ waitingForDecision = true;
2992
3037
  }
2993
3038
  finally {
2994
3039
  finishStartAdmission(cwd, supervisedInvocationId);
2995
3040
  }
3041
+ // Exit explicitly: supervisor IPC, heartbeats and runtime handles keep a
3042
+ // supervised worker alive, so it would otherwise wait forever while its
3043
+ // supervisor waits for it, and `rafi resume` refuses the "live" owner.
3044
+ if (waitingForDecision)
3045
+ process.exit(2);
2996
3046
  }));
2997
3047
  }
2998
3048
  function ticketSizeBucket(projectDir, ticketIds) {
@@ -16,7 +16,6 @@ export interface RiskNoteRepairProposal {
16
16
  rollback: string;
17
17
  }>;
18
18
  }
19
- export declare function extractRiskNoteRepairProposal(output: string): RiskNoteRepairProposal;
20
19
  /** Validate a deliberately narrow repair: only exactly the missing rollback fields may change. */
21
20
  export declare function applyRiskNoteRepairProposal(tickets: import("../tickets/ticketSchema.js").TicketDef[], proposal: RiskNoteRepairProposal): import("../tickets/ticketSchema.js").TicketDef[];
22
21
  export declare function buildPopulateInstruction(approvedPlanPath: string, contextSources?: string[], progressDoc?: string): string;
@@ -5,7 +5,7 @@ import { accent, error, sanitizeTerminalText, success, warning } from "../termin
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
7
7
  import { parse as parseYaml } from "yaml";
8
- import { riskNoteRepairOutput, riskNoteRepairProposalV1Schema, zodIssues } from "rafi-spec";
8
+ import { riskNoteRepairOutput } from "rafi-spec";
9
9
  import { select, text, confirm, isCancel, multiselect } from "@clack/prompts";
10
10
  import { loadConfig } from "../config.js";
11
11
  import { Log } from "../log.js";
@@ -116,26 +116,6 @@ function optionWasProvided(command, name) {
116
116
  }
117
117
  const TICKET_POPULATE_ROLE = "ticket-maker";
118
118
  const RAFI_CONFIG_FILES = ["rafi-config.yaml", "project.yaml"];
119
- const RISK_NOTE_REPAIR_START = "RAFI_RISK_NOTE_REPAIR_START";
120
- const RISK_NOTE_REPAIR_END = "RAFI_RISK_NOTE_REPAIR_END";
121
- export function extractRiskNoteRepairProposal(output) {
122
- const start = output.lastIndexOf(RISK_NOTE_REPAIR_START);
123
- const end = output.lastIndexOf(RISK_NOTE_REPAIR_END);
124
- if (start < 0 || end <= start)
125
- throw new Error(`Ticket Maker repair output is missing ${RISK_NOTE_REPAIR_START}/${RISK_NOTE_REPAIR_END}`);
126
- const raw = output.slice(start + RISK_NOTE_REPAIR_START.length, end).trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
127
- let parsed;
128
- try {
129
- parsed = JSON.parse(raw);
130
- }
131
- catch (error) {
132
- throw new Error(`Ticket Maker risk-note repair is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
133
- }
134
- const validated = riskNoteRepairProposalV1Schema.safeParse(parsed);
135
- if (!validated.success)
136
- throw new Error(`Ticket Maker risk-note repair contract validation failed:\n${zodIssues(validated.error).map((issue) => `- ${issue}`).join("\n")}`);
137
- return validated.data;
138
- }
139
119
  /** Validate a deliberately narrow repair: only exactly the missing rollback fields may change. */
140
120
  export function applyRiskNoteRepairProposal(tickets, proposal) {
141
121
  const targets = tickets.filter(ticket => ticket.risk !== "Low" && !hasRollbackOrMitigationPlan(ticket.rollback));
@@ -1475,7 +1455,16 @@ export async function cmdPopulateCli(opts) {
1475
1455
  checkpoint: scope => { planningRecord = checkpointInterview(dir, planningRecord, { decisions: { ...planningRecord.decisions, populationArtifact: scope } }); },
1476
1456
  dispatch: (instruction, policy) => foreman.runInstruction(instruction, policy),
1477
1457
  });
1478
- const initialPopulation = await population(buildPopulateInstruction(approvedPlan.path, contextSources, ticketsConfig.paths.progressDoc), 0);
1458
+ const populateInstruction = buildPopulateInstruction(approvedPlan.path, contextSources, ticketsConfig.paths.progressDoc);
1459
+ let initialPopulation;
1460
+ try {
1461
+ initialPopulation = await population(populateInstruction, 0);
1462
+ }
1463
+ catch (error) {
1464
+ // One automatic restart of a failed population before stopping for a human.
1465
+ console.log(`foreman tickets: ticket population failed (${String(error).slice(0, 200)}); restarting it once from the first page.`);
1466
+ initialPopulation = await population(populateInstruction, 0);
1467
+ }
1479
1468
  let turn = initialPopulation.turn;
1480
1469
  planningRecord = checkpointInterview(dir, planningRecord, { runtime: { runtime: roleBuilder.runtime, model: roleBuilder.model, sessionId: builder.sessionId() }, decisions: { ...planningRecord.decisions, proposalResponse: turn.result.text, structuredResponse: turn.result.structured } });
1481
1470
  log.write("ticket-populate", {
@@ -1,7 +1,6 @@
1
1
  import type { ContinuityDelta, ResolvedAgentSettings } from "rafi-spec";
2
2
  import type { BuilderAdapter, CompactResult, ContextUsage, NativeAutoCompactionPolicy, NativeCompaction, ProviderSessionUsage, ProviderSettingSwitch, RuntimeFailure, TurnResult } from "./adapters/types.js";
3
3
  import type { BuilderHandoffRequest } from "./handoffs.js";
4
- export declare const CONTINUITY_MARKER = "RAFI_CONTINUITY_DELTA:";
5
4
  export declare class ContinuityValidationError extends Error {
6
5
  readonly problems: string[];
7
6
  constructor(problems: string[]);
@@ -27,7 +26,6 @@ export declare const CONTINUITY_REQUIRED_INSTRUCTION = "Include the required bou
27
26
  export declare function validateContinuityDelta(value: unknown): ContinuityDelta;
28
27
  export declare function mergeContinuityDeltas(deltas: readonly ContinuityDelta[]): ContinuityDelta;
29
28
  export declare function baselineContinuityDelta(nextAction?: string): ContinuityDelta;
30
- export declare function continuityInstruction(role?: "builder" | "qa"): string;
31
29
  export interface ContinuityAdapterOptions {
32
30
  adapter: BuilderAdapter;
33
31
  projectDir: string;
@@ -1,4 +1,5 @@
1
1
  import { persistedProviderWireContract } from "rafi-spec";
2
+ import { markDispatchNotSent } from "./dispatchNotSent.js";
2
3
  import { dispatchWithGraphAccess, withGraphSessionAccess } from "./graph/session.js";
3
4
  import { localBuildAuthority } from "./buildAdmission.js";
4
5
  import { randomUUID, createHash } from "node:crypto";
@@ -9,7 +10,6 @@ import { nativePolicy, operationInstruction, journalStructuredDispatch, continua
9
10
  import { WorkflowDb } from "./workflowDb.js";
10
11
  import { assertSealedDispatch, withFreshRecoveryValidation } from "./structuredDispatchAuthority.js";
11
12
  import { verifyPersistedDispatch } from "./structuredDispatchRecord.js";
12
- export const CONTINUITY_MARKER = "RAFI_CONTINUITY_DELTA:";
13
13
  export class ContinuityValidationError extends Error {
14
14
  problems;
15
15
  constructor(problems) {
@@ -93,15 +93,6 @@ export function mergeContinuityDeltas(deltas) {
93
93
  export function baselineContinuityDelta(nextAction = "Await the first role action.") {
94
94
  return { version: 1, decisions: [], constraints: [], discoveries: [], completedActions: [], evidence: [], failures: [], blockers: [], openWork: [], nextAction };
95
95
  }
96
- export function continuityInstruction(role) {
97
- return [
98
- "Before your final STEP_STATUS line (or as the final line when STEP_STATUS is not requested), emit exactly one single-line continuity record:",
99
- `${CONTINUITY_MARKER} {"version":1,"decisions":[],"constraints":[],"discoveries":[],"completedActions":[],"evidence":[],"failures":[],"blockers":[],"openWork":[],"nextAction":"specific next action"}`,
100
- "The continuity record is JSON. A requested STEP_STATUS line is separate plain text: never add JSON braces, a trailing comma, or a code fence to that status line.",
101
- ...(role === "qa" ? ["QA protocol: this continuity record is required even when the review requests only a report or status. Put it before RAFI_QA_FAILURE_REPORT_START when returning a failure envelope, outside the report JSON. Rafi strips the record before validating the report, so keep the report envelope immediately followed by STEP_STATUS."] : []),
102
- "Keep it concise and cumulative for facts learned this turn. Do not include credentials, hidden reasoning, or raw transcripts.",
103
- ].join("\n");
104
- }
105
96
  /** Enforces a durable role checkpoint after every completed provider turn. */
106
97
  export class ContinuityAdapter {
107
98
  options;
@@ -201,30 +192,35 @@ export class ContinuityAdapter {
201
192
  const beforeSession = this.adapter.sessionRef?.();
202
193
  let original;
203
194
  try {
204
- journal.atomic(() => {
205
- if (this.buildAuthority)
206
- journal.assertBuildAdmission(this.buildAuthority);
207
- else if (journal.buildAdmission())
208
- throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "dispatch requires original build admission authority");
209
- if (process.send && process.env.RAFI_BUILD_WORKER_RUN === this.options.runId) {
210
- const supervisor = journal.supervisorState(this.options.runId);
211
- if (!process.connected || supervisor?.status !== "running" || supervisor.workerGeneration !== Number(process.env.RAFI_BUILD_WORKER_GENERATION))
212
- throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "supervised worker no longer owns dispatch authority");
213
- }
214
- const owner = journal.roleMutationLease(this.options.runId, this.options.role);
215
- const ref = this.adapter.sessionRef?.();
216
- const session = ref?.sessionId ?? this.adapter.sessionId();
217
- if (!(this.validatingFreshRecovery && this.recoveryLeasePending && policy?.responseOnly) && owner && session && (owner.providerSessionId !== session || (owner.sessionKey && ref && owner.sessionKey !== providerSessionKey(ref)))) {
218
- throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "provider session no longer owns role dispatch authority");
219
- }
220
- if (journal.unresolvedRoleDispatches(this.options.runId, this.options.role).filter(item => item.idempotencyKey !== dispatchId).length)
221
- throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "a previous dispatched turn has no durable completion; reconcile it before sending more work");
222
- if (!policy?.selectedDispatch)
223
- journal.planOperation({ runId: this.options.runId, idempotencyKey: dispatchId, kind: "provider-dispatch", intent: { role: this.options.role, sessionRef: this.adapter.sessionRef?.(), instructionDigest: sha(instruction), instructionBytes: Buffer.byteLength(instruction) } });
224
- if (!policy?.selectedDispatch)
225
- journal.updateOperation(dispatchId, "in_progress");
226
- journal.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: "turn_started", payload: { role: this.options.role, dispatchId, instructionDigest: sha(instruction), instructionBytes: Buffer.byteLength(instruction) }, authoritativeStateRevision: this.revision() });
227
- });
195
+ try {
196
+ journal.atomic(() => {
197
+ if (this.buildAuthority)
198
+ journal.assertBuildAdmission(this.buildAuthority);
199
+ else if (journal.buildAdmission())
200
+ throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "dispatch requires original build admission authority");
201
+ if (process.send && process.env.RAFI_BUILD_WORKER_RUN === this.options.runId) {
202
+ const supervisor = journal.supervisorState(this.options.runId);
203
+ if (!process.connected || supervisor?.status !== "running" || supervisor.workerGeneration !== Number(process.env.RAFI_BUILD_WORKER_GENERATION))
204
+ throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "supervised worker no longer owns dispatch authority");
205
+ }
206
+ const owner = journal.roleMutationLease(this.options.runId, this.options.role);
207
+ const ref = this.adapter.sessionRef?.();
208
+ const session = ref?.sessionId ?? this.adapter.sessionId();
209
+ if (!(this.validatingFreshRecovery && this.recoveryLeasePending && policy?.responseOnly) && owner && session && (owner.providerSessionId !== session || (owner.sessionKey && ref && owner.sessionKey !== providerSessionKey(ref)))) {
210
+ throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "provider session no longer owns role dispatch authority");
211
+ }
212
+ if (journal.unresolvedRoleDispatches(this.options.runId, this.options.role).filter(item => item.idempotencyKey !== dispatchId).length)
213
+ throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "a previous dispatched turn has no durable completion; reconcile it before sending more work");
214
+ if (!policy?.selectedDispatch)
215
+ journal.planOperation({ runId: this.options.runId, idempotencyKey: dispatchId, kind: "provider-dispatch", intent: { role: this.options.role, sessionRef: this.adapter.sessionRef?.(), instructionDigest: sha(instruction), instructionBytes: Buffer.byteLength(instruction) } });
216
+ if (!policy?.selectedDispatch)
217
+ journal.updateOperation(dispatchId, "in_progress");
218
+ journal.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: "turn_started", payload: { role: this.options.role, dispatchId, instructionDigest: sha(instruction), instructionBytes: Buffer.byteLength(instruction) }, authoritativeStateRevision: this.revision() });
219
+ });
220
+ }
221
+ catch (error) {
222
+ throw markDispatchNotSent(error);
223
+ }
228
224
  try {
229
225
  original = await dispatchWithGraphAccess(this.options.projectDir, this.adapter, providerInstruction, policy, (text, p) => this.adapter.sendTurn(text, p));
230
226
  }
@@ -0,0 +1,4 @@
1
+ /** Tag an error raised before any provider transport began, so the journal
2
+ * records a known unsent turn instead of an unknown outcome. */
3
+ export declare function markDispatchNotSent(error: unknown): unknown;
4
+ export declare function dispatchWasNotSent(error: unknown): boolean;
@@ -0,0 +1,10 @@
1
+ /** Tag an error raised before any provider transport began, so the journal
2
+ * records a known unsent turn instead of an unknown outcome. */
3
+ export function markDispatchNotSent(error) {
4
+ if (error && typeof error === "object")
5
+ error.dispatchNotSent = true;
6
+ return error;
7
+ }
8
+ export function dispatchWasNotSent(error) {
9
+ return Boolean(error && typeof error === "object" && error.dispatchNotSent);
10
+ }
package/dist/foreman.d.ts CHANGED
@@ -7,7 +7,7 @@ import { type QaNonconvergenceContext, type QaNonconvergenceDecision, type QaRep
7
7
  import type { SessionStrategy } from "rafi-spec";
8
8
  import type { RunObserver } from "./observability.js";
9
9
  import type { QaRecoveryPacket } from "./qaRecovery.js";
10
- /** Parsed STEP_STATUS marker from a builder's turn. */
10
+ /** A role turn's outcome, derived from its native structured result. */
11
11
  export interface StepStatus {
12
12
  kind: "done" | "blocked" | "plan_complete" | "needs_input" | "qa_pass" | "qa_fail" | "unknown";
13
13
  summary?: string;
@@ -21,17 +21,10 @@ export interface StepStatus {
21
21
  error?: string;
22
22
  }
23
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.
24
+ * Workflow-specific completion is opt-in. Callers may interpret a valid
25
+ * structured response before the generic native result handling runs.
26
26
  */
27
27
  export type InstructionCompletion = (text: string) => StepStatus | undefined;
28
- /**
29
- * Pull the STEP_STATUS marker out of a turn's final text.
30
- * Format: `STEP_STATUS: <kind> | key="value" key="value"`.
31
- */
32
- export declare function parseStepStatus(text: string, options?: {
33
- requireFinalLine?: boolean;
34
- }): StepStatus;
35
28
  /** Heuristic: did a marker-less turn end by asking the human something? */
36
29
  export declare function looksLikeQuestion(text: string): boolean;
37
30
  export { MARKER_SPEC } from "./markers.js";
@@ -39,13 +32,13 @@ export { QA_MARKER_SPEC } from "./markers.js";
39
32
  /** Instruction sent on the first turn of a batch. */
40
33
  export declare function buildPrimer(n: number, trackerPath?: string, ticketsEnabled?: boolean, preferredTicketId?: string): string;
41
34
  /** Instruction sent on turns 2–N. */
42
- export declare function buildNextStepInstruction(i: number, n: number): string;
35
+ export declare function buildNextStepInstruction(i: number, n: number, ticketsEnabled?: boolean): string;
43
36
  /** QA review turn: ask the builder to triple-check the ticket or step it just completed. */
44
37
  export declare function buildQaInstruction(): string;
45
38
  /** Follow-up turn after qa_fail: have the builder implement the listed fixes. */
46
39
  export declare function buildQaFixInstruction(issues: string): string;
47
40
  /** Pre-flight planning turn: ask the builder to list its next N steps without implementing anything. */
48
- export declare function buildPlanningTurn(n: number, ticketsContent?: string, preferredTicketId?: string): string;
41
+ export declare function buildPlanningTurn(n: number, ticketsContent?: string, preferredTicketId?: string, ticketsEnabled?: boolean): string;
49
42
  /** Builds the permission callback: classify, log, allow or escalate. */
50
43
  export declare function createPermissionHandler(policy: PermissionPolicy, log: Log, opts?: {
51
44
  interactive?: boolean;