ai-foreman 1.7.29 → 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 (136) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude.d.ts +23 -12
  3. package/dist/adapters/claude.js +227 -23
  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 +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/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 +15 -0
  22. package/dist/buildApproval.js +25 -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 +22 -26
  31. package/dist/cli/tickets.d.ts +0 -7
  32. package/dist/cli/tickets.js +41 -40
  33. package/dist/continuity.d.ts +10 -14
  34. package/dist/continuity.js +198 -179
  35. package/dist/continuityOrigin.d.ts +13 -0
  36. package/dist/continuityOrigin.js +91 -0
  37. package/dist/foreman.d.ts +10 -4
  38. package/dist/foreman.js +51 -193
  39. package/dist/graph/boundary.d.ts +1 -1
  40. package/dist/graph/boundary.js +1 -1
  41. package/dist/graph/host.js +8 -19
  42. package/dist/graph/manager.d.ts +1 -1
  43. package/dist/graph/manager.js +1 -1
  44. package/dist/graph/preparation.d.ts +38 -0
  45. package/dist/graph/preparation.js +7 -6
  46. package/dist/graph/semantic.d.ts +1 -0
  47. package/dist/graph/semantic.js +4 -1
  48. package/dist/graph/session.js +7 -2
  49. package/dist/graph/turn.d.ts +2 -3
  50. package/dist/graph/turn.js +28 -40
  51. package/dist/handoffProtocol.js +2 -2
  52. package/dist/handoffs.d.ts +3 -1
  53. package/dist/handoffs.js +90 -59
  54. package/dist/hostGraphApplicability.d.ts +2 -1
  55. package/dist/hostGraphApplicability.js +17 -19
  56. package/dist/hostQaAugmentation.js +21 -4
  57. package/dist/hostQaComparison.d.ts +21 -2
  58. package/dist/hostQaComparison.js +43 -29
  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 +47 -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 +4 -9
  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/qaFailureDelivery.js +97 -85
  83. package/dist/qaHostTransformation.d.ts +26 -0
  84. package/dist/qaHostTransformation.js +100 -0
  85. package/dist/qaPreparation.d.ts +8 -20
  86. package/dist/qaPreparation.js +133 -248
  87. package/dist/qaPreparationInputs.js +19 -0
  88. package/dist/qaPrerequisites.d.ts +2 -1
  89. package/dist/qaPrerequisites.js +21 -1
  90. package/dist/qaProtocolV2.d.ts +15 -0
  91. package/dist/qaRecovery.js +21 -4
  92. package/dist/qaReview.js +220 -395
  93. package/dist/qaReviewEvidence.d.ts +7 -3
  94. package/dist/qaReviewEvidence.js +15 -4
  95. package/dist/qaRuntime.js +1 -1
  96. package/dist/qaSnapshot.js +71 -21
  97. package/dist/qaStructuredResponse.d.ts +10 -0
  98. package/dist/qaStructuredResponse.js +57 -0
  99. package/dist/qaTimeline.js +5 -3
  100. package/dist/qaVerificationContract.d.ts +1 -2
  101. package/dist/qaVerificationContract.js +24 -35
  102. package/dist/readinessGate.js +5 -4
  103. package/dist/runtimeReadiness.js +23 -15
  104. package/dist/statusReporter.d.ts +2 -0
  105. package/dist/statusReporter.js +2 -0
  106. package/dist/structuredCutover.d.ts +31 -0
  107. package/dist/structuredCutover.js +510 -0
  108. package/dist/structuredCutoverFiles.d.ts +10 -0
  109. package/dist/structuredCutoverFiles.js +181 -0
  110. package/dist/structuredDispatchAuthority.d.ts +22 -0
  111. package/dist/structuredDispatchAuthority.js +110 -0
  112. package/dist/structuredDispatchRecord.d.ts +29 -0
  113. package/dist/structuredDispatchRecord.js +36 -0
  114. package/dist/structuredLiveBudget.d.ts +2 -0
  115. package/dist/structuredLiveBudget.js +23 -0
  116. package/dist/structuredOperation.d.ts +18 -0
  117. package/dist/structuredOperation.js +257 -0
  118. package/dist/supervisedStart.js +35 -21
  119. package/dist/ticketPlanning.d.ts +1 -373
  120. package/dist/ticketPlanning.js +2 -26
  121. package/dist/ticketPopulation.d.ts +0 -3
  122. package/dist/ticketPopulation.js +4 -21
  123. package/dist/ticketPopulationPageAuthority.d.ts +13 -0
  124. package/dist/ticketPopulationPageAuthority.js +30 -0
  125. package/dist/ticketPopulationPaging.d.ts +27 -0
  126. package/dist/ticketPopulationPaging.js +105 -0
  127. package/dist/tickets/renderMarkdown.d.ts +14 -0
  128. package/dist/tickets/renderMarkdown.js +39 -5
  129. package/dist/tickets/ticketLoader.js +10 -0
  130. package/dist/tickets/ticketSchema.d.ts +107 -0
  131. package/dist/tickets/ticketSchema.js +4 -1
  132. package/dist/workflowDb.d.ts +100 -2
  133. package/dist/workflowDb.js +763 -39
  134. package/dist/workflowReader.d.ts +1 -0
  135. package/dist/workflowReader.js +9 -0
  136. package/package.json +15 -7
