ai-foreman 1.7.28 → 2.0.0

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 (144) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude.d.ts +23 -12
  3. package/dist/adapters/claude.js +228 -24
  4. package/dist/adapters/codex.d.ts +17 -7
  5. package/dist/adapters/codex.js +195 -17
  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 +86 -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/planner.js +1 -4
  18. package/dist/branch/runner.d.ts +2 -1
  19. package/dist/branch/runner.js +10 -7
  20. package/dist/buildAdmission.js +4 -0
  21. package/dist/buildApproval.d.ts +30 -0
  22. package/dist/buildApproval.js +68 -0
  23. package/dist/buildAssignment.d.ts +1 -0
  24. package/dist/buildAssignment.js +82 -23
  25. package/dist/buildInterventions.d.ts +2 -2
  26. package/dist/buildInterventions.js +11 -4
  27. package/dist/buildLaunchGate.js +3 -2
  28. package/dist/builderGuidanceFollowup.js +17 -6
  29. package/dist/cli/manager.js +22 -20
  30. package/dist/cli/start.js +32 -24
  31. package/dist/cli/tickets.js +40 -24
  32. package/dist/continuity.d.ts +10 -14
  33. package/dist/continuity.js +198 -179
  34. package/dist/continuityOrigin.d.ts +13 -0
  35. package/dist/continuityOrigin.js +91 -0
  36. package/dist/foreman.d.ts +20 -5
  37. package/dist/foreman.js +57 -187
  38. package/dist/graph/boundary.d.ts +1 -1
  39. package/dist/graph/boundary.js +1 -1
  40. package/dist/graph/host.js +8 -19
  41. package/dist/graph/manager.d.ts +1 -1
  42. package/dist/graph/manager.js +1 -1
  43. package/dist/graph/preparation.d.ts +38 -0
  44. package/dist/graph/preparation.js +7 -6
  45. package/dist/graph/semantic.d.ts +1 -0
  46. package/dist/graph/semantic.js +4 -1
  47. package/dist/graph/session.js +7 -2
  48. package/dist/graph/turn.d.ts +2 -3
  49. package/dist/graph/turn.js +28 -40
  50. package/dist/handoffProtocol.js +2 -2
  51. package/dist/handoffs.d.ts +3 -1
  52. package/dist/handoffs.js +90 -59
  53. package/dist/hostGraphApplicability.d.ts +46 -0
  54. package/dist/hostGraphApplicability.js +97 -0
  55. package/dist/hostQaAugmentation.d.ts +18 -0
  56. package/dist/hostQaAugmentation.js +81 -0
  57. package/dist/hostQaComparison.d.ts +21 -2
  58. package/dist/hostQaComparison.js +43 -28
  59. package/dist/hostSourcePreservation.d.ts +38 -0
  60. package/dist/hostSourcePreservation.js +72 -0
  61. package/dist/interviews.d.ts +7 -0
  62. package/dist/interviews.js +18 -1
  63. package/dist/legacyCommunicationDisplay.d.ts +2 -0
  64. package/dist/legacyCommunicationDisplay.js +2 -0
  65. package/dist/managerPacket.js +3 -3
  66. package/dist/markers.d.ts +4 -3
  67. package/dist/markers.js +4 -20
  68. package/dist/probeIpc.d.ts +14 -0
  69. package/dist/probeIpc.js +29 -0
  70. package/dist/qaBuildGate.d.ts +21 -0
  71. package/dist/qaBuildGate.js +48 -12
  72. package/dist/qaBuilderCoverage.js +22 -13
  73. package/dist/qaBuilderEvidence.d.ts +16 -1
  74. package/dist/qaBuilderEvidence.js +48 -8
  75. package/dist/qaContractCoverage.d.ts +2 -2
  76. package/dist/qaContractCoverage.js +11 -7
  77. package/dist/qaContractDelivery.js +34 -20
  78. package/dist/qaCoverageAssembly.d.ts +22 -0
  79. package/dist/qaCoverageAssembly.js +16 -0
  80. package/dist/qaDeliveryJournal.d.ts +2 -0
  81. package/dist/qaDeliveryJournal.js +19 -0
  82. package/dist/qaEffectiveConfig.d.ts +1 -0
  83. package/dist/qaEffectiveConfig.js +6 -1
  84. package/dist/qaFailureDelivery.js +97 -85
  85. package/dist/qaHostTransformation.d.ts +26 -0
  86. package/dist/qaHostTransformation.js +100 -0
  87. package/dist/qaPreparation.d.ts +8 -20
  88. package/dist/qaPreparation.js +133 -229
  89. package/dist/qaPreparationInputs.js +19 -0
  90. package/dist/qaPreparationStore.js +34 -6
  91. package/dist/qaPrerequisites.d.ts +2 -1
  92. package/dist/qaPrerequisites.js +21 -1
  93. package/dist/qaProtocolV2.d.ts +15 -0
  94. package/dist/qaRecovery.js +21 -4
  95. package/dist/qaReview.js +232 -394
  96. package/dist/qaReviewEvidence.d.ts +7 -3
  97. package/dist/qaReviewEvidence.js +15 -4
  98. package/dist/qaRuntime.js +1 -1
  99. package/dist/qaSnapshot.js +71 -21
  100. package/dist/qaStructuredResponse.d.ts +10 -0
  101. package/dist/qaStructuredResponse.js +57 -0
  102. package/dist/qaTimeline.js +5 -3
  103. package/dist/qaToolRecovery.d.ts +12 -0
  104. package/dist/qaToolRecovery.js +44 -0
  105. package/dist/qaVerificationContract.d.ts +3 -2
  106. package/dist/qaVerificationContract.js +58 -34
  107. package/dist/readinessGate.js +5 -4
  108. package/dist/runtimeReadiness.js +23 -15
  109. package/dist/statusReporter.d.ts +2 -0
  110. package/dist/statusReporter.js +2 -0
  111. package/dist/structuredCutover.d.ts +31 -0
  112. package/dist/structuredCutover.js +510 -0
  113. package/dist/structuredCutoverFiles.d.ts +10 -0
  114. package/dist/structuredCutoverFiles.js +181 -0
  115. package/dist/structuredDispatchAuthority.d.ts +22 -0
  116. package/dist/structuredDispatchAuthority.js +110 -0
  117. package/dist/structuredDispatchRecord.d.ts +29 -0
  118. package/dist/structuredDispatchRecord.js +36 -0
  119. package/dist/structuredLiveBudget.d.ts +2 -0
  120. package/dist/structuredLiveBudget.js +23 -0
  121. package/dist/structuredOperation.d.ts +18 -0
  122. package/dist/structuredOperation.js +257 -0
  123. package/dist/supervisedStart.d.ts +1 -1
  124. package/dist/supervisedStart.js +78 -31
  125. package/dist/supervisor.d.ts +18 -2
  126. package/dist/supervisor.js +74 -17
  127. package/dist/ticketPlanning.d.ts +1 -373
  128. package/dist/ticketPlanning.js +2 -26
  129. package/dist/ticketPopulation.d.ts +0 -3
  130. package/dist/ticketPopulation.js +4 -21
  131. package/dist/ticketPopulationPageAuthority.d.ts +13 -0
  132. package/dist/ticketPopulationPageAuthority.js +30 -0
  133. package/dist/ticketPopulationPaging.d.ts +27 -0
  134. package/dist/ticketPopulationPaging.js +105 -0
  135. package/dist/tickets/renderMarkdown.d.ts +14 -0
  136. package/dist/tickets/renderMarkdown.js +39 -5
  137. package/dist/tickets/ticketLoader.js +10 -0
  138. package/dist/tickets/ticketSchema.d.ts +107 -0
  139. package/dist/tickets/ticketSchema.js +4 -1
  140. package/dist/workflowDb.d.ts +100 -2
  141. package/dist/workflowDb.js +781 -35
  142. package/dist/workflowReader.d.ts +1 -0
  143. package/dist/workflowReader.js +9 -0
  144. package/package.json +15 -7
