ai-foreman 1.7.5 → 1.7.7

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 (78) hide show
  1. package/README.md +17 -2
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +29 -4
  4. package/dist/adapters/claude.d.ts +33 -2
  5. package/dist/adapters/claude.js +208 -17
  6. package/dist/adapters/codex.d.ts +11 -1
  7. package/dist/adapters/codex.js +195 -20
  8. package/dist/adapters/recovering.d.ts +8 -2
  9. package/dist/adapters/recovering.js +23 -3
  10. package/dist/adapters/sessionFailure.d.ts +18 -0
  11. package/dist/adapters/sessionFailure.js +51 -0
  12. package/dist/adapters/types.d.ts +52 -9
  13. package/dist/agentRun.d.ts +4 -1
  14. package/dist/agentRun.js +96 -9
  15. package/dist/branch/currentGuard.d.ts +34 -0
  16. package/dist/branch/currentGuard.js +65 -0
  17. package/dist/branch/github.js +3 -2
  18. package/dist/branch/gitlab.js +3 -2
  19. package/dist/branch/planner.d.ts +5 -0
  20. package/dist/branch/planner.js +37 -3
  21. package/dist/branch/prefix.d.ts +8 -0
  22. package/dist/branch/prefix.js +26 -0
  23. package/dist/branch/resume.d.ts +2 -0
  24. package/dist/branch/resume.js +8 -0
  25. package/dist/branch/runner.d.ts +14 -2
  26. package/dist/branch/runner.js +77 -17
  27. package/dist/buildRuns.d.ts +51 -18
  28. package/dist/buildRuns.js +318 -31
  29. package/dist/cli/handoffs.d.ts +2 -0
  30. package/dist/cli/handoffs.js +74 -0
  31. package/dist/cli/start.js +1040 -62
  32. package/dist/cli/tickets.d.ts +10 -1
  33. package/dist/cli/tickets.js +615 -80
  34. package/dist/continuity.d.ts +86 -0
  35. package/dist/continuity.js +365 -0
  36. package/dist/foreman.d.ts +9 -5
  37. package/dist/foreman.js +76 -20
  38. package/dist/handoffs.d.ts +69 -0
  39. package/dist/handoffs.js +326 -0
  40. package/dist/log.d.ts +1 -1
  41. package/dist/permissions/policy.d.ts +5 -1
  42. package/dist/permissions/policy.js +48 -1
  43. package/dist/qaReview.d.ts +7 -6
  44. package/dist/qaReview.js +31 -20
  45. package/dist/sessionAvailability.d.ts +15 -0
  46. package/dist/sessionAvailability.js +57 -0
  47. package/dist/sessionIdentity.d.ts +33 -0
  48. package/dist/sessionIdentity.js +132 -0
  49. package/dist/sessionLifecycle.d.ts +102 -3
  50. package/dist/sessionLifecycle.js +578 -20
  51. package/dist/statusReporter.d.ts +58 -8
  52. package/dist/statusReporter.js +210 -14
  53. package/dist/ticketPlanning.js +8 -0
  54. package/dist/ticketPopulation.js +88 -12
  55. package/dist/tickets/commands.js +20 -12
  56. package/dist/tickets/details.d.ts +17 -0
  57. package/dist/tickets/details.js +94 -0
  58. package/dist/tickets/eligibility.d.ts +28 -0
  59. package/dist/tickets/eligibility.js +71 -0
  60. package/dist/tickets/groupReset.d.ts +25 -0
  61. package/dist/tickets/groupReset.js +279 -0
  62. package/dist/tickets/groups.d.ts +36 -0
  63. package/dist/tickets/groups.js +68 -0
  64. package/dist/tickets/importer.js +27 -3
  65. package/dist/tickets/recommendations.js +31 -7
  66. package/dist/tickets/reset.d.ts +28 -0
  67. package/dist/tickets/reset.js +214 -0
  68. package/dist/tickets/reviewStandards.d.ts +7 -0
  69. package/dist/tickets/reviewStandards.js +53 -0
  70. package/dist/tickets/setupConfig.d.ts +23 -0
  71. package/dist/tickets/setupConfig.js +115 -3
  72. package/dist/tickets/stateDb.d.ts +42 -1
  73. package/dist/tickets/stateDb.js +261 -23
  74. package/dist/tickets/ticketLoader.js +5 -2
  75. package/dist/tickets/validate.js +4 -0
  76. package/dist/workflowDb.d.ts +102 -2
  77. package/dist/workflowDb.js +460 -8
  78. package/package.json +35 -3