@@ -125,6 +125,31 @@ 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
+ }
128
153
  /** Compute the exact host-owned inputs covered by a recovery plan approval. */
129
154
  export function planApprovalBinding(projectDir, ticketIds, consequences) {
130
155
  const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
@@ -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.
package/dist/cli/start.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { nativePolicy, operationInstruction, operationPayload, structuredTurnErrorMessage } from "../structuredOperation.js";
1
2
  import { finalQaRoleInstructions, resolveEffectiveQaConfiguration } from "../qaEffectiveConfig.js";
2
3
  import { activateQaRecoveryBuilder } from "../qaBuilderActivation.js";
3
4
  import { graphContext, sendGraphTurn } from "../graph/turn.js";
@@ -11,7 +12,7 @@ import { captureFrozenQaSource } from "../qaSnapshot.js";
11
12
  import { finishStartAdmission, superviseStart, supervisedRunId } from "../supervisedStart.js";
12
13
  import { assertBuildAssignmentReconciled, isInterruptedBuilderRecovery, assertInterruptedSourceSnapshot } from "../buildAssignment.js";
13
14
  import { durableHumanDecision, HumanDecisionRequired, HumanDecisionCancelled, servicePendingHumanDecisions } from "../humanDecision.js";
14
- import { requiresBuildApproval, buildScopeRevision, createBuildApprovalGate, validatePlanApprovalBinding } from "../buildApproval.js";
15
+ import { requiresBuildApproval, buildScopeRevision, createBuildApprovalGate, validatePlanApprovalBinding, validateFrozenWorkApprovalBinding } from "../buildApproval.js";
15
16
  import { Command, Option } from "commander";
16
17
  import { accent, error, sanitizeTerminalText, success, warning } from "../terminalStyle.js";
17
18
  import { resolve, join, relative } from "node:path";
@@ -34,7 +35,7 @@ import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "../
34
35
  import { hasRollbackOrMitigationPlan, loadTickets } from "../tickets/ticketLoader.js";
35
36
  import { StateDb } from "../tickets/stateDb.js";
36
37
  import { ticketDefinitionDigest } from "../tickets/riskWaivers.js";
37
- import { applySharedDeliveryBranch, applySizeBranchPolicy, buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
38
+ import { applySharedDeliveryBranch, applySizeBranchPolicy, buildBranchAuditInstruction, buildBranchPlan } from "../branch/planner.js";
38
39
  import { currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
39
40
  import { captureDirtyBaseline } from "../branch/dirtyBaseline.js";
40
41
  import { formatGitHubFailure, preflightGh } from "../branch/github.js";
@@ -42,7 +43,7 @@ import { preflightGlab } from "../branch/gitlab.js";
42
43
  import { readDeliveryUnitSession, runBranchPlan } from "../branch/runner.js";
43
44
  import { branchPlanLogMetadata, presentBranchPlan } from "../branch/presentation.js";
44
45
  import { branchTicketScopeProgress, buildRunSessionBinding, checkpointBuildRun, completeBuildRun, createBuildRun, currentBranchRecoveryTicket, currentBranchTicketProgress, recoveryExecutionTickets, finishBranchTicketScope, finishRecoveredTicketScope, heartbeatBuildRun, persistBuildSession, persistCurrentBuildSession, projectBuildRecovery, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
45
- import { parseProviderContract, qaRemediationProposalV1Schema } from "rafi-spec";
46
+ import { branchAuditOutput, plannerRemediationOutput } from "rafi-spec";
46
47
  import { parse as parseYaml } from "yaml";
47
48
  import { findResumableBranchSessions, formatBranchContinueCommand, formatBranchSummaryFollowupCommands, } from "../branch/resume.js";
48
49
  import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig.js";
@@ -699,9 +700,11 @@ export function buildStartCommand(options = {}) {
699
700
  // recovery remains fail-closed on an exact current-plan approval.
700
701
  if (recoveryRecord && autoApprovePlanUpdates && recoveryTickets.length > 0) {
701
702
  const decision = recoveryRecord.recoveryDecision;
702
- if (!decision?.planApproval)
703
+ if (!decision?.planApproval && !decision?.frozenWorkApproval)
703
704
  fail("saved --yes approval has no exact plan binding; rerun rafi build:resume --yes to approve the current plan");
704
- const approvalCheck = validatePlanApprovalBinding(cwd, recoveryTickets, decision.planApproval);
705
+ const approvalCheck = decision.frozenWorkApproval
706
+ ? validateFrozenWorkApprovalBinding(cwd, recoveryRecord.runId, recoveryTickets, decision.frozenWorkApproval)
707
+ : validatePlanApprovalBinding(cwd, recoveryTickets, decision.planApproval);
705
708
  if (!approvalCheck.valid)
706
709
  fail(`saved --yes approval is stale (${approvalCheck.reason}); rerun rafi build:resume --yes before provider work`);
707
710
  }
@@ -1305,7 +1308,6 @@ export function buildStartCommand(options = {}) {
1305
1308
  try {
1306
1309
  const revision = countDb.continuityHead(runId, "builder")?.authoritativeStateRevision ?? settings.settings_revision;
1307
1310
  countDb.appendContinuityEvent({ runId, role: "builder", kind: "builder_requested_handoff_delta", payload: request.delta, authoritativeStateRevision: revision });
1308
- countDb.publishContinuityCheckpoint({ runId, role: "builder", delta: request.delta, authoritativeStateRevision: revision });
1309
1311
  compactionCount = countDb.successfulCompactionCount(runId, "builder", predecessor.sessionRef?.() ?? sessionId);
1310
1312
  }
1311
1313
  finally {
@@ -1318,6 +1320,7 @@ export function buildStartCommand(options = {}) {
1318
1320
  predecessorSessionId: sessionId,
1319
1321
  predecessorSessionRef: predecessor.sessionRef?.(),
1320
1322
  requestedByBuilder: true,
1323
+ predecessorDispatchId: request.predecessorDispatchId,
1321
1324
  roleState: {
1322
1325
  ...request.roleState,
1323
1326
  frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"),
@@ -1334,7 +1337,8 @@ export function buildStartCommand(options = {}) {
1334
1337
  resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1335
1338
  }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("builder"));
1336
1339
  log.write("handoff-transfer", {
1337
- requestedByBuilder: true, reason: request.reason, generation: transfer.manifest.generation,
1340
+ requestedByBuilder: true,
1341
+ predecessorDispatchId: request.predecessorDispatchId, reason: request.reason, generation: transfer.manifest.generation,
1338
1342
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1339
1343
  acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1340
1344
  });
@@ -2027,10 +2031,13 @@ export function buildStartCommand(options = {}) {
2027
2031
  try {
2028
2032
  auditBuilder = await createBuilder(cwd);
2029
2033
  auditViewer = printEvents(auditBuilder.events());
2030
- const audit = await sendGraphTurn(auditBuilder, buildBranchAuditInstruction(plan.nodes.map((node) => node.ticket)), graphContext(cwd, auditBuilder.sessionRef?.()?.cwd ?? cwd, "branch-dependency-audit", plan.nodes.map(node => node.ticket.id).join(" ")), { purpose: "planning" });
2034
+ const audit = await sendGraphTurn(auditBuilder, `${buildBranchAuditInstruction(plan.nodes.map((node) => node.ticket))}\n${operationInstruction(branchAuditOutput)}`, graphContext(cwd, auditBuilder.sessionRef?.()?.cwd ?? cwd, "branch-dependency-audit", plan.nodes.map(node => node.ticket.id).join(" ")), nativePolicy(branchAuditOutput, { purpose: "planning", journalRunId: auditRunId }));
2031
2035
  if (audit.isError)
2032
- throw new Error(audit.text);
2033
- const auditDependencies = parseAuditDependencies(audit.text);
2036
+ throw new Error(structuredTurnErrorMessage(audit, "Branch dependency audit"));
2037
+ const auditDependencies = operationPayload(audit, branchAuditOutput).dependencies;
2038
+ const selectedIds = new Set(plan.nodes.map(node => node.ticket.id));
2039
+ if (auditDependencies.some(dep => dep.ticket === dep.dependsOn || !selectedIds.has(dep.ticket) || !selectedIds.has(dep.dependsOn)))
2040
+ throw new Error("Branch audit returned a foreign or self dependency");
2034
2041
  plan = buildBranchPlan(tickets, states, {
2035
2042
  steps,
2036
2043
  baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
@@ -3146,8 +3153,7 @@ export function createQaNonconvergenceHandler(projectDir, noninteractive, planne
3146
3153
  `User discussion: ${String(discussion)}`,
3147
3154
  `Complete QA history:\n${JSON.stringify(context.history, null, 2)}`,
3148
3155
  `${diffTruncated ? "Builder diff (TRUNCATED at 128 KiB; inspect the actual worktree before relying on it)" : "Full Builder diff"}:\n${diff || "(no tracked diff)"}`,
3149
- "Return RAFI_QA_REMEDIATION_START, then one JSON object {\"summary\":\"...\",\"fix_instructions\":[\"...\"]}, then RAFI_QA_REMEDIATION_END.",
3150
- "End with STEP_STATUS: plan_complete | summary=\"QA remediation proposed\"",
3156
+ "Submit the native planner-remediation payload with summary and fix_instructions.",
3151
3157
  ].join("\n\n");
3152
3158
  let resumeSessionId;
3153
3159
  let revision = 0;
@@ -3155,24 +3161,14 @@ export function createQaNonconvergenceHandler(projectDir, noninteractive, planne
3155
3161
  revision++;
3156
3162
  const proposalDigest = createHash("sha256").update(JSON.stringify([scope, round, revision, instruction, planner, captureFrozenQaSource(context.builderWorktree).digest])).digest("hex");
3157
3163
  const run = await durableReadOnlyProposal({ projectDir, runId, ticketId: context.ticket.id, digest: proposalDigest, operation: () => runRoleInstruction({
3158
- projectDir, role: "planner", instruction, label: "QA Planner remediation", agent: planner?.make,
3164
+ projectDir, role: "planner", instruction, output: plannerRemediationOutput, label: "QA Planner remediation", agent: planner?.make,
3159
3165
  model: explicitDefaultValue(planner?.model), effort: explicitEffort(planner?.reasoning), fast: planner?.fast,
3160
3166
  resumeSessionId, permissionConfig: readOnlyPermissionConfig(), sandboxMode: "read-only",
3161
3167
  logPath: makeRoleLogPath(projectDir, "qa-remediation"), logEvent: "rafi-plan",
3162
3168
  }) });
3163
- if (run.turn.result.isError || run.turn.status.kind !== "plan_complete")
3169
+ if (run.turn.result.isError || run.turn.status.kind !== "done")
3164
3170
  throw new Error(`Planner remediation failed: ${run.turn.status.error ?? run.turn.result.text.slice(0, 500)}`);
3165
- const match = /RAFI_QA_REMEDIATION_START\s*([\s\S]*?)\s*RAFI_QA_REMEDIATION_END/.exec(run.turn.result.text);
3166
- if (!match)
3167
- throw new Error("Planner remediation did not return the required structured proposal");
3168
- let parsed;
3169
- try {
3170
- parsed = JSON.parse(match[1].replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""));
3171
- }
3172
- catch (error) {
3173
- throw new Error(`Planner remediation proposal is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
3174
- }
3175
- const proposal = parseProviderContract(qaRemediationProposalV1Schema, parsed, "RAFI_QA_REMEDIATION");
3171
+ const proposal = operationPayload(run.turn.result, plannerRemediationOutput);
3176
3172
  console.log(`\nPlanner remediation: ${proposal.summary}\n${proposal.fix_instructions.map((item) => `- ${item}`).join("\n")}`);
3177
3173
  const decision = await ask(`QA remediation approval ${revision}: ${JSON.stringify(proposal)}`, [{ id: "approve", label: "Approve" }, { id: "revise", label: "Revise" }, { id: "cancel", label: "Return to choices" }], () => select({ message: "Use this remediation for a new Builder fix session?", options: [
3178
3174
  { value: "approve", label: "Approve (Recommended)" }, { value: "revise", label: "Discuss/revise" }, { value: "cancel", label: "Cancel and return to choices" },
@@ -3185,7 +3181,7 @@ export function createQaNonconvergenceHandler(projectDir, noninteractive, planne
3185
3181
  if (isCancel(feedback))
3186
3182
  break;
3187
3183
  resumeSessionId = run.sessionId;
3188
- instruction = `Revise the complete QA remediation proposal using this feedback: ${String(feedback)}. Keep the exact structured envelope and final plan_complete marker.`;
3184
+ instruction = `Revise the complete QA remediation proposal using this feedback: ${String(feedback)}. Use the native planner-remediation contract.`;
3189
3185
  }
3190
3186
  }
3191
3187
  };
@@ -1,17 +1,10 @@
1
1
  import { Command } from "commander";
2
2
  import type { StructuredPlanV1 } from "rafi-spec";
3
3
  import { Log } from "../log.js";
4
- import { type StepStatus } from "../foreman.js";
5
4
  import type { EffortLevel } from "../adapters/types.js";
6
5
  import { type RoleBuilderOptions } from "../agentRun.js";
7
6
  import type { TicketsConfig } from "../tickets/config.js";
8
7
  import { type TicketPopulateRuntimeMode, type TicketSourceConfig, type TicketsSetupConfig } from "../tickets/setupConfig.js";
9
- /**
10
- * Ticket Maker may append an explanatory note after its machine envelope.
11
- * Preserve a schema-valid proposal before Foreman's strict generic marker
12
- * repair path replaces the response with a status-only correction.
13
- */
14
- export declare function acceptTicketPopulationCompletion(text: string): StepStatus | undefined;
15
8
  /** Commander preserves comma-delimited values as one argument; accept both documented forms. */
16
9
  export declare function normalizeRiskWaiverTicketIds(values: readonly string[] | undefined): string[];
17
10
  /** Record an explicit, definition-bound exception for existing risk tickets. */