ai-foreman 2.0.0 → 2.0.1

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 (50) hide show
  1. package/dist/adapters/claude.js +4 -3
  2. package/dist/adapters/codex.d.ts +2 -0
  3. package/dist/adapters/codex.js +7 -2
  4. package/dist/branch/git.js +3 -8
  5. package/dist/buildApproval.js +3 -9
  6. package/dist/buildRuns.d.ts +1 -0
  7. package/dist/buildRuns.js +6 -1
  8. package/dist/cli/doctor.js +3 -1
  9. package/dist/cli/start.js +63 -13
  10. package/dist/cli/tickets.d.ts +0 -1
  11. package/dist/cli/tickets.js +11 -22
  12. package/dist/continuity.d.ts +0 -2
  13. package/dist/continuity.js +30 -34
  14. package/dist/dispatchNotSent.d.ts +4 -0
  15. package/dist/dispatchNotSent.js +10 -0
  16. package/dist/foreman.d.ts +5 -12
  17. package/dist/foreman.js +19 -85
  18. package/dist/gitExclude.d.ts +16 -0
  19. package/dist/gitExclude.js +46 -0
  20. package/dist/graph/turn.d.ts +1 -1
  21. package/dist/graph/turn.js +1 -1
  22. package/dist/handoffs.d.ts +3 -3
  23. package/dist/handoffs.js +5 -32
  24. package/dist/humanDecision.d.ts +7 -0
  25. package/dist/humanDecision.js +11 -0
  26. package/dist/observability.js +3 -8
  27. package/dist/qaBuildGate.js +13 -1
  28. package/dist/qaPreparation.js +21 -7
  29. package/dist/qaPreparationStore.d.ts +11 -0
  30. package/dist/qaPreparationStore.js +44 -4
  31. package/dist/qaRecovery.d.ts +0 -3
  32. package/dist/qaRecovery.js +1 -24
  33. package/dist/qaReview.js +32 -22
  34. package/dist/qaSnapshot.js +19 -14
  35. package/dist/qaStructuredResponse.d.ts +8 -0
  36. package/dist/qaStructuredResponse.js +24 -0
  37. package/dist/qaTimeline.js +3 -5
  38. package/dist/recoveryOwners.d.ts +32 -0
  39. package/dist/recoveryOwners.js +87 -0
  40. package/dist/runtimePreflight.d.ts +11 -0
  41. package/dist/runtimePreflight.js +77 -0
  42. package/dist/sources/sourceRegistry.d.ts +0 -3
  43. package/dist/sources/sourceRegistry.js +0 -42
  44. package/dist/structuredOperation.js +26 -4
  45. package/dist/ticketPlanning.d.ts +0 -3
  46. package/dist/ticketPlanning.js +0 -23
  47. package/dist/ticketPopulationPaging.js +23 -1
  48. package/dist/workflowDb.d.ts +16 -1
  49. package/dist/workflowDb.js +89 -22
  50. package/package.json +11 -3
package/dist/foreman.js CHANGED
@@ -41,87 +41,6 @@ const QUESTION_HINTS = [
41
41
  "could you clarify",
42
42
  "which option",
43
43
  ];