@@ -0,0 +1,86 @@
1
+ import type { ContinuityDelta, ResolvedAgentSettings } from "rafi-spec";
2
+ import type { BuilderAdapter, CompactResult, ContextUsage, ProviderSessionUsage, ProviderSettingSwitch, RuntimeFailure, TurnResult } from "./adapters/types.js";
3
+ export declare const CONTINUITY_MARKER = "RAFI_CONTINUITY_DELTA:";
4
+ export declare class ContinuityValidationError extends Error {
5
+ readonly problems: string[];
6
+ constructor(problems: string[]);
7
+ }
8
+ export declare class ContinuityRecoveryRequiredError extends Error {
9
+ readonly runId: string;
10
+ readonly role: "builder" | "qa";
11
+ constructor(runId: string, role: "builder" | "qa", message: string);
12
+ }
13
+ /**
14
+ * Exact-session loss is a host recovery boundary, never a continuity-repair
15
+ * prompt. `guidedRecovery` is true when the provider may already have received
16
+ * the frozen instruction, so a fresh successor must reconcile receipts first.
17
+ */
18
+ export declare class SessionUnavailableContinuityError extends Error {
19
+ readonly runId: string;
20
+ readonly role: "builder" | "qa";
21
+ readonly failure: RuntimeFailure;
22
+ readonly guidedRecovery: boolean;
23
+ constructor(runId: string, role: "builder" | "qa", failure: RuntimeFailure);
24
+ }
25
+ export declare function validateContinuityDelta(value: unknown): ContinuityDelta;
26
+ export declare function parseContinuityDelta(text: string): {
27
+ delta?: ContinuityDelta;
28
+ cleanText: string;
29
+ error?: ContinuityValidationError;
30
+ };
31
+ export declare function mergeContinuityDeltas(deltas: readonly ContinuityDelta[]): ContinuityDelta;
32
+ export declare function baselineContinuityDelta(nextAction?: string): ContinuityDelta;
33
+ export declare function continuityInstruction(): string;
34
+ export interface ContinuityAdapterOptions {
35
+ adapter: BuilderAdapter;
36
+ projectDir: string;
37
+ runId: string;
38
+ role: "builder" | "qa";
39
+ settings: ResolvedAgentSettings;
40
+ authoritativeStateRevision?: () => number;
41
+ /** Fresh successor creation after the same-session repair also fails. */
42
+ createSuccessor?: (handoff: string) => Promise<BuilderAdapter>;
43
+ /**
44
+ * Production recovery path for a double-invalid delta. The callback must
45
+ * publish a versioned handoff, validate a genuinely fresh successor, and
46
+ * move the role lease before returning it.
47
+ */
48
+ recoverWithHandoff?: (input: {
49
+ reason: string;
50
+ reconstruction: string;
51
+ predecessor: BuilderAdapter;
52
+ }) => Promise<BuilderAdapter>;
53
+ /** Explicit compatibility recovery may replace a dead predecessor lease only after its first valid checkpoint. */
54
+ replaceRecoveryLeaseAfterCheckpoint?: boolean;
55
+ /** Host-owned validated Builder handoff request processing at a safe turn boundary. */
56
+ handleHandoffRequest?: (text: string, predecessor: BuilderAdapter, frozenAction: string) => Promise<BuilderAdapter | undefined>;
57
+ }
58
+ /** Enforces a durable role checkpoint after every completed provider turn. */
59
+ export declare class ContinuityAdapter implements BuilderAdapter {
60
+ private readonly options;
61
+ private adapter;
62
+ private readonly queue;
63
+ private sourcePump?;
64
+ private recoveryLeasePending;
65
+ constructor(options: ContinuityAdapterOptions);
66
+ get agent(): "claude" | "codex";
67
+ sendTurn(instruction: string): Promise<TurnResult>;
68
+ sessionId(): string | undefined;
69
+ sessionRef(): import("rafi-spec").ProviderSessionRefV1 | undefined;
70
+ adoptSessionRef(ref: import("rafi-spec").ProviderSessionRefV1): void;
71
+ validateSession(): Promise<import("rafi-spec").SessionAvailabilityV1>;
72
+ compact(): Promise<CompactResult>;
73
+ contextUsage(): Promise<ContextUsage | undefined>;
74
+ sessionUsage(): Promise<ProviderSessionUsage | undefined>;
75
+ switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
76
+ events(): AsyncIterable<import("./adapters/types.js").BuilderEvent>;
77
+ close(): Promise<void>;
78
+ /** Keep the observable wrapper and event stream stable while moving to an accepted successor. */
79
+ adoptValidatedSuccessor(successor: BuilderAdapter): Promise<void>;
80
+ private publish;
81
+ private moveRecoveryLeaseAfterCheckpoint;
82
+ private recordSessionUnavailable;
83
+ private reconstructionHandoff;
84
+ private revision;
85
+ private pumpEvents;
86
+ }
@@ -0,0 +1,365 @@
1
+ import { createHash } from "node:crypto";
2
+ import { BuilderEventQueue } from "./activity.js";
3
+ import { WorkflowDb } from "./workflowDb.js";
4
+ export const CONTINUITY_MARKER = "RAFI_CONTINUITY_DELTA:";
5
+ export class ContinuityValidationError extends Error {
6
+ problems;
7
+ constructor(problems) {
8
+ super(`invalid continuity delta: ${problems.join("; ")}`);
9
+ this.problems = problems;
10
+ }
11
+ }
12
+ export class ContinuityRecoveryRequiredError extends Error {
13
+ runId;
14
+ role;
15
+ constructor(runId, role, message) {
16
+ super(`${message}; run rafi build:resume --run ${runId} --guided-recovery in a TTY`);
17
+ this.runId = runId;
18
+ this.role = role;
19
+ }
20
+ }
21
+ /**
22
+ * Exact-session loss is a host recovery boundary, never a continuity-repair
23
+ * prompt. `guidedRecovery` is true when the provider may already have received
24
+ * the frozen instruction, so a fresh successor must reconcile receipts first.
25
+ */
26
+ export class SessionUnavailableContinuityError extends Error {
27
+ runId;
28
+ role;
29
+ failure;
30
+ guidedRecovery;
31
+ constructor(runId, role, failure) {
32
+ const guidedRecovery = failure.dispatchState === "unknown";
33
+ super(guidedRecovery
34
+ ? `the exact ${role} session became unavailable after dispatch may have begun; run rafi build:resume --run ${runId} --guided-recovery in a TTY`
35
+ : `the exact ${role} session is unavailable before dispatch; inspect run ${runId} and choose fresh-with-handoff recovery`);
36
+ this.runId = runId;
37
+ this.role = role;
38
+ this.failure = failure;
39
+ this.name = "SessionUnavailableContinuityError";
40
+ this.guidedRecovery = guidedRecovery;
41
+ }
42
+ }
43
+ export function validateContinuityDelta(value) {
44
+ const problems = [];
45
+ if (!value || typeof value !== "object" || Array.isArray(value))
46
+ throw new ContinuityValidationError(["delta must be an object"]);
47
+ const input = value;
48
+ if (input.version !== 1)
49
+ problems.push("version must be 1");
50
+ const output = {
51
+ version: 1,
52
+ decisions: strings(input.decisions, "decisions", problems),
53
+ constraints: strings(input.constraints, "constraints", problems),
54
+ discoveries: strings(input.discoveries, "discoveries", problems),
55
+ completedActions: strings(input.completedActions, "completedActions", problems),
56
+ evidence: strings(input.evidence, "evidence", problems),
57
+ failures: strings(input.failures, "failures", problems),
58
+ blockers: strings(input.blockers, "blockers", problems),
59
+ openWork: strings(input.openWork, "openWork", problems),
60
+ nextAction: typeof input.nextAction === "string" ? clean(input.nextAction, 4_000) : "",
61
+ };
62
+ if (!output.nextAction)
63
+ problems.push("nextAction must be a non-empty string");
64
+ if (JSON.stringify(output).length > 64_000)
65
+ problems.push("delta exceeds 64 KiB");
66
+ if (problems.length)
67
+ throw new ContinuityValidationError(problems);
68
+ return output;
69
+ }
70
+ export function parseContinuityDelta(text) {
71
+ const lines = text.split(/\r?\n/);
72
+ const markerIndexes = lines.flatMap((line, index) => line.trimStart().startsWith(CONTINUITY_MARKER) ? [index] : []);
73
+ const cleanText = lines.filter((_line, index) => !markerIndexes.includes(index)).join("\n").trimEnd();
74
+ if (markerIndexes.length !== 1)
75
+ return { cleanText, error: new ContinuityValidationError([markerIndexes.length ? "multiple continuity markers" : "missing continuity marker"]) };
76
+ const raw = lines[markerIndexes[0]].trimStart().slice(CONTINUITY_MARKER.length).trim();
77
+ try {
78
+ return { delta: validateContinuityDelta(JSON.parse(raw)), cleanText };
79
+ }
80
+ catch (error) {
81
+ return { cleanText, error: error instanceof ContinuityValidationError ? error : new ContinuityValidationError([`marker is not valid JSON: ${String(error)}`]) };
82
+ }
83
+ }
84
+ export function mergeContinuityDeltas(deltas) {
85
+ const result = baselineContinuityDelta();
86
+ for (const delta of deltas) {
87
+ for (const key of ["decisions", "constraints", "discoveries", "completedActions", "evidence", "failures", "blockers", "openWork"]) {
88
+ result[key] = unique([...result[key], ...delta[key]]).slice(-500);
89
+ }
90
+ if (delta.nextAction)
91
+ result.nextAction = delta.nextAction;
92
+ }
93
+ return result;
94
+ }
95
+ export function baselineContinuityDelta(nextAction = "Await the first role action.") {
96
+ return { version: 1, decisions: [], constraints: [], discoveries: [], completedActions: [], evidence: [], failures: [], blockers: [], openWork: [], nextAction };
97
+ }
98
+ export function continuityInstruction() {
99
+ return [
100
+ "Before your final STEP_STATUS line (or as the final line when STEP_STATUS is not requested), emit exactly one single-line continuity record:",
101
+ `${CONTINUITY_MARKER} {"version":1,"decisions":[],"constraints":[],"discoveries":[],"completedActions":[],"evidence":[],"failures":[],"blockers":[],"openWork":[],"nextAction":"specific next action"}`,
102
+ "Keep it concise and cumulative for facts learned this turn. Do not include credentials, hidden reasoning, or raw transcripts.",
103
+ ].join("\n");
104
+ }
105
+ /** Enforces a durable role checkpoint after every completed provider turn. */
106
+ export class ContinuityAdapter {
107
+ options;
108
+ adapter;
109
+ queue = new BuilderEventQueue();
110
+ sourcePump;
111
+ recoveryLeasePending;
112
+ constructor(options) {
113
+ this.options = options;
114
+ this.adapter = options.adapter;
115
+ this.recoveryLeasePending = Boolean(options.replaceRecoveryLeaseAfterCheckpoint);
116
+ const db = new WorkflowDb(options.projectDir);
117
+ try {
118
+ db.ensureRun(options.runId);
119
+ if (!db.continuityHead(options.runId, options.role)) {
120
+ db.appendContinuityEvent({ runId: options.runId, role: "host", kind: "role_baseline", payload: { role: options.role, provider: options.settings.make, model: options.settings.model }, authoritativeStateRevision: this.revision() });
121
+ db.publishContinuityCheckpoint({ runId: options.runId, role: options.role, delta: baselineContinuityDelta(), authoritativeStateRevision: this.revision() });
122
+ }
123
+ const initialSession = options.adapter.sessionRef?.() ?? options.adapter.sessionId();
124
+ if (initialSession)
125
+ db.claimInitialRoleLease(options.runId, options.role, initialSession);
126
+ }
127
+ finally {
128
+ db.close();
129
+ }
130
+ this.pumpEvents();
131
+ }
132
+ get agent() { return this.adapter.agent; }
133
+ async sendTurn(instruction) {
134
+ const db = new WorkflowDb(this.options.projectDir);
135
+ try {
136
+ db.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: "turn_started", payload: { role: this.options.role, instructionDigest: sha(instruction), instructionBytes: Buffer.byteLength(instruction) }, authoritativeStateRevision: this.revision() });
137
+ }
138
+ finally {
139
+ db.close();
140
+ }
141
+ const original = await this.adapter.sendTurn(`${instruction}\n\n${continuityInstruction()}`);
142
+ const activeSession = this.adapter.sessionRef?.() ?? this.adapter.sessionId();
143
+ if (activeSession) {
144
+ const leaseDb = new WorkflowDb(this.options.projectDir);
145
+ try {
146
+ leaseDb.claimInitialRoleLease(this.options.runId, this.options.role, activeSession);
147
+ }
148
+ finally {
149
+ leaseDb.close();
150
+ }
151
+ }
152
+ if (original.failure?.category === "session-unavailable") {
153
+ this.recordSessionUnavailable(original.failure, instruction);
154
+ throw new SessionUnavailableContinuityError(this.options.runId, this.options.role, original.failure);
155
+ }
156
+ const parsed = parseContinuityDelta(original.text);
157
+ if (parsed.delta) {
158
+ this.publish(parsed.delta, "turn_completed", original);
159
+ this.moveRecoveryLeaseAfterCheckpoint();
160
+ const successor = await this.options.handleHandoffRequest?.(original.text, this.adapter, instruction);
161
+ if (successor && successor !== this.adapter) {
162
+ await this.adoptValidatedSuccessor(successor);
163
+ return this.sendTurn([
164
+ "Continue the frozen action after the accepted Rafi handoff. Do not repeat completed side effects.",
165
+ instruction,
166
+ ].join("\n\n"));
167
+ }
168
+ return { ...original, text: parsed.cleanText };
169
+ }
170
+ const repair = await this.adapter.sendTurn([
171
+ "Continuity protocol repair only. Do not run tools, repeat work, or change files.",
172
+ `Your prior turn's continuity record was invalid: ${parsed.error?.problems.join("; ")}.`,
173
+ continuityInstruction(),
174
+ "Return only the continuity record.",
175
+ ].join("\n"));
176
+ const repaired = parseContinuityDelta(repair.text);
177
+ if (repaired.delta) {
178
+ this.publish(repaired.delta, "turn_completed_after_repair", original);
179
+ this.moveRecoveryLeaseAfterCheckpoint();
180
+ return { ...original, text: parsed.cleanText };
181
+ }
182
+ const invalidDb = new WorkflowDb(this.options.projectDir);
183
+ try {
184
+ invalidDb.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: "continuity_invalid_twice", payload: { original: parsed.error?.problems, repair: repaired.error?.problems }, authoritativeStateRevision: this.revision() });
185
+ invalidDb.setContinuityHeadState(this.options.runId, this.options.role, "invalid");
186
+ }
187
+ finally {
188
+ invalidDb.close();
189
+ }
190
+ const handoff = this.reconstructionHandoff();
191
+ if (this.options.recoverWithHandoff) {
192
+ let successor;
193
+ try {
194
+ successor = await this.options.recoverWithHandoff({
195
+ reason: "continuity delta and same-session repair were both invalid",
196
+ reconstruction: handoff,
197
+ predecessor: this.adapter,
198
+ });
199
+ }
200
+ catch (error) {
201
+ throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, `fresh validated successor also failed after the same-session repair: ${error instanceof Error ? error.message : String(error)}`);
202
+ }
203
+ if (!successor.sessionId() || successor.sessionId() === this.adapter.sessionId()) {
204
+ await successor.close().catch(() => { });
205
+ throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "validated recovery callback did not return a genuinely fresh successor");
206
+ }
207
+ await this.adoptValidatedSuccessor(successor);
208
+ return { ...original, text: parsed.cleanText };
209
+ }
210
+ if (!this.options.createSuccessor)
211
+ throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "continuity repair failed in the original session and no validated successor is configured");
212
+ const successor = await this.options.createSuccessor(handoff);
213
+ const accepted = await successor.sendTurn(`${handoff}\n\nReply with HANDOFF_ACCEPTED on the first line, then ${continuityInstruction()}`);
214
+ const successorDelta = parseContinuityDelta(accepted.text);
215
+ if (!/^HANDOFF_ACCEPTED\b/m.test(accepted.text) || !successorDelta.delta || !successor.sessionId()) {
216
+ await successor.close().catch(() => { });
217
+ throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "fresh successor did not validate and accept the cumulative checkpoint");
218
+ }
219
+ await this.adoptValidatedSuccessor(successor);
220
+ this.publish(successorDelta.delta, "fresh_successor_accepted", accepted);
221
+ const leaseDb = new WorkflowDb(this.options.projectDir);
222
+ try {
223
+ leaseDb.moveRoleLeaseAfterValidatedRecovery(this.options.runId, this.options.role, successor.sessionRef?.() ?? successor.sessionId(), "double-invalid continuity recovery");
224
+ }
225
+ finally {
226
+ leaseDb.close();
227
+ }
228
+ return { ...original, text: parsed.cleanText };
229
+ }
230
+ sessionId() { return this.adapter.sessionId(); }
231
+ sessionRef() { return this.adapter.sessionRef?.(); }
232
+ adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
233
+ validateSession() { return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
234
+ compact() { return this.adapter.compact ? this.adapter.compact() : Promise.resolve({ ok: false, error: "provider adapter does not expose native compaction" }); }
235
+ contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
236
+ sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
237
+ switchSettings(settings) { return this.adapter.switchSettings ? this.adapter.switchSettings(settings) : Promise.resolve({ ok: false, error: "provider adapter does not support settings changes" }); }
238
+ events() { return this.queue; }
239
+ async close() { await this.adapter.close(); await this.sourcePump?.catch(() => { }); this.queue.close(); }
240
+ /** Keep the observable wrapper and event stream stable while moving to an accepted successor. */
241
+ async adoptValidatedSuccessor(successor) {
242
+ if (successor === this.adapter)
243
+ return;
244
+ const prior = this.adapter;
245
+ this.adapter = successor;
246
+ this.pumpEvents();
247
+ await prior.close().catch(() => { });
248
+ }
249
+ publish(delta, kind, result) {
250
+ const db = new WorkflowDb(this.options.projectDir);
251
+ try {
252
+ const sessionRef = this.adapter.sessionRef?.();
253
+ db.appendContinuityEvent({ runId: this.options.runId, role: this.options.role, kind, payload: { isError: result.isError, numTurns: result.numTurns, sessionId: this.adapter.sessionId(), sessionRef, delta }, authoritativeStateRevision: this.revision(), sessionRef });
254
+ return db.publishContinuityCheckpoint({ runId: this.options.runId, role: this.options.role, delta, authoritativeStateRevision: this.revision(), sessionRef });
255
+ }
256
+ finally {
257
+ db.close();
258
+ }
259
+ }
260
+ moveRecoveryLeaseAfterCheckpoint() {
261
+ if (!this.recoveryLeasePending)
262
+ return;
263
+ const session = this.adapter.sessionRef?.() ?? this.adapter.sessionId();
264
+ if (!session)
265
+ return;
266
+ const db = new WorkflowDb(this.options.projectDir);
267
+ try {
268
+ db.moveRoleLeaseAfterValidatedRecovery(this.options.runId, this.options.role, session, "explicit fresh-recovery-only continuation");
269
+ }
270
+ finally {
271
+ db.close();
272
+ }
273
+ this.recoveryLeasePending = false;
274
+ }
275
+ recordSessionUnavailable(failure, instruction) {
276
+ const sessionRef = this.adapter.sessionRef?.();
277
+ const db = new WorkflowDb(this.options.projectDir);
278
+ try {
279
+ db.appendContinuityEvent({
280
+ runId: this.options.runId,
281
+ role: "host",
282
+ kind: "session_unavailable",
283
+ payload: {
284
+ role: this.options.role,
285
+ phase: failure.phase,
286
+ dispatchState: failure.dispatchState ?? "unknown",
287
+ reason: failure.availability?.reason,
288
+ detail: failure.diagnostics,
289
+ instructionDigest: sha(instruction),
290
+ },
291
+ authoritativeStateRevision: this.revision(),
292
+ sessionRef,
293
+ });
294
+ if (failure.dispatchState === "unknown") {
295
+ db.setContinuityHeadState(this.options.runId, this.options.role, "degraded");
296
+ }
297
+ }
298
+ finally {
299
+ db.close();
300
+ }
301
+ }
302
+ reconstructionHandoff() {
303
+ const db = new WorkflowDb(this.options.projectDir);
304
+ try {
305
+ const checkpoints = db.continuityCheckpoints(this.options.runId, this.options.role);
306
+ const events = db.continuityEvents(this.options.runId, checkpoints.at(-1)?.sequence ?? 0);
307
+ return [
308
+ `Validated cumulative recovery for run ${this.options.runId}, role ${this.options.role}.`,
309
+ JSON.stringify(mergeContinuityDeltas(checkpoints.map((checkpoint) => checkpoint.delta))),
310
+ `Later host facts: ${JSON.stringify(events.map((event) => ({ kind: event.kind, payload: event.payload, digest: event.digest })))}`,
311
+ "The prior in-flight operation is unknown unless a host fact proves completion. Reconcile receipts before retrying side effects.",
312
+ ].join("\n\n");
313
+ }
314
+ finally {
315
+ db.close();
316
+ }
317
+ }
318
+ revision() { return this.options.authoritativeStateRevision?.() ?? this.options.settings.settings_revision; }
319
+ pumpEvents() {
320
+ const source = this.adapter;
321
+ this.sourcePump = (async () => {
322
+ try {
323
+ for await (const event of source.events()) {
324
+ this.queue.push(event);
325
+ if (event.kind === "tool" || event.kind === "session-transition" || event.kind === "context-usage") {
326
+ const db = new WorkflowDb(this.options.projectDir);
327
+ try {
328
+ const payload = event.kind === "tool"
329
+ ? { name: event.name, inputDigest: sha(JSON.stringify(event.input ?? null)) }
330
+ : event;
331
+ db.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: `provider_${event.kind.replace(/-/g, "_")}`, payload, authoritativeStateRevision: this.revision() });
332
+ }
333
+ finally {
334
+ db.close();
335
+ }
336
+ }
337
+ }
338
+ }
339
+ catch (error) {
340
+ this.queue.push({ kind: "error", message: error instanceof Error ? error.message : String(error) });
341
+ }
342
+ })();
343
+ }
344
+ }
345
+ function strings(value, field, problems) {
346
+ if (!Array.isArray(value)) {
347
+ problems.push(`${field} must be an array`);
348
+ return [];
349
+ }
350
+ if (value.length > 500)
351
+ problems.push(`${field} exceeds 500 entries`);
352
+ const result = [];
353
+ for (const entry of value.slice(0, 500)) {
354
+ if (typeof entry !== "string" || !entry.trim())
355
+ problems.push(`${field} entries must be non-empty strings`);
356
+ else
357
+ result.push(clean(entry, 4_000));
358
+ }
359
+ return unique(result);
360
+ }
361
+ function clean(value, maximum) {
362
+ return value.replace(/\b(sk-[A-Za-z0-9_-]{12,}|(?:api[_-]?key|token|password)\s*[:=]\s*\S+)/gi, "[REDACTED]").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "").trim().slice(0, maximum);
363
+ }
364
+ function unique(values) { return [...new Set(values)]; }
365
+ function sha(value) { return createHash("sha256").update(value).digest("hex"); }
package/dist/foreman.d.ts CHANGED
@@ -27,7 +27,7 @@ export declare function looksLikeQuestion(text: string): boolean;
27
27
  export { MARKER_SPEC } from "./markers.js";
