ai-foreman 1.7.25 → 1.7.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +2 -1
  2. package/dist/adapters/claude.d.ts +15 -0
  3. package/dist/adapters/claude.js +149 -28
  4. package/dist/adapters/codex.d.ts +21 -0
  5. package/dist/adapters/codex.js +127 -9
  6. package/dist/adapters/fastMode.d.ts +4 -0
  7. package/dist/adapters/fastMode.js +9 -0
  8. package/dist/adapters/recovering.d.ts +4 -0
  9. package/dist/adapters/recovering.js +10 -0
  10. package/dist/adapters/sessionFailure.d.ts +6 -0
  11. package/dist/adapters/sessionFailure.js +19 -0
  12. package/dist/adapters/types.d.ts +8 -0
  13. package/dist/agentRun.js +1 -0
  14. package/dist/branch/currentGuard.d.ts +8 -0
  15. package/dist/branch/currentGuard.js +5 -0
  16. package/dist/branch/runner.js +52 -46
  17. package/dist/buildApproval.js +2 -1
  18. package/dist/buildAssignment.d.ts +15 -0
  19. package/dist/buildAssignment.js +142 -5
  20. package/dist/buildRuns.d.ts +18 -0
  21. package/dist/buildRuns.js +123 -7
  22. package/dist/buildWorkContext.js +1 -1
  23. package/dist/buildWorkMigration.d.ts +10 -0
  24. package/dist/buildWorkMigration.js +18 -2
  25. package/dist/cli/start.d.ts +13 -0
  26. package/dist/cli/start.js +286 -59
  27. package/dist/cli/state.js +39 -1
  28. package/dist/cli/tickets.d.ts +37 -0
  29. package/dist/cli/tickets.js +367 -12
  30. package/dist/config.js +1 -0
  31. package/dist/continuity.d.ts +1 -0
  32. package/dist/continuity.js +16 -10
  33. package/dist/foreman.d.ts +8 -0
  34. package/dist/foreman.js +158 -18
  35. package/dist/graph/preparation.js +6 -3
  36. package/dist/graph/semantic.js +2 -1
  37. package/dist/graph/turn.js +8 -1
  38. package/dist/handoffProtocol.d.ts +4 -0
  39. package/dist/handoffProtocol.js +20 -0
  40. package/dist/handoffs.d.ts +1 -1
  41. package/dist/handoffs.js +30 -23
  42. package/dist/log.d.ts +1 -1
  43. package/dist/managerPacket.js +1 -1
  44. package/dist/permissions/policy.js +39 -11
  45. package/dist/permissions/shellForms.d.ts +3 -0
  46. package/dist/permissions/shellForms.js +203 -0
  47. package/dist/planChanges.js +1 -1
  48. package/dist/projectDiagnostics.js +30 -0
  49. package/dist/providerQuestions.js +4 -24
  50. package/dist/qaBuildGate.d.ts +6 -1
  51. package/dist/qaBuildGate.js +76 -16
  52. package/dist/qaBuilderActivation.d.ts +19 -0
  53. package/dist/qaBuilderActivation.js +112 -0
  54. package/dist/qaBuilderCoverage.d.ts +2 -1
  55. package/dist/qaBuilderCoverage.js +47 -5
  56. package/dist/qaBuilderEvidence.d.ts +18 -0
  57. package/dist/qaBuilderEvidence.js +60 -0
  58. package/dist/qaContractCoverage.d.ts +12 -1
  59. package/dist/qaContractCoverage.js +34 -2
  60. package/dist/qaContractDelivery.d.ts +2 -2
  61. package/dist/qaContractDelivery.js +88 -10
  62. package/dist/qaEffectiveConfig.js +2 -1
  63. package/dist/qaFailureDelivery.d.ts +23 -0
  64. package/dist/qaFailureDelivery.js +88 -7
  65. package/dist/qaHostFacts.d.ts +2 -0
  66. package/dist/qaHostFacts.js +11 -0
  67. package/dist/qaPreparation.d.ts +38 -1
  68. package/dist/qaPreparation.js +372 -31
  69. package/dist/qaPreparationInputs.js +5 -1
  70. package/dist/qaPreparationPolicy.d.ts +2 -1
  71. package/dist/qaPreparationPolicy.js +39 -9
  72. package/dist/qaRecovery.d.ts +7 -0
  73. package/dist/qaRecovery.js +75 -2
  74. package/dist/qaReview.d.ts +4 -0
  75. package/dist/qaReview.js +72 -15
  76. package/dist/qaReviewEvidence.d.ts +6 -0
  77. package/dist/qaReviewEvidence.js +37 -0
  78. package/dist/qaRollbackRehearsal.d.ts +2 -0
  79. package/dist/qaRollbackRehearsal.js +78 -0
  80. package/dist/qaSnapshot.d.ts +3 -1
  81. package/dist/qaSnapshot.js +3 -2
  82. package/dist/qaVerificationContract.d.ts +9 -0
  83. package/dist/qaVerificationContract.js +143 -17
  84. package/dist/sessionLifecycle.js +1 -1
  85. package/dist/sources/sourceRegistry.js +16 -5
  86. package/dist/stateTransfer.d.ts +14 -0
  87. package/dist/stateTransfer.js +51 -0
  88. package/dist/statusReporter.d.ts +8 -0
  89. package/dist/statusReporter.js +5 -0
  90. package/dist/supervisedStart.js +23 -3
  91. package/dist/supervisor.js +33 -0
  92. package/dist/ticketPlanning.d.ts +373 -0
  93. package/dist/ticketPlanning.js +34 -3
  94. package/dist/ticketPopulation.d.ts +28 -3
  95. package/dist/ticketPopulation.js +125 -15
  96. package/dist/tickets/details.d.ts +2 -1
  97. package/dist/tickets/details.js +7 -3
  98. package/dist/tickets/eligibility.d.ts +6 -0
  99. package/dist/tickets/eligibility.js +12 -1
  100. package/dist/tickets/groupReset.js +100 -96
  101. package/dist/tickets/reset.js +80 -69
  102. package/dist/tickets/riskWaivers.d.ts +3 -0
  103. package/dist/tickets/riskWaivers.js +12 -0
  104. package/dist/tickets/stateDb.d.ts +10 -0
  105. package/dist/tickets/stateDb.js +23 -0
  106. package/dist/tickets/ticketLoader.d.ts +9 -0
  107. package/dist/tickets/ticketLoader.js +15 -2
  108. package/dist/tickets/ticketSchema.d.ts +14 -1
  109. package/dist/tickets/ticketSchema.js +2 -1
  110. package/dist/tickets/validate.js +11 -1
  111. package/dist/workflowDb.d.ts +16 -1
  112. package/dist/workflowDb.js +287 -10
  113. package/package.json +9 -4
