ai-foreman 1.7.29 → 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 (156) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude.d.ts +23 -12
  3. package/dist/adapters/claude.js +231 -26
  4. package/dist/adapters/codex.d.ts +19 -7
  5. package/dist/adapters/codex.js +202 -19
  6. package/dist/adapters/recovering.d.ts +2 -0
  7. package/dist/adapters/recovering.js +5 -1
  8. package/dist/adapters/structured.d.ts +71 -0
  9. package/dist/adapters/structured.js +213 -0
  10. package/dist/adapters/types.d.ts +85 -9
  11. package/dist/agentRun.d.ts +2 -0
  12. package/dist/agentRun.js +2 -1
  13. package/dist/boundedTurnEvidence.d.ts +18 -0
  14. package/dist/boundedTurnEvidence.js +76 -0
  15. package/dist/branch/currentGuard.d.ts +2 -0
  16. package/dist/branch/currentGuard.js +2 -0
  17. package/dist/branch/git.js +3 -8
  18. package/dist/branch/planner.js +1 -4
  19. package/dist/branch/runner.d.ts +2 -1
  20. package/dist/branch/runner.js +10 -7
  21. package/dist/buildAdmission.js +4 -0
  22. package/dist/buildApproval.d.ts +15 -0
  23. package/dist/buildApproval.js +28 -9
  24. package/dist/buildAssignment.d.ts +1 -0
  25. package/dist/buildAssignment.js +82 -23
  26. package/dist/buildInterventions.d.ts +2 -2
  27. package/dist/buildInterventions.js +11 -4
  28. package/dist/buildLaunchGate.js +3 -2
  29. package/dist/buildRuns.d.ts +1 -0
  30. package/dist/buildRuns.js +6 -1
  31. package/dist/builderGuidanceFollowup.js +17 -6
  32. package/dist/cli/doctor.js +3 -1
  33. package/dist/cli/manager.js +22 -20
  34. package/dist/cli/start.js +85 -39
  35. package/dist/cli/tickets.d.ts +0 -8
  36. package/dist/cli/tickets.js +50 -60
  37. package/dist/continuity.d.ts +10 -16
  38. package/dist/continuity.js +223 -208
  39. package/dist/continuityOrigin.d.ts +13 -0
  40. package/dist/continuityOrigin.js +91 -0
  41. package/dist/dispatchNotSent.d.ts +4 -0
  42. package/dist/dispatchNotSent.js +10 -0
  43. package/dist/foreman.d.ts +15 -16
  44. package/dist/foreman.js +70 -278
  45. package/dist/gitExclude.d.ts +16 -0
  46. package/dist/gitExclude.js +46 -0
  47. package/dist/graph/boundary.d.ts +1 -1
  48. package/dist/graph/boundary.js +1 -1
  49. package/dist/graph/host.js +8 -19
  50. package/dist/graph/manager.d.ts +1 -1
  51. package/dist/graph/manager.js +1 -1
  52. package/dist/graph/preparation.d.ts +38 -0
  53. package/dist/graph/preparation.js +7 -6
  54. package/dist/graph/semantic.d.ts +1 -0
  55. package/dist/graph/semantic.js +4 -1
  56. package/dist/graph/session.js +7 -2
  57. package/dist/graph/turn.d.ts +3 -4
  58. package/dist/graph/turn.js +29 -41
  59. package/dist/handoffProtocol.js +2 -2
  60. package/dist/handoffs.d.ts +5 -3
  61. package/dist/handoffs.js +92 -88
  62. package/dist/hostGraphApplicability.d.ts +2 -1
  63. package/dist/hostGraphApplicability.js +17 -19
  64. package/dist/hostQaAugmentation.js +21 -4
  65. package/dist/hostQaComparison.d.ts +21 -2
  66. package/dist/hostQaComparison.js +43 -29
  67. package/dist/hostSourcePreservation.d.ts +38 -0
  68. package/dist/hostSourcePreservation.js +72 -0
  69. package/dist/humanDecision.d.ts +7 -0
  70. package/dist/humanDecision.js +11 -0
  71. package/dist/interviews.d.ts +7 -0
  72. package/dist/interviews.js +18 -1
  73. package/dist/legacyCommunicationDisplay.d.ts +2 -0
  74. package/dist/legacyCommunicationDisplay.js +2 -0
  75. package/dist/managerPacket.js +3 -3
  76. package/dist/markers.d.ts +4 -3
  77. package/dist/markers.js +4 -20
  78. package/dist/observability.js +3 -8
  79. package/dist/probeIpc.d.ts +14 -0
  80. package/dist/probeIpc.js +29 -0
  81. package/dist/qaBuildGate.d.ts +21 -0
  82. package/dist/qaBuildGate.js +60 -13
  83. package/dist/qaBuilderCoverage.js +22 -13
  84. package/dist/qaBuilderEvidence.d.ts +16 -1
  85. package/dist/qaBuilderEvidence.js +48 -8
  86. package/dist/qaContractCoverage.d.ts +2 -2
  87. package/dist/qaContractCoverage.js +4 -9
  88. package/dist/qaContractDelivery.js +34 -20
  89. package/dist/qaCoverageAssembly.d.ts +22 -0
  90. package/dist/qaCoverageAssembly.js +16 -0
  91. package/dist/qaDeliveryJournal.d.ts +2 -0
  92. package/dist/qaDeliveryJournal.js +19 -0
  93. package/dist/qaFailureDelivery.js +97 -85
  94. package/dist/qaHostTransformation.d.ts +26 -0
  95. package/dist/qaHostTransformation.js +100 -0
  96. package/dist/qaPreparation.d.ts +8 -20
  97. package/dist/qaPreparation.js +153 -254
  98. package/dist/qaPreparationInputs.js +19 -0
  99. package/dist/qaPreparationStore.d.ts +11 -0
  100. package/dist/qaPreparationStore.js +44 -4
  101. package/dist/qaPrerequisites.d.ts +2 -1
  102. package/dist/qaPrerequisites.js +21 -1
  103. package/dist/qaProtocolV2.d.ts +15 -0
  104. package/dist/qaRecovery.d.ts +0 -3
  105. package/dist/qaRecovery.js +21 -27
  106. package/dist/qaReview.js +241 -406
  107. package/dist/qaReviewEvidence.d.ts +7 -3
  108. package/dist/qaReviewEvidence.js +15 -4
  109. package/dist/qaRuntime.js +1 -1
  110. package/dist/qaSnapshot.js +76 -21
  111. package/dist/qaStructuredResponse.d.ts +18 -0
  112. package/dist/qaStructuredResponse.js +81 -0
  113. package/dist/qaTimeline.js +2 -2
  114. package/dist/qaVerificationContract.d.ts +1 -2
  115. package/dist/qaVerificationContract.js +24 -35
  116. package/dist/readinessGate.js +5 -4
  117. package/dist/recoveryOwners.d.ts +32 -0
  118. package/dist/recoveryOwners.js +87 -0
  119. package/dist/runtimePreflight.d.ts +11 -0
  120. package/dist/runtimePreflight.js +77 -0
  121. package/dist/runtimeReadiness.js +23 -15
  122. package/dist/sources/sourceRegistry.d.ts +0 -3
  123. package/dist/sources/sourceRegistry.js +0 -42
  124. package/dist/statusReporter.d.ts +2 -0
  125. package/dist/statusReporter.js +2 -0
  126. package/dist/structuredCutover.d.ts +31 -0
  127. package/dist/structuredCutover.js +510 -0
  128. package/dist/structuredCutoverFiles.d.ts +10 -0
  129. package/dist/structuredCutoverFiles.js +181 -0
  130. package/dist/structuredDispatchAuthority.d.ts +22 -0
  131. package/dist/structuredDispatchAuthority.js +110 -0
  132. package/dist/structuredDispatchRecord.d.ts +29 -0
  133. package/dist/structuredDispatchRecord.js +36 -0
  134. package/dist/structuredLiveBudget.d.ts +2 -0
  135. package/dist/structuredLiveBudget.js +23 -0
  136. package/dist/structuredOperation.d.ts +18 -0
  137. package/dist/structuredOperation.js +279 -0
  138. package/dist/supervisedStart.js +35 -21
  139. package/dist/ticketPlanning.d.ts +1 -376
  140. package/dist/ticketPlanning.js +2 -49
  141. package/dist/ticketPopulation.d.ts +0 -3
  142. package/dist/ticketPopulation.js +4 -21
  143. package/dist/ticketPopulationPageAuthority.d.ts +13 -0
  144. package/dist/ticketPopulationPageAuthority.js +30 -0
  145. package/dist/ticketPopulationPaging.d.ts +27 -0
  146. package/dist/ticketPopulationPaging.js +127 -0
  147. package/dist/tickets/renderMarkdown.d.ts +14 -0
  148. package/dist/tickets/renderMarkdown.js +39 -5
  149. package/dist/tickets/ticketLoader.js +10 -0
  150. package/dist/tickets/ticketSchema.d.ts +107 -0
  151. package/dist/tickets/ticketSchema.js +4 -1
  152. package/dist/workflowDb.d.ts +116 -3
  153. package/dist/workflowDb.js +852 -61
  154. package/dist/workflowReader.d.ts +1 -0
  155. package/dist/workflowReader.js +9 -0
  156. package/package.json +23 -7
