ai-foreman 1.7.23 → 1.7.25

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.
@@ -48,6 +48,7 @@ export declare class ActivityReporter {
48
48
  private lastSemanticKey?;
49
49
  private paused;
50
50
  private agentStatusLine?;
51
+ private readonly reportedToolIssues;
51
52
  constructor(command: string, options?: ActivityReporterOptions);
52
53
  begin(label: string): () => void;
53
54
  update(state: string, detail?: string, metadata?: {
@@ -58,6 +59,8 @@ export declare class ActivityReporter {
58
59
  pulse(detail?: string): void;
59
60
  /** A completed tool or turn is distinct from transport/status activity. */
60
61
  completedWork(): void;
62
+ /** One tool may fail recoverably many times while the agent continues. */
63
+ reportToolIssue(name: string): void;
61
64
  note(message: string): void;
62
65
  writePersistent(text: string): void;
63
66
  /** Keep role/provider/context state attached to the current activity status. */
package/dist/activity.js CHANGED
@@ -32,6 +32,7 @@ export class ActivityReporter {
32
32
  lastSemanticKey;
33
33
  paused = 0;
34
34
  agentStatusLine;
35
+ reportedToolIssues = new Set();
35
36
  constructor(command, options = {}) {
36
37
  this.command = command;
37
38
  this.output = options.output ?? {
@@ -95,6 +96,16 @@ export class ActivityReporter {
95
96
  this.lastUsefulProgressAt = this.now();
96
97
  this.usefulProgressWarningPrinted = false;
97
98
  }
99
+ /** One tool may fail recoverably many times while the agent continues. */
100
+ reportToolIssue(name) {
101
+ const key = clean(name, 100).toLowerCase();
102
+ if (this.reportedToolIssues.has(key)) {
103
+ this.pulse();
104
+ return;
105
+ }
106
+ this.reportedToolIssues.add(key);
107
+ this.update("tool reported a failure; agent continuing", name);
108
+ }
98
109
  note(message) {
99
110
  this.clearLine();
100
111
  this.output.write(`${sanitizeTerminalText(message).replace(/[\r\t]+/g, " ").slice(0, 500)}\n`);
@@ -307,7 +318,7 @@ export function reportBuilderEvent(event) {
307
318
  else if (event.status === "cancelled")
308
319
  reporter.update("tool cancelled", event.name);
309
320
  else if (isFailedToolCompletion(event))
310
- reporter.update("tool failed", event.name);
321
+ reporter.reportToolIssue(event.name);
311
322
  else
312
323
  reporter.pulse();
313
324
  break;
@@ -637,7 +637,20 @@ export class ClaudeAdapter {
637
637
  const failed = block.is_error === true;
638
638
  // TIME_ELAPSED alone does not establish success or failure. Preserve it as unknown.
639
639
  const elapsedOnly = /^\s*TIME_ELAPSED\s*$/.test(text);
640
- const status = failed && /timed?\s*out|timeout/i.test(text) ? "timed_out" : failed && /cancel(?:led|ed)/i.test(text) ? "cancelled" : elapsedOnly ? "unknown" : failed ? "failed" : "completed";
640
+ // An SDK `is_error` bit without a terminal reason is not enough to call
641
+ // the tool failed: enterprise gateways can emit provisional errors and
642
+ // continue the same turn. Preserve that state as unknown until a
643
+ // correlated, explicit terminal result arrives.
644
+ const resultReason = elapsedOnly ? "elapsed_placeholder"
645
+ : failed && /timed?\s*out|timeout/i.test(text) ? "timeout"
646
+ : failed && /cancel(?:led|ed)/i.test(text) ? "cancelled"
647
+ : failed && /permission denied/i.test(text) ? "permission_denied"
648
+ : failed && /not found/i.test(text) ? "not_found"
649
+ : failed && /\b(?:exit(?:ed)?|status)\s+[1-9]\d*\b/i.test(text) ? "nonzero_exit"
650
+ : failed && /\b(?:failed|error|exception)\b/i.test(text) ? "explicit_error"
651
+ : failed ? "unexplained_error"
652
+ : "completed";
653
+ const status = resultReason === "timeout" ? "timed_out" : resultReason === "cancelled" ? "cancelled" : ["permission_denied", "not_found", "nonzero_exit", "explicit_error"].includes(resultReason) ? "failed" : resultReason === "completed" ? "completed" : "unknown";
641
654
  if (status !== "unknown") {
642
655
  this.completedToolCalls.add(callId);
643
656
  if (this.completedToolCalls.size > 1000)
@@ -645,6 +658,9 @@ export class ClaudeAdapter {
645
658
  this.toolCalls.delete(callId);
646
659
  }
647
660
  const outputDigest = createHash("sha256").update(text).digest("hex");
661
+ // Persist only correlation metadata. The result text can contain source
662
+ // paths, command output, or credentials and must not enter lifecycle logs.
663
+ this.opts.onLifecycleTrace?.({ phase: "provider-tool-result", at: new Date().toISOString(), sessionId: this._sessionId, providerTurnId: this.activeProviderTurnId, toolCallId: callId, toolName: call?.name ?? "unidentified tool", classification: status, resultReason, isError: failed, outputDigest, rawOutputBytes: Buffer.byteLength(text) });
648
664
  if (status === "unknown") {
649
665
  if (this.unknownToolResults.get(callId) === outputDigest)
650
666
  continue;
@@ -269,6 +269,7 @@ export interface BuilderAdapterOptions {
269
269
  turnDeadlineMs?: number;
270
270
  shutdownTimeoutMs?: number;
271
271
  /** Metadata-only lifecycle trace; no prompts, responses, credentials, or tool inputs. */
272
+ /** Bounded, redacted provider lifecycle diagnostics; never include raw tool output. */
272
273
  onLifecycleTrace?: (event: {
273
274
  phase: string;
274
275
  at: string;
@@ -276,6 +277,14 @@ export interface BuilderAdapterOptions {
276
277
  sessionId?: string;
277
278
  method?: string;
278
279
  timeoutMs?: number;
280
+ providerTurnId?: string;
281
+ toolCallId?: string;
282
+ toolName?: string;
283
+ classification?: string;
284
+ resultReason?: string;
285
+ isError?: boolean;
286
+ outputDigest?: string;
287
+ rawOutputBytes?: number;
279
288
  }) => void;
280
289
  }
281
290
  export interface BuilderAdapter {
@@ -96,7 +96,7 @@ export async function runBranchPlan(opts) {
96
96
  recoveryDb.close();
97
97
  }
98
98
  if (exactInterruptedMerge) {
99
- opts.log.write("branch-resume", { detail: "Base contains exactly the staged tree of its durable interrupted merge" });
99
+ opts.log.write("branch-resume", { runId: opts.runId, detail: "Base contains exactly the staged tree of its durable interrupted merge" });
100
100
  }
101
101
  else if (baseWorktreePolicy === "warn") {
102
102
  console.warn(`foreman: warning: ${error instanceof Error ? error.message : String(error)}`);
@@ -117,7 +117,7 @@ export async function runBranchPlan(opts) {
117
117
  let builderWorkSessions = 0;
118
118
  const qaStream = { reviews: 0, modificationViolations: 0 };
119
119
  for (const issue of opts.plan.issues) {
120
- opts.log.write("branch-issue", { ...issue });
120
+ opts.log.write("branch-issue", { runId: opts.runId, ...issue });
121
121
  notifyIssue(opts.notificationsEnabled, issue.message);
122
122
  if (issue.blocking) {
123
123
  summaries.push({
@@ -244,6 +244,7 @@ export async function runBranchPlan(opts) {
244
244
  const detail = `dependency ${missingDependency} did not complete`;
245
245
  summaries.push(summaryFor(node, "skipped", detail));
246
246
  opts.log.write("branch-issue", {
247
+ runId: opts.runId,
247
248
  ticket: node.ticket.id,
248
249
  code: "dependency_unavailable",
249
250
  message: detail,
@@ -261,6 +262,7 @@ export async function runBranchPlan(opts) {
261
262
  const detail = `dependency ${missingPush} branch was not pushed`;
262
263
  summaries.push(summaryFor(node, "skipped", detail, "skipped"));
263
264
  opts.log.write("branch-issue", {
265
+ runId: opts.runId,
264
266
  ticket: node.ticket.id,
265
267
  code: "dependency_unavailable",
266
268
  message: detail,
@@ -275,6 +277,7 @@ export async function runBranchPlan(opts) {
275
277
  if (!mergeReadiness.ok) {
276
278
  summaries.push(summaryFor(node, "skipped", mergeReadiness.message, "skipped"));
277
279
  opts.log.write("branch-issue", {
280
+ runId: opts.runId,
278
281
  ticket: node.ticket.id,
279
282
  code: mergeReadiness.code,
280
283
  message: mergeReadiness.message,
@@ -295,6 +298,7 @@ export async function runBranchPlan(opts) {
295
298
  admissionDb.close();
296
299
  }
297
300
  opts.log.write("branch-start", {
301
+ runId: opts.runId,
298
302
  ticket: node.ticket.id,
299
303
  branch: node.branch,
300
304
  base: node.baseBranch,
@@ -314,6 +318,7 @@ export async function runBranchPlan(opts) {
314
318
  try {
315
319
  if (resumeSession) {
316
320
  opts.log.write("branch-resume", {
321
+ runId: opts.runId,
317
322
  ticket: node.ticket.id,
318
323
  branch: node.branch,
319
324
  base: node.baseBranch,
@@ -660,7 +665,7 @@ export async function runBranchPlan(opts) {
660
665
  }));
661
666
  if (push.ok) {
662
667
  confirmJournal(opts.projectDir, pushOperation, node.branch, { sha: commit });
663
- opts.log.write("branch-push", { ticket: node.ticket.id, branch: node.branch, status: "pushed" });
668
+ opts.log.write("branch-push", { runId: opts.runId, ticket: node.ticket.id, branch: node.branch, status: "pushed" });
664
669
  summary.pushStatus = "pushed";
665
670
  workflowCheckpoint(opts.projectDir, opts.runId, "push-after", node.ticket.id, { branch: node.branch, sha: commit });
666
671
  }
@@ -677,6 +682,7 @@ export async function runBranchPlan(opts) {
677
682
  output: push.output,
678
683
  };
679
684
  opts.log.write("branch-push", {
685
+ runId: opts.runId,
680
686
  ticket: node.ticket.id,
681
687
  branch: node.branch,
682
688
  status: "failed",
@@ -786,6 +792,7 @@ export async function runBranchPlan(opts) {
786
792
  }
787
793
  summary.pr = { status: "merged", url: mergeCommit };
788
794
  opts.log.write("branch-direct-merge", {
795
+ runId: opts.runId,
789
796
  ticket: node.ticket.id,
790
797
  branch: node.branch,
791
798
  base: node.baseBranch,
@@ -819,6 +826,7 @@ export async function runBranchPlan(opts) {
819
826
  successfulBranches.add(node.ticket.id);
820
827
  summaries.push(summary);
821
828
  opts.log.write("branch-complete", {
829
+ runId: opts.runId,
822
830
  ticket: node.ticket.id,
823
831
  branch: node.branch,
824
832
  commit,
@@ -844,7 +852,7 @@ export async function runBranchPlan(opts) {
844
852
  }
845
853
  if (err instanceof SessionUnavailableError || err instanceof SessionUnavailableContinuityError) {
846
854
  const message = err.message;
847
- opts.log.write("branch-issue", { ticket: node.ticket.id, code: "session_unavailable", message, blocking: true });
855
+ opts.log.write("branch-issue", { runId: opts.runId, ticket: node.ticket.id, code: "session_unavailable", message, blocking: true });
848
856
  notifyIssue(opts.notificationsEnabled, `${node.ticket.id}: ${message}`);
849
857
  summaries.push(summaryFor(node, "blocked", message));
850
858
  opts.onSessionUnavailable?.(err);
@@ -856,7 +864,7 @@ export async function runBranchPlan(opts) {
856
864
  : message.includes("tracker/control")
857
865
  ? "tracker_touched"
858
866
  : "builder_error";
859
- opts.log.write("branch-issue", { ticket: node.ticket.id, code, message, blocking: false });
867
+ opts.log.write("branch-issue", { runId: opts.runId, ticket: node.ticket.id, code, message, blocking: false });
860
868
  notifyIssue(opts.notificationsEnabled, `${node.ticket.id}: ${message}`);
861
869
  journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:runtime-block${continuationSuffix}`, { ticket: node.ticket.id, status: "blocked", detail: message }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: message, actor: "foreman" }));
862
870
  summaries.push(summaryFor(node, "blocked", message));
@@ -1025,6 +1033,7 @@ async function waitForAutoMergeDependenciesInternal(opts, node, provider) {
1025
1033
  }
1026
1034
  if (!waitLogged) {
1027
1035
  opts.log.write("branch-auto-merge-wait", {
1036
+ runId: opts.runId,
1028
1037
  ticket: node.ticket.id,
1029
1038
  pending,
1030
1039
  timeoutMinutes: opts.autoMergeTimeoutMinutes ?? null,
@@ -1046,7 +1055,7 @@ async function waitForAutoMergeDependenciesInternal(opts, node, provider) {
1046
1055
  if (waitSpanId && opts.observer)
1047
1056
  opts.observer.store.finishSpan(waitSpanId, { outcome: waitOutcome });
1048
1057
  if (waitLogged)
1049
- opts.log.write("branch-auto-merge-wait", { ticket: node.ticket.id, outcome: waitOutcome, terminal: true });
1058
+ opts.log.write("branch-auto-merge-wait", { runId: opts.runId, ticket: node.ticket.id, outcome: waitOutcome, terminal: true });
1050
1059
  }
1051
1060
  }
1052
1061
  function autoMergePollMs() {
@@ -1080,6 +1089,7 @@ function blockBranchIssue(opts, node, summary, code, message, details) {
1080
1089
  summary.buildStatus = "blocked";
1081
1090
  summary.detail = message;
1082
1091
  opts.log.write("branch-issue", {
1092
+ runId: opts.runId,
1083
1093
  ticket: node.ticket.id,
1084
1094
  code,
1085
1095
  message,
@@ -1128,6 +1138,7 @@ Branch-mode rules:
1128
1138
  - Do not switch branches.
1129
1139
  - Do not push, create PRs, or run git commit.
1130
1140
  - Do not edit .tickets/, ${progressDoc}, or other tracker state.
1141
+ - If a required test is denied only because you added a pipe, redirection, substitution, or wrapper, retry the exact required-test command without that extra shell syntax. Do not retry other denied actions.
1131
1142
  - If another selected ticket is required before this one can be completed, stop and end with STEP_STATUS: blocked | ticket="${node.ticket.id}" branch_dependency="<ticket-id>" reason="<why>".
1132
1143
  - End with STEP_STATUS: done | ticket="${node.ticket.id}" summary="<what changed>" when the ticket is implemented.
1133
1144
 
@@ -2,6 +2,7 @@ import type { BuilderAdapter } from "./adapters/types.js";
2
2
  import type { StepStatus } from "./foreman.js";
3
3
  import type { TurnResult } from "./adapters/types.js";
4
4
  import { type FrozenQaSourceState } from "./qaSnapshot.js";
5
+ import type { BuildRunRecordV2, ProviderSessionRefV1 } from "rafi-spec";
5
6
  /** Work responses confirm an assignment; they never select the next ticket. */
6
7
  export declare function validateBuildAssignment(ticketId: string, status: StepStatus, responseText?: string): string | undefined;
7
8
  export interface BuildAssignmentCapture {
@@ -19,6 +20,23 @@ type BuildSourceState = Pick<FrozenQaSourceState, "digest" | "head" | "contentDi
19
20
  };
20
21
  export declare class BuildAssignmentRejected extends Error {
21
22
  }
23
+ /** A frozen exact-session Builder decision may defer unrelated pending QA. */
24
+ export declare function isInterruptedBuilderRecovery(run: BuildRunRecordV2 | undefined): boolean;
25
+ /**
26
+ * Retire only the host-side records for a Builder turn that was interrupted
27
+ * before Rafi received a durable response. The next turn is sent to the
28
+ * *same validated provider session*, so it continues the outstanding work
29
+ * rather than treating unknown work as complete or replaying it in a new
30
+ * conversation.
31
+ */
32
+ export declare function prepareExactSessionBuilderContinuation(projectDir: string, runId: string, session: ProviderSessionRefV1): {
33
+ ticketIds: string[];
34
+ dispatchIds: string[];
35
+ assignmentIds: string[];
36
+ };
37
+ /** Retire uncertain execution for an explicitly selected start-over, never claim
38
+ * that a provider did not run or that its preserved implementation passed QA. */
39
+ export declare function retireBuildExecutionForStartOver(projectDir: string, runId: string, operationId: string): void;
22
40
  export interface BuilderGuidanceFollowup {
23
41
  reviewAttemptId: string;
24
42
  requirementsDigest: string;
@@ -26,13 +26,152 @@ export function validateBuildAssignment(ticketId, status, responseText = "") {
26
26
  }
27
27
  export class BuildAssignmentRejected extends Error {
28
28
  }
29
+ /** A frozen exact-session Builder decision may defer unrelated pending QA. */
30
+ export function isInterruptedBuilderRecovery(run) {
31
+ if (!run)
32
+ return false;
33
+ const decision = run.recoveryDecision;
34
+ const continuation = run.interruptedBuilderContinuation;
35
+ return decision?.runId === run.runId && decision.role === "builder" && decision.mode === "exact-session"
36
+ && decision.executionTickets?.length === 1
37
+ && run.tickets.includes(decision.executionTickets[0])
38
+ && Boolean(continuation?.ticketIds?.includes(decision.executionTickets[0]))
39
+ && Boolean(continuation?.sessionRef && decision.predecessorSessionRef && sameSession(continuation.sessionRef, decision.predecessorSessionRef));
40
+ }
41
+ /**
42
+ * Retire only the host-side records for a Builder turn that was interrupted
43
+ * before Rafi received a durable response. The next turn is sent to the
44
+ * *same validated provider session*, so it continues the outstanding work
45
+ * rather than treating unknown work as complete or replaying it in a new
46
+ * conversation.
47
+ */
48
+ export function prepareExactSessionBuilderContinuation(projectDir, runId, session) {
49
+ const db = new WorkflowDb(projectDir);
50
+ try {
51
+ const dispatches = db.unresolvedRoleDispatches(runId, "builder");
52
+ if (!dispatches.length)
53
+ throw new BuildAssignmentRejected("No interrupted Builder dispatch is awaiting exact-session continuation");
54
+ if (dispatches.some(dispatch => !sameSession(dispatch.intent.sessionRef, session))) {
55
+ throw new BuildAssignmentRejected("Interrupted Builder dispatch is not bound to the validated exact provider session");
56
+ }
57
+ const assignments = db.operations(runId).filter(operation => operation.kind === "build-assignment" && ["in_progress", "uncertain"].includes(operation.status));
58
+ if (!assignments.length)
59
+ throw new BuildAssignmentRejected("Interrupted Builder dispatch has no durable in-progress assignment");
60
+ const ticketIds = assignments.map(operation => operation.intent.ticketId)
61
+ .filter((ticketId) => typeof ticketId === "string");
62
+ if (ticketIds.length !== assignments.length)
63
+ throw new BuildAssignmentRejected("Interrupted Builder assignment is missing its ticket identity");
64
+ db.atomic(() => {
65
+ const resumedAt = new Date().toISOString();
66
+ for (const dispatch of dispatches) {
67
+ db.updateOperation(dispatch.idempotencyKey, "failed", {
68
+ result: { recovery: "exact-session-continuation", resumedAt, sessionRef: session },
69
+ error: "Host interruption: the same validated Builder session will reconcile and continue this turn",
70
+ });
71
+ }
72
+ for (const assignment of assignments) {
73
+ db.updateOperation(assignment.idempotencyKey, "failed", {
74
+ result: { recovery: "exact-session-continuation", resumedAt, sessionRef: session },
75
+ error: "Host interruption: work remains assigned to the validated Builder session; do not send it to QA yet",
76
+ });
77
+ }
78
+ const run = db.getRun(runId);
79
+ if (run)
80
+ db.transition(runId, {
81
+ checkpoint: "builder-exact-session-continuation",
82
+ state: { ...run.state, interruptedBuilderContinuation: { ticketIds, sessionRef: session, resumedAt } },
83
+ event: "builder_exact_session_continuation_authorized",
84
+ payload: { ticketIds, sessionRef: session },
85
+ });
86
+ });
87
+ return { ticketIds: [...new Set(ticketIds)], dispatchIds: dispatches.map(item => item.idempotencyKey), assignmentIds: assignments.map(item => item.idempotencyKey) };
88
+ }
89
+ finally {
90
+ db.close();
91
+ }
92
+ }
93
+ /** Retire uncertain execution for an explicitly selected start-over, never claim
94
+ * that a provider did not run or that its preserved implementation passed QA. */
95
+ export function retireBuildExecutionForStartOver(projectDir, runId, operationId) {
96
+ const db = new WorkflowDb(projectDir);
97
+ let admission;
98
+ let lease;
99
+ try {
100
+ const operation = db.getRun(operationId);
101
+ const original = operation?.originalWork;
102
+ if (operation?.kind !== "recovery" || operation.state.operation !== "build-start-over" || original?.run?.runId !== runId)
103
+ throw new Error("Execution retirement requires a recorded start-over intent for this run");
104
+ admission = db.acquireBuildRecoveryAdmission(runId);
105
+ lease = db.acquireLease(runId);
106
+ if (db.readinessProcesses().length)
107
+ throw new Error("Owned process cleanup must be verified before starting over");
108
+ if (db.incompletePublications().some(item => {
109
+ if (item.runId !== runId)
110
+ return false;
111
+ const intent = item.intent;
112
+ return intent.operation !== "build-run-projection" || intent.target !== join(resolve(projectDir), ".foreman", "runs", `${runId}.json`);
113
+ }))
114
+ throw new Error("Finish pending publication recovery before starting over; preserved files may still be awaiting publication");
115
+ db.atomic(() => {
116
+ for (const dispatch of [...db.unresolvedRoleDispatches(runId, "builder"), ...db.unresolvedRoleDispatches(runId, "qa")]) {
117
+ db.updateOperation(dispatch.idempotencyKey, "failed", { result: { recovery: "start-over", operationId, outcomeUnknown: true, providerReplayAuthorized: false }, error: "Operator chose a fresh run from preserved source; historical execution outcome remains unknown" });
118
+ }
119
+ const run = db.getRun(runId);
120
+ if (!run)
121
+ throw new Error("Original run disappeared before start-over");
122
+ db.transition(runId, { checkpoint: "execution-retired-for-start-over", state: { ...run.state, executionRetiredForStartOver: operationId }, event: "execution_retired_for_start_over", payload: { operationId, providerReplayAuthorized: false } });
123
+ });
124
+ }
125
+ finally {
126
+ try {
127
+ if (lease)
128
+ db.releaseLease(lease);
129
+ }
130
+ finally {
131
+ try {
132
+ if (admission)
133
+ db.releaseBuildAdmission(admission);
134
+ }
135
+ finally {
136
+ db.close();
137
+ }
138
+ }
139
+ }
140
+ }
141
+ function sameSession(value, session) {
142
+ if (!value || typeof value !== "object")
143
+ return false;
144
+ const candidate = value;
145
+ return candidate.provider === session.provider
146
+ && candidate.sessionId === session.sessionId
147
+ && candidate.generation === session.generation
148
+ && session.role === "builder" && session.stream === "builder"
149
+ && candidate.role === "builder"
150
+ && candidate.stream === "builder"
151
+ && candidate.cwd === session.cwd
152
+ && candidate.configRoot === session.configRoot;
153
+ }
29
154
  export function assertBuildAssignmentReconciled(projectDir, runId, worktree = projectDir) {
30
155
  const reader = new WorkflowReader(projectDir);
31
156
  try {
32
157
  const runs = [...new Set([runId, ...reader.buildRuns(true).map(run => run.runId)])];
158
+ const retiredRuns = new Set();
33
159
  for (const operation of runs.flatMap(id => reader.operations(id, true))) {
34
160
  if (operation.kind !== "build-assignment")
35
161
  continue;
162
+ if (retiredRuns.has(operation.runId))
163
+ continue;
164
+ const owner = reader.getRun(operation.runId);
165
+ // An explicit start-over retires execution, while retaining assignments
166
+ // as historical evidence. It never authorizes their provider replay.
167
+ const retirement = typeof owner?.state.supersededBy === "string" ? reader.getRun(owner.state.supersededBy) : undefined;
168
+ if (owner?.status === "superseded" && retirement?.kind === "recovery" && retirement.state.operation === "build-start-over"
169
+ && retirement.state.gitPreserved === true && typeof retirement.state.resetId === "string"
170
+ && retirement.originalWork.run?.runId === operation.runId
171
+ && reader.events(operation.runId).some(event => event.type === "execution_retired_for_start_over" && event.payload.operationId === retirement.runId)) {
172
+ retiredRuns.add(operation.runId);
173
+ continue;
174
+ }
36
175
  const result = operation.result;
37
176
  if (result?.reconciliationId)
38
177
  continue;
@@ -31,3 +31,5 @@ export declare function repairOwnership(projectDir: string, value: unknown, user
31
31
  afterLocalCommit?(): void;
32
32
  afterPublication?(): void;
33
33
  }): Record<string, unknown>;
34
+ /** Resume only already-admitted work interrupted while recording its response. */
35
+ export declare function reconcileInterruptedInScopeWork(projectDir: string, runId: string, workId: string): Record<string, unknown>;
@@ -9,6 +9,7 @@ import { loadTickets } from "./tickets/ticketLoader.js";
9
9
  import { bindBuildAuthority, checkpointBuildRun } from "./buildRuns.js";
10
10
  import { classifyProcess } from "./processIdentity.js";
11
11
  import { validateOwnershipRepair } from "./buildWorkReconciliation.js";
12
+ import { randomUUID } from "node:crypto";
12
13
  export function prepareOwnershipRepair(projectDir, runId, workId) {
13
14
  const snapshot = readQaEvidenceSnapshot(projectDir, runId);
14
15
  if (snapshot.availability === "corrupt" || snapshot.availability === "unreadable" || snapshot.availability === "missing")
@@ -24,7 +25,11 @@ export function prepareOwnershipRepair(projectDir, runId, workId) {
24
25
  export function repairOwnership(projectDir, value, userTurn, faults) {
25
26
  validateOwnershipRepair(value);
26
27
  const request = value;
27
- if (!userTurn.trim().startsWith("/qa-repair ") || JSON.stringify(JSON.parse(userTurn.trim().slice("/qa-repair ".length))) !== JSON.stringify(request))
28
+ const explicitHostRequest = userTurn.trim().startsWith("/qa-repair ") && JSON.stringify(JSON.parse(userTurn.trim().slice("/qa-repair ".length))) === JSON.stringify(request);
29
+ const resumeRequest = userTurn.trim() === "rafi resume reconciliation " + JSON.stringify(request)
30
+ && request.choice === "authorize_mapped_work"
31
+ && request.authorization === "Resume reconciled already-approved in-scope work after an interrupted assignment response";
32
+ if (!explicitHostRequest && !resumeRequest)
28
33
  throw new Error("Repair requires the original explicit host request");
29
34
  const reader = new WorkflowReader(projectDir);
30
35
  try {
@@ -109,3 +114,20 @@ export function repairOwnership(projectDir, value, userTurn, faults) {
109
114
  }
110
115
  }
111
116
  }
117
+ /** Resume only already-admitted work interrupted while recording its response. */
118
+ export function reconcileInterruptedInScopeWork(projectDir, runId, workId) {
119
+ const plan = prepareOwnershipRepair(projectDir, runId, workId);
120
+ const conflicts = plan.ownership.conflicts;
121
+ if (!conflicts.length || conflicts.some(conflict => conflict.workId !== workId || conflict.classification !== "assignment_response_conflict")) {
122
+ throw new Error("Interrupted work is not a single in-scope assignment response conflict");
123
+ }
124
+ const request = {
125
+ version: 1, requestId: `resume-${randomUUID()}`, runId, workId,
126
+ expectedRevision: plan.expectedRevision, choice: "authorize_mapped_work",
127
+ inspectedSourceDigest: plan.inspectedSourceDigest, scopeRevision: plan.scopeRevision,
128
+ authorization: "Resume reconciled already-approved in-scope work after an interrupted assignment response",
129
+ attestation: "Rafi retained the interrupted response and inspected the current preserved source; do not replay the provider turn and require fresh QA",
130
+ mapping: plan.requiredPaths.map(path => ({ path, workId })),
131
+ };
132
+ return repairOwnership(projectDir, request, `rafi resume reconciliation ${JSON.stringify(request)}`);
133
+ }
@@ -24,7 +24,9 @@ export declare function validateStartOptions(opts: Record<string, any>, requireC
24
24
  export declare function validateRecoveryArguments(args: readonly string[], projectDir: string): void;
25
25
  /** Validate persisted preparation argv without executing a command or provider. */
26
26
  export declare function validatePreparationArguments(args: readonly string[], projectDir: string): string[];
27
- export declare function buildStartCommand(): Command;
27
+ export declare function buildStartCommand(options?: {
28
+ beforeFreshStart?: (projectDir: string) => Promise<boolean>;
29
+ }): Command;
28
30
  export declare function createQaNonconvergenceHandler(projectDir: string, noninteractive: boolean, planner?: AgentRoleDefaultsV1, observer?: () => RunObserver | undefined, activeRunId?: () => string): (context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>;
29
31
  export declare function createQaReportRecoveryHandler(projectDir: string, noninteractive: boolean, observer?: () => RunObserver | undefined): QaReportRecoveryHandler;
30
32
  /** Make the executable branch plan exactly match each explicit root-to-tip stack. */
package/dist/cli/start.js CHANGED
@@ -8,7 +8,7 @@ import { formatRecoveryCommand, formatDecisionCommands, formatExactRunRecovery }
8
8
  import { durableReadOnlyProposal } from "../readOnlyProposal.js";
9
9
  import { captureFrozenQaSource } from "../qaSnapshot.js";
10
10
  import { finishStartAdmission, superviseStart, supervisedRunId } from "../supervisedStart.js";
11
- import { assertBuildAssignmentReconciled } from "../buildAssignment.js";
11
+ import { assertBuildAssignmentReconciled, isInterruptedBuilderRecovery } from "../buildAssignment.js";
12
12
  import { durableHumanDecision, HumanDecisionRequired, HumanDecisionCancelled, servicePendingHumanDecisions } from "../humanDecision.js";
13
13
  import { requiresBuildApproval, buildScopeRevision, createBuildApprovalGate } from "../buildApproval.js";
14
14
  import { Command, Option } from "commander";
@@ -43,7 +43,7 @@ import { buildRunSessionBinding, checkpointBuildRun, completeBuildRun, createBui
43
43
  import { parse as parseYaml } from "yaml";
44
44
  import { findResumableBranchSessions, formatBranchContinueCommand, formatBranchSummaryFollowupCommands, } from "../branch/resume.js";
45
45
  import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig.js";
46
- import { loadDeliveryConfig, selectDeliveryUnitForRun, selectStacksForRun, updateStackDeliveryState } from "../tickets/delivery.js";
46
+ import { loadDeliveryConfig, selectDeliveryTicketsForRun, selectDeliveryUnitForRun, selectStacksForRun, updateStackDeliveryState } from "../tickets/delivery.js";
47
47
  import { AgentStatusReporter, RoleStatusAdapter } from "../statusReporter.js";
48
48
  import { currentActivity, withActivityPhase } from "../activity.js";
49
49
  import { fireTerminalBell } from "../notify.js";
@@ -450,7 +450,7 @@ export function validatePreparationArguments(args, projectDir) {
450
450
  throw new Error("Saved preparation contains stale internal recovery authority");
451
451
  return savedStartArguments(command, projectDir, options);
452
452
  }
453
- export function buildStartCommand() {
453
+ export function buildStartCommand(options = {}) {
454
454
  return new Command("start")
455
455
  .description("Enlist a builder and drive it through a batch of N steps.")
456
456
  .argument("<project>", "path to the project directory the builder works in")
@@ -557,6 +557,10 @@ export function buildStartCommand() {
557
557
  const cwd = resolve(project);
558
558
  if (!existsSync(cwd))
559
559
  fail(`project directory not found: ${cwd}`);
560
+ // Recovery commands launch a frozen continuation and must not recursively
561
+ // offer a different recovery path. Fresh launches may be redirected first.
562
+ if (!recoveryIntent && options.beforeFreshStart && !await options.beforeFreshStart(cwd))
563
+ return;
560
564
  const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
561
565
  let dirtyWork = recoveryRecord?.runDecisions?.dirtyWork;
562
566
  if (opts.recoverRun && !recoveryRecord)
@@ -628,6 +632,7 @@ export function buildStartCommand() {
628
632
  let deliveryConfig;
629
633
  let selectedStacks = [];
630
634
  let selectedStackTickets;
635
+ let selectedDeliveryTickets;
631
636
  if (isTicketsInitialized(cwd)) {
632
637
  const delivery = loadDeliveryConfig(cwd);
633
638
  deliveryConfig = delivery;
@@ -637,6 +642,7 @@ export function buildStartCommand() {
637
642
  const earlyDb = new StateDb(earlyPaths.stateDb);
638
643
  const earlyStates = earlyDb.getAllStates();
639
644
  earlyDb.close();
645
+ let skippedDeliveryUnits = opts.skipDeliveryUnit ?? [];
640
646
  if (stackCount !== undefined) {
641
647
  const earlyTickets = loadTickets(earlyPaths.tickets);
642
648
  const selection = selectStacksForRun(delivery, earlyTickets, earlyStates, stackCount);
@@ -653,15 +659,23 @@ export function buildStartCommand() {
653
659
  opts.provider = [...providers][0];
654
660
  }
655
661
  else
656
- deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, opts.skipDeliveryUnit ?? []);
662
+ deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, skippedDeliveryUnits);
657
663
  if (deliveryRun?.state === "resume" && !opts.yes && process.stdin.isTTY && process.stdout.isTTY) {
658
664
  const answer = await select({ message: `Resume unfinished delivery unit ${deliveryRun.unit.id} (${deliveryRun.remaining.length} remaining)?`, options: [
659
665
  { value: "resume", label: "Resume (Recommended)" }, { value: "skip", label: "Skip for this run" },
660
666
  ] });
661
667
  if (isCancel(answer))
662
668
  process.exit(0);
663
- if (answer === "skip")
664
- deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, [deliveryRun.unit.id, ...(opts.skipDeliveryUnit ?? [])]);
669
+ if (answer === "skip") {
670
+ skippedDeliveryUnits = [deliveryRun.unit.id, ...skippedDeliveryUnits];
671
+ deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, skippedDeliveryUnits);
672
+ }
673
+ }
674
+ if (stackCount === undefined) {
675
+ selectedDeliveryTickets = selectDeliveryTicketsForRun(delivery, earlyStates, steps, skippedDeliveryUnits);
676
+ if (selectedDeliveryTickets.length < steps) {
677
+ console.log(`foreman: requested --steps ${steps}; admitting ${selectedDeliveryTickets.length} ticket(s). The next delivery unit uses shared/current branch semantics or is not yet eligible, so it will run separately.`);
678
+ }
665
679
  }
666
680
  }
667
681
  }
@@ -1814,7 +1828,7 @@ export function buildStartCommand() {
1814
1828
  branchPrefix,
1815
1829
  maxBranchDepth,
1816
1830
  rootBaseBranches: branchDefaults.rootBaseBranches,
1817
- ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
1831
+ ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? selectedDeliveryTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
1818
1832
  });
1819
1833
  let auditBuilder;
1820
1834
  let auditViewer;
@@ -1834,7 +1848,7 @@ export function buildStartCommand() {
1834
1848
  maxBranchDepth,
1835
1849
  auditDependencies,
1836
1850
  rootBaseBranches: branchDefaults.rootBaseBranches,
1837
- ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
1851
+ ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? selectedDeliveryTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
1838
1852
  });
1839
1853
  auditDependencyCount = auditDependencies.length;
1840
1854
  }
@@ -1975,9 +1989,9 @@ export function buildStartCommand() {
1975
1989
  for (const node of plan.nodes)
1976
1990
  admitApprovedTicket(cwd, masterRun.runId, node.ticket.id, buildScopeRevision(cwd), `build invocation ${invocationRunId}: approved branch plan`);
1977
1991
  masterRun = checkpointBuildRun(cwd, masterRun, "capabilities-resolved", { builderCapabilities });
1978
- const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, masterRun.runId, requestedTicketIds[0]) : undefined;
1992
+ const qaResumedRecovery = recoveryRecord && !isInterruptedBuilderRecovery(recoveryRecord) ? pendingQaRecoveryPacket(cwd, masterRun.runId, requestedTicketIds[0]) : undefined;
1979
1993
  let qaProtocolResumeTicket;
1980
- if (recoveryRecord && !qaResumedRecovery) {
1994
+ if (recoveryRecord && !qaResumedRecovery && !isInterruptedBuilderRecovery(recoveryRecord)) {
1981
1995
  const protocolDb = new WorkflowDb(cwd);
1982
1996
  try {
1983
1997
  const pendingHeads = protocolDb.pendingQaTicketHeads(masterRun.runId);
@@ -2290,10 +2304,10 @@ export function buildStartCommand() {
2290
2304
  buildRun = checkpointBuildRun(cwd, buildRun, "capabilities-resolved", { builderCapabilities });
2291
2305
  activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
2292
2306
  activeContinuityRunId = buildRun.runId;
2293
- const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, buildRun.runId, requestedTicketIds[0]) : undefined;
2307
+ const qaResumedRecovery = recoveryRecord && !isInterruptedBuilderRecovery(recoveryRecord) ? pendingQaRecoveryPacket(cwd, buildRun.runId, requestedTicketIds[0]) : undefined;
2294
2308
  let qaFinalizationTicket;
2295
2309
  let qaProtocolResumeTicket;
2296
- if (recoveryRecord && !qaResumedRecovery) {
2310
+ if (recoveryRecord && !qaResumedRecovery && !isInterruptedBuilderRecovery(recoveryRecord)) {
2297
2311
  const finalizationDb = new WorkflowDb(cwd);
2298
2312
  try {
2299
2313
  const pendingHeads = finalizationDb.pendingQaTicketHeads(buildRun.runId);
@@ -65,7 +65,22 @@ export function runStatus(project) {
65
65
  const batchEnd = [...records].reverse().find((r) => r.event === "batch-end");
66
66
  const retainedRuns = readBuildRuns(projectDir).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
67
67
  const byId = new Map(retainedRuns.map(run => [run.runId, run]));
68
- const recordRunId = [...records].reverse().find(record => typeof record.runId === "string" && (byId.has(record.runId) || record.event === "step" || record.event === "batch-end" || String(record.event).startsWith("branch-")))?.runId;
68
+ // Branch completion is the terminal fact for branch-per-ticket runs. Prefer
69
+ // its durable run identity over later bookkeeping/audit records in the same
70
+ // daily log, which may not themselves have a retained build record.
71
+ const lastProgressIndex = records.reduce((last, record, index) => (record.event === "step" || record.event === "batch-end" || String(record.event).startsWith("branch-") ? index : last), -1);
72
+ const completedBranchRunId = lastProgressIndex >= 0
73
+ && records[lastProgressIndex]?.event === "branch-complete"
74
+ && typeof records[lastProgressIndex]?.runId === "string"
75
+ && byId.has(records[lastProgressIndex].runId)
76
+ ? records[lastProgressIndex].runId
77
+ : undefined;
78
+ // Older branch logs omitted runId. Infer only when the last meaningful
79
+ // record is a completion and exactly one durable completed run owns that
80
+ // ticket; ambiguity remains deliberately unresolved rather than borrowing
81
+ // an unrelated historical result.
82
+ const legacyCompletedBranchRunId = completedBranchRunId ? undefined : legacyCompletedRunId(records, lastProgressIndex, retainedRuns);
83
+ const recordRunId = completedBranchRunId ?? legacyCompletedBranchRunId ?? [...records].reverse().find(record => typeof record.runId === "string" && (byId.has(record.runId) || record.event === "step" || record.event === "batch-end" || String(record.event).startsWith("branch-")))?.runId;
69
84
  const retainedRun = typeof recordRunId === "string" ? byId.get(recordRunId) : undefined;
70
85
  console.log(`${accent("foreman:")} latest run ${sanitizeTerminalText(logs[logs.length - 1] ?? "")}`);
71
86
  console.log(`${accent("foreman:")} ${steps.length} step record(s), ${escalations.length} escalation(s)`);
@@ -116,6 +131,13 @@ export function runStatus(project) {
116
131
  printWorkflowIssues(workflow, activeWorkflows);
117
132
  workflow?.close();
118
133
  }
134
+ function legacyCompletedRunId(records, lastProgressIndex, retainedRuns) {
135
+ const terminal = lastProgressIndex >= 0 ? records[lastProgressIndex] : undefined;
136
+ if (terminal?.event !== "branch-complete" || typeof terminal.runId === "string" || typeof terminal.ticket !== "string")
137
+ return undefined;
138
+ const candidates = retainedRuns.filter(run => run.status === "completed" && run.tickets.includes(terminal.ticket));
139
+ return candidates.length === 1 ? candidates[0].runId : undefined;
140
+ }
119
141
  function printWorkflowIssues(workflow, runs) {
120
142
  if (!workflow || runs.length === 0)
121
143
  return;
@@ -61,5 +61,6 @@ export declare function ticketOwnershipReceipts(projectDir: string, commandName:
61
61
  export declare function buildTicketsCommand(options?: {
62
62
  prepareOwnership?: (projectDir: string, receipts: TicketOwnershipReceipt[]) => void;
63
63
  recordOwnership?: (projectDir: string, receipts: TicketOwnershipReceipt[]) => void;
64
+ restartBuild?: (projectDir: string, runId: string, tickets: string[]) => Promise<void>;
64
65
  }): Command;
65
66
  export {};
@@ -1843,7 +1843,7 @@ export function buildTicketsCommand(options = {}) {
1843
1843
  });
1844
1844
  tickets
1845
1845
  .command("reset [ticketId]")
1846
- .description("Reset one ticket or an explicit ticket scope to pristine active state while retaining history.")
1846
+ .description("Reset tracker state while retaining history; use build:start-over to restart interrupted execution.")
1847
1847
  .option("-p, --project <dir>", "project directory (default: cwd)")
1848
1848
  .option("--all", "open the interactive bulk reset scope chooser")
1849
1849
  .option("--scope <scope>", "bulk scope (all | completed-and-unfinished | unfinished)")
@@ -1898,10 +1898,28 @@ export function buildTicketsCommand(options = {}) {
1898
1898
  for (const row of preview.tickets)
1899
1899
  console.log(` ${row.id}: ${row.title} [${row.status}] — clears ${row.cleared.join(", ")}`);
1900
1900
  if (preview.relatedRuns.length) {
1901
- console.log(" ! Matching build/recovery metadata is not deleted. Use `rafi build:start-over` for a coherent whole-run restart.");
1901
+ console.log(" ! Ticket reset clears tracker state only. It cannot resume a Builder session or resolve pending QA/process recovery.");
1902
1902
  for (const run of preview.relatedRuns)
1903
1903
  console.log(` ${run.runId.slice(0, 8)} [${run.status}] ${run.tickets.join(", ")}`);
1904
1904
  }
1905
+ if (preview.relatedRuns.length) {
1906
+ for (const run of preview.relatedRuns)
1907
+ console.log(` Keep files and restart execution: rafi build:start-over --run ${run.runId} --action restart-current`);
1908
+ if (options.restartBuild && process.stdin.isTTY && process.stdout.isTTY && !opts.yes) {
1909
+ const answer = await select({ message: "These tickets belong to interrupted builds. What should Rafi do?", options: [
1910
+ ...preview.relatedRuns.map(run => ({ value: run.runId, label: `Restart execution from current files for build ${run.runId.slice(0, 8)} (keep implementation)` })),
1911
+ { value: "tracker-only", label: "Reset tracker state only; leave build recovery unchanged" },
1912
+ { value: "cancel", label: "Cancel" },
1913
+ ] });
1914
+ if (isCancel(answer) || answer === "cancel")
1915
+ return;
1916
+ if (answer !== "tracker-only") {
1917
+ const run = preview.relatedRuns.find(item => item.runId === answer);
1918
+ await options.restartBuild(cwd(opts), run.runId, preview.tickets.map(item => item.id).filter(id => run.tickets.includes(id)));
1919
+ return;
1920
+ }
1921
+ }
1922
+ }
1905
1923
  if (!preview.tickets.length)
1906
1924
  return;
1907
1925
  const explicitlyConfirmed = Boolean(opts.yes && (ticketId || opts.scope));
package/dist/foreman.js CHANGED
@@ -151,7 +151,7 @@ export function buildPrimer(n, trackerPath, ticketsEnabled = false, preferredTic
151
151
  Rules:
152
152
  - Do exactly ONE ticket or step this turn, then stop.
153
153
  - ${MARKER_SPEC}
154
- - If a tool action is denied by foreman policy, do not retry it; report it via the blocked marker.${trackerRule}${preferredTicketRule}
154
+ - If a tool action is denied by foreman policy, do not retry the denied form; report it via the blocked marker. The one exception is a required test that you changed by adding a pipe, redirection, substitution, or wrapper: retry the exact required-test command without that extra shell syntax before reporting it blocked.${trackerRule}${preferredTicketRule}
155
155
 
156
156
  This is step 1 of ${n}. Implement the next ticket or step now.`;
157
157
  }
@@ -170,6 +170,8 @@ export function buildQaInstruction() {
170
170
 
171
171
  Triple-check. Do not rubber-stamp your own work. Be skeptical.
172
172
 
173
+ If an otherwise required test is denied only because you added a pipe, redirection, substitution, or wrapper, retry the exact required-test command without that extra shell syntax. Do not retry other denied actions.
174
+
173
175
  If everything is solid, end with STEP_STATUS: qa_pass.
174
176
  If anything is off, return the required RAFI_QA_FAILURE_REPORT_START/RAFI_QA_FAILURE_REPORT_END JSON envelope immediately before STEP_STATUS: qa_fail. The report must contain every check, every blocking finding, and all nonblocking observations. Do NOT fix issues on this turn — just report them. Foreman will instruct the Builder to fix them next.
175
177
 
package/dist/handoffs.js CHANGED
@@ -326,26 +326,29 @@ export class HandoffService {
326
326
  return response;
327
327
  };
328
328
  const acceptancePrompt = [
329
+ handoffAcceptanceInstruction(),
329
330
  "Accept this validated Rafi handoff. Do not repeat completed work and reconcile host receipts before side effects.",
330
331
  staged.markdown,
331
332
  `Manifest JSON: ${JSON.stringify(staged.manifest)}`,
332
333
  ...(options.guidance ? [`Human recovery guidance: ${options.guidance}`] : []),
333
- `Reply with ${HANDOFF_ACCEPTED} on the first line, then ${continuityInstruction()}`,
334
+ handoffAcceptanceInstruction(),
334
335
  ].join("\n\n");
335
336
  let response = await sendAcceptance(acceptancePrompt);
336
- let validation = validateHandoffAcceptance(staged, successor, response.text);
337
+ let acceptanceText = response.rawResponse ?? response.text;
338
+ let validation = validateHandoffAcceptance(staged, successor, acceptanceText);
337
339
  if (validation && (validation.code === "missing-acknowledgement" || validation.code === "invalid-continuity-delta")) {
338
340
  const repairPrompt = [
339
341
  `Your handoff acknowledgement was rejected: ${validation.message}.`,
340
342
  "Correction only: do not use tools, repeat completed work, or perform implementation.",
341
- `Reply with ${HANDOFF_ACCEPTED} on the first line, then ${continuityInstruction()}`,
343
+ handoffAcceptanceInstruction(),
342
344
  ].join("\n\n");
343
345
  response = await sendAcceptance(repairPrompt);
344
- validation = validateHandoffAcceptance(staged, successor, response.text);
346
+ acceptanceText = response.rawResponse ?? response.text;
347
+ validation = validateHandoffAcceptance(staged, successor, acceptanceText);
345
348
  }
346
349
  if (validation)
347
350
  throw new HandoffAcceptanceError(validation.code, staged.manifest.runId, staged.manifest.generation, validation.message);
348
- const parsed = parseContinuityDelta(response.text);
351
+ const parsed = parseContinuityDelta(acceptanceText);
349
352
  const sessionId = successor.sessionId();
350
353
  const observedSuccessorRef = successor.sessionRef?.();
351
354
  const successorRef = observedSuccessorRef ? { ...observedSuccessorRef, generation: staged.manifest.generation, validatedAt: new Date().toISOString() } : undefined;
@@ -513,6 +516,15 @@ function validateHandoffAcceptance(staged, successor, text) {
513
516
  }
514
517
  return undefined;
515
518
  }
519
+ /** Keep the small acknowledgement protocol visible above and after a large handoff packet. */
520
+ function handoffAcceptanceInstruction() {
521
+ return [
522
+ "Mandatory response protocol: do not call tools or add a preface, explanation, or Markdown fence.",
523
+ `Your first non-whitespace line must be exactly ${HANDOFF_ACCEPTED}.`,
524
+ "After it, emit exactly one RAFI_CONTINUITY_DELTA record as instructed below.",
525
+ continuityInstruction(),
526
+ ].join("\n");
527
+ }
516
528
  async function promptHandoffRecovery(error, runtime, allowProviderSwitch) {
517
529
  const { select, isCancel } = await import("@clack/prompts");
518
530
  return pauseActivityForInput(async () => {
@@ -64,6 +64,14 @@ export interface DeliveryUnitProgress {
64
64
  export declare function deliveryProgress(config: DeliveryConfig, states: Map<string, TicketState>): DeliveryUnitProgress[];
65
65
  export declare function formatDeliverySummary(config: DeliveryConfig, states: Map<string, TicketState>): string[];
66
66
  export declare function selectDeliveryUnitForRun(config: DeliveryConfig, states: Map<string, TicketState>, skipUnitIds?: readonly string[]): DeliveryUnitProgress | undefined;
67
+ /**
68
+ * Select the tickets a normal `--steps N` run may admit from delivery units.
69
+ *
70
+ * A delivery unit remains the unit of resume/branch semantics. A multi-unit
71
+ * normal run may contain only per-ticket units: shared and current-branch
72
+ * units have unit-level branch semantics, so they are hard boundaries.
73
+ */
74
+ export declare function selectDeliveryTicketsForRun(config: DeliveryConfig, states: Map<string, TicketState>, steps: number, skipUnitIds?: readonly string[]): string[];
67
75
  export interface StackBatchSelection {
68
76
  stacks: DeliveryStack[];
69
77
  tickets: string[];
@@ -283,6 +283,33 @@ export function selectDeliveryUnitForRun(config, states, skipUnitIds = []) {
283
283
  const progress = deliveryProgress(config, states).filter((item) => !skipped.has(item.unit.id));
284
284
  return progress.find((item) => item.state === "resume") ?? progress.find((item) => item.state === "ready");
285
285
  }
286
+ /**
287
+ * Select the tickets a normal `--steps N` run may admit from delivery units.
288
+ *
289
+ * A delivery unit remains the unit of resume/branch semantics. A multi-unit
290
+ * normal run may contain only per-ticket units: shared and current-branch
291
+ * units have unit-level branch semantics, so they are hard boundaries.
292
+ */
293
+ export function selectDeliveryTicketsForRun(config, states, steps, skipUnitIds = []) {
294
+ const skipped = new Set(skipUnitIds);
295
+ const eligible = deliveryProgress(config, states).filter((item) => !skipped.has(item.unit.id));
296
+ const ordered = [...eligible.filter((item) => item.state === "resume"), ...eligible.filter((item) => item.state === "ready")];
297
+ const selected = [];
298
+ for (const item of ordered) {
299
+ if (selected.length >= steps)
300
+ break;
301
+ // Do not add a unit with different branch semantics to an existing batch.
302
+ // If it is the first eligible unit, it is still valid to select work from
303
+ // that one unit; the caller preserves its unit semantics.
304
+ if (selected.length > 0 && item.unit.branch_mode !== "per-ticket")
305
+ break;
306
+ const available = item.remaining.slice(0, steps - selected.length);
307
+ selected.push(...available);
308
+ if (item.unit.branch_mode !== "per-ticket")
309
+ break;
310
+ }
311
+ return selected;
312
+ }
286
313
  /** Complete preflight simulation for exact `--stacks N` selection. */
287
314
  export function selectStacksForRun(config, ticketDefs, states, count) {
288
315
  if (!Number.isInteger(count) || count < 1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.23",
3
+ "version": "1.7.25",
4
4
  "description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -308,7 +308,7 @@
308
308
  "parse5": "7.2.1",
309
309
  "pdfjs-dist": "4.10.38",
310
310
  "rafi-spec": "0.8.15",
311
- "special-agents": "0.8.14",
311
+ "special-agents": "0.8.15",
312
312
  "yaml": "^2.5.1"
313
313
  },
314
314
  "devDependencies": {