@@ -7,5 +7,35 @@ export declare function approvedBuildScope(projectDir: string, ticketIds?: reado
7
7
  export declare function requiresBuildApproval(projectDir: string, ticketIds: readonly string[] | undefined, explicitYes: boolean, consequences?: Record<string, unknown>): boolean;
8
8
  /** Bind decisions to current files so an old answer cannot approve a changed scope. */
9
9
  export declare function buildScopeRevision(projectDir: string, consequences?: Record<string, unknown>): string;
10
+ export interface PlanApprovalBinding {
11
+ planDigest: string;
12
+ ticketSetDigest: string;
13
+ ticketDefinitionsDigest: string;
14
+ scopeRevision: string;
15
+ workflowConsequenceDigest: string;
16
+ }
17
+ export interface FrozenWorkApprovalBinding {
18
+ kind: "synthetic";
19
+ runId: string;
20
+ workIds: string[];
21
+ admissionDigest: string;
22
+ scopeRevision: string;
23
+ }
24
+ /** Unticketed recovery binds existing authorizations, never a fabricated plan. */
25
+ export declare function frozenWorkApprovalBinding(projectDir: string, runId: string, workIds: readonly string[]): FrozenWorkApprovalBinding;
26
+ export declare function validateFrozenWorkApprovalBinding(projectDir: string, runId: string, workIds: readonly string[], binding: FrozenWorkApprovalBinding): {
27
+ valid: true;
28
+ } | {
29
+ valid: false;
30
+ reason: string;
31
+ };
32
+ /** Compute the exact host-owned inputs covered by a recovery plan approval. */
33
+ export declare function planApprovalBinding(projectDir: string, ticketIds: readonly string[], consequences?: Record<string, unknown>): PlanApprovalBinding;
34
+ export declare function validatePlanApprovalBinding(projectDir: string, ticketIds: readonly string[], approval: PlanApprovalBinding, consequences?: Record<string, unknown>): {
35
+ valid: true;
36
+ } | {
37
+ valid: false;
38
+ reason: string;
39
+ };
10
40
  /** Reuse a decision only while its source and consequences remain unchanged. */
11
41
  export declare function createBuildApprovalGate(projectDir: string, ticketIds: readonly string[] | undefined, explicitYes: boolean, consequences: Record<string, unknown> | undefined, requestApproval: () => Promise<void>): () => Promise<void>;
@@ -125,6 +125,74 @@ export function buildScopeRevision(projectDir, consequences) {
125
125
  });
126
126
  return createHash("sha256").update(JSON.stringify([contents, consequences])).digest("hex");
127
127
  }
128
+ /** Unticketed recovery binds existing authorizations, never a fabricated plan. */
129
+ export function frozenWorkApprovalBinding(projectDir, runId, workIds) {
130
+ if (!workIds.length || new Set(workIds).size !== workIds.length)
131
+ throw new Error("Frozen work approval requires a unique nonempty work scope");
132
+ const db = new WorkflowDb(projectDir);
133
+ try {
134
+ const admissions = [...workIds].sort().map(id => db.assertAdmittedWork(runId, id));
135
+ if (admissions.some(admission => admission.kind !== "synthetic"))
136
+ throw new Error("Frozen work approval cannot replace ticket plan approval");
137
+ return { kind: "synthetic", runId, workIds: [...workIds].sort(), admissionDigest: createHash("sha256").update(canonical(admissions)).digest("hex"), scopeRevision: buildScopeRevision(projectDir) };
138
+ }
139
+ finally {
140
+ db.close();
141
+ }
142
+ }
143
+ export function validateFrozenWorkApprovalBinding(projectDir, runId, workIds, binding) {
144
+ try {
145
+ if (canonical(frozenWorkApprovalBinding(projectDir, runId, workIds)) !== canonical(binding))
146
+ return { valid: false, reason: "Frozen work authorization, scope or configuration changed" };
147
+ return { valid: true };
148
+ }
149
+ catch (error) {
150
+ return { valid: false, reason: String(error) };
151
+ }
152
+ }
153
+ /** Compute the exact host-owned inputs covered by a recovery plan approval. */
154
+ export function planApprovalBinding(projectDir, ticketIds, consequences) {
155
+ const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
156
+ const all = loadTickets(paths.tickets);
157
+ const selected = ticketIds.map(id => all.find(ticket => ticket.id === id));
158
+ if (selected.some(ticket => !ticket))
159
+ throw new Error("plan approval cannot bind a missing ticket definition");
160
+ const config = parse(readFileSync(join(projectDir, "rafi-config.yaml"), "utf8"));
161
+ const docsRoot = config.docs?.root ?? "docs";
162
+ const plan = JSON.parse(readFileSync(join(projectDir, docsRoot, "rafi-plan.json"), "utf8"));
163
+ const planDigest = createHash("sha256").update(canonical({ ...plan, content_digest: "" })).digest("hex");
164
+ const db = new WorkflowDb(projectDir);
165
+ try {
166
+ const approved = db.completedPlanApprovals().some(receipt => {
167
+ const decision = receipt.decisionReceipt;
168
+ return receipt.planId === plan.plan_id && receipt.revision === plan.revision
169
+ && receipt.digest === planDigest && decision?.planDigest === planDigest;
170
+ });
171
+ if (!approved)
172
+ throw new Error("current plan has no matching completed host approval");
173
+ }
174
+ finally {
175
+ db.close();
176
+ }
177
+ const ticketSetDigest = createHash("sha256").update(canonical([...ticketIds].sort())).digest("hex");
178
+ const ticketDefinitionsDigest = createHash("sha256").update(canonical(selected.map(ticket => material(ticket)).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))))).digest("hex");
179
+ const workflow = { build: config.tickets?.build ?? {}, consequences: consequences ?? {} };
180
+ const workflowConsequenceDigest = createHash("sha256").update(canonical(workflow)).digest("hex");
181
+ return { planDigest, ticketSetDigest, ticketDefinitionsDigest, scopeRevision: buildScopeRevision(projectDir, consequences), workflowConsequenceDigest };
182
+ }
183
+ export function validatePlanApprovalBinding(projectDir, ticketIds, approval, consequences) {
184
+ try {
185
+ const current = planApprovalBinding(projectDir, ticketIds, consequences);
186
+ for (const key of ["planDigest", "ticketSetDigest", "ticketDefinitionsDigest", "scopeRevision", "workflowConsequenceDigest"]) {
187
+ if (current[key] !== approval[key])
188
+ return { valid: false, reason: `${key} changed` };
189
+ }
190
+ return { valid: true };
191
+ }
192
+ catch (error) {
193
+ return { valid: false, reason: `approval inputs unavailable: ${String(error)}` };
194
+ }
195
+ }
128
196
  /** Reuse a decision only while its source and consequences remain unchanged. */