package/dist/foreman.js CHANGED
@@ -7,12 +7,13 @@ import { renderBuildWorkContext } from "./buildWorkContext.js";
7
7
  import { loadTicketSetupConfigWithDefaults } from "./tickets/setupConfig.js";
8
8
  import { deliverBuilderGuidanceFollowup } from "./builderGuidanceFollowup.js";
9
9
  import { graphContext, sendGraphTurn } from "./graph/turn.js";
10
+ import { workReportOutput, preflightOutput, qaVerdictOutput } from "rafi-spec";
11
+ import { operationInstruction, operationStatus, operationPayload, nativePolicy, structuredTurnErrorMessage } from "./structuredOperation.js";
10
12
  import { qaDigest } from "./qaProtocolV2.js";
11
13
  import { BuildControlBoundary } from "./buildInterventions.js";
12
14
  import { buildScopeRevision } from "./buildApproval.js";
13
15
  import { durableHumanDecision, HumanDecisionRequired, HumanDecisionCancelled, servicePendingHumanDecisions, decisionResponse } from "./humanDecision.js";
14
16
  import { select, text, isCancel } from "@clack/prompts";
15
- import { MARKER_SPEC, QA_MARKER_SPEC } from "./markers.js";
16
17
  import { countProviderQuestions, handleProviderQuestionTool } from "./providerQuestions.js";
17
18
  import { bestEffortDiagnostic } from "./questionTrace.js";
18
19
  import { signalAttention } from "./notify.js";
@@ -30,22 +31,6 @@ import { join, relative } from "node:path";
30
31
  import { SessionUnavailableError, recoverableBuilderTurnError, sessionUnavailableErrorFromFailure } from "./adapters/sessionFailure.js";
31
32
  import { WorkflowDb } from "./workflowDb.js";
32
33
  import { assertBuildAssignmentReconciled, beginBuildAssignment, BuildAssignmentRejected, captureBuildSource, finishBuildAssignment, validateBuildAssignment } from "./buildAssignment.js";
