ai-foreman 1.7.6 → 1.7.8

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 (69) hide show
  1. package/README.md +26 -1
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +31 -6
  4. package/dist/adapters/claude.d.ts +61 -6
  5. package/dist/adapters/claude.js +544 -36
  6. package/dist/adapters/codex.d.ts +28 -1
  7. package/dist/adapters/codex.js +398 -27
  8. package/dist/adapters/recovering.d.ts +13 -2
  9. package/dist/adapters/recovering.js +32 -4
  10. package/dist/adapters/sessionFailure.d.ts +18 -0
  11. package/dist/adapters/sessionFailure.js +51 -0
  12. package/dist/adapters/types.d.ts +117 -9
  13. package/dist/agentRun.d.ts +4 -1
  14. package/dist/agentRun.js +96 -9
  15. package/dist/branch/currentGuard.d.ts +38 -0
  16. package/dist/branch/currentGuard.js +71 -0
  17. package/dist/branch/planner.d.ts +1 -1
  18. package/dist/branch/planner.js +2 -7
  19. package/dist/branch/prefix.d.ts +8 -0
  20. package/dist/branch/prefix.js +26 -0
  21. package/dist/branch/resume.d.ts +2 -0
  22. package/dist/branch/resume.js +8 -0
  23. package/dist/branch/runner.d.ts +14 -2
  24. package/dist/branch/runner.js +77 -17
  25. package/dist/buildRuns.d.ts +13 -4
  26. package/dist/buildRuns.js +85 -19
  27. package/dist/cli/handoffs.d.ts +2 -0
  28. package/dist/cli/handoffs.js +74 -0
  29. package/dist/cli/start.js +1079 -43
  30. package/dist/cli/tickets.js +199 -3
  31. package/dist/continuity.d.ts +92 -0
  32. package/dist/continuity.js +415 -0
  33. package/dist/foreman.d.ts +5 -1
  34. package/dist/foreman.js +41 -6
  35. package/dist/handoffs.d.ts +69 -0
  36. package/dist/handoffs.js +546 -0
  37. package/dist/log.d.ts +1 -1
  38. package/dist/permissions/policy.d.ts +5 -1
  39. package/dist/permissions/policy.js +48 -1
  40. package/dist/qaReview.d.ts +6 -6
  41. package/dist/qaReview.js +24 -14
  42. package/dist/sessionAvailability.d.ts +15 -0
  43. package/dist/sessionAvailability.js +57 -0
  44. package/dist/sessionIdentity.d.ts +33 -0
  45. package/dist/sessionIdentity.js +132 -0
  46. package/dist/sessionLifecycle.d.ts +198 -5
  47. package/dist/sessionLifecycle.js +1373 -20
  48. package/dist/statusReporter.d.ts +84 -8
  49. package/dist/statusReporter.js +270 -14
  50. package/dist/ticketPlanning.js +8 -0
  51. package/dist/ticketPopulation.js +88 -12
  52. package/dist/tickets/commands.js +20 -12
  53. package/dist/tickets/groupReset.d.ts +25 -0
  54. package/dist/tickets/groupReset.js +279 -0
  55. package/dist/tickets/groups.d.ts +36 -0
  56. package/dist/tickets/groups.js +68 -0
  57. package/dist/tickets/importer.js +27 -3
  58. package/dist/tickets/recommendations.js +31 -7
  59. package/dist/tickets/reset.d.ts +1 -0
  60. package/dist/tickets/reset.js +1 -0
  61. package/dist/tickets/setupConfig.d.ts +1 -0
  62. package/dist/tickets/setupConfig.js +10 -2
  63. package/dist/tickets/stateDb.d.ts +34 -0
  64. package/dist/tickets/stateDb.js +207 -0
  65. package/dist/tickets/ticketLoader.js +5 -2
  66. package/dist/tickets/validate.js +4 -0
  67. package/dist/workflowDb.d.ts +145 -3
  68. package/dist/workflowDb.js +571 -10
  69. package/package.json +32 -4
@@ -1,5 +1,6 @@
1
1
  import type { AgentRuntime } from "../runtimeAuth.js";
2
- import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, TurnResult } from "./types.js";
2
+ import type { BuilderAdapter, BuilderEvent, CompactResult, ContextManagementPolicy, ContextUsage, InterruptResult, ManagedTurnDispatcher, PreparedContextManagement, ProviderSessionUsage, TurnResult } from "./types.js";
3
+ import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
3
4
  export type TurnRecoveryChoice = "retry" | "switch" | "cancel";
