ai-foreman 1.7.22 → 1.7.24

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 { 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,18 @@ type BuildSourceState = Pick<FrozenQaSourceState, "digest" | "head" | "contentDi
19
20
  };
20
21
  export declare class BuildAssignmentRejected extends Error {
21
22
  }
23
+ /**
24
+ * Retire only the host-side records for a Builder turn that was interrupted
25
+ * before Rafi received a durable response. The next turn is sent to the
26
+ * *same validated provider session*, so it continues the outstanding work
27
+ * rather than treating unknown work as complete or replaying it in a new
28
+ * conversation.
29
+ */
30
+ export declare function prepareExactSessionBuilderContinuation(projectDir: string, runId: string, session: ProviderSessionRefV1): {
31
+ ticketIds: string[];
32
+ dispatchIds: string[];
33
+ assignmentIds: string[];
34
+ };
22
35
  export interface BuilderGuidanceFollowup {
23
36
  reviewAttemptId: string;
24
37
  requirementsDigest: string;
@@ -26,6 +26,69 @@ export function validateBuildAssignment(ticketId, status, responseText = "") {
26
26
  }
27
27
  export class BuildAssignmentRejected extends Error {
28
28
  }
29
+ /**
30
+ * Retire only the host-side records for a Builder turn that was interrupted
31
+ * before Rafi received a durable response. The next turn is sent to the
32
+ * *same validated provider session*, so it continues the outstanding work
33
+ * rather than treating unknown work as complete or replaying it in a new
34
+ * conversation.
35
+ */
36
+ export function prepareExactSessionBuilderContinuation(projectDir, runId, session) {
37
+ const db = new WorkflowDb(projectDir);
38
+ try {
39
+ const dispatches = db.unresolvedRoleDispatches(runId, "builder");
40
+ if (!dispatches.length)
41
+ throw new BuildAssignmentRejected("No interrupted Builder dispatch is awaiting exact-session continuation");
42
+ if (dispatches.some(dispatch => !sameSession(dispatch.intent.sessionRef, session))) {
43
+ throw new BuildAssignmentRejected("Interrupted Builder dispatch is not bound to the validated exact provider session");
44
+ }
45
+ const assignments = db.operations(runId).filter(operation => operation.kind === "build-assignment" && ["in_progress", "uncertain"].includes(operation.status));
46
+ if (!assignments.length)
47
+ throw new BuildAssignmentRejected("Interrupted Builder dispatch has no durable in-progress assignment");
48
+ const ticketIds = assignments.map(operation => operation.intent.ticketId)
49
+ .filter((ticketId) => typeof ticketId === "string");
50
+ if (ticketIds.length !== assignments.length)
51
+ throw new BuildAssignmentRejected("Interrupted Builder assignment is missing its ticket identity");
52
+ db.atomic(() => {
53
+ const resumedAt = new Date().toISOString();
54
+ for (const dispatch of dispatches) {
55
+ db.updateOperation(dispatch.idempotencyKey, "failed", {
56
+ result: { recovery: "exact-session-continuation", resumedAt, sessionRef: session },
57
+ error: "Host interruption: the same validated Builder session will reconcile and continue this turn",
58
+ });
59
+ }
60
+ for (const assignment of assignments) {
61
+ db.updateOperation(assignment.idempotencyKey, "failed", {
62
+ result: { recovery: "exact-session-continuation", resumedAt, sessionRef: session },
63
+ error: "Host interruption: work remains assigned to the validated Builder session; do not send it to QA yet",
64
+ });
65
+ }
66
+ const run = db.getRun(runId);
67
+ if (run)
68
+ db.transition(runId, {
69
+ checkpoint: "builder-exact-session-continuation",
70
+ state: { ...run.state, interruptedBuilderContinuation: { ticketIds, sessionRef: session, resumedAt } },
71
+ event: "builder_exact_session_continuation_authorized",
72
+ payload: { ticketIds, sessionRef: session },
73
+ });
74
+ });
75
+ return { ticketIds: [...new Set(ticketIds)], dispatchIds: dispatches.map(item => item.idempotencyKey), assignmentIds: assignments.map(item => item.idempotencyKey) };
76
+ }
77
+ finally {
78
+ db.close();
79
+ }
80
+ }
81
+ function sameSession(value, session) {
82
+ if (!value || typeof value !== "object")
83
+ return false;
84
+ const candidate = value;
85
+ return candidate.provider === session.provider
86
+ && candidate.sessionId === session.sessionId
87
+ && candidate.role === "builder"
88
+ && candidate.stream === "builder"
89
+ && candidate.cwd === session.cwd
90
+ && candidate.configRoot === session.configRoot;
91
+ }
29
92
  export function assertBuildAssignmentReconciled(projectDir, runId, worktree = projectDir) {
30
93
  const reader = new WorkflowReader(projectDir);
31
94
  try {
@@ -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
+ }
package/dist/cli/start.js CHANGED
@@ -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";
@@ -628,6 +628,7 @@ export function buildStartCommand() {
628
628
  let deliveryConfig;
629
629
  let selectedStacks = [];
630
630
  let selectedStackTickets;
631
+ let selectedDeliveryTickets;
631
632
  if (isTicketsInitialized(cwd)) {
632
633
  const delivery = loadDeliveryConfig(cwd);
633
634
  deliveryConfig = delivery;
@@ -637,6 +638,7 @@ export function buildStartCommand() {
637
638
  const earlyDb = new StateDb(earlyPaths.stateDb);
638
639
  const earlyStates = earlyDb.getAllStates();
639
640
  earlyDb.close();
641
+ let skippedDeliveryUnits = opts.skipDeliveryUnit ?? [];
640
642
  if (stackCount !== undefined) {
641
643
  const earlyTickets = loadTickets(earlyPaths.tickets);
642
644
  const selection = selectStacksForRun(delivery, earlyTickets, earlyStates, stackCount);
@@ -653,15 +655,23 @@ export function buildStartCommand() {
653
655
  opts.provider = [...providers][0];
654
656
  }
655
657
  else
656
- deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, opts.skipDeliveryUnit ?? []);
658
+ deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, skippedDeliveryUnits);
657
659
  if (deliveryRun?.state === "resume" && !opts.yes && process.stdin.isTTY && process.stdout.isTTY) {
658
660
  const answer = await select({ message: `Resume unfinished delivery unit ${deliveryRun.unit.id} (${deliveryRun.remaining.length} remaining)?`, options: [
659
661
  { value: "resume", label: "Resume (Recommended)" }, { value: "skip", label: "Skip for this run" },
660
662
  ] });
661
663
  if (isCancel(answer))
662
664
  process.exit(0);
663
- if (answer === "skip")
664
- deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, [deliveryRun.unit.id, ...(opts.skipDeliveryUnit ?? [])]);
665
+ if (answer === "skip") {
666
+ skippedDeliveryUnits = [deliveryRun.unit.id, ...skippedDeliveryUnits];
667
+ deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, skippedDeliveryUnits);
668
+ }
669
+ }
670
+ if (stackCount === undefined) {
671
+ selectedDeliveryTickets = selectDeliveryTicketsForRun(delivery, earlyStates, steps, skippedDeliveryUnits);
672
+ if (selectedDeliveryTickets.length < steps) {
673
+ 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.`);
674
+ }
665
675
  }
666
676
  }
667
677
  }
@@ -1814,7 +1824,7 @@ export function buildStartCommand() {
1814
1824
  branchPrefix,
1815
1825
  maxBranchDepth,
1816
1826
  rootBaseBranches: branchDefaults.rootBaseBranches,
1817
- ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
1827
+ ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? selectedDeliveryTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
1818
1828
  });
1819
1829
  let auditBuilder;
1820
1830
  let auditViewer;
@@ -1834,7 +1844,7 @@ export function buildStartCommand() {
1834
1844
  maxBranchDepth,
1835
1845
  auditDependencies,
1836
1846
  rootBaseBranches: branchDefaults.rootBaseBranches,
1837
- ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
1847
+ ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? selectedDeliveryTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
1838
1848
  });
1839
1849
  auditDependencyCount = auditDependencies.length;
1840
1850
  }
@@ -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;
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,11 +64,15 @@ export interface TicketPlanningContext {
64
64
  }>;
65
65
  existingNext: string[];
66
66
  }
67
+ /** The tracker facts needed to validate a proposal before it is approved. */
68
+ export interface TicketPlanValidationContext {
69
+ futureWorkIds?: Iterable<number>;
70
+ }
67
71
  export declare function readTicketPlanningContext(projectDir: string): TicketPlanningContext;
68
72
  export declare function ticketPlanningFingerprint(projectDir: string): PlanningFingerprint[];
69
73
  export declare function planningFingerprintChanges(projectDir: string, expected: PlanningFingerprint[]): string[];
70
- export declare function extractTicketPlanProposal(output: string, existingTickets?: TicketDef[]): TicketPlanProposal;
71
- export declare function validateTicketPlanProposal(value: unknown, existingTickets?: TicketDef[]): string[];
74
+ export declare function extractTicketPlanProposal(output: string, existingTickets?: TicketDef[], context?: TicketPlanValidationContext): TicketPlanProposal;
75
+ export declare function validateTicketPlanProposal(value: unknown, existingTickets?: TicketDef[], context?: TicketPlanValidationContext): string[];
72
76
  export interface ApplyTicketPlanOptions {
73
77
  expectedFingerprint: PlanningFingerprint[];
74
78
  docsRoot: string;
@@ -12,6 +12,9 @@ import { normalizeDeliveryConfig, validateDeliveryConfig } from "./tickets/deliv
12
12
  import { loadTicketSetupConfig, mergeTicketSetup, saveTicketSetupConfig } from "./tickets/setupConfig.js";
13
13
  export const PROPOSAL_START = "RAFI_PROPOSAL_START";
14
14
  export const PROPOSAL_END = "RAFI_PROPOSAL_END";
15
+ const TICKET_STATUSES = new Set([
16
+ "planned", "next", "in_progress", "blocked", "done", "canceled", "obsolete",
17
+ ]);
15
18
  export function readTicketPlanningContext(projectDir) {
16
19
  const config = loadTicketsConfig(projectDir);
17
20
  const paths = resolveTicketPaths(config, projectDir);
@@ -41,7 +44,7 @@ export function ticketPlanningFingerprint(projectDir) {
41
44
  export function planningFingerprintChanges(projectDir, expected) {
42
45
  return expected.filter((item) => fileHash(join(projectDir, item.path)) !== item.sha256).map((item) => item.path);
43
46
  }
44
- export function extractTicketPlanProposal(output, existingTickets = []) {
47
+ export function extractTicketPlanProposal(output, existingTickets = [], context = {}) {
45
48
  const start = output.lastIndexOf(PROPOSAL_START);
46
49
  const end = output.lastIndexOf(PROPOSAL_END);
47
50
  if (start < 0 || end <= start)
@@ -54,12 +57,12 @@ export function extractTicketPlanProposal(output, existingTickets = []) {
54
57
  catch (err) {
55
58
  throw new Error(`planner proposal is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
56
59
  }
57
- const issues = validateTicketPlanProposal(parsed, existingTickets);
60
+ const issues = validateTicketPlanProposal(parsed, existingTickets, context);
58
61
  if (issues.length)
59
62
  throw new Error(`invalid planner proposal:\n${issues.map((issue) => `- ${issue}`).join("\n")}`);
60
63
  return parsed;
61
64
  }
62
- export function validateTicketPlanProposal(value, existingTickets = []) {
65
+ export function validateTicketPlanProposal(value, existingTickets = [], context = {}) {
63
66
  const issues = [];
64
67
  if (!value || typeof value !== "object" || Array.isArray(value))
65
68
  return ["proposal must be an object"];
@@ -109,9 +112,26 @@ export function validateTicketPlanProposal(value, existingTickets = []) {
109
112
  if (!ids.has(id))
110
113
  issues.push(`supersession references unknown ticket ${id}`);
111
114
  }
112
- for (const change of p.state_changes)
113
- if (!ids.has(change.id) || !change.reason?.trim())
114
- issues.push(`invalid state change for ${change.id}`);
115
+ for (const change of p.state_changes) {
116
+ if (!change || typeof change !== "object" || !ids.has(change.id) || !change.reason?.trim()) {
117
+ issues.push(`invalid state change for ${String(change && typeof change === "object" ? change.id : undefined)}`);
118
+ continue;
119
+ }
120
+ if (!TICKET_STATUSES.has(change.status))
121
+ issues.push(`state change for ${change.id} has invalid status ${String(change.status)}`);
122
+ }
123
+ const futureWorkIds = context.futureWorkIds ? new Set(context.futureWorkIds) : undefined;
124
+ for (const disposition of p.future_work) {
125
+ if (!disposition || typeof disposition !== "object" || !Number.isInteger(disposition.id)) {
126
+ issues.push(`future work disposition has invalid id ${String(disposition && typeof disposition === "object" ? disposition.id : undefined)}`);
127
+ continue;
128
+ }
129
+ if (!["accepted", "rejected", "merged", "queued"].includes(disposition.disposition)) {
130
+ issues.push(`future work item ${disposition.id} has invalid disposition ${String(disposition.disposition)}`);
131
+ }
132
+ if (futureWorkIds && !futureWorkIds.has(disposition.id))
133
+ issues.push(`future work item ${disposition.id} does not exist`);
134
+ }
115
135
  for (const item of p.source_reconciliation) {
116
136
  if (!item.source?.trim() || !item.item?.trim())
117
137
  issues.push("source reconciliation needs source and item");
@@ -151,7 +171,8 @@ export function applyApprovedTicketPlan(projectDir, proposal, opts) {
151
171
  const existing = loadTickets(paths.tickets);
152
172
  if (existing.length > 0 && !existsSync(paths.stateDb))
153
173
  throw new Error("tickets exist but the ignored ticket-state database is missing; restore it before planning so completed work is not treated as new");
154
- const proposalIssues = validateTicketPlanProposal(proposal, existing);
174
+ const currentContext = readTicketPlanningContext(root);
175
+ const proposalIssues = validateTicketPlanProposal(proposal, existing, { futureWorkIds: currentContext.futureWork.flatMap((item) => typeof item.id === "number" ? [item.id] : []) });
155
176
  if (proposalIssues.length)
156
177
  throw new Error(`approved proposal is invalid:\n${proposalIssues.map((issue) => `- ${issue}`).join("\n")}`);
157
178
  const lockPath = join(root, ".tickets", "planning.lock");
@@ -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.22",
3
+ "version": "1.7.24",
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",