129
197
  export function createBuildApprovalGate(projectDir, ticketIds, explicitYes, consequences, requestApproval) {
130
198
  let approvedRevision;
@@ -17,6 +17,7 @@ export interface BuildAssignmentCapture {
17
17
  type BuildSourceState = Pick<FrozenQaSourceState, "digest" | "head" | "contentDigest" | "capturedAt" | "pathInventory" | "stagedDiff" | "unstagedDiff" | "untracked"> & {
18
18
  captureMode: "git" | "filesystem";
19
19
  omissions?: string[];
20
+ fileByteLengths?: Record<string, number>;
20
21
  };
21
22
  export declare class BuildAssignmentRejected extends Error {
22
23
  }
@@ -1,3 +1,5 @@
1
+ import { operationPayload, operationResponse, operationStatus } from "./structuredOperation.js";
2
+ import { workReportOutput } from "rafi-spec";
1
3
  import { assertBuilderContractCoverage } from "./qaBuilderCoverage.js";
2
4
  import { resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
3
5
  import { contractDigest } from "./qaVerificationContract.js";
@@ -118,7 +120,7 @@ export function prepareInterruptedBuilderSourceContinuation(projectDir, runId) {
118
120
  db.updateOperation(dispatch.idempotencyKey, "failed", { result: { ...receipt, priorResult: dispatch.result }, error: "Interrupted provider outcome remains unknown; verified stopped local execution is replaced by source inspection and fresh QA, without replay" });
119
121
  db.updateOperation(assignment.idempotencyKey, "failed", { result: { ...receipt, priorResult: assignment.result }, error: "Interrupted assignment preserved for source-bound continuation; completion is not established" });
120
122
  const event = db.appendContinuityEvent({ runId, role: "host", kind: "interrupted_source_reconciled", payload: { ...receipt, dispatchIds: dispatches.map(op => op.idempotencyKey), assignmentId: assignment.idempotencyKey }, authoritativeStateRevision: db.continuityHead(runId, "run")?.authoritativeStateRevision ?? 0 });
121
- db.publishContinuityCheckpoint({ runId, role: "builder", state: "current", authoritativeStateRevision: event.authoritativeStateRevision, delta: {
123
+ db.publishContinuityCheckpoint({ runId, role: "builder", state: "current", hostEventKind: "source-reconciliation", inputCheckpointDigests: [], authoritativeStateRevision: event.authoritativeStateRevision, delta: {
122
124
  version: 1, decisions: [`Continue already-approved ticket ${intent.ticketId} in the SAME build from preserved source; this host reconciliation is not a successful provider turn.`],
123
125
  constraints: ["Never replay the interrupted provider instruction or assume its side effects did not happen. Inspect current files first, implement only missing approved work, and require independent QA. Preserve all completed tickets and accepted QA.", "The predecessor session's compaction history remains unknown; use a validated fresh handoff before any new implementation."],
124
126
  discoveries: [], completedActions: [], evidence: [`Host-observed source digest ${source.digest}; interrupted assignment ${assignment.idempotencyKey}; reconciliation ${reconciliationId}`],
@@ -384,7 +386,23 @@ export function beginBuildAssignment(projectDir, runId, ticketId, worktree, inst
384
386
  }
385
387
  }
386
388
  export function finishBuildAssignment(projectDir, assignment, result, status) {
387
- let rejection = result.failure?.dispatchState === "not-sent" ? undefined : validateBuildAssignment(assignment.ticketId, status, result.text);
389
+ let rejection;
390
+ if (result.failure?.dispatchState !== "not-sent") {
391
+ try {
392
+ const response = operationResponse(result, workReportOutput);
393
+ if (!["completed", "blocked", "needs-input"].includes(response.result.kind))
394
+ throw new Error("Unconsumed intermediate assignment result");
395
+ if (result.structured?.state !== "candidate" || result.structured.dispatchId !== result.turnId)
396
+ throw new Error("Assignment receipt dispatch mismatch");
397
+ const nativeStatus = operationStatus(result, workReportOutput, assignment.ticketId);
398
+ if (nativeStatus.kind !== status.kind || status.ticket && status.ticket !== assignment.ticketId)
399
+ throw new Error("Assignment status disagrees with the native result");
400
+ status = nativeStatus;
401
+ }
402
+ catch (error) {
403
+ rejection = `Builder assignment has no valid native result: ${error instanceof Error ? error.message : String(error)}`;
404
+ }
405
+ }
388
406
  // Source capture can fail after an external turn. Keep the response even then.
389
407
  let after;
390
408
  let sourceError;
@@ -408,12 +426,13 @@ export function finishBuildAssignment(projectDir, assignment, result, status) {
408
426
  }
409
427
  }
410
428
  const responseDigest = db.putEvidence("qa", Buffer.from(result.rawResponse ?? result.text));
429
+ const structuredResponseDigest = result.structured ? db.putEvidence("qa", Buffer.from(JSON.stringify(result.structured))) : undefined;
411
430
  const cleanedResponseDigest = db.putEvidence("qa", Buffer.from(result.cleanedResponse ?? result.text));
412
431
  const providerInstructionDigest = result.providerInstruction ? db.putEvidence("qa", Buffer.from(result.providerInstruction)) : undefined;
413
- db.updateOperation(assignment.operationId, result.failure?.dispatchState === "unknown" || result.isError && !result.failure?.dispatchState && status.kind === "unknown" ? "uncertain" : "confirmed", { result: { turnId: result.turnId, responseDigest, cleanedResponseDigest, providerInstructionDigest, providerMetadata: result.providerMetadata, dispatchState: result.failure?.dispatchState, status, rejection, sourceError, after: after ? retainSource(db, after) : undefined } });
432
+ db.updateOperation(assignment.operationId, result.failure?.dispatchState === "unknown" || result.isError && !result.failure?.dispatchState && status.kind === "unknown" ? "uncertain" : "confirmed", { result: { turnId: result.turnId, responseDigest, structuredResponseDigest, cleanedResponseDigest, providerInstructionDigest, providerMetadata: result.providerMetadata, dispatchState: result.failure?.dispatchState, status, rejection, sourceError, after: after ? retainSource(db, after) : undefined } });
414
433
  const admission = db.assertAdmittedWork(assignment.runId, assignment.ticketId);
415
434
  db.qaPreparationStore().metric(assignment.runId, assignment.ticketId, admission.requirementsDigest, `metric:builder-result:${assignment.operationId}`, "phase-observation", { phase: "builder", durationMs: Math.max(0, Date.now() - Date.parse(assignment.before.capturedAt)), costUsd: result.costAuthoritative ? result.costUsd : null, inputTokens: result.inputTokens ?? null, outputTokens: result.outputTokens ?? null });
416
- db.finishGuidance(assignment.guidanceIds, "builder", { submitted: result.failure?.dispatchState === "not-sent" ? false : result.turnId ? true : undefined, receipt: result.turnId ? { turnId: result.turnId, responseDigest, providerInstructionDigest, postSourceDigest: after?.digest } : undefined, applied: !rejection && status.kind === "done" && assignment.guidanceIds.every(id => result.text.includes(id)) });
435
+ db.finishGuidance(assignment.guidanceIds, "builder", { submitted: result.failure?.dispatchState === "not-sent" ? false : result.turnId ? true : undefined, receipt: result.turnId ? { turnId: result.turnId, responseDigest, providerInstructionDigest, postSourceDigest: after?.digest } : undefined, applied: !rejection && status.kind === "done" });
417
436
  if (rejection || sourceError) {
418
437
  const run = db.getRun(assignment.runId);
419
438
  db.transition(assignment.runId, { checkpoint: "build-assignment-rejected", state: { ...run.state, assignmentConflict: { operationId: assignment.operationId, ticketId: assignment.ticketId, worktree: assignment.worktree, responseDigest, rejection, sourceError } }, event: "build_assignment_rejected", payload: { operationId: assignment.operationId, ticketId: assignment.ticketId, returnedTicketId: status.ticket, responseDigest, rejection, sourceError } });
@@ -425,7 +444,7 @@ export function finishBuildAssignment(projectDir, assignment, result, status) {
425
444
  }
426
445
  if (!rejection && !sourceError && !result.isError && status.kind === "done" && after) {
427
446
  try {
428
- queueGraphMaintenance(projectDir, assignment.worktree, assignment.operationId, result.text, after.contentDigest !== assignment.before.contentDigest);
447
+ queueGraphMaintenance(projectDir, assignment.worktree, assignment.operationId, operationPayload(result, workReportOutput).graphMaintenance === "meaningful-indexed-change", after.contentDigest !== assignment.before.contentDigest);
429
448
  }
430
449
  catch (error) {
431
450
  console.error(`Graph maintenance deferred: ${String(error)}`);
@@ -436,7 +455,7 @@ export function finishBuildAssignment(projectDir, assignment, result, status) {
436
455
  function retainSource(db, source) {
437
456
  const staged = retainDiff(db, source.stagedDiff);
438
457
  const unstaged = retainDiff(db, source.unstagedDiff);
439
- return { digest: source.digest, head: source.head, captureMode: source.captureMode, omissions: source.omissions, contentDigest: source.contentDigest, capturedAt: source.capturedAt, pathInventory: source.pathInventory,
458
+ return { digest: source.digest, head: source.head, captureMode: source.captureMode, omissions: source.omissions, contentDigest: source.contentDigest, capturedAt: source.capturedAt, pathInventory: source.pathInventory, fileByteLengths: source.fileByteLengths,
440
459
  stagedDiffDigest: staged.digest, stagedDiffChunks: staged.chunks, stagedDiffBytes: source.stagedDiff.length,
441
460
  unstagedDiffDigest: unstaged.digest, unstagedDiffChunks: unstaged.chunks, unstagedDiffBytes: source.unstagedDiff.length,
442
461
  untracked: source.untracked.map(file => { const evidence = retainDiff(db, file.bytes); return { path: file.path, mode: file.mode, kind: file.kind, digest: file.digest, evidenceDigest: evidence.digest, evidenceChunks: evidence.chunks, byteLength: file.bytes.length }; }) };
@@ -452,31 +471,71 @@ function retainDiff(db, bytes) {
452
471
  }
453
472
  export function captureBuildSource(worktree) {
454
473
  const head = spawnSync("git", ["-C", worktree, "rev-parse", "--verify", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
455
- if (head.status === 0)
456
- return { ...captureFrozenQaSource(worktree), captureMode: "git" };
474
+ if (head.status === 0) {
475
+ const source = captureFrozenQaSource(worktree);
476
+ // Length is only a hint for comparing a final prefix with the already
477
+ // digest-bound baseline hash. A stale length can never manufacture a match.
478
+ const fileByteLengths = {};
479
+ for (const item of source.pathInventory) {
480
+ if (!item.worktreeObject?.startsWith("file:"))
481
+ continue;
482
+ try {
483
+ const stat = lstatSync(join(worktree, item.path));
484
+ if (stat.isFile())
485
+ fileByteLengths[item.path] = stat.size;
486
+ }
487
+ catch { /* unavailable comparison stays explicit */ }
488
+ }
489
+ return { ...source, captureMode: "git", fileByteLengths };
490
+ }
457
491
  if (head.error?.code !== "ENOENT" && !/not a git repository|Needed a single revision|unknown revision/.test(head.stderr ?? ""))
458
492
  throw head.error ?? new Error(`Git source identity is unavailable: ${head.stderr}`);
459
493
  const capture = () => {
460
494
  const root = resolve(worktree);
461
495
  const untracked = [];
462
- const ignored = new Set([".git", ".rafi", ".foreman", "node_modules"]);
463
- const visit = (directory) => {
464
- for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
465
- if (ignored.has(entry.name))
496
+ const gitPaths = spawnSync("git", ["-C", root, "ls-files", "--others", "--exclude-standard", "-z"], { encoding: "buffer", stdio: ["ignore", "pipe", "pipe"] });
497
+ if (gitPaths.status === 0) {
498
+ for (const path of gitPaths.stdout.toString().split("\0").filter(Boolean).sort()) {
499
+ if (path === ".foreman" || path.startsWith(".foreman/") || path === ".rafi" || path.startsWith(".rafi/"))
466
500
  continue;
467
- const path = join(directory, entry.name);
468
- if (entry.isDirectory())
469
- visit(path);
470
- else if (entry.isFile() || entry.isSymbolicLink()) {
471
- const kind = entry.isSymbolicLink() ? "symlink" : "file";
472
- const bytes = kind === "symlink" ? Buffer.from(readlinkSync(path)) : readFileSync(path);
473
- untracked.push({ path: relative(root, path).replace(/\\/g, "/"), kind, bytes, mode: lstatSync(path).mode, digest: createHash("sha256").update(bytes).digest("hex") });
501
+ const absolute = join(root, path);
502
+ let stat;
503
+ try {
504
+ stat = lstatSync(absolute);
474
505
  }
475
- else
476
- throw new Error(`Unsupported source path type: ${path}`);
506
+ catch {
507
+ continue;
508
+ }
509
+ // Git reports an untracked nested repository as its directory. It is
510
+ // a separate source boundary, not a reason to recursively hash its
511
+ // entire checkout (MoneyTree's vendor/reference trees can be GBs).
512
+ if (!stat.isFile() && !stat.isSymbolicLink())
513
+ continue;
514
+ const kind = stat.isSymbolicLink() ? "symlink" : "file";
515
+ const bytes = kind === "symlink" ? Buffer.from(readlinkSync(absolute)) : readFileSync(absolute);
516
+ untracked.push({ path, kind, bytes, mode: stat.mode, digest: createHash("sha256").update(bytes).digest("hex") });
477
517
  }
478
- };
479
- visit(root);
518
+ }
519
+ else {
520
+ const ignored = new Set([".git", ".rafi", ".foreman", "node_modules"]);
521
+ const visit = (directory) => {
522
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
523
+ if (ignored.has(entry.name))
524
+ continue;
525
+ const path = join(directory, entry.name);
526
+ if (entry.isDirectory())
527
+ visit(path);
528
+ else if (entry.isFile() || entry.isSymbolicLink()) {
529
+ const kind = entry.isSymbolicLink() ? "symlink" : "file";
530
+ const bytes = kind === "symlink" ? Buffer.from(readlinkSync(path)) : readFileSync(path);
531
+ untracked.push({ path: relative(root, path).replace(/\\/g, "/"), kind, bytes, mode: lstatSync(path).mode, digest: createHash("sha256").update(bytes).digest("hex") });
532
+ }
533
+ else
534
+ throw new Error(`Unsupported source path type: ${path}`);
535
+ }
536
+ };
537
+ visit(root);
538
+ }
480
539
  const contentDigest = createHash("sha256").update(JSON.stringify(untracked.map(({ bytes: _, ...identity }) => identity))).digest("hex");
481
540
  return { digest: contentDigest, head: "unavailable", captureMode: "filesystem", omissions: ["Git baseline/diff unavailable: project is not a repository or has no first commit"], contentDigest, capturedAt: new Date().toISOString(),
482
541
  pathInventory: untracked.map(file => ({ path: file.path, staged: [], unstaged: [], untracked: { kind: file.kind, mode: file.mode, digest: file.digest } })), untracked, stagedDiff: Buffer.alloc(0), unstagedDiff: Buffer.alloc(0) };
@@ -72,7 +72,7 @@ export declare function verifyBuilderGuidance(db: Database.Database, ids: string
72
72
  certificateId: string;
73
73
  sourceStateDigest: string;
74
74
  reviewBasisDigest: string;
75
- }, response: string, turnReceiptDigest: string): void;
75
+ }, response: string | readonly string[], turnReceiptDigest: string): void;
76
76
  /** Called only inside the owning process's existing dispatch transaction. */
77
77
  export declare function reserveGuidance(db: Database.Database, runId: string, workId: string, recipient: InstructionRecipient, operationId: string, sourceDigest: string, instructionText: string, reserve?: boolean): {
78
78
  text: string;
@@ -87,4 +87,4 @@ export declare function verifyGuidance(db: Database.Database, ids: string[], cer
87
87
  certificateId: string;
88
88
  sourceStateDigest: string;
89
89
  reviewBasisDigest: string;
90
- }, response: string): void;
90
+ }, response: string | readonly string[]): void;
@@ -4,6 +4,8 @@ import { join } from "node:path";
4
4
  import { realpathSync as realpath } from "node:fs";
5
5
  import { validateManagerActionRequestV1 } from "rafi-spec";
6
6
  import { registerHandbackWriter } from "./qaHandbackMigration.js";
7
+ import { registerQaGraphJournalWriter } from "./qaDeliveryJournal.js";
8
+ import { assertStructuredCutoverComplete, STRUCTURED_CUTOVER_REASON } from "./structuredCutover.js";
7
9
  import { assertAdmittedWork } from "./buildWorkAdmission.js";
8
10
  export class BuildControlBoundary extends Error {
9
11
  }
@@ -81,6 +83,11 @@ export class BuildInterventionControl {
81
83
  this.db = new Database(join(projectDir, ".rafi/recovery.sqlite3"), { fileMustExist: true });
82
84
  try {
83
85
  registerHandbackWriter(this.db);
86
+ registerQaGraphJournalWriter(this.db);
87
+ assertStructuredCutoverComplete(this.db);
88
+ const run = this.db.prepare("SELECT legacy,checkpoint,state_json FROM workflow_runs WHERE run_id=?").get(runId);
89
+ if (!run || run.legacy || run.checkpoint === STRUCTURED_CUTOVER_REASON || JSON.parse(run.state_json).structuredCommunicationCutover)
90
+ throw new Error("Cutover workflow cannot receive Manager controls");
84
91
  if (this.db.pragma("user_version", { simple: true }) !== 4)
85
92
  throw new Error("Manager controls require the work-admission upgrade");
86
93
  const project = this.db.prepare("SELECT canonical_root FROM build_project_identity").get();
@@ -259,10 +266,10 @@ export function assertFinalizationControls(db, runId, workId) {
259
266
  export function builderVerificationContext(db, runId, workId, sourceDigest) {
260
267
  const records = db.prepare("SELECT instruction_id FROM build_instructions WHERE run_id=? AND work_id=? ORDER BY sequence").all(runId, workId).map(row => instruction(db, row.instruction_id));
261
268
  const eligible = records.filter(record => (record.request.action === "guide_builder" || record.request.action === "supersede" && !record.deliveries.some(delivery => delivery.recipient === "qa")) && record.deliveries.some(delivery => delivery.recipient === "builder" && ["acknowledged", "applied"].includes(delivery.state) && delivery.receipt?.postSourceDigest === sourceDigest));
262
- return { ids: eligible.map(record => record.instructionId), text: eligible.length ? "\n\nBuilder guidance awaiting independent verification on this exact source:\n" + eligible.map(record => `Instruction ${record.instructionId}: ${record.request.text}`).join("\n") + "\nOnly for a relevant result independently established with supporting evidence, emit MANAGER_INSTRUCTION_VERIFIED: <instruction ID>. A general pass alone is insufficient." : "" };
269
+ return { ids: eligible.map(record => record.instructionId), text: eligible.length ? "\n\nBuilder guidance awaiting independent verification on this exact source:\n" + eligible.map(record => `Instruction ${record.instructionId}: ${record.request.text}`).join("\n") + "\nOnly for a relevant result independently established with supporting evidence, include the exact instruction ID in the native qa-verdict payload.verifiedInstructionIds array. A general pass alone is insufficient." : "" };
263
270
  }
264
271
  export function verifyBuilderGuidance(db, ids, certificate, response, turnReceiptDigest) {
265
- const verified = new Set([...response.matchAll(/^MANAGER_INSTRUCTION_VERIFIED:\s*([A-Za-z0-9_.:-]+)\s*$/gm)].map(match => match[1]));
272
+ const verified = new Set(typeof response === "string" ? [...response.matchAll(/^MANAGER_INSTRUCTION_VERIFIED:\s*([A-Za-z0-9_.:-]+)\s*$/gm)].map(match => match[1]) : response);
266
273
  for (const id of ids) {
267
274
  if (!verified.has(id))
268
275
  continue;
@@ -298,7 +305,7 @@ export function reserveGuidance(db, runId, workId, recipient, operationId, sourc
298
305
  throw new BuildControlBoundary("Both-agent guidance is waiting for Builder's scoped response before a fresh QA review");
299
306
  selected.push(record);
300
307
  }
301
- const text = instructionText + (selected.length ? "\n\nAuthorized one-use Manager guidance (mandatory requirements and findings still apply):\n" + selected.map(record => `Instruction ${record.instructionId} (${record.textDigest}):\n${record.request.text}`).join("\n\n") + (recipient === "qa" ? "\nFor each instruction whose relevant result you independently established on this source, include MANAGER_INSTRUCTION_VERIFIED: <instruction ID> with the supporting verification evidence. A general passing review alone does not verify an unrelated instruction." : "\nReport each instruction ID whose requested action you applied, with evidence; delivery alone is not independent verification.") : "");
308
+ const text = instructionText + (selected.length ? "\n\nAuthorized one-use Manager guidance (mandatory requirements and findings still apply):\n" + selected.map(record => `Instruction ${record.instructionId} (${record.textDigest}):\n${record.request.text}`).join("\n\n") + (recipient === "qa" ? "\nFor each instruction whose relevant result you independently established on this source, include the exact instruction ID in payload.verifiedInstructionIds with the supporting verification evidence. A general passing review alone does not verify an unrelated instruction." : "\nReport each instruction ID whose requested action you applied, with evidence; delivery alone is not independent verification.") : "");
302
309
  if (reserve)
303
310
  for (const record of selected)
304
311
  setDelivery(db, record.instructionId, { recipient, state: "reserved", operationId: `${operationId}:${record.instructionId}:${recipient}`, sourceDigest, instructionDigest: hash(text) });
@@ -315,7 +322,7 @@ export function finishGuidance(db, ids, recipient, result) {
315
322
  } }).immediate();
316
323
  }
317
324
  export function verifyGuidance(db, ids, certificate, response) {
318
- const verified = new Set([...response.matchAll(/^MANAGER_INSTRUCTION_VERIFIED:\s*([A-Za-z0-9_.:-]+)\s*$/gm)].map(match => match[1]));
325
+ const verified = new Set(typeof response === "string" ? [...response.matchAll(/^MANAGER_INSTRUCTION_VERIFIED:\s*([A-Za-z0-9_.:-]+)\s*$/gm)].map(match => match[1]) : response);
319
326
  for (const id of ids) {
320
327
  if (!verified.has(id))
321
328
  continue;
@@ -1,3 +1,4 @@
1
+ import { parseHostIpc } from "rafi-spec";
1
2
  /** Minimal pre-CLI child. No providers or mutable workflow code before acknowledgement. */
2
3
  import { WorkflowDb } from "./workflowDb.js";
3
4
  import { pathToFileURL } from "node:url";
@@ -14,7 +15,7 @@ finally {
14
15
  await new Promise((resolve, reject) => {
15
16
  const timeout = setTimeout(() => finish(new Error("Resume startup acknowledgement timed out")), 10_000);
16
17
  const disconnected = () => finish(new Error("Resume launcher exited before acknowledgement"));
17
- const message = (value) => { if (value === "rafi-launch-ack")
18
+ const message = (value) => { const ack = parseHostIpc(value); if (ack?.kind === "rafi-launch-ack" && ack.token === token && ack.pid === process.pid)
18
19
  finish(); };
19
20
  function finish(error) {
20
21
  clearTimeout(timeout);
@@ -27,7 +28,7 @@ await new Promise((resolve, reject) => {
27
28
  }
28
29
  process.once("disconnect", disconnected);
29
30
  process.on("message", message);
30
- process.send({ kind: "rafi-launch-registered", token });
31
+ process.send({ version: 1, kind: "rafi-launch-registered", token, pid: process.pid });
31
32
  });
32
33
  // The durable claim still validates the token atomically after acknowledgement.
33
34
  if (!process.env.RAFI_BUILD_WORKER_RUN)
@@ -1,9 +1,11 @@
1
1
  import { collectBuilderContractCoverage } from "./qaBuilderCoverage.js";
2
+ import { observeHostBuilderTests } from "./qaBuilderEvidence.js";
2
3
  import { maintainGraphTask } from "./graph/boundary.js";
3
4
  import { ensureBuilderContract } from "./qaBuildGate.js";
4
5
  import { graphContext, sendGraphTurn } from "./graph/turn.js";
5
- import { beginBuildAssignment, finishBuildAssignment } from "./buildAssignment.js";
6
- import { parseStepStatus } from "./foreman.js";
6
+ import { beginBuildAssignment, captureBuildSource, finishBuildAssignment } from "./buildAssignment.js";
7
+ import { workReportOutput } from "rafi-spec";
8
+ import { nativePolicy, operationInstruction, operationStatus } from "./structuredOperation.js";
7
9
  /** One journaled mutation shared by current-branch and isolated-worktree resume. */
8
10
  export async function deliverBuilderGuidanceFollowup(scope, instruction, followup, host) {
9
11
  try {
@@ -14,17 +16,26 @@ export async function deliverBuilderGuidanceFollowup(scope, instruction, followu
14
16
  instruction += "\n" + await ensureBuilderContract(scope.projectDir, scope.runId, scope.ticketId, scope.worktree, adapter);
15
17
  const assignment = beginBuildAssignment(scope.projectDir, scope.runId, scope.ticketId, scope.worktree, instruction, followup, adapter);
16
18
  // The handback policy prevents provider wrappers from retrying this mutation.
17
- const result = await sendGraphTurn(adapter, assignment.instruction, graphContext(scope.projectDir, scope.worktree, "manager-guidance", assignment.instruction, { logicalTaskId: assignment.operationId, operationId: assignment.operationId, runId: scope.runId, workId: scope.ticketId }), { handback: true, logicalActionId: assignment.operationId });
18
- const status = parseStepStatus(result.text);
19
+ const hostToolObservations = [];
20
+ const initialSourceDigest = captureBuildSource(scope.worktree).digest;
21
+ const stopHostObservation = observeHostBuilderTests(adapter, scope.worktree, hostToolObservations);
22
+ let result;
23
+ try {
24
+ result = await sendGraphTurn(adapter, `${assignment.instruction}\n${operationInstruction(workReportOutput)}`, graphContext(scope.projectDir, scope.worktree, "manager-guidance", assignment.instruction, { logicalTaskId: assignment.operationId, operationId: assignment.operationId, runId: scope.runId, workId: scope.ticketId }), nativePolicy(workReportOutput, { handback: true, logicalActionId: assignment.operationId, journalRunId: scope.runId }));
25
+ }
26
+ finally {
27
+ stopHostObservation();
28
+ }
29
+ const status = operationStatus(result, workReportOutput, scope.ticketId);
19
30
  if (!result.isError && !result.failure && status.kind === "done")
20
- await collectBuilderContractCoverage(scope.projectDir, scope.runId, scope.ticketId, scope.worktree, adapter, assignment.operationId, assignment.instruction, result);
31
+ await collectBuilderContractCoverage(scope.projectDir, scope.runId, scope.ticketId, scope.worktree, adapter, assignment.operationId, assignment.instruction, result, hostToolObservations, initialSourceDigest);
21
32
  const rejection = finishBuildAssignment(scope.projectDir, assignment, result, status);
22
33
  if (!rejection && !result.isError && status.kind === "done")
23
34
  await maintainGraphTask(scope.projectDir, scope.worktree, assignment.operationId, adapter).catch(() => undefined);
24
35
  await host.completed?.(assignment.operationId, adapter);
25
36
  if (rejection || result.isError || result.failure || status.kind !== "done" || !result.turnId)
26
37
  return { ok: false, detail: rejection ?? result.failure?.diagnostics ?? `Builder follow-up stopped with ${status.kind}; reconcile its result before continuing`, response: result.text };
27
- return { ok: true, response: result.text, summary: status.summary ?? "Builder applied waiting guidance", providerTurnId: result.turnId };
38
+ return { ok: true, outcome: "remediation-reported", response: result.text, summary: status.summary ?? "Builder applied waiting guidance", providerTurnId: result.turnId };
28
39
  }
29
40
  catch (error) {
30
41
  // A thrown submission leaves the assignment in progress. The existing
@@ -1,5 +1,7 @@
1
+ import { managerOutput } from "rafi-spec";
2
+ import { nativePolicy, operationInstruction, operationResponse } from "../structuredOperation.js";
1
3
  import { dispatchWithGraphAccess } from "../graph/session.js";
2
- import { createManagerGraphEvidence, parseManagerGraphRequest } from "../graph/manager.js";
4
+ import { createManagerGraphEvidence } from "../graph/manager.js";
3
5
  import { prepareOwnershipRepair, repairOwnership } from "../buildOwnershipRepair.js";
4
6
  import { BuildInterventionControl, parseManagerAction } from "../buildInterventions.js";
5
7
  import { randomUUID } from "node:crypto";
@@ -9,14 +11,14 @@ import { resolve } from "node:path";
9
11
  import { join } from "node:path";
10
12
  import { mkdtempSync, writeFileSync } from "node:fs";
11
13
  import { tmpdir } from "node:os";
12
- import { ManagerEvidenceService, parseManagerEvidenceRequestV2 } from "../managerEvidence.js";
14
+ import { ManagerEvidenceService } from "../managerEvidence.js";
13
15
  import { buildManagerEvidencePacketV2 } from "../managerPacket.js";
14
16
  import { Command } from "commander";
15
17
  import { createRoleBuilder, readOnlyPermissionConfig } from "../agentRun.js";
16
18
  import { Log } from "../log.js";
17
19
  import { buildManagerEvidencePacket, buildManagerProjectPacket } from "../managerPacket.js";
18
20
  import { ManagerSessionRecorder } from "../observability.js";
19
- import { collectManagerProjectDiagnostics, executeManagerEvidenceRequest, MANAGER_LOOKUP_MAX_ROUNDS, parseManagerEvidenceRequest, resolveManagerQuestionRuns } from "../projectDiagnostics.js";
21
+ import { collectManagerProjectDiagnostics, executeManagerEvidenceRequest, MANAGER_LOOKUP_MAX_ROUNDS, resolveManagerQuestionRuns } from "../projectDiagnostics.js";
20
22
  import { accent, sanitizeTerminalText } from "../terminalStyle.js";
21
23
  export function buildManagerCommand(options = {}) {
22
24
  return new Command("manager")
@@ -87,11 +89,16 @@ export async function runManager(projectDir, options) {
87
89
  const packet = buildManagerProjectPacket(report, question, packetState, referencedRunIds);
88
90
  packetState = packet.state;
89
91
  const graphEvidence = createManagerGraphEvidence(projectDir, currentFocusRunId);
90
- const send = (text) => dispatchWithGraphAccess(projectDir, activeRole.builder, text, undefined, (prompt, policy) => activeRole.builder.sendTurn(prompt, policy), graphEvidence.access());
92
+ const send = async (text) => {
93
+ const turn = await dispatchWithGraphAccess(projectDir, activeRole.builder, `${text}\n${operationInstruction(managerOutput)}`, nativePolicy(managerOutput, { responseOnly: true }), (prompt, policy) => activeRole.builder.sendTurn(prompt, policy), graphEvidence.access());
94
+ if (turn.isError || turn.failure)
95
+ throw new Error("Manager operation failed; no answer accepted");
96
+ return operationResponse(turn, managerOutput).result;
97
+ };
91
98
  let result = await send(`${packet.prompt}\n\n${graphEvidence.guidance}`);
92
99
  let questionLookupRounds = 0;
93
100
  while (questionLookupRounds < MANAGER_LOOKUP_MAX_ROUNDS) {
94
- const graphRequest = parseManagerGraphRequest(result.text);
101
+ const graphRequest = (result.kind === "evidence-request" && result.evidenceType === "graph" ? result.request : undefined);
95
102
  if (graphRequest) {
96
103
  questionLookupRounds++;
97
104
  lookupRounds++;
@@ -100,7 +107,7 @@ export async function runManager(projectDir, options) {
100
107
  result = await send(`Untrusted host-scoped graph evidence; verify claims against source. Remaining lookup rounds: ${MANAGER_LOOKUP_MAX_ROUNDS - questionLookupRounds}. Answer the original question: ${question}\n${graphPacket}`);
101
108
  continue;
102
109
  }
103
- const evidenceRequest = parseManagerEvidenceRequestV2(result.text);
110
+ const evidenceRequest = (result.kind === "evidence-request" && result.evidenceType === "qa" ? result.request : undefined);
104
111
  if (evidenceRequest) {
105
112
  questionLookupRounds++;
106
113
  lookupRounds++;
@@ -120,15 +127,9 @@ export async function runManager(projectDir, options) {
120
127
  result = await send(buildManagerEvidencePacketV2(response, question));
121
128
  continue;
122
129
  }
123
- const request = parseManagerEvidenceRequest(result.text);
124
- if (!request) {
125
- if (!looksLikeEvidenceRequest(result.text))
126
- break;
127
- questionLookupRounds += 1;
128
- lookupRounds += 1;
129
- result = await send(buildManagerEvidencePacket({ version: 1, requestId: "invalid-request", results: [{ kind: "list_runs", status: "invalid", limitation: "The evidence envelope was invalid. Use only the documented fixed fields and read-only operations, then answer with any remaining limitation disclosed." }], lookupRound: questionLookupRounds, remainingRounds: MANAGER_LOOKUP_MAX_ROUNDS - questionLookupRounds, digest: report.digest }, question));
130
- continue;
131
- }
130
+ const request = (result.kind === "evidence-request" && result.evidenceType === "run" ? result.request : undefined);
131
+ if (!request)
132
+ break;
132
133
  questionLookupRounds += 1;
133
134
  lookupRounds += 1;
134
135
  lookupOperations += Math.min(6, request.operations.length);
@@ -136,8 +137,8 @@ export async function runManager(projectDir, options) {
136
137
  packetState.lastEvidenceScope = request.operations.flatMap(operation => "runIds" in operation ? operation.runIds : []);
137
138
  result = await send(buildManagerEvidencePacket(response, question));
138
139
  }
139
- const unfulfilled = parseManagerEvidenceRequest(result.text);
140
- const pendingEvidence = parseManagerEvidenceRequestV2(result.text);
140
+ const unfulfilled = (result.kind === "evidence-request" && result.evidenceType === "run" ? result.request : undefined);
141
+ const pendingEvidence = (result.kind === "evidence-request" && result.evidenceType === "qa" ? result.request : undefined);
141
142
  if (pendingEvidence) {
142
143
  const page = evidence.execute(pendingEvidence);
143
144
  if (options.ask) {
@@ -152,9 +153,10 @@ export async function runManager(projectDir, options) {
152
153
  result = await send(buildManagerEvidencePacket(limitation, question));
153
154
  }
154
155
  if (!pendingEvidence) {
155
- const answer = parseManagerEvidenceRequest(result.text) || parseManagerGraphRequest(result.text) || looksLikeEvidenceRequest(result.text)
156
- ? "Manager could not complete an answer within the bounded evidence lookup budget. Continue with /qa-attempts <run> <ticket> or /qa-report <run> <ticket> <attempt>."
157
- : result.text.trim();
156
+ const answer = result.kind === "completed" ? result.payload.answer
157
+ : result.kind === "blocked" ? `${result.reason} Next: ${result.nextAction}`
158
+ : result.kind === "needs-input" ? result.question
159
+ : "Manager could not complete an answer within the bounded evidence lookup budget. Continue with /qa-attempts <run> <ticket> or /qa-report <run> <ticket> <attempt>.";
158
160
  // One-shot evidence requests can mix JSON with prose, so add the label
159
161
  // only when this answer stream has not emitted machine-readable data.
160
162
  // Model-provided text is sanitized only for display, never for storage.