@@ -9,6 +9,8 @@ import { BuilderEventQueue, withActivityPhase } from "../activity.js";
9
9
  import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
10
10
  import { canonicalSessionPath, createProviderSessionRef, validateProviderSessionScope } from "../sessionIdentity.js";
11
11
  import { SessionUnavailableError, sessionUnavailableResult } from "./sessionFailure.js";
12
+ import { isFastModeUnavailable, reportFastModeUnavailable } from "./fastMode.js";
13
+ import { handoffSystemInstructions } from "../handoffProtocol.js";
12
14
  /** Compatibility parser for recorded pre-app-server JSONL fixtures. */
13
15
  export function parseCodexLine(raw) {
14
16
  const type = raw.type;
@@ -42,6 +44,7 @@ export class CodexAdapter {
42
44
  sessionAvailability;
43
45
  initialized = false;
44
46
  closed = false;
47
+ closePromise;
45
48
  activeText = [];
46
49
  usage;
47
50
  usageRevision = 0;
@@ -67,12 +70,73 @@ export class CodexAdapter {
67
70
  answeredApprovalRequests = new Set();
68
71
  activeProviderTurnSpanId;
69
72
  toolSpans = new Map();
73
+ effectiveModel;
74
+ fastPreparedModel;
75
+ fastTier;
76
+ fastUnavailableModels = new Set();
77
+ turnSawTool = false;
78
+ /** Native compaction is non-blocking. The next host turn gets this reminder. */
79
+ qaContractReminder;
80
+ qaContractReminderPending = false;
81
+ qaContractReminderEvent;
82
+ disableFastMode() {
83
+ const model = this.opts.model ?? this.effectiveModel ?? "default";
84
+ if (!this.fastUnavailableModels.has(model))
85
+ reportFastModeUnavailable(model);
86
+ this.fastUnavailableModels.add(model);
87
+ this.fastTier = undefined;
88
+ }
89
+ async prepareFastMode() {
90
+ const model = this.opts.model ?? this.effectiveModel ?? "default";
91
+ if (!this.opts.fast || this.fastPreparedModel === model)
92
+ return;
93
+ this.fastPreparedModel = model;
94
+ this.fastTier = undefined;
95
+ if (this.fastUnavailableModels.has(model))
96
+ return;
97
+ try {
98
+ let cursor;
99
+ for (let page = 0; page < 20; page++) {
100
+ const result = await this.request("model/list", { includeHidden: true, ...(cursor ? { cursor } : {}) });
101
+ const entry = result.data?.find(item => item.model === model || item.id === model);
102
+ if (entry) {
103
+ this.fastTier = entry.serviceTiers?.find(tier => tier.name?.toLowerCase() === "fast" || ["fast", "priority"].includes(tier.id))?.id;
104
+ // Older app-server versions publish only the legacy speed list.
105
+ if (entry.serviceTiers === undefined && entry.additionalSpeedTiers?.includes("fast"))
106
+ this.fastTier = "fast";
107
+ break;
108
+ }
109
+ if (!result.nextCursor || result.nextCursor === cursor)
110
+ break;
111
+ cursor = result.nextCursor;
112
+ }
113
+ }
114
+ catch { /* Optional capability discovery never blocks a standard turn. */ }
115
+ if (!this.fastTier)
116
+ this.disableFastMode();
117
+ }
70
118
  constructor(opts) {
71
119
  this.opts = opts;
72
120
  this._sessionId = opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId;
73
121
  this._sessionRef = opts.resumeSessionRef;
74
122
  }
75
123
  contractCapabilities() { return { sameSessionAcceptance: true, nativeCompactionBarrier: false }; }
124
+ contractCompactionSequence() { return this.nativeCompactionSequence; }
125
+ configureQaContractReminder(reminder, onEvent) {
126
+ this.qaContractReminder = reminder;
127
+ this.qaContractReminderEvent = onEvent;
128
+ onEvent?.({ kind: "installed", at: new Date().toISOString(), sessionId: this._sessionId });
129
+ }
130
+ qaReminderEvent(kind) {
131
+ this.qaContractReminderEvent?.({ kind, at: new Date().toISOString(), sessionId: this._sessionId });
132
+ }
133
+ instructionWithPendingQaReminder(instruction) {
134
+ if (!this.qaContractReminderPending || !this.qaContractReminder)
135
+ return { instruction, includedReminder: false };
136
+ // Do not clear this before the provider has completed a turn. A failed or
137
+ // disconnected dispatch is not evidence that Codex received the reminder.
138
+ return { instruction: `${this.qaContractReminder}\n\nContinue the assigned ticket now.\n\n${instruction}`, includedReminder: true };
139
+ }
76
140
  buildInstruction(instruction) {
77
141
  return [this.opts.systemPromptAppend,
78
142
  this.opts.sessionRole === "builder" ? `Builder runtime contract: shell network ${this.effectiveNetworkAccess() ? "explicitly approved for this build" : "disabled"}; sandbox escalation ${this.effectiveApprovalPolicy() === "on-request" ? "requires separate approval for each exact operation" : "disabled"}. Web tools do not establish shell network access. If acquisition needs unavailable capabilities, request input before running acquisition, or use a local source/dependency bundle with verified provenance. A ticket answer does not change runtime permissions.` : undefined,
@@ -93,20 +157,32 @@ export class CodexAdapter {
93
157
  args.push("-m", this.opts.model);
94
158
  if (this.opts.effort)
95
159
  args.push("-c", `model_reasoning_effort=${this.opts.effort}`);
96
- else if (this.opts.fast)
97
- args.push("-c", "model_reasoning_effort=low");
98
160
  if (this._sessionId)
99
161
  args.push("resume", this._sessionId);
100
162
  args.push(instruction);
101
163
  return args;
102
164
  }
103
165
  async sendTurn(instruction, policy) {
166
+ const prepared = this.instructionWithPendingQaReminder(instruction);
167
+ instruction = prepared.instruction;
104
168
  const turnId = randomUUID();
105
169
  this.activeProviderTurnId = turnId;
106
170
  const observer = this.opts.observer;
107
171
  if (!observer) {
108
172
  try {
109
- return await withActivityPhase(`Codex ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(instruction, policy));
173
+ const result = await withActivityPhase(`Codex ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(instruction, policy));
174
+ if (prepared.includedReminder && !result.isError && !result.failure) {
175
+ this.qaContractReminderPending = false;
176
+ this.qaReminderEvent("reminder-delivered");
177
+ }
178
+ else if (prepared.includedReminder)
179
+ this.qaReminderEvent("reminder-retained");
180
+ return result;
181
+ }
182
+ catch (error) {
183
+ if (prepared.includedReminder)
184
+ this.qaReminderEvent("reminder-retained");
185
+ throw error;
110
186
  }
111
187
  finally {
112
188
  this.activeProviderTurnId = undefined;
@@ -120,9 +196,17 @@ export class CodexAdapter {
120
196
  try {
121
197
  const result = await withActivityPhase(`Codex ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(instruction, policy));
122
198
  observer.store.finishSpan(spanId, { outcome: result.isError ? "failed" : "completed", attributes: { usage: result.usage } });
199
+ if (prepared.includedReminder && !result.isError && !result.failure) {
200
+ this.qaContractReminderPending = false;
201
+ this.qaReminderEvent("reminder-delivered");
202
+ }
203
+ else if (prepared.includedReminder)
204
+ this.qaReminderEvent("reminder-retained");
123
205
  return result;
124
206
  }
125
207
  catch (error) {
208
+ if (prepared.includedReminder)
209
+ this.qaReminderEvent("reminder-retained");
126
210
  observer.store.finishSpan(spanId, { outcome: "failed", attributes: { error: String(error).slice(0, 500) } });
127
211
  throw error;
128
212
  }
@@ -144,23 +228,38 @@ export class CodexAdapter {
144
228
  const providerInstruction = this.buildInstruction(instruction);
145
229
  try {
146
230
  await this.ensureThread();
231
+ if (this.opts.fast)
232
+ await this.prepareFastMode();
147
233
  const toolsBefore = this.observedToolCalls;
234
+ this.turnSawTool = false;
148
235
  this.activeText = [];
149
236
  this.lastTurnTokens = undefined;
150
237
  this.turnUsageBaseline = this.providerSessionUsage ? { ...this.providerSessionUsage } : undefined;
151
238
  const completion = this.waitFor("turn/completed", (params) => params.threadId === this._sessionId, this.providerIdleTimeoutMs(), true);
239
+ void completion.catch(() => { });
152
240
  turnStartDispatched = true;
153
- const started = await this.request("turn/start", {
241
+ const startParams = {
154
242
  threadId: this._sessionId,
155
243
  input: [{ type: "text", text: providerInstruction, text_elements: [] }],
156
244
  cwd: this.opts.cwd,
157
- model: this.opts.model ?? null,
158
- effort: this.opts.effort ?? (this.opts.fast ? "low" : null),
245
+ model: this.opts.model ?? this.effectiveModel ?? null,
246
+ effort: this.opts.effort ?? null,
247
+ ...(this.opts.fast ? { serviceTier: this.fastTier ?? "default" } : {}),
159
248
  approvalPolicy: policy?.responseOnly || policy?.purpose && policy.purpose !== "implementation" ? "never" : this.effectiveApprovalPolicy(),
160
249
  sandboxPolicy: policy?.responseOnly || policy?.purpose && policy.purpose !== "implementation" || this.opts.sandboxMode === "read-only" || this.opts.sessionRole === "qa"
161
250
  ? { type: "readOnly", networkAccess: false }
162
251
  : { type: "workspaceWrite", writableRoots: [this.opts.cwd], networkAccess: this.effectiveNetworkAccess() },
163
- });
252
+ };
253
+ let started;
254
+ try {
255
+ started = await this.request("turn/start", startParams);
256
+ }
257
+ catch (error) {
258
+ if (!this.fastTier || !isFastModeUnavailable(String(error)) || this.turnSawTool || this.activeText.length)
259
+ throw error;
260
+ this.disableFastMode();
261
+ started = await this.request("turn/start", { ...startParams, serviceTier: "default" });
262
+ }
164
263
  this.activeNativeTurnId = String(started.turn?.id ?? "") || undefined;
165
264
  const params = await new OperationDeadline("Codex active turn", this.opts.turnDeadlineMs ?? 3_600_000).run(() => completion);
166
265
  const turn = params.turn;
@@ -168,6 +267,10 @@ export class CodexAdapter {
168
267
  const failed = turn?.status === "failed" || responseOnlyViolation;
169
268
  const error = turn?.error;
170
269
  const text = this.activeText.join("\n");
270
+ if (failed && !responseOnlyViolation && this.fastTier && !this.turnSawTool && !text && isFastModeUnavailable(String(error?.message ?? ""))) {
271
+ this.disableFastMode();
272
+ return this.sendTurnInternal(instruction, policy);
273
+ }
171
274
  const result = {
172
275
  text: failed ? normalizeRuntimeErrorText("codex", String(responseOnlyViolation ? "Response-only correction used tools; its result cannot authorize completion" : error?.message ?? text), null, "app-server turn") : text,
173
276
  isError: failed, numTurns: 1, costUsd: 0, costAuthoritative: false,
@@ -381,6 +484,8 @@ export class CodexAdapter {
381
484
  }
382
485
  async sessionUsage() { return this.providerSessionUsage ? { ...this.providerSessionUsage } : undefined; }
383
486
  async switchSettings(settings) {
487
+ if (settings.model !== this.opts.model || settings.fast !== this.opts.fast)
488
+ this.fastPreparedModel = undefined;
384
489
  this.opts.model = settings.model;
385
490
  this.opts.effort = settings.effort;
386
491
  this.opts.fast = settings.fast;
@@ -417,7 +522,8 @@ export class CodexAdapter {
417
522
  }
418
523
  observeEvents(listener) { return this.eventQueue.observe(listener); }
419
524
  events() { return this.eventQueue; }
420
- async close() {
525
+ close() { return this.closePromise ??= this.closeInternal(); }
526
+ async closeInternal() {
421
527
  if (this.closed)
422
528
  return;
423
529
  this.closed = true;
@@ -475,7 +581,7 @@ export class CodexAdapter {
475
581
  model: this.opts.model ?? null, approvalPolicy: this.effectiveApprovalPolicy(),
476
582
  config: { sandbox_workspace_write: { network_access: this.effectiveNetworkAccess() } },
477
583
  sandbox: this.opts.sandboxMode === "read-only" || this.opts.sessionRole === "qa" ? "read-only" : "workspace-write",
478
- developerInstructions: this.opts.systemPromptAppend ?? null,
584
+ developerInstructions: handoffSystemInstructions(this.opts.sessionRole, this.opts.systemPromptAppend) ?? null,
479
585
  });
480
586
  }
481
587
  catch (error) {
@@ -546,6 +652,7 @@ export class CodexAdapter {
546
652
  }
547
653
  this.sessionAvailability = { version: 1, status: "available", checkedAt: new Date().toISOString(), observedCwd: providerCwd ?? canonicalSessionPath(this.opts.cwd), sessionRef: this._sessionRef };
548
654
  this.threadAttached = true;
655
+ this.effectiveModel = typeof result.model === "string" ? result.model : this.opts.model;
549
656
  this.eventQueue.push({ kind: "session-transition", transition: method === "thread/resume" ? "resumed" : "started" });
550
657
  }
551
658
  threadAttached = false;
@@ -614,6 +721,7 @@ export class CodexAdapter {
614
721
  if (item)
615
722
  this.eventQueue.push({ kind: "provider-item", provider: "codex", lifecycle: "started", itemType: String(item.type ?? "unknown"), payload: exactProviderItem(item), payloadCompleteness: "complete", providerTurnId: this.activeProviderTurnId, eventId: randomUUID(), observedAt: new Date().toISOString() });
616
723
  if (isCodexToolItem(item)) {
724
+ this.turnSawTool = true;
617
725
  this.observedToolCalls += 1;
618
726
  const callId = codexItemId(item) ?? randomUUID();
619
727
  const name = codexToolName(item);
@@ -633,6 +741,7 @@ export class CodexAdapter {
633
741
  if (item)
634
742
  this.eventQueue.push({ kind: "provider-item", provider: "codex", lifecycle: "completed", itemType: String(item.type ?? "unknown"), payload: exactProviderItem(item), payloadCompleteness: "complete", providerTurnId: this.activeProviderTurnId, eventId: randomUUID(), observedAt: new Date().toISOString() });
635
743
  if (isCodexToolItem(item)) {
744
+ this.turnSawTool = true;
636
745
  const callId = codexItemId(item);
637
746
  const spanId = callId ? this.toolSpans.get(callId) : undefined;
638
747
  const status = typeof item?.status === "string" ? item.status : undefined;
@@ -663,6 +772,13 @@ export class CodexAdapter {
663
772
  provider: "codex",
664
773
  usageRevision: this.usageRevision,
665
774
  });
775
+ // A native compaction does not invalidate completed work or QA handoff.
776
+ // The next Rafi-controlled turn re-supplies the full requirement
777
+ // contract and tells the Builder to check it before continuing.
778
+ if (this.qaContractReminder) {
779
+ this.qaContractReminderPending = true;
780
+ this.qaReminderEvent("compaction-observed");
781
+ }
666
782
  this.eventQueue.push({ kind: "session-transition", transition: "compacted", detail: "provider-native automatic compaction" });
667
783
  }
668
784
  else {
@@ -710,6 +826,8 @@ export class CodexAdapter {
710
826
  else if (message.method === "error") {
711
827
  const error = params.error;
712
828
  const reason = String(error?.message ?? params.message ?? "Codex app-server error");
829
+ if (this.fastTier && isFastModeUnavailable(reason) && !this.activeText.length && !this.turnSawTool)
830
+ return;
713
831
  if (params.willRetry === true) {
714
832
  const retryId = randomUUID();
715
833
  this.eventQueue.push(this.opts.observer
@@ -0,0 +1,4 @@
1
+ /** Only explicit speed-option rejection permits a same-session retry. Auth,
2
+ * generic model failures, timeouts, and uncertain transport failures do not. */
3
+ export declare function isFastModeUnavailable(detail: string): boolean;
4
+ export declare function reportFastModeUnavailable(model?: string): void;
@@ -0,0 +1,9 @@
1
+ /** Only explicit speed-option rejection permits a same-session retry. Auth,
2
+ * generic model failures, timeouts, and uncertain transport failures do not. */
3
+ export function isFastModeUnavailable(detail) {
4
+ return /(?:fast(?:[ _-]mode)?|service[ _-]tier|speed\s*[=:]\s*["']?fast)[\s\S]{0,160}(?:not supported|unsupported|not available|unavailable|not enabled|disabled|not allowed|requires|invalid|unrecognized|unknown)|(?:not supported|unsupported|not available|unavailable|invalid|unrecognized|unknown)[\s\S]{0,100}(?:--fast|fast[ _-]mode|service[ _-]tier)/i.test(detail);
5
+ }
6
+ export function reportFastModeUnavailable(model) {
7
+ const label = (model ?? "the selected model").replace(/[\x00-\x1f\x7f-\x9f]/g, "").slice(0, 200);
8
+ console.error(`rafi: Fast mode not available with ${label}; continuing at standard speed.`);
9
+ }
@@ -24,6 +24,9 @@ export declare class RecoveringAdapter implements BuilderAdapter {
24
24
  private eventPump;
25
25
  private closed;
26
26
  private contractEnforcing;
27
+ /** Reapply a Codex post-compaction reminder if recovery recreates its adapter. */
28
+ private qaContractReminder?;
29
+ private qaContractReminderEvent?;
27
30
  constructor(opts: RecoveringAdapterOptions);
28
31
  graphRuntimeSettings(): Pick<import("./types.js").BuilderAdapterOptions, "model" | "effort" | "fast" | "runtimeExecutable"> | undefined;
29
32
  get agent(): "claude" | "codex";
@@ -34,6 +37,7 @@ export declare class RecoveringAdapter implements BuilderAdapter {
34
37
  enableContractEnforcement(): void;
35
38
  contractCompactionSequence(): number;
36
39
  acceptContractDelivery(sequence: number): void;
40
+ configureQaContractReminder(reminder: string, onEvent?: Parameters<NonNullable<BuilderAdapter["configureQaContractReminder"]>>[1]): void;
37
41
  sendTurn(text: string, policy?: Parameters<BuilderAdapter["sendTurn"]>[1]): Promise<TurnResult>;
38
42
  sessionId(): string | undefined;
39
43
  sessionRef(): ProviderSessionRefV1 | undefined;
@@ -11,6 +11,9 @@ export class RecoveringAdapter {
11
11
  eventPump;
12
12
  closed = false;
13
13
  contractEnforcing = false;
14
+ /** Reapply a Codex post-compaction reminder if recovery recreates its adapter. */
15
+ qaContractReminder;
16
+ qaContractReminderEvent;
14
17
  constructor(opts) {
15
18
  this.opts = opts;
16
19
  this.adapter = opts.initial;
@@ -25,6 +28,11 @@ export class RecoveringAdapter {
25
28
  enableContractEnforcement() { this.contractEnforcing = true; this.adapter.enableContractEnforcement?.(); }
26
29
  contractCompactionSequence() { return this.adapter.contractCompactionSequence?.() ?? 0; }
27
30
  acceptContractDelivery(sequence) { this.adapter.acceptContractDelivery?.(sequence); }
31
+ configureQaContractReminder(reminder, onEvent) {
32
+ this.qaContractReminder = reminder;
33
+ this.qaContractReminderEvent = onEvent;
34
+ this.adapter.configureQaContractReminder?.(reminder, onEvent);
35
+ }
28
36
  async sendTurn(text, policy) {
29
37
  while (true) {
30
38
  const result = await this.adapter.sendTurn(text, policy);
@@ -123,6 +131,8 @@ export class RecoveringAdapter {
123
131
  await this.adapter.close().catch(() => { });
124
132
  await this.eventPump.catch(() => { });
125
133
  this.adapter = await this.opts.recreate(runtime, resumeSessionId, resumeSessionRef);
134
+ if (this.qaContractReminder)
135
+ this.adapter.configureQaContractReminder?.(this.qaContractReminder, this.qaContractReminderEvent);
126
136
  this.runtime = runtime;
127
137
  this.opts.onRuntimeChange?.(runtime);
128
138
  this.eventPump = this.forwardEvents(this.adapter);
@@ -16,3 +16,9 @@ export declare class SessionUnavailableError extends Error {
16
16
  export declare function sessionUnavailableResult(error: SessionUnavailableError): TurnResult;
17
17
  export declare function isSessionUnavailableFailure(value: unknown): boolean;
18
18
  export declare function sessionUnavailableErrorFromFailure(failure: RuntimeFailure): SessionUnavailableError;
19
+ /**
20
+ * A Builder turn whose dispatch outcome is unknown must enter durable source
21
+ * recovery. It is not an ordinary ticket blocker: the provider may have
22
+ * applied part of the requested change before its stream failed.
23
+ */
24
+ export declare function recoverableBuilderTurnError(result: TurnResult): SessionUnavailableError | undefined;
@@ -49,3 +49,22 @@ export function sessionUnavailableErrorFromFailure(failure) {
49
49
  availability: failure.availability,
50
50
  });
51
51
  }
52
+ /**
53
+ * A Builder turn whose dispatch outcome is unknown must enter durable source
54
+ * recovery. It is not an ordinary ticket blocker: the provider may have
55
+ * applied part of the requested change before its stream failed.
56
+ */
57
+ export function recoverableBuilderTurnError(result) {
58
+ const failure = result.failure;
59
+ if (!failure || failure.dispatchState !== "unknown")
60
+ return undefined;
61
+ return new SessionUnavailableError({
62
+ runtime: failure.runtime,
63
+ phase: failure.phase === "preflight" || failure.phase === "attach" || failure.phase === "turn" ? failure.phase : "turn",
64
+ dispatchState: "unknown",
65
+ executable: failure.executable,
66
+ cwd: failure.cwd,
67
+ diagnostics: result.text || failure.diagnostics,
68
+ availability: failure.availability,
69
+ });
70
+ }
@@ -6,6 +6,8 @@ import type { ConfigurableAgentRole, ProviderSessionRefV1, RuntimeProbeCategory,
6
6
  import type { RunObserver } from "../observability.js";
7
7
  /** A permission request surfaced by a builder before it runs a tool. */
8
8
  export interface PermissionRequest {
9
+ /** Provider-observed tool cwd, distinct from immutable session launch scope. */
10
+ executionCwd?: string;
9
11
  toolName: string;
10
12
  input: Record<string, unknown>;
11
13
  /** Human-readable prompt from the agent, when available. */
@@ -297,6 +299,12 @@ export interface BuilderAdapter {
297
299
  enableContractEnforcement?(): void;
298
300
  contractCompactionSequence?(): number;
299
301
  acceptContractDelivery?(sequence: number): void;
302
+ /** Codex re-sends this exact contract reminder on its first host turn after native compaction. */
303
+ configureQaContractReminder?(reminder: string, onEvent?: (event: {
304
+ kind: "installed" | "compaction-observed" | "reminder-delivered" | "reminder-retained";
305
+ at: string;
306
+ sessionId?: string;
307
+ }) => void): void;
300
308
  /** Send one instruction; resolves when that turn completes. */
301
309
  sendTurn(text: string, policy?: {
302
310
  handback?: boolean;
package/dist/agentRun.js CHANGED
@@ -54,6 +54,7 @@ export function readOnlyPermissionConfig() {
54
54
  "npm run typecheck",
55
55
  "npm run lint",
56
56
  "npm run check",
57
+ "node --test",
57
58
  "pnpm test",
58
59
  "pnpm run test",
59
60
  "pnpm typecheck",
@@ -21,6 +21,14 @@ export declare class CurrentWorkflowGuardAdapter implements BuilderAdapter {
21
21
  readonly agent: BuilderAdapter["agent"];
22
22
  constructor(adapter: BuilderAdapter, cwd: string, expected?: CurrentWorkflowIdentity);
23
23
  sendTurn(text: string, policy?: Parameters<BuilderAdapter["sendTurn"]>[1]): Promise<TurnResult>;
24
+ contractCapabilities(): {
25
+ sameSessionAcceptance: boolean;
26
+ nativeCompactionBarrier: boolean;
27
+ };
28
+ enableContractEnforcement(): void;
29
+ contractCompactionSequence(): number;
30
+ acceptContractDelivery(sequence: number): void;
31
+ configureQaContractReminder(reminder: string, onEvent?: Parameters<NonNullable<BuilderAdapter["configureQaContractReminder"]>>[1]): void;
24
32
  sessionId(): string | undefined;
25
33
  sessionRef(): ProviderSessionRefV1 | undefined;
26
34
  prepareSession(): Promise<ProviderSessionRefV1>;
@@ -43,6 +43,11 @@ export class CurrentWorkflowGuardAdapter {
43
43
  assertCurrentWorkflowIdentity(this.cwd, this.expected);
44
44
  return result;
45
45
  }
46
+ contractCapabilities() { return this.adapter.contractCapabilities?.() ?? { sameSessionAcceptance: false, nativeCompactionBarrier: false }; }
47
+ enableContractEnforcement() { this.adapter.enableContractEnforcement?.(); }
48
+ contractCompactionSequence() { return this.adapter.contractCompactionSequence?.() ?? 0; }
49
+ acceptContractDelivery(sequence) { this.adapter.acceptContractDelivery?.(sequence); }
50
+ configureQaContractReminder(reminder, onEvent) { this.adapter.configureQaContractReminder?.(reminder, onEvent); }
46
51
  sessionId() { return this.adapter.sessionId(); }
47
52
  sessionRef() { return this.adapter.sessionRef?.(); }
48
53
  async prepareSession() {
@@ -22,7 +22,7 @@ import { join, resolve } from "node:path";
22
22
  import { WorkflowDb } from "../workflowDb.js";
23
23
  import { assertBuildAssignmentReconciled, BuildAssignmentRejected } from "../buildAssignment.js";
24
24
  import { currentActivity, withActivityPhase } from "../activity.js";
25
- import { SessionUnavailableError } from "../adapters/sessionFailure.js";
25
+ import { SessionUnavailableError, recoverableBuilderTurnError } from "../adapters/sessionFailure.js";
26
26
  import { SessionUnavailableContinuityError } from "../continuity.js";
27
27
  export function readDeliveryUnitSession(projectDir, unitId) {
28
28
  const path = deliverySessionPath(projectDir, unitId);
@@ -304,7 +304,7 @@ export async function runBranchPlan(opts) {
304
304
  base: node.baseBranch,
305
305
  dependencies: node.dependencies,
306
306
  });
307
- workflowCheckpoint(opts.projectDir, opts.runId, "builder-before", node.ticket.id, { branch: node.branch, worktree: node.worktreePath });
307
+ workflowCheckpoint(opts.projectDir, opts.runId, qaOnlyRecovery ? "qa-recovery-before" : "builder-before", node.ticket.id, { branch: node.branch, worktree: node.worktreePath });
308
308
  const continuationDb = new WorkflowDb(opts.projectDir);
309
309
  const continuationSession = continuations.length ? continuationDb.branchResumeSession(opts.runId, node.ticket.id) : undefined;
310
310
  continuationDb.close();
@@ -391,50 +391,52 @@ export async function runBranchPlan(opts) {
391
391
  ? await opts.observer.withContext({ role: "builder", stream: "builder", ticketId: node.ticket.id, deliveryUnitId: node.deliveryUnitId }, turn)
392
392
  : await turn();
393
393
  builder = foreman.builderAdapter();
394
- workflowCheckpoint(opts.projectDir, opts.runId, "builder-after", node.ticket.id, { status: status.kind, sessionId: builder.sessionId(), worktree: worktreePath });
395
- const sessionId = builder.sessionId();
396
- const sessionRef = builder.sessionRef?.();
397
- if (sessionId)
398
- builderStream = { sessionId, ...(sessionRef ? { sessionRef } : {}), worktreePath };
399
- if (sessionId) {
400
- opts.recordBuilderSession?.(sessionRef ?? sessionId, node.ticket.id, worktreePath);
401
- workflowCheckpoint(opts.projectDir, opts.runId, "builder-session-scoped", node.ticket.id, { sessionId, sessionRef, worktree: worktreePath, branch: node.branch });
402
- }
403
- if (sessionId) {
404
- const resumeSession = {
405
- ticket: node.ticket.id,
406
- branch: node.branch,
407
- base: node.baseBranch,
408
- worktreePath,
409
- sessionId,
410
- sessionRef: builder.sessionRef?.(),
411
- agent: opts.agent,
412
- model: opts.model,
413
- effort: opts.effort,
414
- fast: opts.fast,
415
- qaEnabled: opts.qaEnabled,
416
- createPr: createsReview,
417
- completionMode,
418
- reviewProvider,
419
- prReady: opts.prReady,
420
- keepWorktrees: opts.keepWorktrees,
421
- deliveryUnitId: node.deliveryUnitId,
422
- deliveryUnitFinal: node.deliveryUnitFinal,
423
- logPath: "structured-recovery",
424
- ts: new Date().toISOString(),
425
- };
426
- opts.log.write("branch-session", resumeSession);
427
- const resumeDb = new WorkflowDb(opts.projectDir);
428
- try {
429
- resumeDb.recordBranchResumeSession(opts.runId, resumeSession);
430
- }
431
- finally {
432
- resumeDb.close();
394
+ if (!qaOnlyRecovery) {
395
+ workflowCheckpoint(opts.projectDir, opts.runId, "builder-after", node.ticket.id, { status: status.kind, sessionId: builder.sessionId(), worktree: worktreePath });
396
+ const sessionId = builder.sessionId();
397
+ const sessionRef = builder.sessionRef?.();
398
+ if (sessionId)
399
+ builderStream = { sessionId, ...(sessionRef ? { sessionRef } : {}), worktreePath };
400
+ if (sessionId) {
401
+ opts.recordBuilderSession?.(sessionRef ?? sessionId, node.ticket.id, worktreePath);
402
+ workflowCheckpoint(opts.projectDir, opts.runId, "builder-session-scoped", node.ticket.id, { sessionId, sessionRef, worktree: worktreePath, branch: node.branch });
433
403
  }
434
- if (node.deliveryUnitId) {
435
- const path = deliverySessionPath(opts.projectDir, node.deliveryUnitId);
436
- mkdirSync(join(opts.projectDir, ".foreman", "delivery-sessions"), { recursive: true });
437
- writeFileSync(path, `${JSON.stringify({ runId: opts.runId, unitId: node.deliveryUnitId, branch: node.branch, worktreePath, sessionId, sessionRef: builder.sessionRef?.(), ticket: node.ticket.id }, null, 2)}\n`, "utf8");
404
+ if (sessionId) {
405
+ const resumeSession = {
406
+ ticket: node.ticket.id,
407
+ branch: node.branch,
408
+ base: node.baseBranch,
409
+ worktreePath,
410
+ sessionId,
411
+ sessionRef: builder.sessionRef?.(),
412
+ agent: opts.agent,
413
+ model: opts.model,
414
+ effort: opts.effort,
415
+ fast: opts.fast,
416
+ qaEnabled: opts.qaEnabled,
417
+ createPr: createsReview,
418
+ completionMode,
419
+ reviewProvider,
420
+ prReady: opts.prReady,
421
+ keepWorktrees: opts.keepWorktrees,
422
+ deliveryUnitId: node.deliveryUnitId,
423
+ deliveryUnitFinal: node.deliveryUnitFinal,
424
+ logPath: "structured-recovery",
425
+ ts: new Date().toISOString(),
426
+ };
427
+ opts.log.write("branch-session", resumeSession);
428
+ const resumeDb = new WorkflowDb(opts.projectDir);
429
+ try {
430
+ resumeDb.recordBranchResumeSession(opts.runId, resumeSession);
431
+ }
432
+ finally {
433
+ resumeDb.close();
434
+ }
435
+ if (node.deliveryUnitId) {
436
+ const path = deliverySessionPath(opts.projectDir, node.deliveryUnitId);
437
+ mkdirSync(join(opts.projectDir, ".foreman", "delivery-sessions"), { recursive: true });
438
+ writeFileSync(path, `${JSON.stringify({ runId: opts.runId, unitId: node.deliveryUnitId, branch: node.branch, worktreePath, sessionId, sessionRef: builder.sessionRef?.(), ticket: node.ticket.id }, null, 2)}\n`, "utf8");
439
+ }
438
440
  }
439
441
  }
440
442
  opts.log.write("step", {
@@ -446,8 +448,12 @@ export async function runBranchPlan(opts) {
446
448
  costUsd: result.costUsd,
447
449
  isError: result.isError,
448
450
  });
449
- if (result.isError)
451
+ if (result.isError) {
452
+ const recoverable = recoverableBuilderTurnError(result);
453
+ if (recoverable)
454
+ throw recoverable;
450
455
  throw new Error(`builder turn errored: ${result.text.slice(0, 200)}`);
456
+ }
451
457
  if (status.branchDependency) {
452
458
  throw new Error(`builder requested branch_dependency=${status.branchDependency}; retry is not available after ticket start in this run`);
453
459
  }
@@ -14,7 +14,7 @@ function canonical(value) {
14
14
  }
15
15
  function material(ticket) {
16
16
  return { id: ticket.id, plan_ref: ticket.plan_ref, title: ticket.title, summary: ticket.summary, acceptance: ticket.acceptance,
17
- required_tests: ticket.required_tests, depends_on: ticket.depends_on, likely_files: ticket.likely_files, rollback: ticket.rollback, source_refs: ticket.source_refs };
17
+ required_tests: ticket.required_tests, depends_on: ticket.depends_on, likely_files: ticket.likely_files, rollback: ticket.rollback, documentation: ticket.documentation ?? { required: false, targets: [], criteria: [] }, source_refs: ticket.source_refs };
18
18
  }
19
19
  /** Reuse only source-bound approval. Ambiguous legacy provenance still asks. */
20
20
  export function approvedBuildScope(projectDir, ticketIds) {
@@ -52,6 +52,7 @@ export function approvedBuildScope(projectDir, ticketIds) {
52
52
  const ids = new Map(all.filter(item => item.plan_ref?.plan_id === ref.plan_id && item.plan_ref.revision === ref.revision).map(item => [item.plan_ref.slice_ref, item.id]));
53
53
  const legacy = slice && !ticket.rollback ? { ...ticket, title: slice.title, summary: slice.summary,
54
54
  acceptance: slice.acceptance, required_tests: slice.required_tests, likely_files: slice.likely_files,
55
+ documentation: slice.documentation ?? { required: false, targets: [], criteria: [] },
55
56
  depends_on: (slice.depends_on ?? []).map((id) => ids.get(id)),
56
57
  source_refs: slice.source_refs?.map((source) => ({ source: source.source_id, item: source.item ?? "document", source_id: source.source_id, fingerprint: source.fingerprint, ...(source.url !== undefined ? { url: source.url } : {}), ...(source.note !== undefined ? { note: source.note } : {}) })) } : undefined;
57
58
  if (canonical(material(approved ?? legacy ?? {})) !== canonical(material(ticket)))
@@ -22,6 +22,21 @@ export declare class BuildAssignmentRejected extends Error {
22
22
  }
23
23
  /** A frozen exact-session Builder decision may defer unrelated pending QA. */
24
24
  export declare function isInterruptedBuilderRecovery(run: BuildRunRecordV2 | undefined): boolean;
25
+ /** Recovery requires current source, assignment and admission proofs.
26
+ * A V3 or migrated record alone never grants continuation authority;
27
+ * full eligibility is verified before any unknown receipt is reconciled. */
28
+ export declare function canReconcileInterruptedBuilderSource(projectDir: string, runId: string): boolean;
29
+ /** Resume the existing approved build from observed local source, never replay
30
+ * the interrupted instruction or pretend its unknown outcome was successful.
31
+ * The caller must own recovery admission and the original run's workflow lease. */
32
+ export declare function prepareInterruptedBuilderSourceContinuation(projectDir: string, runId: string): {
33
+ ticketIds: string[];
34
+ reconciliationId: string;
35
+ sourceDigest: string;
36
+ contentDigest: string;
37
+ };
38
+ /** A launch may not consume a recovery snapshot after product source changes. */
39
+ export declare function assertInterruptedSourceSnapshot(projectDir: string, run: BuildRunRecordV2): void;
25
40
  /**
26
41
  * Retire only the host-side records for a Builder turn that was interrupted
27
42
  * before Rafi received a durable response. The next turn is sent to the