44
- /**
45
- * Pull the STEP_STATUS marker out of a turn's final text.
46
- * Format: `STEP_STATUS: <kind> | key="value" key="value"`.
47
- */
48
- export function parseStepStatus(text, options = {}) {
49
- const markerLines = text.split(/\r?\n/).filter((line) => /^\s*STEP_STATUS:/.test(line));
50
- const markerCount = markerLines.length;
51
- if (markerCount > 1) {
52
- return { kind: "unknown", error: "builder emitted multiple STEP_STATUS markers" };
53
- }
54
- const lines = text.trimEnd().split(/\r?\n/).filter((line) => line.trim().length > 0);
55
- const last = lines[lines.length - 1] ?? "";
56
- if (!last.includes("STEP_STATUS:")) {
57
- if (markerCount === 1 && options.requireFinalLine !== false) {
58
- return { kind: "unknown", error: "STEP_STATUS marker was not the final non-empty line" };
59
- }
60
- if (markerCount === 0)
61
- return { kind: "unknown" };
62
- }
63
- const marker = options.requireFinalLine === false ? markerLines[0]?.trim() ?? "" : last;
64
- const match = marker.match(/^STEP_STATUS:\s*(done|blocked|plan_complete|needs_input|qa_pass|qa_fail)\b\s*(?:\|\s*(.*))?$/i);
65
- if (!match) {
66
- return { kind: "unknown", error: "malformed STEP_STATUS marker" };
67
- }
68
- const kind = match[1].toLowerCase();
69
- const fields = parseMarkerFields(match[2] ?? "");
70
- if (fields instanceof Error) {
71
- return { kind: "unknown", error: fields.message };
72
- }
73
- return {
74
- kind,
75
- summary: fields.summary,
76
- next: fields.next,
77
- reason: fields.reason,
78
- question: fields.question,
79
- issues: fields.issues,
80
- ticket: fields.ticket,
81
- branchDependency: fields.branch_dependency,
82
- choices: fields.choices
83
- ? fields.choices.split("|").map((c) => c.trim()).filter(Boolean)
84
- : undefined,
85
- };
86
- }
87
- function parseMarkerFields(input) {
88
- const fields = {};
89
- let rest = input.trim();
90
- while (rest.length > 0) {
91
- const key = rest.match(/^(\w+)="/);
92
- if (!key)
93
- return new Error(`malformed STEP_STATUS field near: ${rest.slice(0, 40)}`);
94
- const name = key[1];
95
- let i = key[0].length;
96
- let value = "";
97
- let closed = false;
98
- while (i < rest.length) {
99
- const ch = rest[i];
100
- if (ch === "\\") {
101
- const next = rest[i + 1];
102
- if (next === undefined)
103
- return new Error(`unterminated escape in STEP_STATUS field: ${name}`);
104
- value += next;
105
- i += 2;
106
- continue;
107
- }
108
- if (ch === "\"") {
109
- closed = true;
110
- i++;
111
- break;
112
- }
113
- value += ch;
114
- i++;
115
- }
116
- if (!closed)
117
- return new Error(`unterminated STEP_STATUS field: ${name}`);
118
- if (fields[name] !== undefined)
119
- return new Error(`duplicate STEP_STATUS field: ${name}`);
120
- fields[name] = value;
121
- rest = rest.slice(i).trim();
122
- }
123
- return fields;
124
- }
125
44
  /** Heuristic: did a marker-less turn end by asking the human something? */
126
45
  export function looksLikeQuestion(text) {
127
46
  const tail = text.trim().toLowerCase().slice(-400);
@@ -151,6 +70,17 @@ export function buildPrimer(n, trackerPath, ticketsEnabled = false, preferredTic
151
70
  const preferredTicketRule = preferredTicketId
152
71
  ? `\n- Resume and finish ticket ${preferredTicketId} first. Do not substitute a different queued ticket.`
153
72
  : "";
73
+ // With structured tickets the host assigns one whole ticket per step and QA
74
+ // reviews that ticket after the turn, so "step" must never mean a sub-task.
75
+ if (ticketsEnabled)
76
+ return `You are being run by an automated foreman. We will work through up to ${n} assigned tickets, one complete ticket per turn.
77
+
78
+ Rules:
79
+ - Implement the ENTIRE assigned ticket this turn: every acceptance criterion and required test. Do not split it into sub-steps across turns; independent QA reviews the whole ticket as soon as you report.
80
+ - Return the result using the selected native JSON output contract.
81
+ - If a tool action is denied by foreman policy, do not retry the denied form; report it using the blocked result. The one exception is a required test that you changed by adding a pipe, redirection, substitution, or wrapper: retry the exact required-test command without that extra shell syntax before reporting it blocked.${trackerRule}${preferredTicketRule}
82
+
83
+ This is ticket 1 of up to ${n} in this batch. Implement the assigned ticket completely now.`;
154
84
  return `You are being run by an automated foreman. We will work through your next ${n} tickets or implementation steps, one per turn.
155
85
 
156
86
  Rules:
@@ -161,7 +91,9 @@ Rules:
161
91
  This is step 1 of ${n}. Implement the next ticket or step now.`;
162
92
  }
163
93
  /** Instruction sent on turns 2–N. */
164
- export function buildNextStepInstruction(i, n) {
94
+ export function buildNextStepInstruction(i, n, ticketsEnabled = false) {
95
+ if (ticketsEnabled)
96
+ return `Implement the assigned ticket completely now — every acceptance criterion and required test, not a sub-step. This is ticket ${i} of up to ${n} in this batch; independent QA reviews the whole ticket when you report. Return the completed work-report through the selected native JSON contract.`;
165
97
  return `Implement the next ticket or step now (exactly one) — this is step ${i} of ${n}. Return the completed work-report through the selected native JSON contract.`;
166
98
  }
167
99
  /** QA review turn: ask the builder to triple-check the ticket or step it just completed. */
@@ -190,13 +122,15 @@ ${issues}
190
122
  Fix every one of them now. Submit the selected native remediation result so Foreman can rerun independent QA. Verify that your fixes resolve the reported issues.`;
191
123
  }
192
124
  /** Pre-flight planning turn: ask the builder to list its next N steps without implementing anything. */
193
- export function buildPlanningTurn(n, ticketsContent, preferredTicketId) {
125
+ export function buildPlanningTurn(n, ticketsContent, preferredTicketId, ticketsEnabled = false) {
194
126
  const header = ticketsContent
195
127
  ? `Here is the project's ticket list:\n\n${ticketsContent}\n\n`
196
128
  : "";
197
129
  const preferred = preferredTicketId
198
130
  ? ` The first item must be ticket ${preferredTicketId}; this is an interrupted ticket being resumed.`
199
131
  : "";
132
+ if (ticketsEnabled)
133
+ return `${header}Before we begin, list up to ${n} ticket(s) you plan to implement, in dependency order, one entry per complete ticket.${preferred} Never split one ticket into several entries; each entry is implemented and independently reviewed as a whole ticket. A ticket whose dependencies are earlier entries in your list may be included. List fewer than ${n} if fewer can be built. Do not implement anything yet; return the ordered tickets through the build-preflight response.`;
200
134
  return `${header}Before we begin, list the next ${n} ticket(s) or step(s) you plan to implement, in order.${preferred} Be specific — reference ticket IDs or titles where applicable. Do not implement anything yet; return the ordered steps through the build-preflight response.`;
201
135
  }
202
136
  /** Builds the permission callback: classify, log, allow or escalate. */
@@ -586,7 +520,7 @@ export class Foreman {
586
520
  /** Send a planning turn and return the builder's response text. Does not count toward steps. */
587
521
  async runPreflight(n, ticketsContent, preferredTicketId, executionTickets) {
588
522
  this.assertNotInterrupted();
589
- const instruction = buildPlanningTurn(n, ticketsContent, preferredTicketId) + (executionTickets ? `\nAuthorized ticket scope: ${executionTickets.join(", ")}. Plan only these tickets. Tickets awaiting answers are not eligible for implementation.` : "");
523
+ const instruction = buildPlanningTurn(n, ticketsContent, preferredTicketId, this.ticketsEnabled) + (executionTickets ? `\nAuthorized ticket scope: ${executionTickets.join(", ")}. Plan only these tickets. Tickets awaiting answers are not eligible for implementation.` : "");
590
524
  if (this.beforeBuilderTurn)
591
525
  this.builder = await this.beforeBuilderTurn(this.builder, instruction);
592
526
  this.assertNotInterrupted();
@@ -1018,7 +952,7 @@ export class Foreman {
1018
952
  // have no preflight still receive the primer for compatibility.
1019
953
  let instruction = i === 1 && !this.preflightPlan
1020
954
  ? buildPrimer(n, trackerPath, this.ticketsEnabled, pendingTicketId)
1021
- : buildNextStepInstruction(i, n);
955
+ : buildNextStepInstruction(i, n, this.ticketsEnabled);
1022
956
  if (pendingTicketId)
1023
957
  instruction += `\n\nAssigned ticket: ${pendingTicketId}. Implement exactly this ticket. Do not substitute another ticket. Return the native work report for this assigned ticket.`;
1024
958
  const continuations = answered.filter(decision => decision.interruptionId === `ticket:${pendingTicketId}`);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Add missing ignore entries for Rafi's local state to a repository.
3
+ *
4
+ * Prefers the repository-local `.git/info/exclude`. A git template
5
+ * (`init.templateDir`) can install that file as a symlink to one shared
6
+ * file, so appending through it would add this project's entries to every
7
+ * repository using the template. Such a link is first replaced by a regular
8
+ * file with the same rules, keeping the change local to this repository.
9
+ *
10
+ * Without a local exclude file, entries go to `.gitignore` when
11
+ * `fallbackToGitignore` is set; otherwise nothing is written.
12
+ */
13
+ export declare function ensureLocalGitExcludes(projectDir: string, entries: readonly string[], options?: {
14
+ fallbackToGitignore?: boolean;
15
+ leadingBlankLine?: boolean;
16
+ }): void;
@@ -0,0 +1,46 @@
1
+ import { appendFileSync, existsSync, lstatSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Add missing ignore entries for Rafi's local state to a repository.
5
+ *
6
+ * Prefers the repository-local `.git/info/exclude`. A git template
7
+ * (`init.templateDir`) can install that file as a symlink to one shared
8
+ * file, so appending through it would add this project's entries to every
9
+ * repository using the template. Such a link is first replaced by a regular
10
+ * file with the same rules, keeping the change local to this repository.
11
+ *
12
+ * Without a local exclude file, entries go to `.gitignore` when
13
+ * `fallbackToGitignore` is set; otherwise nothing is written.
14
+ */
15
+ export function ensureLocalGitExcludes(projectDir, entries, options = {}) {
16
+ const localExclude = join(projectDir, ".git", "info", "exclude");
17
+ let path;
18
+ let isLink = false;
19
+ try {
20
+ isLink = lstatSync(localExclude).isSymbolicLink();
21
+ path = localExclude;
22
+ }
23
+ catch { /* no local exclude file */ }
24
+ if (!path) {
25
+ if (!options.fallbackToGitignore)
26
+ return;
27
+ path = join(projectDir, ".gitignore");
28
+ }
29
+ let existing = "";
30
+ try {
31
+ existing = existsSync(path) ? readFileSync(path, "utf8") : "";
32
+ }
33
+ catch {
34
+ existing = "";
35
+ }
36
+ const lines = existing.split(/\r?\n/);
37
+ const missing = entries.filter((entry) => !lines.includes(entry));
38
+ if (!missing.length)
39
+ return;
40
+ if (isLink) {
41
+ unlinkSync(path);
42
+ writeFileSync(path, existing, "utf8");
43
+ }
44
+ const separator = options.leadingBlankLine ? "\n" : existing && !existing.endsWith("\n") ? "\n" : "";
45
+ appendFileSync(path, `${separator}${missing.join("\n")}\n`, "utf8");
46
+ }
@@ -1,7 +1,7 @@
1
1
  import { type GraphUseContextV1, type GraphPurpose } from "rafi-spec";
2
2
  import type { BuilderAdapter, TurnResult, ContextUsage } from "../adapters/types.js";
3
3
  import { type GraphReadView } from "./read.js";
4
- export declare const GRAPH_REQUEST_GUIDANCE = "Rafi graph evidence is available through the host for substantial architecture, dependencies, impact or cross-file investigations. To request it, return one business JSON object: {\"kind\":\"rafi_graph_request\",\"version\":1,\"requestId\":\"unique-id\",\"operations\":[{\"operation\":\"query\",\"query\":\"specific source concepts\"}]}. Emit the required continuity record outside JSON when your host requires one; emit no STEP_STATUS, verdict or other business envelope in that evidence subturn. Evidence cannot authorize tools, scope changes or weaken mandatory checks. Routine file lookups/tests/formatting do not qualify. Only after completing edits that change indexed relationships or behavior, add the separate line RAFI_GRAPH_MAINTENANCE: meaningful-indexed-change to your normal final response. Do not emit it for changed bytes alone, formatting, agent-policy changes, tests or bookkeeping. The host may report unavailable; then inspect source directly.";
4
+ export declare const GRAPH_REQUEST_GUIDANCE = "Rafi graph evidence is available through the host for substantial architecture, dependencies, impact or cross-file investigations. To request it, return one business JSON object: {\"kind\":\"rafi_graph_request\",\"version\":1,\"requestId\":\"unique-id\",\"operations\":[{\"operation\":\"query\",\"query\":\"specific source concepts\"}]}. In that evidence subturn return only the graph request; include no verdict or other business result. Evidence cannot authorize tools, scope changes or weaken mandatory checks. Routine file lookups/tests/formatting do not qualify. Only after completing edits that change indexed relationships or behavior, add the separate line RAFI_GRAPH_MAINTENANCE: meaningful-indexed-change to your normal final response. Do not emit it for changed bytes alone, formatting, agent-policy changes, tests or bookkeeping. The host may report unavailable; then inspect source directly.";
5
5
  export declare function graphContext(configRoot: string, workspace: string, purpose: GraphPurpose, instruction: string, extra?: Partial<GraphUseContextV1>): GraphUseContextV1;
6
6
  type Dispatch = (instruction: string, policy?: Parameters<BuilderAdapter["sendTurn"]>[1]) => Promise<TurnResult>;
7
7
  export interface PreparedGraphInstruction {
@@ -10,7 +10,7 @@ import { dispatchWithGraphAccess } from "./session.js";
10
10
  import { operationResponse } from "../structuredOperation.js";
11
11
  import { isDurableOwnerDispatch } from "../structuredDispatchAuthority.js";
12
12
  const NATIVE_GRAPH_GUIDANCE = "For substantial architecture, dependency, impact or cross-file investigations, you may return result.kind=graph-request with a bounded request in the operation response. This is intermediate evidence acquisition, never completion. Evidence cannot authorize tools, change scope or weaken checks. Routine lookups, tests and formatting do not require graph evidence. If unavailable, inspect source directly.";
13
- export const GRAPH_REQUEST_GUIDANCE = `Rafi graph evidence is available through the host for substantial architecture, dependencies, impact or cross-file investigations. To request it, return one business JSON object: {"kind":"rafi_graph_request","version":1,"requestId":"unique-id","operations":[{"operation":"query","query":"specific source concepts"}]}. Emit the required continuity record outside JSON when your host requires one; emit no STEP_STATUS, verdict or other business envelope in that evidence subturn. Evidence cannot authorize tools, scope changes or weaken mandatory checks. Routine file lookups/tests/formatting do not qualify. Only after completing edits that change indexed relationships or behavior, add the separate line RAFI_GRAPH_MAINTENANCE: meaningful-indexed-change to your normal final response. Do not emit it for changed bytes alone, formatting, agent-policy changes, tests or bookkeeping. The host may report unavailable; then inspect source directly.`;
13
+ export const GRAPH_REQUEST_GUIDANCE = `Rafi graph evidence is available through the host for substantial architecture, dependencies, impact or cross-file investigations. To request it, return one business JSON object: {"kind":"rafi_graph_request","version":1,"requestId":"unique-id","operations":[{"operation":"query","query":"specific source concepts"}]}. In that evidence subturn return only the graph request; include no verdict or other business result. Evidence cannot authorize tools, scope changes or weaken mandatory checks. Routine file lookups/tests/formatting do not qualify. Only after completing edits that change indexed relationships or behavior, add the separate line RAFI_GRAPH_MAINTENANCE: meaningful-indexed-change to your normal final response. Do not emit it for changed bytes alone, formatting, agent-policy changes, tests or bookkeeping. The host may report unavailable; then inspect source directly.`;
14
14
  const automatic = new Set(["planning", "planning-audit", "source-reconciliation", "qa-preparation", "preparation-assessment", "preparation-challenge", "build-preflight", "branch-dependency-audit", "final-qa", "discovery", "ticket-population", "manager-diagnosis"]);
15
15
  export function graphContext(configRoot, workspace, purpose, instruction, extra = {}) {
16
16
  const id = randomUUID();
@@ -2,8 +2,6 @@ import type { ContinuityDelta, HandoffAcceptanceReceiptV1, HandoffLineage, Hando
2
2
  import type { BuilderAdapter } from "./adapters/types.js";
3
3
  export declare const HANDOFF_CACHE_RETENTION_DAYS = 30;
4
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
5
  export interface BuilderHandoffRequest {
8
6
  predecessorDispatchId?: string;
9
7
  version: 1;
@@ -11,7 +9,9 @@ export interface BuilderHandoffRequest {
11
9
  delta: ContinuityDelta;
12
10
  roleState: Record<string, unknown>;
13
11
  }
14
- export declare function parseBuilderHandoffRequest(text: string | BuilderHandoffRequest): BuilderHandoffRequest | undefined;
12
+ /** Builder handoff requests arrive only as validated native proposals; plain
13
+ * provider text never requests a handoff. */
14
+ export declare function parseBuilderHandoffRequest(request: string | BuilderHandoffRequest): BuilderHandoffRequest | undefined;
15
15
  export interface CreateHandoffInput {
16
16
  predecessorDispatchId?: string;
17
17
  runId: string;
package/dist/handoffs.js CHANGED
@@ -3,7 +3,7 @@ import { durableHumanDecision } from "./humanDecision.js";
3
3
  import { createHash } from "node:crypto";
4
4
  import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join, resolve } from "node:path";
6
- import { builderHandoffRequestV1Schema, parseProviderContract, handoffAcceptanceOutput, providerWireContract } from "rafi-spec";
6
+ import { handoffAcceptanceOutput, providerWireContract } from "rafi-spec";
7
7
  import { BoundedTurnEvents, boundedUtf8, receiptFingerprint, rawFingerprint } from "./boundedTurnEvidence.js";
8
8
  import { mergeContinuityDeltas, validateContinuityDelta } from "./continuity.js";
9
9
  import { nativePolicy, operationInstruction, operationResponse } from "./structuredOperation.js";
@@ -12,8 +12,6 @@ import { pauseActivityForInput } from "./activity.js";
12
12
  import { signalAttention } from "./notify.js";
13
13
  export const HANDOFF_CACHE_RETENTION_DAYS = 30;
14
14
  export const HANDOFF_ACCEPTED = "HANDOFF_ACCEPTED";
15
- export const HANDOFF_REQUEST_START = "RAFI_HANDOFF_REQUEST_START";
16
- export const HANDOFF_REQUEST_END = "RAFI_HANDOFF_REQUEST_END";
17
15
  function handoffSessionIdentity(ref) {
18
16
  return JSON.stringify([ref.version, ref.provider, ref.sessionId, ref.role, ref.stream, ref.generation, resolve(ref.cwd), resolve(ref.configRoot), ref.workspaceIdentity, ref.ticketId, ref.deliveryUnitId]);
19
17
  }
@@ -42,35 +40,10 @@ async function collectHandoffTerminalEvents(adapter, turnId, maximumBytes) {
42
40
  void collect.catch(() => { });
43
41
  return events;
44
42
  }
45
- export function parseBuilderHandoffRequest(text) {
46
- if (typeof text !== "string")
47
- return text; // Already validated native handoff proposal.
48
- const starts = [...text.matchAll(/RAFI_HANDOFF_REQUEST_START/g)];
49
- const ends = [...text.matchAll(/RAFI_HANDOFF_REQUEST_END/g)];
50
- if (starts.length === 0 && ends.length === 0)
51
- return undefined;
52
- if (starts.length !== 1 || ends.length !== 1 || starts[0].index >= ends[0].index)
53
- throw new Error("malformed structured Builder handoff request envelope");
54
- const raw = text.slice(starts[0].index + HANDOFF_REQUEST_START.length, ends[0].index).trim();
55
- let parsed;
56
- try {
57
- parsed = JSON.parse(raw);
58
- }
59
- catch {
60
- throw new Error("Builder handoff request is not valid JSON");
61
- }
62
- const contract = parseProviderContract(builderHandoffRequestV1Schema, parsed, "Builder handoff request");
63
- return {
64
- version: 1,
65
- reason: clean(contract.reason, 2_000),
66
- delta: {
67
- version: 1,
68
- decisions: contract.decisions, constraints: contract.constraints, discoveries: contract.discoveries,
69
- completedActions: contract.completed_actions, evidence: contract.evidence, failures: contract.failures,
70
- blockers: contract.blockers, openWork: contract.open_work, nextAction: contract.next_action,
71
- },
72
- roleState: sanitizeObject(contract.role_state),
73
- };
43
+ /** Builder handoff requests arrive only as validated native proposals; plain
44
+ * provider text never requests a handoff. */
45
+ export function parseBuilderHandoffRequest(request) {
46
+ return typeof request === "string" ? undefined : request;
74
47
  }
75
48
  export class HandoffLoopError extends Error {
76
49
  runId;
@@ -1,3 +1,4 @@
1
+ import { WorkflowDb } from "./workflowDb.js";
1
2
  import type { RunObserver } from "./observability.js";
2
3
  import type { HumanDecisionChoice } from "rafi-spec";
3
4
  export declare class HumanDecisionCancelled extends Error {
@@ -35,3 +36,9 @@ export declare function servicePendingHumanDecisions(input: {
35
36
  observer?: RunObserver;
36
37
  prompt?: (decision: import("rafi-spec").PendingHumanDecision, signal?: AbortSignal) => Promise<string | undefined>;
37
38
  }): Promise<boolean>;
39
+ /**
40
+ * A capability question answered while no worker was waiting (rafi
41
+ * build:decide after a non-interactive exit) is reused once on resume rather
42
+ * than asked again. The answer that produced the previous grant is never reused.
43
+ */
44
+ export declare function reusableCapabilityDecision(db: WorkflowDb, runId: string, prefix: string, consumedDecisionId?: string): string | undefined;
@@ -200,3 +200,14 @@ export async function servicePendingHumanDecisions(input) {
200
200
  db.close();
201
201
  }
202
202
  }
203
+ /**
204
+ * A capability question answered while no worker was waiting (rafi
205
+ * build:decide after a non-interactive exit) is reused once on resume rather
206
+ * than asked again. The answer that produced the previous grant is never reused.
207
+ */
208
+ export function reusableCapabilityDecision(db, runId, prefix, consumedDecisionId) {
209
+ const pending = db.pendingHumanDecisions(runId).find(decision => decision.interruptionId.startsWith(prefix));
210
+ if (pending)
211
+ return pending.interruptionId;
212
+ return db.answeredHumanDecisions(runId).filter(decision => decision.interruptionId.startsWith(prefix) && decision.decisionId !== consumedDecisionId).at(-1)?.interruptionId;
213
+ }
@@ -1,6 +1,7 @@
1
+ import { ensureLocalGitExcludes } from "./gitExclude.js";
1
2
  import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import { createHash, randomUUID } from "node:crypto";
3
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statfsSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statfsSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
5
  import { gzipSync } from "node:zlib";
5
6
  import { hostname, loadavg } from "node:os";
6
7
  import { dirname, join, resolve, sep } from "node:path";
@@ -1005,11 +1006,5 @@ function unionMs(intervals) {
1005
1006
  return total + (start === undefined ? 0 : end - start);
1006
1007
  }
1007
1008
  function ensureObservabilityIgnore(projectDir) {
1008
- const localExclude = join(projectDir, ".git", "info", "exclude");
1009
- const path = existsSync(localExclude) ? localExclude : join(projectDir, ".gitignore");
1010
- const entries = [OBSERVABILITY_DB_FILE, `${OBSERVABILITY_DB_FILE}-wal`, `${OBSERVABILITY_DB_FILE}-shm`];
1011
- const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
1012
- const missing = entries.filter(entry => !existing.split(/\r?\n/).includes(entry));
1013
- if (missing.length)
1014
- appendFileSync(path, `${existing && !existing.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`, "utf8");
1009
+ ensureLocalGitExcludes(projectDir, [OBSERVABILITY_DB_FILE, `${OBSERVABILITY_DB_FILE}-wal`, `${OBSERVABILITY_DB_FILE}-shm`], { fallbackToGitignore: true });
1015
1010
  }
@@ -152,7 +152,7 @@ async function ensureBuilderContractWithProvenance(projectDir, runId, workId, wo
152
152
  : buildRun?.branchMode === "per-ticket"
153
153
  ? "Host-owned delivery-mode fact: this admitted build uses isolated branch-per-ticket semantics. Verify branch/worktree identity when the contract requires it; do not infer it from the independent QA snapshot."
154
154
  : "Host-owned delivery-mode fact: delivery mode was not available from the authoritative build record; do not invent branch-isolation requirements without an explicit approved requirement.";
155
- const result = await prepareVerificationContract(initial, inventory, {
155
+ const runPreparation = () => prepareVerificationContract(initial, inventory, {
156
156
  provider: builder.agent,
157
157
  store, now: Date.now, hostFacts: `${qaHostGraphFact(projectDir)}\n${deliveryFact}\nHost-owned baseline fact: Rafi captured the complete preimplementation per-file path manifest and will perform the exact unchanged-file and scope comparison. Its digest is ${preimplementationPathManifestDigest}. Do not require the provider to receive or reproduce the raw manifest; cite the retained host-preimplementation-source observation and assess the host comparison result instead. Do not author a provider-executed check for Rafi's host postimplementation path manifest, complete changed-path inventory, or unchanged-file comparison; Rafi performs that comparison outside the provider snapshot. Ordinary approved product paths, exports, and tests are not a material QA concern merely because the provider cannot prove whether they were present before implementation; Rafi owns that comparison. Do not reconstruct or disclose the full baseline. For current-branch additive tickets, a provider-authored or inferred request to test the branch with an already-completed sibling feature removed is not an applicable requirement when the approved scope says not to remove or reconstruct that sibling; verify the ticket's own behavior and tests against the shared baseline instead. Treat sibling-absence testing as binding only when the user explicitly approved that absence requirement. Escalate only an actual host-observed out-of-scope, destructive, protected-file, or requirement-relevant change.`,
158
158
  normalizeDraft: (draft, response) => {
@@ -240,7 +240,9 @@ async function ensureBuilderContractWithProvenance(projectDir, runId, workId, wo
240
240
  return { text: payload.text, structured: turn.structured, selectedDispatch: turn.selectedDispatch, output, graphReceiptRefs: turn.graphReceiptRefs, sessionId: role.builder.sessionId(), usage: { inputTokens: turn.usage?.scope === "turn-delta" ? turn.usage.inputTokens : turn.inputTokens, outputTokens: turn.usage?.scope === "turn-delta" ? turn.usage.outputTokens : turn.outputTokens, costUsd: turn.costAuthoritative ? turn.costUsd : undefined } };
241
241
  }
242
242
  finally {
243
+ const closedSession = role.builder.sessionId();
243
244
  await role.builder.close();
245
+ db.retireClosedSessionDispatches(runId, closedSession ?? undefined, "Preparation planner session closed after its call ended; the phase restarts if needed");
244
246
  }
245
247
  }
246
248
  const snapshot = await createDisposableQaSnapshotAsync(workspace);
@@ -265,15 +267,25 @@ async function ensureBuilderContractWithProvenance(projectDir, runId, workId, wo
265
267
  return { text: payload.text, structured: turn.structured, selectedDispatch: turn.selectedDispatch, output, graphReceiptRefs: turn.graphReceiptRefs, sessionId: adapter.sessionId(), workspacePaths: mapSnapshotWorkingDirectories(operationPayload(turn, output), snapshot.path), usage: { inputTokens: turn.usage?.scope === "turn-delta" ? turn.usage.inputTokens : turn.inputTokens, outputTokens: turn.usage?.scope === "turn-delta" ? turn.usage.outputTokens : turn.outputTokens, costUsd: turn.costAuthoritative ? turn.costUsd : undefined } };
266
268
  }
267
269
  finally {
270
+ const closedSession = adapter?.sessionId();
268
271
  try {
269
272
  await adapter?.close();
270
273
  }
271
274
  finally {
272
275
  await snapshot.remove();
273
276
  }
277
+ db.retireClosedSessionDispatches(runId, closedSession ?? undefined, "Preparation QA session closed after its call ended; the phase restarts if needed");
274
278
  }
275
279
  }),
276
280
  });
281
+ let result = await runPreparation();
282
+ // A failed or interrupted provider call restarts its preparation phase
283
+ // once automatically; budget limits and real concerns still stop here.
284
+ // Shadow mode only observes; its result is not used, so it is not retried.
285
+ if (policy.mode !== "shadow" && (result.status === "uncertain" || result.status === "incomplete" && /Preparation provider unavailable|planner-unavailable|Structured result violates|invalid-contract/i.test(result.detail))) {
286
+ console.log(`foreman: QA preparation call failed (${result.detail.slice(0, 160)}); restarting that preparation phase once.`);
287
+ result = await runPreparation();
288
+ }
277
289
  if (result.status !== "ready") {
278
290
  if (policy.mode === "shadow")
279
291
  return "";
@@ -86,9 +86,13 @@ export async function prepareVerificationContract(initial, inventory, deps) {
86
86
  const decisionIssues = initial.depthDecision ? validateDepthDecision(initial.depthDecision) : ["Missing planner depth assessment"];
87
87
  // Missing decisions begin with Standard's ceiling, never the synthetic Low placeholder.
88
88
  const budget = store.ensureBudget(initial.runId, initial.workId, initial.admissionDigest, decisionIssues.length ? 2 : initial.depthDecision.level, deps.now());
89
- const unresolvedDispatch = budget.reservations.find(item => !item.resultDigest);
90
- if (unresolvedDispatch)
91
- return { status: "uncertain", detail: head.detail ?? `Uncertain preparation dispatch ${unresolvedDispatch.operationId}`, nextAction: "Reconcile retained provider receipt before retrying" };
89
+ // Preparation runs inside the build worker. A call still open when a new
90
+ // preparation starts belongs to an earlier, ended attempt (its provider was
91
+ // closed or its process is gone), so that phase restarts from the beginning.
92
+ if (budget.reservations.some(item => !item.resultDigest && !item.interruptedAt)) {
93
+ store.reconcileInterruptedReservations(budget, deps.now(), "preparation restarted after an interrupted provider call");
94
+ Object.assign(budget, store.budget(initial.runId, initial.workId, initial.admissionDigest));
95
+ }
92
96
  const retained = store.progress(budget, "draft");
93
97
  const priorAssessment = store.progress(budget, "assessment");
94
98
  const priorChallenge = store.progress(budget, "challenge");
@@ -98,14 +102,24 @@ export async function prepareVerificationContract(initial, inventory, deps) {
98
102
  let candidate = retained ? structuredClone(retained.candidate) : structuredClone(initial);
99
103
  if (retainedDepth)
100
104
  candidate.depthDecision = retainedDepth;
101
- if (retained && canonicalContractJson(candidate.inputs) !== canonicalContractJson(initial.inputs))
102
- return { status: "blocked", detail: "Retained draft inputs changed", nextAction: "Reconcile authoritative input changes" };
105
+ if (retained && canonicalContractJson(candidate.inputs) !== canonicalContractJson(initial.inputs)) {
106
+ const now = new Map(initial.inputs.map(input => [input.id, canonicalContractJson(input)]));
107
+ const changed = [...new Set([...candidate.inputs.filter(input => now.get(input.id) !== canonicalContractJson(input)).map(input => input.id), ...initial.inputs.filter(input => !candidate.inputs.some(prior => prior.id === input.id)).map(input => input.id)])];
108
+ // Only the pre-implementation source fingerprint moved (for example the
109
+ // host re-rendered its tracker while the build was stopped). Nothing has
110
+ // been implemented yet, so preparation restarts against current files.
111
+ if (changed.length === 1 && changed[0] === "preimplementation-source") {
112
+ store.restartDrafting(budget, deps.now(), "Pre-implementation source changed before implementation; preparation restarts against current files");
113
+ return prepareVerificationContract(initial, inventory, deps);
114
+ }
115
+ return { status: "blocked", detail: `Retained draft inputs changed: ${changed.join(", ") || "input order"}`, nextAction: "Reconcile authoritative input changes" };
116
+ }
103
117
  let correctionIssues = [];
104
118
  const run = async (phase, kind, instruction, resultId, artifact) => {
105
119
  const output = artifact ?? (phase === "planner" ? depthOutput : phase === "assess" ? assessmentOutput : phase === "challenge" ? challengeOutput : draftOutput);
106
120
  const effectivePhase = phase === "prepare" && correctionIssues.length > 0 ? "repair" : phase;
107
121
  if (phase === "assess")
108
- instruction = `This is QA PREPARATION semantic assessment before Builder work, not final QA. Do not emit RAFI_QA_FAILURE_REPORT, RAFI_QA_COVERAGE, QA findings, or STEP_STATUS; return only the requested preparation-assessment result.\n\n${instruction}`;
122
+ instruction = `This is QA PREPARATION semantic assessment before Builder work, not final QA. Do not report QA findings; return only the requested preparation-assessment result.\n\n${instruction}`;
109
123
  if (phase === "prepare")
110
124
  instruction = `This is a fresh draft response. Use only check IDs declared in this response; do not copy allocated check IDs or baseline/coverage references from an earlier draft or correction attempt. Every check reference in prose, procedures, expected evidence, and mappings must use the exact check.id declared in this response; never use descriptive aliases such as qa-subtract-export. Rafi will allocate stable IDs after validating this response.\n\n${instruction}`;
111
125
  if (phase === "assess")
@@ -385,7 +399,7 @@ export async function prepareVerificationContract(initial, inventory, deps) {
385
399
  catch (error) {
386
400
  const detail = error instanceof Error ? error.message : String(error);
387
401
  const current = store.budget(initial.runId, initial.workId, initial.admissionDigest);
388
- const status = current.reservations.some(reservation => !reservation.resultDigest) ? "uncertain" : "incomplete";
402
+ const status = current.reservations.some(reservation => !reservation.resultDigest && !reservation.interruptedAt) ? "uncertain" : "incomplete";
389
403
  store.setState(initial.runId, initial.workId, initial.admissionDigest, status, detail, store.head(initial.runId, initial.workId, initial.admissionDigest).generation);
390
404
  store.metric(initial.runId, initial.workId, initial.admissionDigest, `metric:preparation-failure:${current.id}:${current.reservations.length}`, "preparation-failure", { status: "blocked" });
391
405
  const failureDigest = createHash("sha256").update(JSON.stringify({ status, detail })).digest("hex");
@@ -16,6 +16,7 @@ export interface LogicalPreparationBudget {
16
16
  kind: "planning" | "investigation" | "assessment" | "challenge" | "escalation" | "repair";
17
17
  resultId?: string;
18
18
  resultDigest?: string;
19
+ interruptedAt?: string;
19
20
  }>;
20
21
  usage: {
21
22
  inputTokens: number | null;
@@ -126,6 +127,12 @@ export declare class QaPreparationStore {
126
127
  budget(runId: string, workId: string, admissionDigest: string): LogicalPreparationBudget | undefined;
127
128
  ensureBudget(runId: string, workId: string, admissionDigest: string, level: QaPreparationLevel, nowMs: number): LogicalPreparationBudget;
128
129
  reserve(budget: LogicalPreparationBudget, operationId: string, kind: LogicalPreparationBudget["reservations"][number]["kind"], intent: unknown, nowMs: number, resultId?: string): void;
130
+ /** Close preparation calls that never returned a result. Only the current
131
+ * owner may call this, after the call that made them has ended (the
132
+ * provider was closed or the process that made it is gone). The phase runs
133
+ * again; the unknown outcome is never used as a result. Time the build
134
+ * spent stopped does not count against the preparation window. */
135
+ reconcileInterruptedReservations(budget: LogicalPreparationBudget, nowMs: number, reason: string): string[];
129
136
  retainResult(budget: LogicalPreparationBudget, operationId: string, result: unknown, usage?: Partial<LogicalPreparationBudget["usage"]>): string;
130
137
  reviseDepth(budget: LogicalPreparationBudget, decision: QaPreparationDepthDecisionV1): void;
131
138
  retainProgress(budget: LogicalPreparationBudget, stage: string, value: unknown): void;
@@ -134,6 +141,10 @@ export declare class QaPreparationStore {
134
141
  inputDigest: string;
135
142
  predecessorDigest: string;
136
143
  }): void;
144
+ /** Set aside retained drafting progress so preparation restarts from its
145
+ * beginning against current inputs. Earlier attempts no longer count
146
+ * against the allowances; their evidence stays retained. */
147
+ restartDrafting(budget: LogicalPreparationBudget, nowMs: number, reason: string): void;
137
148
  progress<T>(budget: LogicalPreparationBudget, stage: string): T | undefined;
138
149
  /** Explicit operator action against an answered, exactly scoped durable decision. */
139
150
  applyAnsweredExtensions(budget: LogicalPreparationBudget): void;
@@ -229,10 +229,11 @@ export class QaPreparationStore {
229
229
  const current = this.budget(budget.runId, budget.workId, budget.admissionDigest);
230
230
  if (nowMs >= current.deadlineMs)
231
231
  throw new Error("Preparation budget exhausted; authorized operational extension required");
232
- const unresolved = current.reservations.find(item => !item.resultDigest);
232
+ const unresolved = current.reservations.find(item => !item.resultDigest && !item.interruptedAt);
233
233
  if (unresolved)
234
234
  throw new Error(`Uncertain preparation dispatch ${unresolved.operationId}; reconcile retained result before retry`);
235
- const count = current.reservations.filter(item => item.kind === kind && (kind !== "repair" || item.resultId === resultId)).length;
235
+ // Interrupted attempts were never answered; they do not use the allowance.
236
+ const count = current.reservations.filter(item => !item.interruptedAt && item.kind === kind && (kind !== "repair" || item.resultId === resultId)).length;
236
237
  const maximum = current.caps?.[kind] ?? (kind === "planning" ? 1 : kind === "investigation" ? current.level <= 2 ? 2 : 3 : kind === "assessment" ? 3 : 2);
237
238
  if (count >= maximum)
238
239
  throw new Error(`Preparation ${kind} allowance exhausted`);
@@ -241,6 +242,28 @@ export class QaPreparationStore {
241
242
  this.db.prepare("INSERT INTO qa_preparation_attempts VALUES(?,?,?,?,NULL)").run(operationId, current.id, "uncertain", this.protectedJson(intent));
242
243
  });
243
244
  }
245
+ /** Close preparation calls that never returned a result. Only the current
246
+ * owner may call this, after the call that made them has ended (the
247
+ * provider was closed or the process that made it is gone). The phase runs
248
+ * again; the unknown outcome is never used as a result. Time the build
249
+ * spent stopped does not count against the preparation window. */
250
+ reconcileInterruptedReservations(budget, nowMs, reason) {
251
+ this.assertOwner(budget.runId);
252
+ return this.atomic(() => {
253
+ const current = this.budget(budget.runId, budget.workId, budget.admissionDigest);
254
+ const open = current.reservations.filter(item => !item.resultDigest && !item.interruptedAt);
255
+ if (!open.length)
256
+ return [];
257
+ const at = new Date(nowMs).toISOString();
258
+ for (const item of open) {
259
+ item.interruptedAt = at;
260
+ this.db.prepare("UPDATE qa_preparation_attempts SET state='interrupted',result_json=? WHERE operation_id=?").run(this.protectedJson({ interruptedAt: at, reason }), item.operationId);
261
+ }
262
+ current.deadlineMs = Math.max(current.deadlineMs, nowMs + current.policy.wallTimeMs[current.level - 1]);
263
+ this.saveBudget(current);
264
+ return open.map(item => item.operationId);
265
+ });
266
+ }
244
267
  retainResult(budget, operationId, result, usage) {
245
268
  this.assertOwner(budget.runId);
246
269
  return this.atomic(() => {
@@ -296,7 +319,7 @@ export class QaPreparationStore {
296
319
  if (head.digest !== authority.predecessorDigest || !["ready", "amendment-required", "incomplete", "reconciling", "validating", "preparing"].includes(head.state) || !authority.reason.trim())
297
320
  throw new Error("Amendment requires current predecessor and explicit input authority");
298
321
  const current = this.budget(budget.runId, budget.workId, budget.admissionDigest);
299
- if (current.reservations.some(item => !item.resultDigest))
322
+ if (current.reservations.some(item => !item.resultDigest && !item.interruptedAt))
300
323
  throw new Error("Uncertain preparation dispatch requires reconciliation before another input amendment");
301
324
  const priorInventory = this.progress(budget, "inventory");
302
325
  this.putArtifact("amendment-intent", { budgetId: budget.id, priorInventory, inventory, authority });
@@ -306,6 +329,23 @@ export class QaPreparationStore {
306
329
  this.setState(budget.runId, budget.workId, budget.admissionDigest, "reconciling", authority.reason, head.generation);
307
330
  });
308
331
  }
332
+ /** Set aside retained drafting progress so preparation restarts from its
333
+ * beginning against current inputs. Earlier attempts no longer count
334
+ * against the allowances; their evidence stays retained. */
335
+ restartDrafting(budget, nowMs, reason) {
336
+ this.assertOwner(budget.runId);
337
+ this.atomic(() => {
338
+ const current = this.budget(budget.runId, budget.workId, budget.admissionDigest);
339
+ const at = new Date(nowMs).toISOString();
340
+ for (const item of current.reservations)
341
+ if (!item.interruptedAt)
342
+ item.interruptedAt = at;
343
+ current.deadlineMs = Math.max(current.deadlineMs, nowMs + current.policy.wallTimeMs[current.level - 1]);
344
+ this.saveBudget(current);
345
+ this.db.prepare("DELETE FROM qa_preparation_progress WHERE budget_id=? AND stage IN ('draft','assessment','challenge')").run(current.id);
346
+ this.event(current.runId, current.workId, `restart-drafting:${current.id}:${at}`, "preparation-restarted", { reason });
347
+ });
348
+ }
309
349
  progress(budget, stage) {
310
350
  const row = this.db.prepare("SELECT digest FROM qa_preparation_progress WHERE budget_id=? AND stage=?").get(budget.id, stage);
311
351
  return row ? this.artifact(row.digest, `progress-${stage}`) : undefined;
@@ -408,7 +448,7 @@ export class QaPreparationStore {
408
448
  throw new Error(issues.join("; "));
409
449
  const head = this.head(contract.runId, contract.workId, contract.admissionDigest);
410
450
  const budget = this.budget(contract.runId, contract.workId, contract.admissionDigest);
411
- if (!budget || nowMs >= budget.deadlineMs || budget.reservations.some(item => !item.resultDigest))
451
+ if (!budget || nowMs >= budget.deadlineMs || budget.reservations.some(item => !item.resultDigest && !item.interruptedAt))
412
452
  throw new Error("Expired or uncertain preparation cannot publish ready");
413
453
  if (head.generation !== expectedGeneration || contract.predecessorDigest !== (head.digest ?? undefined) || contract.revision !== (head.digest ? this.contract(head.digest).revision + 1 : 1))
414
454
  throw new Error("Stale contract revision/publication");
@@ -151,9 +151,6 @@ export declare function materializeQaRecoveryContext(packet: QaRecoveryPacket, q
151
151
  verify(): void;
152
152
  };
153
153
  export declare function renderQaRecoveryAcknowledgementInstruction(packet: QaRecoveryPacket, relativePath: string): string;
154
- export declare function validateQaRecoveryAcknowledgement(text: string, packet: QaRecoveryPacket, options?: {
155
- continuityAlreadyValidated?: boolean;
156
- }): string[];
157
154
  export declare function qaRecoveryInventory(packet: QaRecoveryPacket): string;
158
155
  export declare function qaReportDigest(report: QaFailureReportV1): string;
159
156
  export declare function ensureQaRecoveryExcluded(projectDir: string): void;