28
28
  export { QA_MARKER_SPEC } from "./markers.js";
29
29
  /** Instruction sent on the first turn of a batch. */
30
- export declare function buildPrimer(n: number, trackerPath?: string, ticketsEnabled?: boolean): string;
30
+ export declare function buildPrimer(n: number, trackerPath?: string, ticketsEnabled?: boolean, preferredTicketId?: string): string;
31
31
  /** Instruction sent on turns 2–N. */
32
32
  export declare function buildNextStepInstruction(i: number, n: number): string;
33
33
  /** QA review turn: ask the builder to triple-check the ticket or step it just completed. */
@@ -35,7 +35,7 @@ export declare function buildQaInstruction(): string;
35
35
  /** Follow-up turn after qa_fail: have the builder implement the listed fixes. */
36
36
  export declare function buildQaFixInstruction(issues: string): string;
37
37
  /** Pre-flight planning turn: ask the builder to list its next N steps without implementing anything. */
38
- export declare function buildPlanningTurn(n: number, ticketsContent?: string): string;
38
+ export declare function buildPlanningTurn(n: number, ticketsContent?: string, preferredTicketId?: string): string;
39
39
  /** Builds the permission callback: classify, log, allow or escalate. */
40
40
  export declare function createPermissionHandler(policy: PermissionPolicy, log: Log, opts?: {
41
41
  interactive?: boolean;
@@ -62,10 +62,13 @@ export declare class Foreman {
62
62
  private readonly builderFactory?;
63
63
  private readonly builderSessionStrategy;
64
64
  private readonly qaNonconvergence?;
65
+ private readonly beforeBuilderTurn?;
66
+ private readonly builderSessionBoundary?;
67
+ private readonly qaSessionBoundary?;
65
68
  private readonly ticketsEnabled;
66
69
  private readonly qaStream;
67
70
  private builderWorkSessions;
68
- constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined);
71
+ constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined, beforeBuilderTurn?: ((adapter: BuilderAdapter, frozenAction: string) => Promise<BuilderAdapter>) | undefined, builderSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy) => Promise<BuilderAdapter>) | undefined, qaSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>) | undefined);
69
72
  /**
70
73
  * Send one instruction and resolve any needs_input exchanges before returning.
71
74
  * The returned result and status always reflect the final (non-needs_input) turn.
@@ -74,7 +77,7 @@ export declare class Foreman {
74
77
  private prepareBuilderBoundary;
75
78
  private doTurnWith;
76
79
  /** Send a planning turn and return the builder's response text. Does not count toward steps. */
77
- runPreflight(n: number, ticketsContent?: string): Promise<string>;
80
+ runPreflight(n: number, ticketsContent?: string, preferredTicketId?: string): Promise<string>;
78
81
  /** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
79
82
  sendPreflightFeedback(feedback: string): Promise<void>;
80
83
  /** Send one custom instruction through the same needs_input loop as a batch turn. */
@@ -94,9 +97,10 @@ export declare class Foreman {
94
97
  summary?: string;
95
98
  }>;
96
99
  qaSessionId(): string | undefined;
100
+ qaSessionRef(): import("rafi-spec").ProviderSessionRefV1 | undefined;
97
101
  builderSessionId(): string | undefined;
98
102
  builderAdapter(): BuilderAdapter;
99
103
  close(): Promise<void>;
100
104
  private ticketForQa;
101
- runBatch(n: number, trackerPath?: string): Promise<BatchResult>;
105
+ runBatch(n: number, trackerPath?: string, onTicketStart?: (ticketId: string) => void | Promise<void>, preferredTicketId?: string): Promise<BatchResult>;
102
106
  }