33
- /** Compare Builder product state without Rafi's own tracker bookkeeping. */
34
- function builderProductDigest(source, hostOwnedPaths) {
35
- const ignored = new Set(hostOwnedPaths.map(path => path.replace(/\\/g, "/").replace(/^\.\//, "")));
36
- const hostOwned = (path) => {
37
- const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "");
38
- return ignored.has(normalized)
39
- || [".foreman/", ".rafi/", ".tickets/", ".codex/", ".agents/", "docs-rafi/"]
40
- .some(prefix => normalized.startsWith(prefix))
41
- || [".gitignore", "AGENTS.md", "rafi-config.yaml", "foreman.yaml"].includes(normalized);
42
- };
43
- const inventory = source.pathInventory
44
- .filter(item => !hostOwned(item.path))
45
- .map(item => ({ ...item, path: item.path.replace(/\\/g, "/") }))
46
- .sort((a, b) => a.path.localeCompare(b.path));
47
- return createHash("sha256").update(JSON.stringify(inventory)).digest("hex");
48
- }
49
34
  /** Phrases that suggest the builder ended its turn by asking the human. */
50
35
  const QUESTION_HINTS = [
51
36
  "should i",
@@ -56,87 +41,6 @@ const QUESTION_HINTS = [
56
41
  "could you clarify",
57
42
  "which option",
58
43
  ];
59
- /**
60
- * Pull the STEP_STATUS marker out of a turn's final text.
61
- * Format: `STEP_STATUS: <kind> | key="value" key="value"`.
62
- */
63
- export function parseStepStatus(text, options = {}) {
64
- const markerLines = text.split(/\r?\n/).filter((line) => /^\s*STEP_STATUS:/.test(line));
65
- const markerCount = markerLines.length;
66
- if (markerCount > 1) {
67
- return { kind: "unknown", error: "builder emitted multiple STEP_STATUS markers" };
68
- }
69
- const lines = text.trimEnd().split(/\r?\n/).filter((line) => line.trim().length > 0);
70
- const last = lines[lines.length - 1] ?? "";
71
- if (!last.includes("STEP_STATUS:")) {
72
- if (markerCount === 1 && options.requireFinalLine !== false) {
73
- return { kind: "unknown", error: "STEP_STATUS marker was not the final non-empty line" };
74
- }
75
- if (markerCount === 0)
76
- return { kind: "unknown" };
77
- }
78
- const marker = options.requireFinalLine === false ? markerLines[0]?.trim() ?? "" : last;
79
- const match = marker.match(/^STEP_STATUS:\s*(done|blocked|plan_complete|needs_input|qa_pass|qa_fail)\b\s*(?:\|\s*(.*))?$/i);
80
- if (!match) {
81
- return { kind: "unknown", error: "malformed STEP_STATUS marker" };
82
- }
83
- const kind = match[1].toLowerCase();
84
- const fields = parseMarkerFields(match[2] ?? "");
85
- if (fields instanceof Error) {
86
- return { kind: "unknown", error: fields.message };
87
- }
88
- return {
89
- kind,
90
- summary: fields.summary,
91
- next: fields.next,
92
- reason: fields.reason,
93
- question: fields.question,
94
- issues: fields.issues,
95
- ticket: fields.ticket,
96
- branchDependency: fields.branch_dependency,
97
- choices: fields.choices
98
- ? fields.choices.split("|").map((c) => c.trim()).filter(Boolean)
99
- : undefined,
100
- };
101
- }
102
- function parseMarkerFields(input) {
103
- const fields = {};
104
- let rest = input.trim();
105
- while (rest.length > 0) {
106
- const key = rest.match(/^(\w+)="/);
107
- if (!key)
108
- return new Error(`malformed STEP_STATUS field near: ${rest.slice(0, 40)}`);
109
- const name = key[1];
110
- let i = key[0].length;
111
- let value = "";
112
- let closed = false;
113
- while (i < rest.length) {
114
- const ch = rest[i];
115
- if (ch === "\\") {
116
- const next = rest[i + 1];
117
- if (next === undefined)
118
- return new Error(`unterminated escape in STEP_STATUS field: ${name}`);
119
- value += next;
120
- i += 2;
121
- continue;
122
- }
123
- if (ch === "\"") {
124
- closed = true;
125
- i++;
126
- break;
127
- }
128
- value += ch;
129
- i++;
130
- }
131
- if (!closed)
132
- return new Error(`unterminated STEP_STATUS field: ${name}`);
133
- if (fields[name] !== undefined)
134
- return new Error(`duplicate STEP_STATUS field: ${name}`);
135
- fields[name] = value;
136
- rest = rest.slice(i).trim();
137
- }
138
- return fields;
139
- }
140
44
  /** Heuristic: did a marker-less turn end by asking the human something? */
141
45
  export function looksLikeQuestion(text) {
142
46
  const tail = text.trim().toLowerCase().slice(-400);
@@ -155,7 +59,7 @@ export function buildPrimer(n, trackerPath, ticketsEnabled = false, preferredTic
155
59
  `\n- Foreman records completion and regenerates the tracker after independent QA passes. Do not run ticket-complete commands or mark tracker records done yourself.` +
156
60
  `\n- When available and permitted, \`foreman tickets discover --summary "..." --rationale "..."\` can log newly discovered work and \`foreman tickets update <id> --next-action "..."\` can record optional mid-turn notes.` +
157
61
  `\n- If a ticket CLI is unavailable, include those notes in your response; it is not an implementation blocker.` +
158
- `\n- Always include ticket="<id>" in your STEP_STATUS marker so foreman can update the tracker.`;
62
+ `\n- The host binds your result to the assigned ticket; report completion only for that exact work.`;
159
63
  }
160
64
  else if (trackerPath) {
161
65
  trackerRule = `\n- After completing each ticket or step, update its status in the ticket progress tracker at \`${trackerPath}\`, following the Standard Update Workflow documented in that file.`;
@@ -166,18 +70,31 @@ export function buildPrimer(n, trackerPath, ticketsEnabled = false, preferredTic
166
70
  const preferredTicketRule = preferredTicketId
167
71
  ? `\n- Resume and finish ticket ${preferredTicketId} first. Do not substitute a different queued ticket.`
168
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.`;
169
84
  return `You are being run by an automated foreman. We will work through your next ${n} tickets or implementation steps, one per turn.
170
85
 
171
86
  Rules:
172
87
  - Do exactly ONE ticket or step this turn, then stop.
173
- - ${MARKER_SPEC}
174
- - If a tool action is denied by foreman policy, do not retry the denied form; report it via the blocked marker. 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}
88
+ - Return the result using the selected native JSON output contract.
89
+ - 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}
175
90
 
176
91
  This is step 1 of ${n}. Implement the next ticket or step now.`;
177
92
  }
178
93
  /** Instruction sent on turns 2–N. */
179
- export function buildNextStepInstruction(i, n) {
180
- return `Implement the next ticket or step now (exactly one) — this is step ${i} of ${n}. Then end with the STEP_STATUS marker line.`;
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.`;
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.`;
181
98
  }
182
99
  /** QA review turn: ask the builder to triple-check the ticket or step it just completed. */
183
100
  export function buildQaInstruction() {
@@ -194,10 +111,7 @@ The independent QA workspace is a disposable snapshot and may have a detached HE
194
111
 
195
112
  If an otherwise required test is denied only because you added a pipe, redirection, substitution, or wrapper, retry the exact required-test command without that extra shell syntax. Do not retry other denied actions.
196
113
 
197
- If everything is solid, end with STEP_STATUS: qa_pass.
198
- If anything is off, return the required RAFI_QA_FAILURE_REPORT_START/RAFI_QA_FAILURE_REPORT_END JSON envelope immediately before STEP_STATUS: qa_fail. The report must contain every check, every blocking finding, and all nonblocking observations. Do NOT fix issues on this turn — just report them. Foreman will instruct the Builder to fix them next.
199
-
200
- ${QA_MARKER_SPEC}`;
114
+ Return a native qa-verdict result. Use a passed payload only when verification succeeds. A failed payload must contain the full report: every check, blocking finding, and nonblocking observation. Do not fix issues during this review; the Builder receives failures separately.`;
201
115
  }
202
116
  /** Follow-up turn after qa_fail: have the builder implement the listed fixes. */
203
117
  export function buildQaFixInstruction(issues) {
@@ -205,17 +119,19 @@ export function buildQaFixInstruction(issues) {
205
119
 
206
120
  ${issues}
207
121
 
208
- Fix every one of them now. Then end with STEP_STATUS: done so foreman can re-run QA on the fixes. Triple-check that your fixes actually resolve the issues before emitting done.`;
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.`;
209
123
  }
210
124
  /** Pre-flight planning turn: ask the builder to list its next N steps without implementing anything. */
211
- export function buildPlanningTurn(n, ticketsContent, preferredTicketId) {
125
+ export function buildPlanningTurn(n, ticketsContent, preferredTicketId, ticketsEnabled = false) {
212
126
  const header = ticketsContent
213
127
  ? `Here is the project's ticket list:\n\n${ticketsContent}\n\n`
214
128
  : "";
215
129
  const preferred = preferredTicketId
216
130
  ? ` The first item must be ticket ${preferredTicketId}; this is an interrupted ticket being resumed.`
217
131
  : "";
218
- 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; output the numbered list only. Do not emit a STEP_STATUS marker on this turn.`;
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.`;
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.`;
219
135
  }
220
136
  /** Builds the permission callback: classify, log, allow or escalate. */
221
137
  export function createPermissionHandler(policy, log, opts = {}) {
@@ -276,11 +192,11 @@ export function createPermissionHandler(policy, log, opts = {}) {
276
192
  behavior: "deny",
277
193
  message: `Foreman policy: this action needs human approval (${verdict.reason}) ` +
278
194
  `and was NOT performed. Do not retry this exact action. An allow-listed alternative is permitted; for project tests use a literal in-worktree cd, project-local PYTHONPATH, existing virtualenv Python, or uv run --offline --no-sync with an allowed test command. If no permitted alternative can complete the step, ` +
279
- `end your turn with: STEP_STATUS: blocked | reason="needs human approval: ${verdict.reason}".`,
195
+ `return a native blocked result explaining the required approval: ${verdict.reason}.`,
280
196
  };
281
197
  };
282
198
  }
283
- /** Drives one builder through a batch of N steps via the STEP_STATUS protocol. */
199
+ /** Drives one builder through contracted native operations. */
284
200
  export class BuildInterruptedError extends Error {
285
201
  constructor() { super("Build interrupted; current work is preserved for recovery"); this.name = "BuildInterruptedError"; }
286
202
  }
@@ -392,7 +308,7 @@ export class Foreman {
392
308
  * The returned result and status always reflect the final (non-needs_input) turn.
393
309
  */
394
310
  async doTurn(instruction) {
395
- return this.doTurnWith(this.builder, instruction);
311
+ return this.runBuilderWork(instruction);
396
312
  }
397
313
  async prepareBuilderBoundary(frozenAction) {
398
314
  if (!this.builderFactory || !this.projectDir)
@@ -440,19 +356,24 @@ export class Foreman {
440
356
  this.builder = await this.builderFactory(this.projectDir);
441
357
  this.log.write("branch-session", { role: "builder", transition: "missing-session-fresh", continuityLost: true });
442
358
  }
443
- async doTurnWith(adapter, instruction, mode = "builder", completion) {
359
+ async doTurnWith(adapter, instruction, mode = "builder", output, completion) {
444
360
  return this.projectDir
445
- ? withGraphSessionAccess(this.projectDir, adapter, () => this.doTurnWithOwned(adapter, instruction, mode, completion))
446
- : this.doTurnWithOwned(adapter, instruction, mode, completion);
361
+ ? withGraphSessionAccess(this.projectDir, adapter, () => this.doTurnWithOwned(adapter, instruction, mode, output, completion))
362
+ : this.doTurnWithOwned(adapter, instruction, mode, output, completion);
447
363
  }
448
- async doTurnWithOwned(adapter, instruction, mode = "builder", completion) {
364
+ async doTurnWithOwned(adapter, instruction, mode = "builder", output, completion) {
365
+ if (completion)
366
+ throw new Error("Text completion predicates cannot authorize native operation results");
449
367
  const ticket = mode === "builder" ? this.currentTicketId : undefined;
368
+ const statusOf = (turn) => operationStatus(turn, output, ticket);
450
369
  const hostToolObservations = [];
451
370
  const scopedInstruction = (text) => !ticket || text.includes(`Ticket scope: ${ticket}.`) ? text
452
- : `${text}\nTicket scope: ${ticket}. Do not substitute another ticket. Include ticket="${ticket}" in any STEP_STATUS marker.`;
371
+ : `${text}\nTicket scope: ${ticket}. Do not substitute another ticket.`;
453
372
  instruction = scopedInstruction(instruction);
373
+ instruction += `\n\n${operationInstruction(output)}`;
454
374
  const send = async (text, policy) => {
455
375
  this.assertNotInterrupted();
376
+ policy = nativePolicy(output, { ...policy, dispatchId: undefined, journalRunId: this.qaRunId });
456
377
  const context = ticket && this.projectDir ? renderBuildWorkContext(this.ticketForQa(0, ticket), loadTicketSetupConfigWithDefaults(this.projectDir).build.validation_checklist) : "";
457
378
  const contractContext = ticket && this.projectDir ? await ensureBuilderContract(this.projectDir, this.qaRunId, ticket, this.builderWorktree ?? this.projectDir, adapter, this.qaFactory ? (...args) => this.createOwnedQa(...args) : undefined) : "";
458
379
  this.assertNotInterrupted();
@@ -472,8 +393,8 @@ export class Foreman {
472
393
  finally {
473
394
  stopHostObservation();
474
395
  }
475
- const parsed = parseStepStatus(response.text);
476
- const applicable = parsed.kind === "unknown" && looksLikeQuestion(response.text) ? { kind: "needs_input", question: lastLine(response.text) } : parsed;
396
+ const parsed = statusOf(response);
397
+ const applicable = parsed;
477
398
  if (!this.interruptionRequested && assignment && !response.isError && !response.failure && ["done", "plan_complete"].includes(applicable.kind))
478
399
  await collectBuilderContractCoverage(this.projectDir, assignment.runId, assignment.ticketId, assignment.worktree, adapter, assignment.operationId, submitted, response, hostToolObservations, initialSourceDigest);
479
400
  const rejection = assignment ? finishBuildAssignment(this.projectDir, assignment, response, applicable) : undefined;
@@ -496,60 +417,16 @@ export class Foreman {
496
417
  this.builder = await this.beforeBuilderTurn(this.builder, instruction);
497
418
  adapter = this.builder;
498
419
  }
499
- const turnPolicy = { logicalActionId: this.currentTicketId ?? createHash("sha256").update(instruction).digest("hex") };
420
+ const turnPolicy = { logicalActionId: randomUUID() };
500
421
  let result = await send(instruction, turnPolicy);
501
422
  await this.observeBuilderNative(adapter);
502
- let status = parseStepStatus(result.text);
503
- if (!result.isError && !result.failure) {
504
- const completed = completion?.(result.text);
505
- if (completed)
506
- return { result, status: completed };
507
- }
423
+ let status = statusOf(result);
508
424
  if (ticket && validateBuildAssignment(ticket, status))
509
425
  return { result, status };
510
426
  if (result.isError || result.failure)
511
427
  return { result: { ...result, isError: true }, status };
512
- if (status.kind === "unknown") {
513
- if (!status.error && looksLikeQuestion(result.text)) {
514
- status = { kind: "needs_input", question: lastLine(result.text), choices: ["Continue", "Cancel"] };
515
- }
516
- else {
517
- if (adapter === this.builder && this.beforeBuilderTurn) {
518
- this.builder = await this.beforeBuilderTurn(this.builder, instruction);
519
- adapter = this.builder;
520
- }
521
- if (this.projectDir) {
522
- const budget = new WorkflowDb(this.projectDir);
523
- try {
524
- if (!budget.reserveRecoveryAllowance(this.qaRunId, turnPolicy.logicalActionId, "protocol-correction", budget.autonomyPolicy(this.qaRunId)?.limits.protocolCorrections ?? 1))
525
- return { result, status: { kind: "blocked", reason: "shared protocol correction budget exhausted" } };
526
- }
527
- finally {
528
- budget.close();
529
- }
530
- }
531
- const correction = mode === "qa"
532
- ? "Protocol correction only: based on the review already completed, return exactly one final STEP_STATUS: qa_pass or STEP_STATUS: qa_fail marker. Do not repeat QA, tests, tools, or compaction."
533
- : "Protocol correction only: based on the work already completed, return exactly one final STEP_STATUS: done, plan_complete, blocked, or needs_input marker. Do not repeat implementation, tools, or compaction.";
534
- result = await send([
535
- correction,
536
- `The previous marker was rejected: ${JSON.stringify(status.error ?? "missing final marker")}.`,
537
- "STEP_STATUS is a separate plain-text line, not JSON. Do not append braces, commas, a code fence, or commentary after it. Keep the continuity JSON on its own preceding line.",
538
- ...(mode === "builder" ? [
539
- `Only if the assigned work is complete, the exact marker form is: STEP_STATUS: done${ticket ? ` | ticket="${ticket}"` : ""}`,
540
- `If blocked, use: STEP_STATUS: blocked | ${ticket ? `ticket="${ticket}" ` : ""}reason="the actual blocker"`,
541
- "Choose the status supported by the work already performed; do not invent completion.",
542
- ] : ["If successful, use exactly: STEP_STATUS: qa_pass", "If failed, retain the required failure report and use exactly: STEP_STATUS: qa_fail"]),
543
- ].join("\n"), { ...turnPolicy, responseOnly: true });
544
- await this.observeBuilderNative(adapter);
545
- if (result.isError || result.failure)
546
- return { result: { ...result, isError: true }, status: { kind: "blocked", reason: result.text } };
547
- status = parseStepStatus(result.text);
548
- if (status.kind === "unknown")
549
- status = { ...status, error: `protocol correction exhausted: ${status.error ?? "missing final marker"}` };
550
- }
551
- }
552
- let blockerExplanations = 0;
428
+ if (status.kind === "unknown")
429
+ return { result: { ...result, isError: true }, status };
553
430
  while (true) {
554
431
  if (ticket && validateBuildAssignment(ticket, status))
555
432
  return { result, status };
@@ -632,69 +509,23 @@ export class Foreman {
632
509
  await this.observeBuilderNative(adapter);
633
510
  if (result.isError || result.failure)
634
511
  return { result: { ...result, isError: true }, status: { kind: "blocked", reason: result.text } };
635
- status = parseStepStatus(result.text);
636
- if (completion) {
637
- const completed = completion(result.text);
638
- if (completed)
639
- return { result, status: completed };
640
- }
512
+ status = statusOf(result);
641
513
  if (ticket && validateBuildAssignment(ticket, status))
642
514
  return { result, status };
643
515
  }
644
- if (status.kind !== "blocked" || (status.reason?.startsWith("user chose safe pause") || status.reason?.includes("Rafi is waiting for input")) || !process.stdin.isTTY || !process.stdout.isTTY)
645
- break;
646
- if (blockerExplanations++ >= 1)
647
- break;
648
- if (this.projectDir) {
649
- const budget = new WorkflowDb(this.projectDir);
650
- try {
651
- if (!budget.reserveRecoveryAllowance(this.qaRunId, this.currentTicketId ?? "instruction", "blocker-explanation", 1))
652
- break;
653
- }
654
- finally {
655
- budget.close();
656
- }
657
- }
658
- const reason = status.reason ?? "the agent reported an unspecified blocker";
659
- this.log.write("blocked-recovery", { reason, role: adapter === this.builder ? "builder" : "qa" });
660
- const recoveryInstruction = buildBlockerRecoveryInstruction(reason);
661
- if (adapter === this.builder && this.beforeBuilderTurn) {
662
- this.builder = await this.beforeBuilderTurn(this.builder, scopedInstruction(recoveryInstruction));
663
- adapter = this.builder;
664
- }
665
- result = await send(recoveryInstruction, turnPolicy);
666
- await this.observeBuilderNative(adapter);
667
- if (result.isError || result.failure)
668
- return { result: { ...result, isError: true }, status: { kind: "blocked", reason: result.text } };
669
- status = parseStepStatus(result.text);
670
- if (status.kind === "unknown") {
671
- if (this.projectDir && this.qaRunId) {
672
- const budget = new WorkflowDb(this.projectDir);
673
- try {
674
- if (!budget.reserveRecoveryAllowance(this.qaRunId, turnPolicy.logicalActionId, "protocol-correction", budget.autonomyPolicy(this.qaRunId)?.limits.protocolCorrections ?? 1))
675
- break;
676
- }
677
- finally {
678
- budget.close();
679
- }
680
- }
681
- result = await send('Protocol correction only: return the blocker approaches now using exactly one final STEP_STATUS: needs_input marker with question="..." and choices="recommended (Recommended)|alternative|alternative". Do not repeat tools or implementation.', { ...turnPolicy, responseOnly: true });
682
- await this.observeBuilderNative(adapter);
683
- if (result.isError || result.failure)
684
- return { result: { ...result, isError: true }, status: { kind: "blocked", reason: result.text } };
685
- status = parseStepStatus(result.text);
686
- }
516
+ break;
687
517
  }
688
518
  return { result, status };
689
519
  }
690
520
  /** Send a planning turn and return the builder's response text. Does not count toward steps. */
691
521
  async runPreflight(n, ticketsContent, preferredTicketId, executionTickets) {
692
522
  this.assertNotInterrupted();
693
- 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.` : "");
694
524
  if (this.beforeBuilderTurn)
695
525
  this.builder = await this.beforeBuilderTurn(this.builder, instruction);
696
526
  this.assertNotInterrupted();
697
- const result = this.projectDir ? await sendGraphTurn(this.builder, instruction, graphContext(this.projectDir, this.builderWorktree ?? this.projectDir, "build-preflight", instruction), { purpose: "planning" }) : await this.builder.sendTurn(instruction, { purpose: "planning" });
527
+ const policy = nativePolicy(preflightOutput, { purpose: "planning", journalRunId: this.qaRunId });
528
+ const result = this.projectDir ? await sendGraphTurn(this.builder, `${instruction}\n\n${operationInstruction(preflightOutput)}`, graphContext(this.projectDir, this.builderWorktree ?? this.projectDir, "build-preflight", instruction), policy) : await this.builder.sendTurn(instruction, policy);
698
529
  this.assertNotInterrupted();
699
530
  await this.observeBuilderNative(this.builder);
700
531
  this.log.write("preflight", {
@@ -703,9 +534,9 @@ export class Foreman {
703
534
  isError: result.isError,
704
535
  });
705
536
  if (result.isError)
706
- throw new Error(result.text);
707
- this.preflightPlan = result.text;
708
- return result.text;
537
+ throw new Error(structuredTurnErrorMessage(result, "Build preflight"));
538
+ this.preflightPlan = operationPayload(result, preflightOutput).steps.map((step, index) => `${index + 1}. ${step.ticket ? step.ticket + ": " : ""}${step.work}`).join("\n");
539
+ return this.preflightPlan;
709
540
  }
710
541
  /** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
711
542
  async sendPreflightFeedback(feedback) {
@@ -713,21 +544,26 @@ export class Foreman {
713
544
  if (this.beforeBuilderTurn)
714
545
  this.builder = await this.beforeBuilderTurn(this.builder, feedback);
715
546
  this.assertNotInterrupted();
716
- const result = this.projectDir ? await sendGraphTurn(this.builder, feedback, graphContext(this.projectDir, this.builderWorktree ?? this.projectDir, "build-preflight", feedback), { purpose: "planning" }) : await this.builder.sendTurn(feedback, { purpose: "planning" });
547
+ const policy = nativePolicy(preflightOutput, { purpose: "planning", journalRunId: this.qaRunId });
548
+ const result = this.projectDir ? await sendGraphTurn(this.builder, feedback, graphContext(this.projectDir, this.builderWorktree ?? this.projectDir, "build-preflight", feedback), policy) : await this.builder.sendTurn(feedback, policy);
717
549
  this.assertNotInterrupted();
718
550
  await this.observeBuilderNative(this.builder);
719
551
  this.log.write("preflight", { feedback: true, costUsd: result.costUsd, isError: result.isError });
720
552
  if (result.isError)
721
- throw new Error(result.text);
722
- this.preflightPlan = result.text;
553
+ throw new Error(structuredTurnErrorMessage(result, "Build preflight"));
554
+ this.preflightPlan = operationPayload(result, preflightOutput).steps.map((step, index) => `${index + 1}. ${step.ticket ? step.ticket + ": " : ""}${step.work}`).join("\n");
723
555
  }
724
556
  /** Send one custom instruction through the same needs_input loop as a batch turn. */
725
- async runInstruction(instruction, options = {}) {
726
- return this.doTurnWith(this.builder, instruction, "builder", options.completion);
557
+ async runInstruction(instruction, options) {
558
+ return this.doTurnWith(this.builder, instruction, "builder", options.output, options.completion);
559
+ }
560
+ /** The sole convenience path that selects the Builder's work-report contract. */
561
+ async runBuilderWork(instruction) {
562
+ return this.doTurnWith(this.builder, instruction, "builder", workReportOutput);
727
563
  }
728
564
  /** Ask an already-active role session to propose choices for its reported blocker. */
729
- async resolveBlocker(adapter, reason, mode = "builder") {
730
- return this.doTurnWith(adapter, buildBlockerRecoveryInstruction(reason), mode);
565
+ async resolveBlocker(adapter, reason, mode, output) {
566
+ return this.doTurnWith(adapter, buildBlockerRecoveryInstruction(reason), mode, output);
731
567
  }
732
568
  async observeBuilderNative(adapter) {
733
569
  if (adapter === this.builder)
@@ -807,7 +643,7 @@ export class Foreman {
807
643
  },
808
644
  evidence: ({ cycle, outcome, detail, qaDiff }) => this.log.write("qa", { stepIndex, cycle, outcome, detail, qaDiff, disposable: true }),
809
645
  onNonconvergence: this.qaNonconvergence,
810
- resolveBlocked: (adapter, reason) => this.resolveBlocker(adapter, reason, "qa"),
646
+ resolveBlocked: (adapter, reason) => this.resolveBlocker(adapter, reason, "qa", qaVerdictOutput),
811
647
  });
812
648
  if (resumedRecovery) {
813
649
  const recoveryDb = new WorkflowDb(this.projectDir);
@@ -1116,9 +952,9 @@ export class Foreman {
1116
952
  // have no preflight still receive the primer for compatibility.
1117
953
  let instruction = i === 1 && !this.preflightPlan
1118
954
  ? buildPrimer(n, trackerPath, this.ticketsEnabled, pendingTicketId)
1119
- : buildNextStepInstruction(i, n);
955
+ : buildNextStepInstruction(i, n, this.ticketsEnabled);
1120
956
  if (pendingTicketId)
1121
- instruction += `\n\nAssigned ticket: ${pendingTicketId}. Implement exactly this ticket. Do not substitute another ticket. End with ticket="${pendingTicketId}" in the STEP_STATUS marker.`;
957
+ instruction += `\n\nAssigned ticket: ${pendingTicketId}. Implement exactly this ticket. Do not substitute another ticket. Return the native work report for this assigned ticket.`;
1122
958
  const continuations = answered.filter(decision => decision.interruptionId === `ticket:${pendingTicketId}`);
1123
959
  const continuationKeys = [];
1124
960
  if (continuations.length) {
@@ -1140,14 +976,6 @@ export class Foreman {
1140
976
  }
1141
977
  if (i > 1)
1142
978
  await this.prepareBuilderBoundary(instruction);
1143
- // Current-branch builds do not always populate builderWorktree. The
1144
- // project directory is still the authoritative source boundary, so a
1145
- // plan-only resumed turn must be compared there before QA can run.
1146
- const builderSourceRoot = this.builderWorktree ?? this.projectDir;
1147
- const hostOwnedSourcePaths = this.projectDir && this.ticketsEnabled
1148
- ? generatedTrackerDirtyPaths(loadTicketsConfig(this.projectDir).paths)
1149
- : [];
1150
- const sourceBeforeTurn = builderSourceRoot ? captureBuildSource(builderSourceRoot) : undefined;
1151
979
  this.builderWorkSessions += 1;
1152
980
  const turn = () => this.doTurn(instruction);
1153
981
  let response;
@@ -1190,41 +1018,6 @@ export class Foreman {
1190
1018
  detail = `builder turn errored: ${result.text.slice(0, 200)}`;
1191
1019
  break;
1192
1020
  }
1193
- // A resumed provider can answer the mandatory primer with a planning
1194
- // response while still emitting a done marker. Do not send unchanged
1195
- // source to QA or mark the ticket complete. Retry the bounded
1196
- // implementation once; if it still produces no work, stop as a real
1197
- // Builder blocker.
1198
- if (["done", "plan_complete"].includes(status.kind) && pendingTicketId && sourceBeforeTurn && builderSourceRoot) {
1199
- const sourceAfterTurn = captureBuildSource(builderSourceRoot);
1200
- const beforeProductDigest = builderProductDigest(sourceBeforeTurn, hostOwnedSourcePaths);
1201
- const afterProductDigest = builderProductDigest(sourceAfterTurn, hostOwnedSourcePaths);
1202
- // plan_complete is explicitly a planning response. Even if the
1203
- // provider touched bookkeeping while planning, never send that state
1204
- // to QA as if implementation were complete.
1205
- const responseIsPlanningOnly = /\bplan(?:ning)?\s+only\b|\bdo not implement(?: yet)?\b|\bawait(?:ing)? (?:approval|go-ahead)\b/i.test(result.text)
1206
- && !/\bimplemented\b|\badded\b|\bchanged\b/i.test(result.text);
1207
- if (status.kind === "plan_complete" || responseIsPlanningOnly || afterProductDigest === beforeProductDigest) {
1208
- this.log.write("step", { index: i, statusKind: "plan_only_without_source_change", ticket: pendingTicketId });
1209
- const retry = await this.doTurn(`The previous turn did not complete implementation. Implement the assigned ticket now: ${pendingTicketId}. Inspect the current files, make the required changes and tests, then report truthful evidence with STEP_STATUS: done | ticket="${pendingTicketId}" summary="...". Do not return another plan-only response.`);
1210
- result = retry.result;
1211
- status = retry.status;
1212
- if (result.isError) {
1213
- const recoverable = recoverableBuilderTurnError(result);
1214
- if (recoverable)
1215
- throw recoverable;
1216
- outcome = "blocked";
1217
- detail = `builder implementation retry errored: ${result.text.slice(0, 200)}`;
1218
- break;
1219
- }
1220
- const sourceAfterRetry = captureBuildSource(builderSourceRoot);
1221
- if (status.kind === "plan_complete" || (status.kind === "done" && builderProductDigest(sourceAfterRetry, hostOwnedSourcePaths) === beforeProductDigest)) {
1222
- outcome = "needs-human";
1223
- detail = `Builder returned planning-only responses without changing product source for ${pendingTicketId}`;
1224
- break;
1225
- }
1226
- }
1227
- }
1228
1021
  if (status.kind === "done" || status.kind === "plan_complete") {
1229
1022
  let qaSummary;
1230
1023
  let qaWaived = false;
@@ -1368,7 +1161,7 @@ export class Foreman {
1368
1161
  ? status.error
1369
1162
  : looksLikeQuestion(result.text)
1370
1163
  ? `builder ended with a question: ${lastLine(result.text)}`
1371
- : "builder did not emit a STEP_STATUS marker";
1164
+ : "builder did not submit a valid native operation result";
1372
1165
  break;
1373
1166
  }
1374
1167
  if (completed >= n && this.projectDir) {
@@ -1402,8 +1195,7 @@ function buildBlockerRecoveryInstruction(reason) {
1402
1195
  `You reported this blocker: ${reason}`,
1403
1196
  "Do not end the run. Analyze the durable state and propose two or three safe, materially different approaches the user can choose from.",
1404
1197
  "Put your recommended approach first and end its label with (Recommended). Include the important consequence or tradeoff in every choice label.",
1405
- "Do not perform more implementation or QA work until the user chooses. End exactly with:",
1406
- 'STEP_STATUS: needs_input | question="How should Rafi unblock this work?" choices="recommended approach and consequence (Recommended)|alternative and consequence|another alternative and consequence"',
1198
+ "Do not perform more implementation or QA work until the user chooses. Return a native needs-input result with the question and concrete choices.",
1407
1199
  "After the user answers, apply that guidance in this same session. If still blocked, report blocked again so Rafi can offer a new set of approaches.",
1408
1200
  ].join("\n\n");
1409
1201
  }
@@ -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
+ }
@@ -11,5 +11,5 @@ export interface GraphPendingTask {
11
11
  }
12
12
  /** A completed editing assignment plus host-observed source change is required.
13
13
  * Changed bytes alone are deliberately insufficient to establish this trigger. */
14
- export declare function queueGraphMaintenance(root: string, workspace: string, logicalTaskId: string, response: string, sourceChanged: boolean): void;
14
+ export declare function queueGraphMaintenance(root: string, workspace: string, logicalTaskId: string, response: string | boolean, sourceChanged: boolean): void;
15
15
  export declare function maintainGraphTask(root: string, workspace: string, logicalTaskId: string, host: BuilderAdapter): Promise<unknown>;
@@ -9,7 +9,7 @@ export const GRAPH_CHANGE_SIGNAL = "RAFI_GRAPH_MAINTENANCE: meaningful-indexed-c
9
9
  /** A completed editing assignment plus host-observed source change is required.
10
10
  * Changed bytes alone are deliberately insufficient to establish this trigger. */
11
11
  export function queueGraphMaintenance(root, workspace, logicalTaskId, response, sourceChanged) {
12
- if (!sourceChanged || !response.split(/\r?\n/).some(line => line.trim() === GRAPH_CHANGE_SIGNAL))
12
+ if (!sourceChanged || !(typeof response === "boolean" ? response : response.split(/\r?\n/).some(line => line.trim() === GRAPH_CHANGE_SIGNAL)))
13
13
  return;
14
14
  const effective = loadGraphConfig(root);
15
15
  if (!effective.enabled || effective.config?.maintenance !== "selective")