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
@@ -0,0 +1,415 @@
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
+ turnDispatcher;
113
+ constructor(options) {
114
+ this.options = options;
115
+ this.adapter = options.adapter;
116
+ this.recoveryLeasePending = Boolean(options.replaceRecoveryLeaseAfterCheckpoint);
117
+ const db = new WorkflowDb(options.projectDir);
118
+ try {
119
+ db.ensureRun(options.runId);
120
+ if (!db.continuityHead(options.runId, options.role)) {
121
+ 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() });
122
+ db.publishContinuityCheckpoint({ runId: options.runId, role: options.role, delta: baselineContinuityDelta(), authoritativeStateRevision: this.revision() });
123
+ }
124
+ const initialSession = options.adapter.sessionRef?.() ?? options.adapter.sessionId();
125
+ if (initialSession)
126
+ db.claimInitialRoleLease(options.runId, options.role, initialSession);
127
+ }
128
+ finally {
129
+ db.close();
130
+ }
131
+ this.pumpEvents();
132
+ }
133
+ get agent() { return this.adapter.agent; }
134
+ async sendTurn(instruction) {
135
+ const db = new WorkflowDb(this.options.projectDir);
136
+ try {
137
+ 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() });
138
+ }
139
+ finally {
140
+ db.close();
141
+ }
142
+ const original = await this.providerTurn(`${instruction}\n\n${continuityInstruction()}`);
143
+ const activeSession = this.adapter.sessionRef?.() ?? this.adapter.sessionId();
144
+ if (activeSession) {
145
+ const leaseDb = new WorkflowDb(this.options.projectDir);
146
+ try {
147
+ leaseDb.claimInitialRoleLease(this.options.runId, this.options.role, activeSession);
148
+ }
149
+ finally {
150
+ leaseDb.close();
151
+ }
152
+ }
153
+ if (original.failure?.category === "session-unavailable") {
154
+ this.recordSessionUnavailable(original.failure, instruction);
155
+ throw new SessionUnavailableContinuityError(this.options.runId, this.options.role, original.failure);
156
+ }
157
+ if (original.interrupted?.reason === "compaction-boundary") {
158
+ // The coordinator owns the validated handoff and replay of this frozen
159
+ // action. A protocol-repair turn here would run in the interrupted
160
+ // predecessor (or race successor adoption) without a completed delta.
161
+ const interruptedDb = new WorkflowDb(this.options.projectDir);
162
+ try {
163
+ interruptedDb.appendContinuityEvent({
164
+ runId: this.options.runId,
165
+ role: "host",
166
+ kind: "turn_interrupted_at_compaction_boundary",
167
+ payload: {
168
+ role: this.options.role,
169
+ providerEventId: original.interrupted.providerEventId,
170
+ instructionDigest: sha(instruction),
171
+ },
172
+ authoritativeStateRevision: this.revision(),
173
+ sessionRef: this.adapter.sessionRef?.(),
174
+ });
175
+ }
176
+ finally {
177
+ interruptedDb.close();
178
+ }
179
+ return original;
180
+ }
181
+ const parsed = parseContinuityDelta(original.text);
182
+ if (parsed.delta) {
183
+ this.publish(parsed.delta, "turn_completed", original);
184
+ this.moveRecoveryLeaseAfterCheckpoint();
185
+ const successor = await this.options.handleHandoffRequest?.(original.text, this.adapter, instruction);
186
+ if (successor && successor !== this.adapter) {
187
+ await this.adoptValidatedSuccessor(successor);
188
+ return this.sendTurn([
189
+ "Continue the frozen action after the accepted Rafi handoff. Do not repeat completed side effects.",
190
+ instruction,
191
+ ].join("\n\n"));
192
+ }
193
+ return { ...original, text: parsed.cleanText };
194
+ }
195
+ const repair = await this.providerTurn([
196
+ "Continuity protocol repair only. Do not run tools, repeat work, or change files.",
197
+ `Your prior turn's continuity record was invalid: ${parsed.error?.problems.join("; ")}.`,
198
+ continuityInstruction(),
199
+ "Return only the continuity record.",
200
+ ].join("\n"));
201
+ if (repair.interrupted?.reason === "compaction-boundary") {
202
+ // Preserve the predecessor and let the outer managed dispatch perform
203
+ // the validated handoff/replay. Treating this as another invalid delta
204
+ // would race continuity recovery against the coordinator and could lose
205
+ // the original frozen action.
206
+ const interruptedDb = new WorkflowDb(this.options.projectDir);
207
+ try {
208
+ interruptedDb.appendContinuityEvent({
209
+ runId: this.options.runId,
210
+ role: "host",
211
+ kind: "continuity_repair_interrupted_at_compaction_boundary",
212
+ payload: {
213
+ role: this.options.role,
214
+ providerEventId: repair.interrupted.providerEventId,
215
+ instructionDigest: sha(instruction),
216
+ },
217
+ authoritativeStateRevision: this.revision(),
218
+ sessionRef: this.adapter.sessionRef?.(),
219
+ });
220
+ }
221
+ finally {
222
+ interruptedDb.close();
223
+ }
224
+ return repair;
225
+ }
226
+ const repaired = parseContinuityDelta(repair.text);
227
+ if (repaired.delta) {
228
+ this.publish(repaired.delta, "turn_completed_after_repair", original);
229
+ this.moveRecoveryLeaseAfterCheckpoint();
230
+ return { ...original, text: parsed.cleanText };
231
+ }
232
+ const invalidDb = new WorkflowDb(this.options.projectDir);
233
+ try {
234
+ invalidDb.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: "continuity_invalid_twice", payload: { original: parsed.error?.problems, repair: repaired.error?.problems }, authoritativeStateRevision: this.revision() });
235
+ invalidDb.setContinuityHeadState(this.options.runId, this.options.role, "invalid");
236
+ }
237
+ finally {
238
+ invalidDb.close();
239
+ }
240
+ const handoff = this.reconstructionHandoff();
241
+ if (this.options.recoverWithHandoff) {
242
+ let successor;
243
+ try {
244
+ successor = await this.options.recoverWithHandoff({
245
+ reason: "continuity delta and same-session repair were both invalid",
246
+ reconstruction: handoff,
247
+ predecessor: this.adapter,
248
+ });
249
+ }
250
+ catch (error) {
251
+ 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)}`);
252
+ }
253
+ if (!successor.sessionId() || successor.sessionId() === this.adapter.sessionId()) {
254
+ await successor.close().catch(() => { });
255
+ throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "validated recovery callback did not return a genuinely fresh successor");
256
+ }
257
+ await this.adoptValidatedSuccessor(successor);
258
+ return { ...original, text: parsed.cleanText };
259
+ }
260
+ // A raw create-and-send fallback cannot install native context control,
261
+ // account compactions, or validate the scoped successor atomically. Refuse
262
+ // it instead of allowing continuity recovery to escape the managed gate.
263
+ throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "continuity repair failed in the original session and no lifecycle-managed recoverWithHandoff callback is configured");
264
+ }
265
+ sessionId() { return this.adapter.sessionId(); }
266
+ sessionRef() { return this.adapter.sessionRef?.(); }
267
+ adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
268
+ validateSession() { return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
269
+ compact() { return this.adapter.compact ? this.adapter.compact() : Promise.resolve({ ok: false, error: "provider adapter does not expose native compaction" }); }
270
+ prepareContextManagement(policy) { if (!this.adapter.prepareContextManagement)
271
+ return Promise.reject(new Error("provider adapter does not expose native context management")); return this.adapter.prepareContextManagement(policy); }
272
+ updateContextManagement(policy) { if (!this.adapter.updateContextManagement)
273
+ return Promise.reject(new Error("provider adapter does not expose native context reconfiguration")); return this.adapter.updateContextManagement(policy); }
274
+ interruptTurnAtCompactionBoundary(providerEventId) { return this.adapter.interruptTurnAtCompactionBoundary?.(providerEventId) ?? Promise.resolve({ ok: false, error: "provider adapter does not expose compaction-boundary interruption", providerEventId }); }
275
+ installManagedTurnDispatcher(dispatcher) {
276
+ this.turnDispatcher = dispatcher;
277
+ this.adapter.installManagedTurnDispatcher?.(dispatcher);
278
+ }
279
+ contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
280
+ sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
281
+ switchSettings(settings) { return this.adapter.switchSettings ? this.adapter.switchSettings(settings) : Promise.resolve({ ok: false, error: "provider adapter does not support settings changes" }); }
282
+ events() { return this.queue; }
283
+ async close() { await this.adapter.close(); await this.sourcePump?.catch(() => { }); this.queue.close(); }
284
+ /** Keep the observable wrapper and event stream stable while moving to an accepted successor. */
285
+ async adoptValidatedSuccessor(successor) {
286
+ if (successor === this.adapter)
287
+ return;
288
+ const prior = this.adapter;
289
+ this.adapter = successor;
290
+ if (this.turnDispatcher)
291
+ successor.installManagedTurnDispatcher?.(this.turnDispatcher);
292
+ this.pumpEvents();
293
+ await prior.close().catch(() => { });
294
+ }
295
+ providerTurn(text) {
296
+ const invoke = () => this.adapter.sendTurn(text);
297
+ return this.turnDispatcher ? this.turnDispatcher(text, invoke) : invoke();
298
+ }
299
+ publish(delta, kind, result) {
300
+ const db = new WorkflowDb(this.options.projectDir);
301
+ try {
302
+ const sessionRef = this.adapter.sessionRef?.();
303
+ 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 });
304
+ return db.publishContinuityCheckpoint({ runId: this.options.runId, role: this.options.role, delta, authoritativeStateRevision: this.revision(), sessionRef });
305
+ }
306
+ finally {
307
+ db.close();
308
+ }
309
+ }
310
+ moveRecoveryLeaseAfterCheckpoint() {
311
+ if (!this.recoveryLeasePending)
312
+ return;
313
+ const session = this.adapter.sessionRef?.() ?? this.adapter.sessionId();
314
+ if (!session)
315
+ return;
316
+ const db = new WorkflowDb(this.options.projectDir);
317
+ try {
318
+ db.moveRoleLeaseAfterValidatedRecovery(this.options.runId, this.options.role, session, "explicit fresh-recovery-only continuation");
319
+ }
320
+ finally {
321
+ db.close();
322
+ }
323
+ this.recoveryLeasePending = false;
324
+ }
325
+ recordSessionUnavailable(failure, instruction) {
326
+ const sessionRef = this.adapter.sessionRef?.();
327
+ const db = new WorkflowDb(this.options.projectDir);
328
+ try {
329
+ db.appendContinuityEvent({
330
+ runId: this.options.runId,
331
+ role: "host",
332
+ kind: "session_unavailable",
333
+ payload: {
334
+ role: this.options.role,
335
+ phase: failure.phase,
336
+ dispatchState: failure.dispatchState ?? "unknown",
337
+ reason: failure.availability?.reason,
338
+ detail: failure.diagnostics,
339
+ instructionDigest: sha(instruction),
340
+ },
341
+ authoritativeStateRevision: this.revision(),
342
+ sessionRef,
343
+ });
344
+ if (failure.dispatchState === "unknown") {
345
+ db.setContinuityHeadState(this.options.runId, this.options.role, "degraded");
346
+ }
347
+ }
348
+ finally {
349
+ db.close();
350
+ }
351
+ }
352
+ reconstructionHandoff() {
353
+ const db = new WorkflowDb(this.options.projectDir);
354
+ try {
355
+ const checkpoints = db.continuityCheckpoints(this.options.runId, this.options.role);
356
+ const events = db.continuityEvents(this.options.runId, checkpoints.at(-1)?.sequence ?? 0);
357
+ return [
358
+ `Validated cumulative recovery for run ${this.options.runId}, role ${this.options.role}.`,
359
+ JSON.stringify(mergeContinuityDeltas(checkpoints.map((checkpoint) => checkpoint.delta))),
360
+ `Later host facts: ${JSON.stringify(events.map((event) => ({ kind: event.kind, payload: event.payload, digest: event.digest })))}`,
361
+ "The prior in-flight operation is unknown unless a host fact proves completion. Reconcile receipts before retrying side effects.",
362
+ ].join("\n\n");
363
+ }
364
+ finally {
365
+ db.close();
366
+ }
367
+ }
368
+ revision() { return this.options.authoritativeStateRevision?.() ?? this.options.settings.settings_revision; }
369
+ pumpEvents() {
370
+ const source = this.adapter;
371
+ this.sourcePump = (async () => {
372
+ try {
373
+ for await (const event of source.events()) {
374
+ this.queue.push(event);
375
+ if (event.kind === "tool" || event.kind === "session-transition" || event.kind === "context-usage" || event.kind === "context-compaction") {
376
+ const db = new WorkflowDb(this.options.projectDir);
377
+ try {
378
+ const payload = event.kind === "tool"
379
+ ? { name: event.name, inputDigest: sha(JSON.stringify(event.input ?? null)) }
380
+ : event;
381
+ db.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: `provider_${event.kind.replace(/-/g, "_")}`, payload, authoritativeStateRevision: this.revision() });
382
+ }
383
+ finally {
384
+ db.close();
385
+ }
386
+ }
387
+ }
388
+ }
389
+ catch (error) {
390
+ this.queue.push({ kind: "error", message: error instanceof Error ? error.message : String(error) });
391
+ }
392
+ })();
393
+ }
394
+ }
395
+ function strings(value, field, problems) {
396
+ if (!Array.isArray(value)) {
397
+ problems.push(`${field} must be an array`);
398
+ return [];
399
+ }
400
+ if (value.length > 500)
401
+ problems.push(`${field} exceeds 500 entries`);
402
+ const result = [];
403
+ for (const entry of value.slice(0, 500)) {
404
+ if (typeof entry !== "string" || !entry.trim())
405
+ problems.push(`${field} entries must be non-empty strings`);
406
+ else
407
+ result.push(clean(entry, 4_000));
408
+ }
409
+ return unique(result);
410
+ }
411
+ function clean(value, maximum) {
412
+ 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);
413
+ }
414
+ function unique(values) { return [...new Set(values)]; }
415
+ function sha(value) { return createHash("sha256").update(value).digest("hex"); }
package/dist/foreman.d.ts CHANGED
@@ -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.
@@ -94,6 +97,7 @@ 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>;
package/dist/foreman.js CHANGED
@@ -9,6 +9,7 @@ import { runIsolatedQa } from "./qaReview.js";
9
9
  import { createHash } from "node:crypto";
10
10
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
11
11
  import { join, relative } from "node:path";
12
+ import { SessionUnavailableError, sessionUnavailableErrorFromFailure } from "./adapters/sessionFailure.js";
12
13
  /** Phrases that suggest the builder ended its turn by asking the human. */
13
14
  const QUESTION_HINTS = [
14
15
  "should i",
@@ -225,10 +226,13 @@ export class Foreman {
225
226
  builderFactory;
226
227
  builderSessionStrategy;
227
228
  qaNonconvergence;
229
+ beforeBuilderTurn;
230
+ builderSessionBoundary;
231
+ qaSessionBoundary;
228
232
  ticketsEnabled;
229
233
  qaStream = { reviews: 0, modificationViolations: 0 };
230
234
  builderWorkSessions = 0;
231
- constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence) {
235
+ constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence, beforeBuilderTurn, builderSessionBoundary, qaSessionBoundary) {
232
236
  this.builder = builder;
233
237
  this.log = log;
234
238
  this.notificationsEnabled = notificationsEnabled;
@@ -241,6 +245,9 @@ export class Foreman {
241
245
  this.builderFactory = builderFactory;
242
246
  this.builderSessionStrategy = builderSessionStrategy;
243
247
  this.qaNonconvergence = qaNonconvergence;
248
+ this.beforeBuilderTurn = beforeBuilderTurn;
249
+ this.builderSessionBoundary = builderSessionBoundary;
250
+ this.qaSessionBoundary = qaSessionBoundary;
244
251
  this.ticketsEnabled = !!(projectDir && isTicketsInitialized(projectDir));
245
252
  }
246
253
  /**
@@ -250,9 +257,14 @@ export class Foreman {
250
257
  async doTurn(instruction) {
251
258
  return this.doTurnWith(this.builder, instruction);
252
259
  }
253
- async prepareBuilderBoundary() {
260
+ async prepareBuilderBoundary(frozenAction) {
254
261
  if (!this.builderFactory || !this.projectDir)
255
262
  return;
263
+ if (this.builderSessionBoundary) {
264
+ this.builder = await this.builderSessionBoundary(this.builder, frozenAction, this.builderSessionStrategy);
265
+ this.log.write("branch-session", { role: "builder", transition: this.builderSessionStrategy === "fresh" ? "validated-handoff" : "accounted-compaction", workSession: this.builderWorkSessions + 1, sessionId: this.builder.sessionId() });
266
+ return;
267
+ }
256
268
  if (this.builderSessionStrategy === "fresh") {
257
269
  await this.builder.close();
258
270
  this.builder = await this.builderFactory(this.projectDir);
@@ -263,7 +275,17 @@ export class Foreman {
263
275
  if (priorSession && this.builder.compact) {
264
276
  let error = "";
265
277
  for (let attempt = 1; attempt <= 2; attempt++) {
266
- const result = await this.builder.compact().catch((cause) => ({ ok: false, error: cause instanceof Error ? cause.message : String(cause) }));
278
+ let result;
279
+ try {
280
+ result = await this.builder.compact();
281
+ }
282
+ catch (cause) {
283
+ if (cause instanceof SessionUnavailableError)
284
+ throw cause;
285
+ result = { ok: false, error: cause instanceof Error ? cause.message : String(cause) };
286
+ }
287
+ if (result.failure?.category === "session-unavailable")
288
+ throw sessionUnavailableErrorFromFailure(result.failure);
267
289
  if (result.ok) {
268
290
  this.log.write("branch-session", { role: "builder", transition: attempt === 1 ? "compacted" : "compaction-retry-succeeded", workSession: this.builderWorkSessions + 1, sessionId: priorSession });
269
291
  return;
@@ -282,8 +304,14 @@ export class Foreman {
282
304
  this.log.write("branch-session", { role: "builder", transition: "missing-session-fresh", continuityLost: true });
283
305
  }
284
306
  async doTurnWith(adapter, instruction) {
307
+ if (adapter === this.builder && this.beforeBuilderTurn) {
308
+ this.builder = await this.beforeBuilderTurn(this.builder, instruction);
309
+ adapter = this.builder;
310
+ }
285
311
  let result = await adapter.sendTurn(instruction);
286
312
  let status = parseStepStatus(result.text);
313
+ if (result.failure?.category === "session-unavailable")
314
+ return { result, status };
287
315
  if (status.kind === "unknown") {
288
316
  if (!status.error && looksLikeQuestion(result.text)) {
289
317
  status = { kind: "needs_input", question: lastLine(result.text), choices: ["Continue", "Cancel"] };
@@ -326,6 +354,8 @@ export class Foreman {
326
354
  /** Send a planning turn and return the builder's response text. Does not count toward steps. */
327
355
  async runPreflight(n, ticketsContent, preferredTicketId) {
328
356
  const instruction = buildPlanningTurn(n, ticketsContent, preferredTicketId);
357
+ if (this.beforeBuilderTurn)
358
+ this.builder = await this.beforeBuilderTurn(this.builder, instruction);
329
359
  const result = await this.builder.sendTurn(instruction);
330
360
  this.log.write("preflight", {
331
361
  ticketsProvided: ticketsContent !== undefined,
@@ -338,6 +368,8 @@ export class Foreman {
338
368
  }
339
369
  /** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
340
370
  async sendPreflightFeedback(feedback) {
371
+ if (this.beforeBuilderTurn)
372
+ this.builder = await this.beforeBuilderTurn(this.builder, feedback);
341
373
  const result = await this.builder.sendTurn(feedback);
342
374
  this.log.write("preflight", { feedback: true, costUsd: result.costUsd, isError: result.isError });
343
375
  if (result.isError)
@@ -361,11 +393,13 @@ export class Foreman {
361
393
  qaStrategy: this.qaSessionStrategy,
362
394
  state: this.qaStream,
363
395
  createQa: this.qaFactory,
396
+ sessionBoundary: this.qaSessionBoundary,
364
397
  maxCycles: this.qaMaxCycles,
365
398
  fix: async (issues) => {
366
- await this.prepareBuilderBoundary();
399
+ const instruction = buildQaFixInstruction(issues);
400
+ await this.prepareBuilderBoundary(instruction);
367
401
  this.builderWorkSessions += 1;
368
- const fix = await this.doTurn(buildQaFixInstruction(issues));
402
+ const fix = await this.doTurn(instruction);
369
403
  this.log.write("qa-fix", { stepIndex, statusKind: fix.status.kind, costUsd: fix.result.costUsd, isError: fix.result.isError });
370
404
  return fix.result.isError || fix.status.kind !== "done"
371
405
  ? { ok: false, detail: fix.status.reason ?? fix.status.error ?? fix.result.text.slice(0, 200) }
@@ -442,6 +476,7 @@ export class Foreman {
442
476
  return this.runQa(stepIndex);
443
477
  }
444
478
  qaSessionId() { return this.qaStream.sessionId ?? this.qaReviewer?.sessionId(); }
479
+ qaSessionRef() { return this.qaStream.sessionRef ?? this.qaReviewer?.sessionRef?.(); }
445
480
  builderSessionId() { return this.builder.sessionId(); }
446
481
  builderAdapter() { return this.builder; }
447
482
  async close() { await this.builder.close(); }
@@ -505,7 +540,7 @@ export class Foreman {
505
540
  ? buildPrimer(n, trackerPath, this.ticketsEnabled, preferredTicketId)
506
541
  : buildNextStepInstruction(i, n);
507
542
  if (i > 1)
508
- await this.prepareBuilderBoundary();
543
+ await this.prepareBuilderBoundary(instruction);
509
544
  this.builderWorkSessions += 1;
510
545
  const { result, status } = await this.doTurn(instruction);
511
546
  this.log.write("step", {
@@ -0,0 +1,69 @@
1
+ import type { ContinuityDelta, HandoffLineage, HandoffManifestV1, ProviderSessionRefV1, SessionUsageSample } from "rafi-spec";
2
+ import type { BuilderAdapter } from "./adapters/types.js";
3
+ export declare const HANDOFF_CACHE_RETENTION_DAYS = 30;
4
+ export declare const HANDOFF_ACCEPTED = "HANDOFF_ACCEPTED";
5
+ export declare const HANDOFF_REQUEST_START = "RAFI_HANDOFF_REQUEST_START";
6
+ export declare const HANDOFF_REQUEST_END = "RAFI_HANDOFF_REQUEST_END";
7
+ export interface BuilderHandoffRequest {
8
+ version: 1;
9
+ reason: string;
10
+ delta: ContinuityDelta;
11
+ roleState: Record<string, unknown>;
12
+ }
13
+ export declare function parseBuilderHandoffRequest(text: string): BuilderHandoffRequest | undefined;
14
+ export interface CreateHandoffInput {
15
+ runId: string;
16
+ role: "builder" | "qa";
17
+ reason: string;
18
+ predecessorSessionId?: string;
19
+ predecessorSessionRef?: ProviderSessionRefV1;
20
+ roleState?: Record<string, unknown>;
21
+ sessionUsage?: SessionUsageSample;
22
+ compactionCount: number;
23
+ compactMaximum: number;
24
+ resources?: Array<{
25
+ label: string;
26
+ content: string | Buffer;
27
+ authoritative: boolean;
28
+ }>;
29
+ requestedByBuilder?: boolean;
30
+ /** Internal recovery path: use the last valid checkpoint while its head is marked degraded/invalid. */
31
+ allowNonCurrentContinuity?: boolean;
32
+ }
33
+ export interface StagedHandoff {
34
+ manifest: HandoffManifestV1;
35
+ markdown: string;
36
+ lineage: HandoffLineage;
37
+ cacheDirectory: string;
38
+ }
39
+ export interface HandoffTransferResult extends StagedHandoff {
40
+ successor: BuilderAdapter;
41
+ successorSessionId: string;
42
+ acceptanceCheckpointDigest: string;
43
+ }
44
+ export declare class HandoffLoopError extends Error {
45
+ readonly runId: string;
46
+ constructor(runId: string);
47
+ }
48
+ /** Durable, validated ownership transfer; cache copies are never authoritative. */
49
+ export declare class HandoffService {
50
+ readonly projectDir: string;
51
+ readonly cacheRoot: string;
52
+ constructor(projectDir: string);
53
+ stage(input: CreateHandoffInput, now?: Date): StagedHandoff;
54
+ transfer(input: CreateHandoffInput, createSuccessor: (handoff: StagedHandoff) => Promise<BuilderAdapter>): Promise<HandoffTransferResult>;
55
+ acceptStaged(staged: StagedHandoff, successor: BuilderAdapter): Promise<HandoffTransferResult>;
56
+ inspect(runId: string, generation?: number): {
57
+ manifest: HandoffManifestV1;
58
+ markdown: string;
59
+ lineage: HandoffLineage;
60
+ };
61
+ loadStaged(runId: string, generation: number): StagedHandoff;
62
+ pruneCache(runId: string, keepLatest?: number): string[];
63
+ pruneExpiredCache(now?: Date, retentionDays?: number): string[];
64
+ private materialize;
65
+ private safeRunCache;
66
+ private consecutiveBuilderRequests;
67
+ }
68
+ export declare function renderHandoffMarkdown(manifest: HandoffManifestV1): string;
69
+ export declare function writeHandoffInspection(path: string, content: string): void;