4
5
  export interface RecoveringAdapterOptions {
5
6
  initial: BuilderAdapter;
@@ -7,9 +8,10 @@ export interface RecoveringAdapterOptions {
7
8
  enabled: boolean;
8
9
  allowSwitch: boolean;
9
10
  label: string;
10
- recreate: (runtime: AgentRuntime, resumeSessionId?: string) => Promise<BuilderAdapter>;
11
+ recreate: (runtime: AgentRuntime, resumeSessionId?: string, resumeSessionRef?: ProviderSessionRefV1) => Promise<BuilderAdapter>;
11
12
  choose?: (result: TurnResult, runtime: AgentRuntime, otherRuntime: AgentRuntime) => Promise<TurnRecoveryChoice>;
12
13
  onRuntimeChange?: (runtime: AgentRuntime) => void;
14
+ onSessionRef?: (ref: ProviderSessionRefV1) => void;
13
15
  }
14
16
  /** Adds interactive recovery around structured runtime turn failures. */
15
17
  export declare class RecoveringAdapter implements BuilderAdapter {
@@ -19,12 +21,21 @@ export declare class RecoveringAdapter implements BuilderAdapter {
19
21
  private readonly eventQueue;
20
22
  private eventPump;
21
23
  private closed;
24
+ private turnDispatcher?;
22
25
  constructor(opts: RecoveringAdapterOptions);
23
26
  get agent(): "claude" | "codex";
24
27
  sendTurn(text: string): Promise<TurnResult>;
25
28
  sessionId(): string | undefined;
29
+ sessionRef(): ProviderSessionRefV1 | undefined;
30
+ adoptSessionRef(ref: ProviderSessionRefV1): void;
31
+ validateSession(): Promise<SessionAvailabilityV1>;
26
32
  compact(): Promise<CompactResult>;
33
+ prepareContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
34
+ updateContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
35
+ interruptTurnAtCompactionBoundary(providerEventId?: string): Promise<InterruptResult>;
36
+ installManagedTurnDispatcher(dispatcher: ManagedTurnDispatcher): void;
27
37
  contextUsage(): Promise<ContextUsage | undefined>;
38
+ sessionUsage(): Promise<ProviderSessionUsage | undefined>;
28
39
  events(): AsyncIterable<BuilderEvent>;
29
40
  close(): Promise<void>;
30
41
  private replaceAdapter;
@@ -9,6 +9,7 @@ export class RecoveringAdapter {
9
9
  eventQueue = new AsyncQueue();
10
10
  eventPump;
11
11
  closed = false;
12
+ turnDispatcher;
12
13
  constructor(opts) {
13
14
  this.opts = opts;
14
15
  this.adapter = opts.initial;
@@ -20,9 +21,23 @@ export class RecoveringAdapter {
20
21
  }
21
22
  async sendTurn(text) {
22
23
  while (true) {
23
- const result = await this.adapter.sendTurn(text);
24
+ const invoke = () => this.adapter.sendTurn(text);
25
+ const result = this.turnDispatcher ? await this.turnDispatcher(text, invoke) : await invoke();
26
+ const sessionRef = this.adapter.sessionRef?.();
27
+ if (sessionRef && this.opts.onSessionRef) {
28
+ try {
29
+ this.opts.onSessionRef(sessionRef);
30
+ }
31
+ catch (error) {
32
+ this.eventQueue.push({ kind: "error", message: `failed to persist scoped provider session binding: ${error instanceof Error ? error.message : String(error)}` });
33
+ }
34
+ }
24
35
  if (!this.opts.enabled || !result.isError || !result.failure)
25
36
  return result;
37
+ // The host cannot know whether a missing exact session received this
38
+ // instruction. Never replay it or silently switch providers.
39
+ if (result.failure.category === "session-unavailable")
40
+ return result;
26
41
  const otherRuntime = this.runtime === "claude" ? "codex" : "claude";
27
42
  const choice = this.opts.choose
28
43
  ? await this.opts.choose(result, this.runtime, otherRuntime)
@@ -38,7 +53,8 @@ export class RecoveringAdapter {
38
53
  reportBuilderEvent(recoveryEvent);
39
54
  const nextRuntime = choice === "switch" ? otherRuntime : this.runtime;
40
55
  const resumeSessionId = choice === "retry" ? this.adapter.sessionId() : undefined;
41
- await this.replaceAdapter(nextRuntime, resumeSessionId);
56
+ const resumeSessionRef = choice === "retry" ? this.adapter.sessionRef?.() : undefined;
57
+ await this.replaceAdapter(nextRuntime, resumeSessionId, resumeSessionRef);
42
58
  if (choice === "switch") {
43
59
  log.info(`Using ${nextRuntime} for the rest of this run. This is a fresh provider session; conversational continuity was not transferred.`);
44
60
  }
@@ -47,8 +63,20 @@ export class RecoveringAdapter {
47
63
  sessionId() {
48
64
  return this.adapter.sessionId();
49
65
  }
66
+ sessionRef() { return this.adapter.sessionRef?.(); }
67
+ adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
68
+ validateSession() {
69
+ return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped", detail: "wrapped adapter does not expose scoped session validation" });
70
+ }
50
71
  compact() { return this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" }); }
72
+ prepareContextManagement(policy) { if (!this.adapter.prepareContextManagement)
73
+ return Promise.reject(new Error("native context management unavailable")); return this.adapter.prepareContextManagement(policy); }
74
+ updateContextManagement(policy) { if (!this.adapter.updateContextManagement)
75
+ return Promise.reject(new Error("native context reconfiguration unavailable")); return this.adapter.updateContextManagement(policy); }
76
+ interruptTurnAtCompactionBoundary(providerEventId) { return this.adapter.interruptTurnAtCompactionBoundary?.(providerEventId) ?? Promise.resolve({ ok: false, error: "compaction-boundary interruption unavailable", providerEventId }); }
77
+ installManagedTurnDispatcher(dispatcher) { this.turnDispatcher = dispatcher; }
51
78
  contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
79
+ sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
52
80
  events() {
53
81
  return this.eventQueue;
54
82
  }
@@ -60,10 +88,10 @@ export class RecoveringAdapter {
60
88
  await this.eventPump.catch(() => { });
61
89
  this.eventQueue.close();
62
90
  }
63
- async replaceAdapter(runtime, resumeSessionId) {
91
+ async replaceAdapter(runtime, resumeSessionId, resumeSessionRef) {
64
92
  await this.adapter.close().catch(() => { });
65
93
  await this.eventPump.catch(() => { });
66
- this.adapter = await this.opts.recreate(runtime, resumeSessionId);
94
+ this.adapter = await this.opts.recreate(runtime, resumeSessionId, resumeSessionRef);
67
95
  this.runtime = runtime;
68
96
  this.opts.onRuntimeChange?.(runtime);
69
97
  this.eventPump = this.forwardEvents(this.adapter);
@@ -0,0 +1,18 @@
1
+ import type { SessionAvailabilityV1 } from "rafi-spec";
2
+ import type { RuntimeFailure, SessionFailurePhase, TurnDispatchState, TurnResult } from "./types.js";
3
+ export declare class SessionUnavailableError extends Error {
4
+ readonly failure: RuntimeFailure;
5
+ constructor(input: {
6
+ runtime: "claude" | "codex";
7
+ phase: SessionFailurePhase;
8
+ dispatchState: TurnDispatchState;
9
+ executable: string;
10
+ cwd: string;
11
+ diagnostics: string;
12
+ availability?: SessionAvailabilityV1;
13
+ cause?: unknown;
14
+ });
15
+ }
16
+ export declare function sessionUnavailableResult(error: SessionUnavailableError): TurnResult;
17
+ export declare function isSessionUnavailableFailure(value: unknown): boolean;
18
+ export declare function sessionUnavailableErrorFromFailure(failure: RuntimeFailure): SessionUnavailableError;
@@ -0,0 +1,51 @@
1
+ export class SessionUnavailableError extends Error {
2
+ failure;
3
+ constructor(input) {
4
+ super(input.diagnostics, input.cause === undefined ? undefined : { cause: input.cause });
5
+ this.name = "SessionUnavailableError";
6
+ this.failure = {
7
+ runtime: input.runtime,
8
+ phase: input.phase,
9
+ category: "session-unavailable",
10
+ executable: input.executable,
11
+ cwd: input.cwd,
12
+ diagnostics: input.diagnostics,
13
+ dispatchState: input.dispatchState,
14
+ ...(input.availability ? { availability: input.availability } : {}),
15
+ };
16
+ }
17
+ }
18
+ export function sessionUnavailableResult(error) {
19
+ return {
20
+ text: error.message,
21
+ isError: true,
22
+ numTurns: 0,
23
+ costUsd: 0,
24
+ costAuthoritative: false,
25
+ failure: error.failure,
26
+ };
27
+ }
28
+ export function isSessionUnavailableFailure(value) {
29
+ if (value instanceof SessionUnavailableError)
30
+ return true;
31
+ if (!value || typeof value !== "object")
32
+ return false;
33
+ const failure = "failure" in value ? value.failure : value;
34
+ return failure?.category === "session-unavailable";
35
+ }
36
+ export function sessionUnavailableErrorFromFailure(failure) {
37
+ if (failure.category !== "session-unavailable")
38
+ throw new Error("runtime failure is not a session-unavailable failure");
39
+ const phase = failure.phase === "preflight" || failure.phase === "attach" || failure.phase === "turn"
40
+ ? failure.phase
41
+ : "turn";
42
+ return new SessionUnavailableError({
43
+ runtime: failure.runtime,
44
+ phase,
45
+ dispatchState: failure.dispatchState ?? "unknown",
46
+ executable: failure.executable,
47
+ cwd: failure.cwd,
48
+ diagnostics: failure.diagnostics,
49
+ availability: failure.availability,
50
+ });
51
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Agent-agnostic interface over a coding agent ("builder").
3
3
  */
4
- import type { RuntimeProbeCategory, RuntimeProbePhase } from "rafi-spec";
4
+ import type { ConfigurableAgentRole, ProviderSessionRefV1, RuntimeProbeCategory, RuntimeProbePhase, SessionAvailabilityV1 } from "rafi-spec";
5
5
  /** A permission request surfaced by a builder before it runs a tool. */
6
6
  export interface PermissionRequest {
7
7
  toolName: string;
@@ -36,21 +36,78 @@ export type PermissionDecision = {
36
36
  /** Decides each permission request. Supplied by the foreman, called by the adapter. */
37
37
  export type PermissionHandler = (req: PermissionRequest) => Promise<PermissionDecision>;
38
38
  /** Result of a single completed turn. */
39
+ export type SessionFailurePhase = "preflight" | "attach" | "turn";
40
+ export type TurnDispatchState = "not-sent" | "unknown";
41
+ export interface RuntimeFailure {
42
+ runtime: "claude" | "codex";
43
+ phase: RuntimeProbePhase | SessionFailurePhase;
44
+ category: RuntimeProbeCategory;
45
+ executable: string;
46
+ cwd: string;
47
+ diagnostics: string;
48
+ dispatchState?: TurnDispatchState;
49
+ availability?: SessionAvailabilityV1;
50
+ }
39
51
  export interface TurnResult {
40
52
  /** Final assistant message text — where the STEP_STATUS marker lives. */
41
53
  text: string;
42
54
  isError: boolean;
43
55
  numTurns: number;
44
56
  costUsd: number;
45
- failure?: {
46
- runtime: "claude" | "codex";
47
- phase: RuntimeProbePhase;
48
- category: RuntimeProbeCategory;
49
- executable: string;
50
- cwd: string;
51
- diagnostics: string;
57
+ /** True only when the provider supplied this value; never inferred by Rafi. */
58
+ costAuthoritative?: boolean;
59
+ inputTokens?: number;
60
+ outputTokens?: number;
61
+ failure?: RuntimeFailure;
62
+ /** A provider turn stopped at a context-compaction boundary, not a failure. */
63
+ interrupted?: {
64
+ reason: "compaction-boundary";
65
+ providerEventId?: string;
52
66
  };
53
67
  }
68
+ export type ContextCompactionOrigin = "provider-auto" | "rafi-manual" | "recovery" | "boundary";
69
+ export type ContextCompactionPhase = "started" | "succeeded" | "failed";
70
+ /** Native context policy installed before a Builder/QA role may do real work. */
71
+ export interface ContextManagementPolicy {
72
+ role: "builder" | "qa";
73
+ configuredThresholdPercent: number;
74
+ compactMaximum: number;
75
+ settingsRevision: number;
76
+ model: string;
77
+ /** Continue a provider-monotonic sequence after process recovery. */
78
+ providerSequenceStart?: number;
79
+ /** A persisted, model-matched maximum may avoid a discovery turn. */
80
+ knownModelContextWindow?: number;
81
+ /** Native compaction stays enabled until the host vetoes a boundary. */
82
+ nativeCompactionEnabled?: boolean;
83
+ }
84
+ export interface PreparedContextManagement {
85
+ modelContextWindow: number;
86
+ configuredTokenLimit: number;
87
+ installedNativeTokenLimit: number;
88
+ installedNativePercent: number;
89
+ sample?: ContextUsage;
90
+ }
91
+ export interface ContextCompactionEvent {
92
+ kind: "context-compaction";
93
+ phase: ContextCompactionPhase;
94
+ origin: ContextCompactionOrigin;
95
+ providerEventId: string;
96
+ providerSequence: number;
97
+ provider: "claude" | "codex";
98
+ sessionId?: string;
99
+ sessionRef?: ProviderSessionRefV1;
100
+ observedAt: string;
101
+ postCompactSample?: ContextUsage;
102
+ reason?: string;
103
+ }
104
+ export interface InterruptResult {
105
+ ok: boolean;
106
+ error?: string;
107
+ providerEventId?: string;
108
+ }
109
+ /** Host-owned gate used by wrappers that need additional provider turns. */
110
+ export type ManagedTurnDispatcher = (text: string, invoke: () => Promise<TurnResult>) => Promise<TurnResult>;
54
111
  /** Observability events emitted while a builder works. */
55
112
  export type BuilderEvent = {
56
113
  kind: "text";
@@ -86,7 +143,12 @@ export type BuilderEvent = {
86
143
  used: number;
87
144
  maximum?: number;
88
145
  percentage?: number;
89
- } | {
146
+ observedAt?: string;
147
+ source?: "provider-event" | "provider-query" | "post-compact";
148
+ sequence?: number;
149
+ sessionId?: string;
150
+ model?: string;
151
+ } | ContextCompactionEvent | {
90
152
  kind: "error";
91
153
  message: string;
92
154
  };
@@ -94,10 +156,26 @@ export interface ContextUsage {
94
156
  used: number;
95
157
  maximum?: number;
96
158
  percentage?: number;
159
+ observedAt?: string;
160
+ source?: "provider-event" | "provider-query" | "post-compact";
161
+ /** Monotonic within one scoped provider session. */
162
+ sequence?: number;
163
+ sessionId?: string;
164
+ model?: string;
165
+ }
166
+ export interface ProviderSessionUsage {
167
+ inputTokens?: number;
168
+ outputTokens?: number;
169
+ totalTokens?: number;
170
+ authoritativeCostUsd?: number;
171
+ observedAt: string;
172
+ source: "provider" | "turn-aggregate";
97
173
  }
98
174
  export interface CompactResult {
99
175
  ok: boolean;
100
176
  error?: string;
177
+ /** Preserve exact-session loss so lifecycle policy never treats it as an ordinary retryable compaction failure. */
178
+ failure?: RuntimeFailure;
101
179
  }
102
180
  export interface ProviderSettingSwitch {
103
181
  model?: string;
@@ -115,7 +193,19 @@ export interface BuilderAdapterOptions {
115
193
  /** Permission decision callback. */
116
194
  permission: PermissionHandler;
117
195
  /** Resume a prior session instead of starting fresh. */
196
+ /** @deprecated Foreman-controlled recovery must use resumeSessionRef. */
118
197
  resumeSessionId?: string;
198
+ /** Location-scoped provider conversation to validate before exact resume. */
199
+ resumeSessionRef?: ProviderSessionRefV1;
200
+ /** Canonical Rafi configuration/recovery root. Defaults to cwd for compatibility callers. */
201
+ configRoot?: string;
202
+ /** Metadata used when the provider first reveals a fresh session identity. */
203
+ sessionRole?: ConfigurableAgentRole;
204
+ sessionStream?: string;
205
+ sessionGeneration?: number;
206
+ workspaceIdentity?: string;
207
+ ticketId?: string;
208
+ deliveryUnitId?: string;
119
209
  /** Override the model; omit for the agent's default. */
120
210
  model?: string;
121
211
  /** Reasoning effort level. Claude also accepts "max"; Codex supports up to "xhigh". */
@@ -128,6 +218,8 @@ export interface BuilderAdapterOptions {
128
218
  systemPromptAppend?: string;
129
219
  /** Skill names to preload for this session (Claude: lazy-loaded; Codex: flattened). */
130
220
  skills?: string[];
221
+ /** Builder/QA native policy used even for setup and handoff-acceptance turns. */
222
+ contextManagementPolicy?: ContextManagementPolicy;
131
223
  }
132
224
  export interface BuilderAdapter {
133
225
  readonly agent: "claude" | "codex";
@@ -135,10 +227,26 @@ export interface BuilderAdapter {
135
227
  sendTurn(text: string): Promise<TurnResult>;
136
228
  /** Current session id, once known — used for resume. */
137
229
  sessionId(): string | undefined;
230
+ /** Current location-scoped session reference, once known. */
231
+ sessionRef?(): ProviderSessionRefV1 | undefined;
232
+ /** Host-only metadata promotion after a validated handoff is accepted. */
233
+ adoptSessionRef?(ref: ProviderSessionRefV1): void;
234
+ /** Attach/probe a requested exact session without starting a provider turn. */
235
+ validateSession?(): Promise<SessionAvailabilityV1>;
138
236
  /** Provider-native compaction on the exact live conversation. */
139
237
  compact?(): Promise<CompactResult>;
238
+ /** Initialize the session and install native in-turn context controls. */
239
+ prepareContextManagement?(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
240
+ /** Safely reconfigure native in-turn controls on the same session. */
241
+ updateContextManagement?(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
242
+ /** Stop only at a provider-observed compaction boundary. */
243
+ interruptTurnAtCompactionBoundary?(providerEventId?: string): Promise<InterruptResult>;
244
+ /** Route wrapper-owned repair/retry/follow-up turns through the role gate. */
245
+ installManagedTurnDispatcher?(dispatcher: ManagedTurnDispatcher): void;
140
246
  /** Truthful provider context occupancy, when exposed by the provider. */
141
247
  contextUsage?(): Promise<ContextUsage | undefined>;
248
+ /** Provider/session cumulative totals; distinct from live context occupancy. */
249
+ sessionUsage?(): Promise<ProviderSessionUsage | undefined>;
142
250
  /** Attempt a provider-supported in-conversation model/reasoning transition. */
143
251
  switchSettings?(settings: ProviderSettingSwitch): Promise<CompactResult>;
144
252
  /** Stream of observability events. Iterate to drive a live view. */
@@ -5,7 +5,7 @@ import type { BuilderAdapter, BuilderAdapterOptions, EffortLevel, TurnResult } f
5
5
  import type { AnsweredProviderQuestion } from "./providerQuestions.js";
6
6
  import { type RoleBundle } from "./roles.js";
7
7
  import type { AgentRuntime } from "./runtimeAuth.js";
8
- import type { AgentRoleDefaultsV1 } from "rafi-spec";
8
+ import type { AgentRoleDefaultsV1, ProviderSessionRefV1 } from "rafi-spec";
9
9
  export type { EffortLevel } from "./adapters/types.js";
10
10
  export declare const VALID_EFFORT: readonly EffortLevel[];
11
11
  export interface RoleBuilderOptions {
@@ -23,6 +23,8 @@ export interface RoleBuilderOptions {
23
23
  extraSkills?: string[];
24
24
  sandboxMode?: BuilderAdapterOptions["sandboxMode"];
25
25
  resumeSessionId?: string;
26
+ /** Location-scoped exact session. Raw IDs are resolved through durable project bindings or treated as legacy candidates. */
27
+ resumeSessionRef?: ProviderSessionRefV1;
26
28
  /** Observe successfully answered provider-native questions. */
27
29
  onAnsweredQuestion?: (event: AnsweredProviderQuestion) => void;
28
30
  /** Observe any provider-native question attempt, including denied prompts. */
@@ -51,6 +53,7 @@ export interface RoleInstructionRunResult {
51
53
  model?: string;
52
54
  effort?: EffortLevel;
53
55
  sessionId?: string;
56
+ sessionRef?: ProviderSessionRefV1;
54
57
  logPath: string;
55
58
  roleBundle: RoleBundle;
56
59
  skills: string[];
package/dist/agentRun.js CHANGED
@@ -6,12 +6,15 @@ import { PermissionPolicy } from "./permissions/policy.js";
6
6
  import { ClaudeAdapter } from "./adapters/claude.js";
7
7
  import { CodexAdapter } from "./adapters/codex.js";
8
8
  import { RecoveringAdapter } from "./adapters/recovering.js";
9
+ import { SessionUnavailableError } from "./adapters/sessionFailure.js";
9
10
  import { Foreman, createPermissionHandler } from "./foreman.js";
10
11
  import { printEvents } from "./cli/events.js";
11
12
  import { loadRoleBundle } from "./roles.js";
12
13
  import { ensureRuntimeReadyForCommand } from "./cli/runtimeAuthPrompt.js";
13
14
  import { resolveAgentForProject } from "./cli/runtimeSelection.js";
14
15
  import { parse as parseYaml } from "yaml";
16
+ import { captureWorkspaceIdentity, createProviderSessionRef, resolveUniqueSessionBinding } from "./sessionIdentity.js";
17
+ import { WorkflowDb } from "./workflowDb.js";
15
18
  export const VALID_EFFORT = ["low", "medium", "high", "xhigh"];
16
19
  export function assertEffortLevel(effort) {
17
20
  if (effort && !VALID_EFFORT.includes(effort)) {
@@ -87,10 +90,29 @@ export async function createRoleBuilder(opts) {
87
90
  throw new Error(`project directory not found: ${opts.projectDir}`);
88
91
  }
89
92
  const saved = readRoleDefaultsForExecution(opts.projectDir, opts.role);
93
+ if (opts.resumeSessionRef && opts.resumeSessionId && opts.resumeSessionRef.sessionId !== opts.resumeSessionId) {
94
+ throw new Error("resume session ID does not match the supplied location-scoped reference");
95
+ }
96
+ let requestedResumeRef = opts.resumeSessionRef;
97
+ if (!requestedResumeRef && opts.resumeSessionId) {
98
+ const bindingDb = new WorkflowDb(opts.projectDir);
99
+ try {
100
+ requestedResumeRef = resolveUniqueSessionBinding(bindingDb.providerSessionBindings(opts.resumeSessionId), opts.resumeSessionId);
101
+ }
102
+ finally {
103
+ bindingDb.close();
104
+ }
105
+ }
90
106
  const configuredEffort = saved?.reasoning && saved.reasoning !== "default" ? saved.reasoning : undefined;
91
107
  const effectiveEffort = opts.effort ?? (VALID_EFFORT.includes(configuredEffort) ? configuredEffort : undefined);
92
108
  assertEffortLevel(effectiveEffort);
93
- let runtime = resolveAgentForProject(opts.projectDir, opts.agent ?? saved?.make);
109
+ let runtime = resolveAgentForProject(opts.projectDir, opts.agent ?? requestedResumeRef?.provider ?? saved?.make);
110
+ if (requestedResumeRef && requestedResumeRef.provider !== runtime) {
111
+ throw new Error(`session ${requestedResumeRef.sessionId} belongs to ${requestedResumeRef.provider}, but ${runtime} was selected`);
112
+ }
113
+ if (requestedResumeRef && requestedResumeRef.role !== configurableRole(opts.role)) {
114
+ throw new Error(`session ${requestedResumeRef.sessionId} belongs to role ${requestedResumeRef.role}, not ${configurableRole(opts.role)}`);
115
+ }
94
116
  const config = loadConfig(join(opts.projectDir, "foreman.yaml"));
95
117
  const log = opts.log ?? new Log(makeLogPath(opts.projectDir, opts.label.replace(/\s+/g, "-")));
96
118
  const roleBundle = loadRoleBundle(opts.role, { projectDir: opts.projectDir });
@@ -99,7 +121,7 @@ export async function createRoleBuilder(opts) {
99
121
  const ready = await ensureRuntimeReadyForCommand(opts.projectDir, runtime, {
100
122
  label: opts.label,
101
123
  yes: Boolean(opts.yes),
102
- allowSwitch: opts.allowSwitch,
124
+ allowSwitch: requestedResumeRef || opts.resumeSessionId ? false : opts.allowSwitch,
103
125
  model: initialModel,
104
126
  });
105
127
  runtime = ready.runtime;
@@ -108,13 +130,42 @@ export async function createRoleBuilder(opts) {
108
130
  const policy = new PermissionPolicy(opts.permissionConfig ?? config.permissions, opts.projectDir);
109
131
  const skills = mergeSkills(roleBundle.skills, opts.extraSkills);
110
132
  const interactive = !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY);
111
- const makeAdapter = async (nextRuntime, resumeSessionId) => {
133
+ const makeAdapter = async (nextRuntime, resumeSessionId, resumeSessionRef) => {
134
+ let scopedRef = resumeSessionRef;
135
+ if (!scopedRef && resumeSessionId) {
136
+ const bindingDb = new WorkflowDb(opts.projectDir);
137
+ try {
138
+ scopedRef = resolveUniqueSessionBinding(bindingDb.providerSessionBindings(resumeSessionId), resumeSessionId);
139
+ }
140
+ finally {
141
+ bindingDb.close();
142
+ }
143
+ scopedRef ??= createProviderSessionRef({
144
+ provider: nextRuntime,
145
+ sessionId: resumeSessionId,
146
+ role: configurableRole(opts.role),
147
+ stream: opts.role,
148
+ generation: 0,
149
+ cwd: opts.projectDir,
150
+ configRoot: opts.projectDir,
151
+ workspaceIdentity: captureWorkspaceIdentity(opts.projectDir),
152
+ source: "legacy-inferred",
153
+ });
154
+ }
155
+ if (scopedRef && scopedRef.provider !== nextRuntime) {
156
+ throw new Error(`session ${scopedRef.sessionId} belongs to ${scopedRef.provider}, but ${nextRuntime} was selected`);
157
+ }
112
158
  const adapterOpts = {
113
159
  cwd: opts.projectDir,
160
+ configRoot: opts.projectDir,
114
161
  runtimeExecutable,
115
162
  runtimePhase: phaseForRole(opts.role),
116
163
  model,
117
- resumeSessionId,
164
+ ...(scopedRef ? { resumeSessionRef: scopedRef } : {}),
165
+ sessionRole: configurableRole(opts.role),
166
+ sessionStream: opts.role,
167
+ sessionGeneration: scopedRef?.generation ?? 0,
168
+ workspaceIdentity: captureWorkspaceIdentity(opts.projectDir),
118
169
  permission: createPermissionHandler(policy, log, {
119
170
  interactive,
120
171
  onAnsweredQuestion: opts.onAnsweredQuestion,
@@ -126,18 +177,46 @@ export async function createRoleBuilder(opts) {
126
177
  systemPromptAppend: roleBundle.system || undefined,
127
178
  skills: skills.length > 0 ? skills : undefined,
128
179
  };
129
- return nextRuntime === "codex"
180
+ const adapter = nextRuntime === "codex"
130
181
  ? new CodexAdapter(adapterOpts)
131
182
  : await ClaudeAdapter.create(adapterOpts);
183
+ if (scopedRef && adapter.agent === "codex") {
184
+ const availability = await adapter.validateSession();
185
+ if (availability.status !== "available") {
186
+ await adapter.close().catch(() => { });
187
+ throw new SessionUnavailableError({
188
+ runtime: "codex",
189
+ phase: "preflight",
190
+ dispatchState: "not-sent",
191
+ executable: runtimeExecutable,
192
+ cwd: opts.projectDir,
193
+ diagnostics: availability.detail ?? `Codex session ${scopedRef.sessionId} is ${availability.status}`,
194
+ availability,
195
+ });
196
+ }
197
+ }
198
+ return adapter;
132
199
  };
133
- const initial = await makeAdapter(runtime, opts.resumeSessionId);
200
+ const initial = await makeAdapter(runtime, opts.resumeSessionId, requestedResumeRef);
201
+ const persistSessionRef = (ref) => {
202
+ const bindingDb = new WorkflowDb(opts.projectDir);
203
+ try {
204
+ bindingDb.recordProviderSessionBinding(ref);
205
+ }
206
+ finally {
207
+ bindingDb.close();
208
+ }
209
+ };
210
+ const initialRef = initial.sessionRef?.();
211
+ if (initialRef)
212
+ persistSessionRef(initialRef);
134
213
  const builder = new RecoveringAdapter({
135
214
  initial,
136
215
  runtime,
137
216
  label: opts.label,
138
217
  enabled: interactive,
139
- allowSwitch: opts.allowSwitch !== false && !opts.resumeSessionId,
140
- recreate: async (nextRuntime, resumeSessionId) => {
218
+ allowSwitch: opts.allowSwitch !== false && !opts.resumeSessionId && !requestedResumeRef,
219
+ recreate: async (nextRuntime, resumeSessionId, resumeSessionRef) => {
141
220
  const nextReady = await ensureRuntimeReadyForCommand(opts.projectDir, nextRuntime, {
142
221
  label: opts.label,
143
222
  allowSwitch: false,
@@ -146,8 +225,9 @@ export async function createRoleBuilder(opts) {
146
225
  runtime = nextReady.runtime;
147
226
  model = nextReady.model;
148
227
  runtimeExecutable = nextReady.executable;
149
- return makeAdapter(runtime, resumeSessionId);
228
+ return makeAdapter(runtime, resumeSessionId, resumeSessionRef);
150
229
  },
230
+ onSessionRef: persistSessionRef,
151
231
  });
152
232
  return { builder, runtime, model, effort, roleBundle, skills, log };
153
233
  }
@@ -225,6 +305,7 @@ export async function runRoleInstruction(opts) {
225
305
  model: builder.agent === roleBuilder.runtime ? roleBuilder.model : undefined,
226
306
  effort,
227
307
  sessionId: builder.sessionId(),
308
+ sessionRef: builder.sessionRef?.(),
228
309
  statusKind: turn.status.kind,
229
310
  summary: turn.status.summary,
230
311
  reason: turn.status.reason,
@@ -238,6 +319,7 @@ export async function runRoleInstruction(opts) {
238
319
  model: builder.agent === roleBuilder.runtime ? roleBuilder.model : undefined,
239
320
  effort,
240
321
  sessionId: builder.sessionId(),
322
+ sessionRef: builder.sessionRef?.(),
241
323
  logPath,
242
324
  roleBundle,
243
325
  skills,
@@ -250,3 +332,8 @@ export async function runRoleInstruction(opts) {
250
332
  throw err;
251
333
  }
252
334
  }
335
+ function configurableRole(role) {
336
+ return ["builder", "qa", "planner", "ticket-maker", "uninstaller"].includes(role)
337
+ ? role
338
+ : "builder";
339
+ }
@@ -0,0 +1,38 @@
1
+ import type { BuilderAdapter, BuilderEvent, CompactResult, ContextManagementPolicy, ContextUsage, InterruptResult, ManagedTurnDispatcher, PreparedContextManagement, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "../adapters/types.js";
2
+ import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
3
+ export interface CurrentWorkflowIdentity {
4
+ worktree: string;
5
+ ref: string;
6
+ }
7
+ export declare class CurrentWorkflowChangedError extends Error {
8
+ readonly expected: CurrentWorkflowIdentity;
9
+ readonly actual: CurrentWorkflowIdentity;
10
+ constructor(expected: CurrentWorkflowIdentity, actual: CurrentWorkflowIdentity);
11
+ }
12
+ export declare function captureCurrentWorkflowIdentity(cwd: string): CurrentWorkflowIdentity;
13
+ export declare function assertCurrentWorkflowIdentity(cwd: string, expected: CurrentWorkflowIdentity): void;
14
+ export declare function currentWorkflowIdentityKey(identity: CurrentWorkflowIdentity): string;
15
+ export declare function captureCurrentWorkflowSessionIdentity(cwd: string): string;
16
+ /** Fence every provider boundary against an unexpected user/provider ref switch. */
17
+ export declare class CurrentWorkflowGuardAdapter implements BuilderAdapter {
18
+ private readonly adapter;
19
+ private readonly cwd;
20
+ private readonly expected;
21
+ readonly agent: BuilderAdapter["agent"];
22
+ constructor(adapter: BuilderAdapter, cwd: string, expected?: CurrentWorkflowIdentity);
23
+ sendTurn(text: string): Promise<TurnResult>;
24
+ sessionId(): string | undefined;
25
+ sessionRef(): ProviderSessionRefV1 | undefined;
26
+ adoptSessionRef(ref: ProviderSessionRefV1): void;
27
+ validateSession(): Promise<SessionAvailabilityV1>;
28
+ compact(): Promise<CompactResult>;
29
+ prepareContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
30
+ updateContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
31
+ interruptTurnAtCompactionBoundary(providerEventId?: string): Promise<InterruptResult>;
32
+ installManagedTurnDispatcher(dispatcher: ManagedTurnDispatcher): void;
33
+ contextUsage(): Promise<ContextUsage | undefined>;
34
+ sessionUsage(): Promise<ProviderSessionUsage | undefined>;
35
+ switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
36
+ events(): AsyncIterable<BuilderEvent>;
37
+ close(): Promise<void>;
38
+ }